From 933923c76ffd5bcdb8a41535ec93606b29a26580 Mon Sep 17 00:00:00 2001 From: Katie Geer Date: Fri, 13 Mar 2026 10:48:54 -0700 Subject: [PATCH 01/43] docs: Add roles page under Account & Privacy section (#51413) Add new page detailing the roles available for users in their organizations. Release Notes: - N/A *or* Added/Fixed/Improved ... --- docs/src/SUMMARY.md | 1 + docs/src/roles.md | 71 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 docs/src/roles.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 7fae303160702216a8c75095597293c375751c82..1522563d2cbeac0a2391aa30db4ab18b6522b18c 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -183,6 +183,7 @@ # Account & Privacy - [Authenticate](./authentication.md) +- [Roles](./roles.md) - [Privacy and Security](./ai/privacy-and-security.md) - [Worktree Trust](./worktree-trust.md) - [AI Improvement](./ai/ai-improvement.md) diff --git a/docs/src/roles.md b/docs/src/roles.md new file mode 100644 index 0000000000000000000000000000000000000000..6c1ce7a8928955d16f8f70c024fd4133c85837bc --- /dev/null +++ b/docs/src/roles.md @@ -0,0 +1,71 @@ +--- +title: Roles - Zed +description: Understand Zed's organization roles and what each role can access, manage, and configure. +--- + +# Roles + +Every member of a Zed organization is assigned a role that determines +what they can access and configure. + +## Role Types {#roles} + +Every member of an organization is assigned one of three roles: + +| Role | Description | +| ---------- | ------------------------------------------------------ | +| **Owner** | Full control, including billing and ownership transfer | +| **Admin** | Full control, except billing | +| **Member** | Standard access, no privileged actions | + +### Owner {#role-owner} + +An owner has full control over the organization, including: + +- Invite and remove members +- Assign and change member roles +- Manage billing, payment methods, and invoices +- Configure data-sharing policies +- Disable Zed's collaborative features +- Control whether members can use Zed-hosted models and Zed's edit predictions +- Transfer ownership to another member + +### Admin {#role-admin} + +Admins have the same capabilities as the Owner, except they cannot: + +- Access or modify billing settings +- Transfer organization ownership + +This role is suited for team leads or managers who handle day-to-day +member access without needing visibility into payment details. + +### Member {#role-member} + +Members have standard access to Zed. They cannot access billing or +organization settings. + +## Managing User Roles {#managing-users} + +Owners and Admins can manage organization members from the Zed +dashboard within the Members page. + +### Inviting Members {#inviting-members} + +1. On the Members page, select **+ Invite Member**. +2. Enter the member's company email address and choose a role. +3. The invitee receives an email with instructions to join. After + accepting, they authenticate via GitHub. + +### Changing a Member's Role {#changing-roles} + +1. On the Members page, find the member. You can filter by role or + search by name. +2. Open the three-dot menu and select a new role. + +### Removing a Member {#removing-members} + +1. On the Members page, find the member. +2. Select **Remove** and confirm. + +Removing a member removes their access to organization settings and any organization-managed features. They can continue using Zed on their own. From d820b079f949071df8cc7bcab528873db920590d Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Fri, 13 Mar 2026 19:07:56 +0100 Subject: [PATCH 02/43] extension_ci: Fix main repository version bumps (#51515) Release Notes: - N/A --- .github/workflows/extension_bump.yml | 28 ++++++++-- .../src/tasks/workflows/extension_bump.rs | 51 ++++++++++++++----- 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index 31f34c9299cee8b464162d501aecaa2bb70035d6..85c8771246c9910c51fd9e6ba98244e347e6d2db 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -83,6 +83,11 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: clean: false + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + path: ~/.rustup - name: extension_bump::install_bump_2_version run: pip install bump2version --break-system-packages - id: bump-version @@ -139,7 +144,7 @@ jobs: token: ${{ steps.generate-token.outputs.token }} sign-commits: true assignees: ${{ github.actor }} - timeout-minutes: 3 + timeout-minutes: 5 defaults: run: shell: bash -euxo pipefail {0} @@ -160,6 +165,21 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: clean: false + - id: determine-tag + name: extension_bump::determine_tag + run: | + EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')" + + if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then + TAG="v${CURRENT_VERSION}" + else + TAG="${EXTENSION_ID}-v${CURRENT_VERSION}" + fi + + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + env: + CURRENT_VERSION: ${{ needs.check_version_changed.outputs.current_version }} + WORKING_DIR: ${{ inputs.working-directory }} - name: extension_bump::create_version_tag uses: actions/github-script@v7 with: @@ -167,10 +187,12 @@ jobs: github.rest.git.createRef({ owner: context.repo.owner, repo: context.repo.repo, - ref: 'refs/tags/v${{ needs.check_version_changed.outputs.current_version }}', + ref: 'refs/tags/${{ steps.determine-tag.outputs.tag }}', sha: context.sha }) github-token: ${{ steps.generate-token.outputs.token }} + outputs: + tag: ${{ steps.determine-tag.outputs.tag }} timeout-minutes: 1 defaults: run: @@ -206,7 +228,7 @@ jobs: with: extension-name: ${{ steps.get-extension-id.outputs.extension_id }} push-to: zed-industries/extensions - tag: v${{ needs.check_version_changed.outputs.current_version }} + tag: ${{ needs.create_version_label.outputs.tag }} env: COMMITTER_TOKEN: ${{ steps.generate-token.outputs.token }} defaults: diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs index 91d2e5645f9f5e9fd24dbceaf5e2ad6886e41cb6..671e80c25262edee6bfc3b73bc9677985d898aaf 100644 --- a/tooling/xtask/src/tasks/workflows/extension_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -6,7 +6,7 @@ use crate::tasks::workflows::{ runners, steps::{ self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, - NamedJob, checkout_repo, dependant_job, named, + NamedJob, cache_rust_dependencies_namespace, checkout_repo, dependant_job, named, }, vars::{ JobOutput, StepOutput, WorkflowInput, WorkflowSecret, @@ -41,16 +41,17 @@ pub(crate) fn extension_bump() -> Workflow { &app_id, &app_secret, ); - let create_label = create_version_label( + let (create_label, tag) = create_version_label( &dependencies, &version_changed, ¤t_version, &app_id, &app_secret, ); + let tag = tag.as_job_output(&create_label); let trigger_release = trigger_release( &[&check_version_changed, &create_label], - current_version, + tag, &app_id, &app_secret, ); @@ -120,9 +121,10 @@ fn create_version_label( current_version: &JobOutput, app_id: &WorkflowSecret, app_secret: &WorkflowSecret, -) -> NamedJob { +) -> (NamedJob, StepOutput) { let (generate_token, generated_token) = generate_token(&app_id.to_string(), &app_secret.to_string(), None); + let (determine_tag_step, tag) = determine_tag(current_version); let job = steps::dependant_job(dependencies) .defaults(extension_job_defaults()) .cond(Expression::new(format!( @@ -130,16 +132,18 @@ fn create_version_label( github.ref == 'refs/heads/main' && {version_changed} == 'true'", version_changed = version_changed_output.expr(), ))) + .outputs([(tag.name.to_owned(), tag.to_string())]) .runs_on(runners::LINUX_SMALL) .timeout_minutes(1u32) .add_step(generate_token) .add_step(steps::checkout_repo()) - .add_step(create_version_tag(current_version, generated_token)); + .add_step(determine_tag_step) + .add_step(create_version_tag(&tag, generated_token)); - named::job(job) + (named::job(job), tag) } -fn create_version_tag(current_version: &JobOutput, generated_token: StepOutput) -> Step { +fn create_version_tag(tag: &StepOutput, generated_token: StepOutput) -> Step { named::uses("actions", "github-script", "v7").with( Input::default() .add( @@ -148,7 +152,7 @@ fn create_version_tag(current_version: &JobOutput, generated_token: StepOutput) github.rest.git.createRef({{ owner: context.repo.owner, repo: context.repo.repo, - ref: 'refs/tags/v{current_version}', + ref: 'refs/tags/{tag}', sha: context.sha }})"# }, @@ -157,6 +161,26 @@ fn create_version_tag(current_version: &JobOutput, generated_token: StepOutput) ) } +fn determine_tag(current_version: &JobOutput) -> (Step, StepOutput) { + let step = named::bash(formatdoc! {r#" + EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')" + + if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then + TAG="v${{CURRENT_VERSION}}" + else + TAG="${{EXTENSION_ID}}-v${{CURRENT_VERSION}}" + fi + + echo "tag=${{TAG}}" >> "$GITHUB_OUTPUT" + "#}) + .id("determine-tag") + .add_env(("CURRENT_VERSION", current_version.to_string())) + .add_env(("WORKING_DIR", "${{ inputs.working-directory }}")); + + let tag = StepOutput::new(&step, "tag"); + (step, tag) +} + /// Compares the current and previous commit and checks whether versions changed inbetween. pub(crate) fn compare_versions() -> (Step, StepOutput, StepOutput) { let check_needs_bump = named::bash(formatdoc! { @@ -209,9 +233,10 @@ fn bump_extension_version( version_changed = version_changed_output.expr(), ))) .runs_on(runners::LINUX_SMALL) - .timeout_minutes(3u32) + .timeout_minutes(5u32) .add_step(generate_token) .add_step(steps::checkout_repo()) + .add_step(cache_rust_dependencies_namespace()) .add_step(install_bump_2_version()) .add_step(bump_version) .add_step(create_pull_request( @@ -353,7 +378,7 @@ fn create_pull_request( fn trigger_release( dependencies: &[&NamedJob], - version: JobOutput, + tag: JobOutput, app_id: &WorkflowSecret, app_secret: &WorkflowSecret, ) -> NamedJob { @@ -372,7 +397,7 @@ fn trigger_release( .add_step(generate_token) .add_step(checkout_repo()) .add_step(get_extension_id) - .add_step(release_action(extension_id, version, generated_token)); + .add_step(release_action(extension_id, tag, generated_token)); named::job(job) } @@ -393,13 +418,13 @@ fn get_extension_id() -> (Step, StepOutput) { fn release_action( extension_id: StepOutput, - version: JobOutput, + tag: JobOutput, generated_token: StepOutput, ) -> Step { named::uses("huacnlee", "zed-extension-action", "v2") .add_with(("extension-name", extension_id.to_string())) .add_with(("push-to", "zed-industries/extensions")) - .add_with(("tag", format!("v{version}"))) + .add_with(("tag", tag.to_string())) .add_env(("COMMITTER_TOKEN", generated_token.to_string())) } From b531c40942d8ae8e76b7c12659d58bacee1974d3 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Fri, 13 Mar 2026 19:16:02 +0100 Subject: [PATCH 03/43] glsl/html: Clean up some things (#51516) Release Notes: - N/A --- extensions/glsl/languages/glsl/highlights.scm | 170 +++++++----------- extensions/html/src/html.rs | 7 +- 2 files changed, 67 insertions(+), 110 deletions(-) diff --git a/extensions/glsl/languages/glsl/highlights.scm b/extensions/glsl/languages/glsl/highlights.scm index 9e40610ff5494102f8524b287ad2e50ec48d78db..9f0754b61ed2f8a596e186063224499f2afd1188 100644 --- a/extensions/glsl/languages/glsl/highlights.scm +++ b/extensions/glsl/languages/glsl/highlights.scm @@ -1,108 +1,68 @@ -"break" @keyword - -"case" @keyword - -"const" @keyword - -"continue" @keyword - -"default" @keyword - -"do" @keyword - -"else" @keyword - -"enum" @keyword - -"extern" @keyword - -"for" @keyword - -"if" @keyword - -"inline" @keyword - -"return" @keyword - -"sizeof" @keyword - -"static" @keyword - -"struct" @keyword - -"switch" @keyword - -"typedef" @keyword - -"union" @keyword - -"volatile" @keyword - -"while" @keyword - -"#define" @keyword - -"#elif" @keyword - -"#else" @keyword - -"#endif" @keyword - -"#if" @keyword - -"#ifdef" @keyword - -"#ifndef" @keyword - -"#include" @keyword - -(preproc_directive) @keyword - -"--" @operator - -"-" @operator - -"-=" @operator - -"->" @operator - -"=" @operator - -"!=" @operator - -"*" @operator - -"&" @operator - -"&&" @operator - -"+" @operator - -"++" @operator - -"+=" @operator - -"<" @operator - -"==" @operator - -">" @operator - -"||" @operator - -"." @delimiter - -";" @delimiter +[ + "break" + "case" + "const" + "continue" + "default" + "do" + "else" + "enum" + "extern" + "for" + "if" + "inline" + "return" + "sizeof" + "static" + "struct" + "switch" + "typedef" + "union" + "volatile" + "while" + "#define" + "#elif" + "#else" + "#endif" + "#if" + "#ifdef" + "#ifndef" + "#include" + (preproc_directive) +] @keyword -(string_literal) @string +[ + "--" + "-" + "-=" + "->" + "=" + "!=" + "*" + "&" + "&&" + "+" + "++" + "+=" + "<" + "==" + ">" + "||" + "." + ";" +] @operator -(system_lib_string) @string +[ + (string_literal) + (system_lib_string) +] @string (null) @constant -(number_literal) @number - -(char_literal) @number +[ + (number_literal) + (char_literal) +] @number (identifier) @variable @@ -110,11 +70,11 @@ (statement_identifier) @label -(type_identifier) @type - -(primitive_type) @type - -(sized_type_specifier) @type +[ + (type_identifier) + (primitive_type) + (sized_type_specifier) +] @type (call_expression function: (identifier) @function) diff --git a/extensions/html/src/html.rs b/extensions/html/src/html.rs index 337689ebddd427769ab985ad82512f76b601e67c..a5e38c97b3613ca735fb4eea8f26472ab3f66049 100644 --- a/extensions/html/src/html.rs +++ b/extensions/html/src/html.rs @@ -95,11 +95,8 @@ impl zed::Extension for HtmlExtension { server_id: &LanguageServerId, worktree: &zed::Worktree, ) -> Result> { - let settings = LspSettings::for_worktree(server_id.as_ref(), worktree) - .ok() - .and_then(|lsp_settings| lsp_settings.settings) - .unwrap_or_default(); - Ok(Some(settings)) + LspSettings::for_worktree(server_id.as_ref(), worktree) + .map(|lsp_settings| lsp_settings.settings) } fn language_server_initialization_options( From fe7fa37c0bace7bbcd45a63f5dbbab958dd40ce0 Mon Sep 17 00:00:00 2001 From: jamarju Date: Fri, 13 Mar 2026 19:30:01 +0100 Subject: [PATCH 04/43] gpui_macos: Skip IME for Cmd+key events on non-QWERTY layouts (#51394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #51297 On non-QWERTY layouts, all Cmd+key events are routed through the macOS IME because `key_char` is always `None` when Cmd is held. For certain characters (dead keys like backtick, and non-dead keys like ç), the IME calls `insertText:` instead of `doCommandBySelector:`, consuming the event before it reaches GPUI's keybinding system or macOS system shortcuts. This adds `!platform` to the IME-path condition in `handle_key_event` so Cmd+key events bypass the IME (except when composing). GPUI handles them if a binding matches, otherwise `performKeyEquivalent:` returns `NO` and macOS handles them. **This won't fully fix Cmd+backtick window cycling by itself** because Zed's key_equivalents system maps default keybindings onto the physical backtick key on various layouts. For example, `cmd-'` (ToggleSelectedDiffHunks) maps to the backtick key on Spanish, `cmd-=` (IncreaseBufferFontSize) on Scandinavian layouts, `cmd-"` (ExpandAllDiffHunks) on German/Portuguese/Swiss, and `cmd-}` (ActivateNextItem) on Spanish-ISO. These Zed bindings shadow the backtick key and consume the event before macOS can cycle windows. I'd appreciate guidance on the preferred approach to resolve these keybinding conflicts -- IMO, Zed's default shortcuts should not be interfering with Cmd+backtick for any layout. Release Notes: - Fixed Cmd+key shortcuts being consumed by the IME on non-QWERTY keyboard layouts, preventing Zed keybindings and macOS system shortcuts from working with special characters. --- crates/gpui_macos/src/window.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 290b2b704672028c79d99ef7eddad7ce37ed230e..b783a4d083131fac70095d22718796ef761adee3 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -1799,10 +1799,13 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: // may need them even if there is no marked text; // however we skip keys with control or the input handler adds control-characters to the buffer. // and keys with function, as the input handler swallows them. + // and keys with platform (Cmd), so that Cmd+key events (e.g. Cmd+`) are not + // consumed by the IME on non-QWERTY / dead-key layouts. if is_composing || (key_down_event.keystroke.key_char.is_none() && !key_down_event.keystroke.modifiers.control - && !key_down_event.keystroke.modifiers.function) + && !key_down_event.keystroke.modifiers.function + && !key_down_event.keystroke.modifiers.platform) { { let mut lock = window_state.as_ref().lock(); From 08abc48f1dfc2fae1fc0a059c71fb0902ff47b1e Mon Sep 17 00:00:00 2001 From: "zed-zippy[bot]" <234243425+zed-zippy[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:31:40 +0100 Subject: [PATCH 05/43] glsl: Bump to v0.2.1 (#51517) This PR bumps the version of the GLSL extension to v0.2.1. Release Notes: - N/A Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- Cargo.lock | 2 +- extensions/glsl/Cargo.toml | 2 +- extensions/glsl/extension.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 65d7f7ccb5ae148e337257d52f71ac2cc4aeebc0..01293ca7ff014d5210b064a32706c1f5ab10767f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22120,7 +22120,7 @@ dependencies = [ [[package]] name = "zed_glsl" -version = "0.2.0" +version = "0.2.1" dependencies = [ "zed_extension_api 0.1.0", ] diff --git a/extensions/glsl/Cargo.toml b/extensions/glsl/Cargo.toml index fd39ac82debb3eabf78219a730e090c002c88395..902a6f3aafcd123603c93ad52ee0d988019e00cf 100644 --- a/extensions/glsl/Cargo.toml +++ b/extensions/glsl/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_glsl" -version = "0.2.0" +version = "0.2.1" edition.workspace = true publish.workspace = true license = "Apache-2.0" diff --git a/extensions/glsl/extension.toml b/extensions/glsl/extension.toml index 867b679ea6b9cf0f42e87938e85b5b69bbd435e3..df41e9c204e920dc5802d6a33fa9c5b2ae16270b 100644 --- a/extensions/glsl/extension.toml +++ b/extensions/glsl/extension.toml @@ -1,7 +1,7 @@ id = "glsl" name = "GLSL" description = "GLSL support." -version = "0.2.0" +version = "0.2.1" schema_version = 1 authors = ["Mikayla Maki "] repository = "https://github.com/zed-industries/zed" From 55c94985e54d775d7706b8ac9cf54da81e417906 Mon Sep 17 00:00:00 2001 From: Katie Geer Date: Fri, 13 Mar 2026 11:38:19 -0700 Subject: [PATCH 06/43] docs: Add callouts about student plan for usage and spend limits (#51506) Add details about how student plan differs in token usage and spend limits Release Notes: - N/A *or* Added/Fixed/Improved ... --- docs/src/ai/plans-and-usage.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/src/ai/plans-and-usage.md b/docs/src/ai/plans-and-usage.md index bebc4c4fb30dab6379a645209d21eccda65459d5..bc9e4854475799938dc7383e29edd84bf9493a66 100644 --- a/docs/src/ai/plans-and-usage.md +++ b/docs/src/ai/plans-and-usage.md @@ -7,9 +7,9 @@ description: Understand Zed's AI plans, token-based usage metering, spend limits ## Available Plans {#plans} -For costs and more information on pricing, visit [Zed’s pricing page](https://zed.dev/pricing). +For costs and more information on pricing, visit [Zed's pricing page](https://zed.dev/pricing). -Zed works without AI features or a subscription. No [authentication](../authentication.md) required for the editor itself. +Zed works without AI features or a subscription. No [authentication](../authentication.md) is required for the editor itself. ## Usage {#usage} @@ -17,6 +17,8 @@ Usage of Zed's hosted models is measured on a token basis, converted to dollars Zed Pro comes with $5 of monthly dollar credit. A trial of Zed Pro includes $20 of credit, usable for 14 days. Monthly included credit resets on your monthly billing date. +The [Zed Student plan](https://zed.dev/education) includes $10/month in token credits. The Student plan is available free for one year to verified university students. + To view your current usage, you can visit your account at [dashboard.zed.dev/account](https://dashboard.zed.dev/account). Information from our metering and billing provider, Orb, is embedded on that page. ## Spend Limits {#usage-spend-limits} @@ -25,7 +27,9 @@ At the top of [the Account page](https://dashboard.zed.dev/account), you'll find The default value for all Pro users is $10, for a total monthly spend with Zed of $20 ($10 for your Pro subscription, $10 in incremental token spend). This can be set to $0 to limit your spend with Zed to exactly $10/month. If you adjust this limit _higher_ than $10 and consume more than $10 of incremental token spend, you'll be billed via [threshold billing](./billing.md#threshold-billing). -Once the spend limit is hit, we’ll stop any further usage until your token spend limit resets. +Once the spend limit is hit, we'll stop any further usage until your token spend limit resets. + +> **Note:** Spend limits are a Zed Pro feature. Student plan users do not currently have the ability to configure spend limits; usage is capped at the $10/month included credit. ## Business Usage {#business-usage} From db362f5ba68b7f63e2bce43592e03c64e206b174 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Fri, 13 Mar 2026 19:42:56 +0100 Subject: [PATCH 07/43] extension_ci: Use proper PR description for main repository (#51519) Release Notes: - N/A (https://tenor.com/view/ironic-star-wars-chode-gif-5274592) --- .github/workflows/extension_bump.yml | 8 +++++++- tooling/xtask/src/tasks/workflows/extension_bump.rs | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index 85c8771246c9910c51fd9e6ba98244e347e6d2db..52b54d74c1c597d3052ff3238f0e40da5da962f4 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -121,7 +121,13 @@ jobs: else { echo "title=${EXTENSION_ID}: Bump to v${NEW_VERSION}"; - echo "body=This PR bumps the version of the ${EXTENSION_NAME} extension to v${NEW_VERSION}"; + echo "body<> "$GITHUB_OUTPUT" fi diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs index 671e80c25262edee6bfc3b73bc9677985d898aaf..9ba72975cc57d4cd67e567d62fceb806f8a2864e 100644 --- a/tooling/xtask/src/tasks/workflows/extension_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -332,7 +332,13 @@ fn bump_version( else {{ echo "title=${{EXTENSION_ID}}: Bump to v${{NEW_VERSION}}"; - echo "body=This PR bumps the version of the ${{EXTENSION_NAME}} extension to v${{NEW_VERSION}}"; + echo "body<> "$GITHUB_OUTPUT" fi From 165c033896a95ac12704705e0a61ba5bb01ed660 Mon Sep 17 00:00:00 2001 From: Katie Geer Date: Fri, 13 Mar 2026 11:44:59 -0700 Subject: [PATCH 08/43] seo: Expand /docs/ai/tools examples + cross-link from external-agents (#49758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to support pushing `/docs/ai/tools` into the top 10 for "agent tools" (8,900 monthly volume, currently position 11). **`docs/src/ai/tools.md`** — adds a concrete usage example to 7 tools: `diagnostics`, `grep`, `fetch`, `edit_file`, `terminal`, `web_search`, and `subagent`. Each example shows a realistic scenario rather than restating the description. **`docs/src/ai/external-agents.md`** — adds a single sentence cross-linking to `tools.md` after the supported-agents intro paragraph, for users who land on that page looking for what the built-in agent can do. --- docs/src/ai/external-agents.md | 2 ++ docs/src/ai/tools.md | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/src/ai/external-agents.md b/docs/src/ai/external-agents.md index 7a76e795f127651201a6483986ebbc917088bf96..dc3b246f34f28a7a0560992e64b1918f2fe69a9e 100644 --- a/docs/src/ai/external-agents.md +++ b/docs/src/ai/external-agents.md @@ -9,6 +9,8 @@ Zed supports many external agents, including CLI-based ones, through the [Agent Zed supports [Gemini CLI](https://github.com/google-gemini/gemini-cli) (the reference ACP implementation), [Claude Agent](https://platform.claude.com/docs/en/agent-sdk/overview), [Codex](https://developers.openai.com/codex), [GitHub Copilot](https://github.com/github/copilot-language-server-release), and [additional agents](#add-more-agents) you can configure. +For Zed's built-in agent and the full list of tools it can use natively, see [Agent Tools](./tools.md). + > Note that Zed's interaction with external agents is strictly UI-based; the billing, legal, and terms arrangement is directly between you and the agent provider. > Zed does not charge for use of external agents, and our [zero-data retention agreements/privacy guarantees](./ai-improvement.md) are **_only_** applicable for Zed's hosted models. diff --git a/docs/src/ai/tools.md b/docs/src/ai/tools.md index faafc76b164f7f786c91c212bf51960f24a6bb0a..bc57f3c378fbc03429fe84993c349b0a5b3ce0d0 100644 --- a/docs/src/ai/tools.md +++ b/docs/src/ai/tools.md @@ -19,10 +19,14 @@ Gets errors and warnings for either a specific file or the entire project, usefu When a path is provided, shows all diagnostics for that specific file. When no path is provided, shows a summary of error and warning counts for all files in the project. +**Example:** After editing `src/parser.rs`, call `diagnostics` with that path to check for type errors immediately. After a larger refactor touching many files, call it without a path to see a project-wide count of errors before deciding what to fix next. + ### `fetch` Fetches a URL and returns the content as Markdown. Useful for providing docs as context. +**Example:** Fetching a library's changelog page to check whether a breaking API change was introduced in a recent version before writing integration code. + ### `find_path` Quickly finds files by matching glob patterns (like "\*_/_.js"), returning matching file paths alphabetically. @@ -31,6 +35,8 @@ Quickly finds files by matching glob patterns (like "\*_/_.js"), returning match Searches file contents across the project using regular expressions, preferred for finding symbols in code without knowing exact file paths. +**Example:** To find every call site of a function before renaming it, search for `parse_config\(` — the regex matches the function name followed by an opening parenthesis, filtering out comments or variable names that happen to contain the string. + ### `list_directory` Lists files and directories in a given path, providing an overview of filesystem contents. @@ -55,6 +61,8 @@ Allows the Agent to work through problems, brainstorm ideas, or plan without exe Searches the web for information, providing results with snippets and links from relevant web pages, useful for accessing real-time information. +**Example:** Looking up whether a known bug in a dependency has been patched in a recent release, or finding the current API signature for a third-party library when the local docs are out of date. + ## Edit Tools ### `copy_path` @@ -73,6 +81,8 @@ Deletes a file or directory (including contents recursively) at the specified pa Edits files by replacing specific text with new content. +**Example:** Updating a function signature — the agent identifies the exact lines to replace and provides the updated version, leaving the surrounding code untouched. For widespread renames, it pairs this with `grep` to find every occurrence first. + ### `move_path` Moves or renames a file or directory in the project, performing a rename if only the filename differs. @@ -89,8 +99,12 @@ Saves files that have unsaved changes. Used when files need to be saved before f Executes shell commands and returns the combined output, creating a new shell process for each invocation. +**Example:** After editing a Rust file, run `cargo test --package my_crate 2>&1 | tail -30` to confirm the changes don't break existing tests. Or run `git diff --stat` to review which files have been modified before wrapping up a task. + ## Other Tools ### `spawn_agent` -Spawns a subagent with its own context window to perform a delegated task. Each subagent has access to the same tools as the parent agent. +Spawns a subagent with its own context window to perform a delegated task. Useful for running parallel investigations, completing self-contained tasks, or performing research where only the outcome matters. Each subagent has access to the same tools as the parent agent. + +**Example:** While refactoring the authentication module, spawn a subagent to investigate how session tokens are validated elsewhere in the codebase. The parent agent continues its work and reviews the subagent's findings when it completes — keeping both context windows focused on a single task. From b63a2aba482d0d8dc1bce445f4a38232ac0b77e1 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:00:48 -0300 Subject: [PATCH 09/43] agent_ui: Fix new thread in location setting renderer and flag (#51527) Follow up to https://github.com/zed-industries/zed/pull/51384 This PR fixes the settings UI rendering of this setting by adding a default value and also wraps it in the feature flag (only the settings UI rendering), given it's not widely available just yet. Release Notes: - N/A --- Cargo.lock | 1 + assets/settings/default.json | 4 ++++ crates/settings_content/src/agent.rs | 13 ++++++++++++- crates/settings_ui/Cargo.toml | 1 + crates/settings_ui/src/page_data.rs | 27 ++++++++++++++++++--------- crates/settings_ui/src/settings_ui.rs | 2 +- 6 files changed, 37 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 01293ca7ff014d5210b064a32706c1f5ab10767f..cbc83f81168b43049a6bd7bf2fcfd5514f3f3a77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15711,6 +15711,7 @@ dependencies = [ "edit_prediction", "edit_prediction_ui", "editor", + "feature_flags", "fs", "futures 0.3.31", "fuzzy", diff --git a/assets/settings/default.json b/assets/settings/default.json index 7af6ce7e44d9abde7b29c80bb170cd13f3c2e786..29bddd6b3df1af14e66ed3a0aff2e3d8c0cb59d4 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1083,6 +1083,10 @@ "tools": {}, }, }, + // Whether to start a new thread in the current local project or in a new Git worktree. + // + // Default: local_project + "new_thread_location": "local_project", // Where to show notifications when the agent has either completed // its response, or else needs confirmation before it can run a // tool action. diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 8061e591b0a3f81e8b8081a0b363c112fb388ce4..1b71f9b33c58b6980431d25f2af51007ae861a1c 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -11,7 +11,18 @@ use crate::DockPosition; /// Where new threads should start by default. #[derive( - Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom, + Clone, + Copy, + Debug, + Default, + PartialEq, + Eq, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + strum::VariantArray, + strum::VariantNames, )] #[serde(rename_all = "snake_case")] pub enum NewThreadLocation { diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 66fefed910cc85e22e731fe9470d2ee511364336..7632c2857a41ba43fe7d2b2d517752f53b8f694d 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -28,6 +28,7 @@ cpal.workspace = true edit_prediction.workspace = true edit_prediction_ui.workspace = true editor.workspace = true +feature_flags.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 9243e14521010c5b3aa2a9092c6e0a687a989306..5a8e1c1440c7899269fe0382c03b0068937781b5 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1,3 +1,4 @@ +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; use gpui::{Action as _, App}; use itertools::Itertools as _; use settings::{ @@ -74,7 +75,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec { terminal_page(), version_control_page(), collaboration_page(), - ai_page(), + ai_page(cx), network_page(), ] } @@ -6978,7 +6979,7 @@ fn collaboration_page() -> SettingsPage { } } -fn ai_page() -> SettingsPage { +fn ai_page(cx: &App) -> SettingsPage { fn general_section() -> [SettingsPageItem; 2] { [ SettingsPageItem::SectionHeader("General"), @@ -6998,8 +6999,8 @@ fn ai_page() -> SettingsPage { ] } - fn agent_configuration_section() -> [SettingsPageItem; 13] { - [ + fn agent_configuration_section(cx: &App) -> Box<[SettingsPageItem]> { + let mut items = vec![ SettingsPageItem::SectionHeader("Agent Configuration"), SettingsPageItem::SubPageLink(SubPageLink { title: "Tool Permissions".into(), @@ -7010,11 +7011,14 @@ fn ai_page() -> SettingsPage { files: USER, render: render_tool_permissions_setup_page, }), - SettingsPageItem::SettingItem(SettingItem { + ]; + + if cx.has_flag::() { + items.push(SettingsPageItem::SettingItem(SettingItem { title: "New Thread Location", description: "Whether to start a new thread in the current local project or in a new Git worktree.", field: Box::new(SettingField { - json_path: Some("agent.default_start_thread_in"), + json_path: Some("agent.new_thread_location"), pick: |settings_content| { settings_content .agent @@ -7031,7 +7035,10 @@ fn ai_page() -> SettingsPage { }), metadata: None, files: USER, - }), + })); + } + + items.extend([ SettingsPageItem::SettingItem(SettingItem { title: "Single File Review", description: "When enabled, agent edits will also be displayed in single-file buffers for review.", @@ -7236,7 +7243,9 @@ fn ai_page() -> SettingsPage { metadata: None, files: USER, }), - ] + ]); + + items.into_boxed_slice() } fn context_servers_section() -> [SettingsPageItem; 2] { @@ -7321,7 +7330,7 @@ fn ai_page() -> SettingsPage { title: "AI", items: concat_sections![ general_section(), - agent_configuration_section(), + agent_configuration_section(cx), context_servers_section(), edit_prediction_language_settings_section(), edit_prediction_display_sub_section() diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 26417a5469955cd89a12564248e36be288004a15..6388dc5a283656e89d805455732a0044cf43e353 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -530,7 +530,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) - .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) From 0ccdd9bf0942555c4d200519156e0f541a4e5ab1 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:01:00 -0300 Subject: [PATCH 10/43] agent_ui: Auto-expand and then collapse thinking blocks (#51525) With these newer models that come with different thinking levels, it's become more frequent to want to see what the thinking is outputting. Thus far in Zed, the thinking block would show up automatically collapsed and every time you wanted to see it, you had to expand it manually. This PR changes that by making the thinking block automatically _expanded_ instead, but as soon as it's done, it collapses again. Release Notes: - Agent: Improved visibility of thinking blocks by making them auto-expanded while in progress. --- crates/agent_ui/src/connection_view.rs | 10 ++- .../src/connection_view/thread_view.rs | 73 +++++++++++++++---- 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index d2226e675a6a242588074dd2e7b646a7376c8c37..4d352c6a8494f97358ee012740e539c750308886 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -1263,13 +1263,14 @@ impl ConnectionView { } } AcpThreadEvent::EntryUpdated(index) => { - if let Some(entry_view_state) = self - .thread_view(&thread_id) - .map(|active| active.read(cx).entry_view_state.clone()) - { + if let Some(active) = self.thread_view(&thread_id) { + let entry_view_state = active.read(cx).entry_view_state.clone(); entry_view_state.update(cx, |view_state, cx| { view_state.sync_entry(*index, thread, window, cx) }); + active.update(cx, |active, cx| { + active.auto_expand_streaming_thought(cx); + }); } } AcpThreadEvent::EntriesRemoved(range) => { @@ -1301,6 +1302,7 @@ impl ConnectionView { if let Some(active) = self.thread_view(&thread_id) { active.update(cx, |active, _cx| { active.thread_retry_status.take(); + active.clear_auto_expand_tracking(); }); } if is_subagent { diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 35df60b567de86762a9af330013df0fab35f3f01..eed8de86c841350d507b040287088989ae23c023 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -194,6 +194,7 @@ pub struct ThreadView { pub expanded_tool_calls: HashSet, pub expanded_tool_call_raw_inputs: HashSet, pub expanded_thinking_blocks: HashSet<(usize, usize)>, + auto_expanded_thinking_block: Option<(usize, usize)>, pub subagent_scroll_handles: RefCell>, pub edits_expanded: bool, pub plan_expanded: bool, @@ -425,6 +426,7 @@ impl ThreadView { expanded_tool_calls: HashSet::default(), expanded_tool_call_raw_inputs: HashSet::default(), expanded_thinking_blocks: HashSet::default(), + auto_expanded_thinking_block: None, subagent_scroll_handles: RefCell::new(HashMap::default()), edits_expanded: false, plan_expanded: false, @@ -4573,6 +4575,53 @@ impl ThreadView { .into_any_element() } + /// If the last entry's last chunk is a streaming thought block, auto-expand it. + /// Also collapses the previously auto-expanded block when a new one starts. + pub(crate) fn auto_expand_streaming_thought(&mut self, cx: &mut Context) { + let key = { + let thread = self.thread.read(cx); + if thread.status() != ThreadStatus::Generating { + return; + } + let entries = thread.entries(); + let last_ix = entries.len().saturating_sub(1); + match entries.get(last_ix) { + Some(AgentThreadEntry::AssistantMessage(msg)) => match msg.chunks.last() { + Some(AssistantMessageChunk::Thought { .. }) => { + Some((last_ix, msg.chunks.len() - 1)) + } + _ => None, + }, + _ => None, + } + }; + + if let Some(key) = key { + if self.auto_expanded_thinking_block != Some(key) { + if let Some(old_key) = self.auto_expanded_thinking_block.replace(key) { + self.expanded_thinking_blocks.remove(&old_key); + } + self.expanded_thinking_blocks.insert(key); + cx.notify(); + } + } else if self.auto_expanded_thinking_block.is_some() { + // The last chunk is no longer a thought (model transitioned to responding), + // so collapse the previously auto-expanded block. + self.collapse_auto_expanded_thinking_block(); + cx.notify(); + } + } + + fn collapse_auto_expanded_thinking_block(&mut self) { + if let Some(key) = self.auto_expanded_thinking_block.take() { + self.expanded_thinking_blocks.remove(&key); + } + } + + pub(crate) fn clear_auto_expand_tracking(&mut self) { + self.auto_expanded_thinking_block = None; + } + fn render_thinking_block( &self, entry_ix: usize, @@ -4594,20 +4643,6 @@ impl ThreadView { .entry(entry_ix) .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix)); - let thinking_content = { - div() - .id(("thinking-content", chunk_ix)) - .when_some(scroll_handle, |this, scroll_handle| { - this.track_scroll(&scroll_handle) - }) - .text_ui_sm(cx) - .overflow_hidden() - .child(self.render_markdown( - chunk, - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - )) - }; - v_flex() .gap_1() .child( @@ -4663,11 +4698,19 @@ impl ThreadView { .when(is_open, |this| { this.child( div() + .id(("thinking-content", chunk_ix)) .ml_1p5() .pl_3p5() .border_l_1() .border_color(self.tool_card_border_color(cx)) - .child(thinking_content), + .when_some(scroll_handle, |this, scroll_handle| { + this.track_scroll(&scroll_handle) + }) + .overflow_hidden() + .child(self.render_markdown( + chunk, + MarkdownStyle::themed(MarkdownFont::Agent, window, cx), + )), ) }) .into_any_element() From 8fc880e7a061761b94df4620cc24a09d77a50332 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Fri, 13 Mar 2026 15:18:00 -0500 Subject: [PATCH 11/43] ep: Ensure predictions are not refreshed while following (#51489) Release Notes: - N/A --- crates/edit_prediction/src/edit_prediction.rs | 25 ++- .../src/edit_prediction_tests.rs | 170 +++++++++++++++++- crates/editor/src/edit_prediction_tests.rs | 66 ++++++- crates/editor/src/editor.rs | 11 +- crates/zeta_prompt/src/zeta_prompt.rs | 26 +-- 5 files changed, 279 insertions(+), 19 deletions(-) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 0dd387e627a29fcd48b0523dd72990bbc05a5311..c7497fa11da3c7ec6a260aa6fe388d019e8fe24a 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -41,7 +41,7 @@ use settings::{ use std::collections::{VecDeque, hash_map}; use std::env; use text::{AnchorRangeExt, Edit}; -use workspace::Workspace; +use workspace::{AppState, Workspace}; use zeta_prompt::{ZetaFormat, ZetaPromptInput}; use std::mem; @@ -1912,6 +1912,10 @@ impl EditPredictionStore { return; } + if currently_following(&project, cx) { + return; + } + let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { return; }; @@ -2048,6 +2052,25 @@ impl EditPredictionStore { pub const THROTTLE_TIMEOUT: Duration = Duration::from_millis(300); } +fn currently_following(project: &Entity, cx: &App) -> bool { + let Some(app_state) = AppState::try_global(cx).and_then(|app_state| app_state.upgrade()) else { + return false; + }; + + app_state + .workspace_store + .read(cx) + .workspaces() + .filter_map(|workspace| workspace.upgrade()) + .any(|workspace| { + workspace.read(cx).project().entity_id() == project.entity_id() + && workspace + .read(cx) + .leader_for_pane(workspace.read(cx).active_pane()) + .is_some() + }) +} + fn is_ep_store_provider(provider: EditPredictionProvider) -> bool { match provider { EditPredictionProvider::Zed diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index f377f3f705f8d3e04fd4718bbfd650ae4189ba37..dc52ef6ab57428d6293cea126c695f7c659e2f53 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -8,6 +8,7 @@ use cloud_llm_client::{ EditPredictionRejectReason, EditPredictionRejection, RejectEditPredictionsBody, predict_edits_v3::{PredictEditsV3Request, PredictEditsV3Response}, }; + use futures::{ AsyncReadExt, FutureExt, StreamExt, channel::{mpsc, oneshot}, @@ -35,11 +36,12 @@ use util::{ test::{TextRangeMarker, marked_text_ranges_by}, }; use uuid::Uuid; +use workspace::{AppState, CollaboratorId, MultiWorkspace}; use zeta_prompt::ZetaPromptInput; use crate::{ BufferEditPrediction, EDIT_PREDICTION_SETTLED_QUIESCENCE, EditPredictionId, - EditPredictionStore, REJECT_REQUEST_DEBOUNCE, + EditPredictionJumpsFeatureFlag, EditPredictionStore, REJECT_REQUEST_DEBOUNCE, }; #[gpui::test] @@ -178,6 +180,172 @@ async fn test_current_state(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + + cx.update(|cx| { + cx.update_flags( + false, + vec![EditPredictionJumpsFeatureFlag::NAME.to_string()], + ); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "1.txt": "Hello!\nHow\nBye\n", + "2.txt": "Hola!\nComo\nAdios\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let app_state = cx.update(|cx| { + let app_state = AppState::test(cx); + AppState::set_global(Arc::downgrade(&app_state), cx); + app_state + }); + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + cx.update(|cx| { + AppState::set_global(Arc::downgrade(workspace.read(cx).app_state()), cx); + }); + let _ = app_state; + + let buffer1 = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/1.txt"), cx).unwrap(); + project.set_active_path(Some(path.clone()), cx); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot1 = buffer1.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot1.anchor_before(language::Point::new(1, 3)); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_project(&project, cx); + ep_store.register_buffer(&buffer1, &project, cx); + ep_store.refresh_prediction_from_buffer(project.clone(), buffer1.clone(), position, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + respond_tx + .send(model_response( + &request, + indoc! {r" + --- a/root/1.txt + +++ b/root/1.txt + @@ ... @@ + Hello! + -How + +How are you? + Bye + "}, + )) + .unwrap(); + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.reject_current_prediction(EditPredictionRejectReason::Discarded, &project, cx); + }); + + let _ = multi_workspace.update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.start_following(CollaboratorId::Agent, window, cx); + }); + }); + cx.run_until_parked(); + + let diagnostic = lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: "Sentence is incomplete".to_string(), + ..Default::default() + }; + + project.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + LanguageServerId(0), + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path!("/root/2.txt")).unwrap(), + diagnostics: vec![diagnostic.clone()], + version: None, + }, + None, + language::DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap(); + }); + }); + + cx.run_until_parked(); + assert_no_predict_request_ready(&mut requests.predict); + + let _ = multi_workspace.update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.unfollow(CollaboratorId::Agent, window, cx); + }); + }); + cx.run_until_parked(); + + project.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + LanguageServerId(0), + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path!("/root/2.txt")).unwrap(), + diagnostics: vec![diagnostic], + version: None, + }, + None, + language::DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap(); + }); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + respond_tx + .send(model_response( + &request, + indoc! {r#" + --- a/root/2.txt + +++ b/root/2.txt + @@ ... @@ + Hola! + -Como + +Como estas? + Adios + "#}, + )) + .unwrap(); + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + let prediction = ep_store + .prediction_at(&buffer1, None, &project, cx) + .unwrap(); + assert_matches!( + prediction, + BufferEditPrediction::Jump { prediction } if prediction.snapshot.file().unwrap().full_path(cx) == Path::new(path!("root/2.txt")) + ); + }); +} + #[gpui::test] async fn test_simple_request(cx: &mut TestAppContext) { let (ep_store, mut requests) = init_test_with_fake_client(cx); diff --git a/crates/editor/src/edit_prediction_tests.rs b/crates/editor/src/edit_prediction_tests.rs index a997a5f86dfbd3582c0566b8e3351777e0345219..c82915c686e977178398430948f28f8178f216df 100644 --- a/crates/editor/src/edit_prediction_tests.rs +++ b/crates/editor/src/edit_prediction_tests.rs @@ -4,7 +4,13 @@ use edit_prediction_types::{ use gpui::{Entity, KeyBinding, Modifiers, prelude::*}; use indoc::indoc; use multi_buffer::{Anchor, MultiBufferSnapshot, ToPoint}; -use std::{ops::Range, sync::Arc}; +use std::{ + ops::Range, + sync::{ + Arc, + atomic::{self, AtomicUsize}, + }, +}; use text::{Point, ToOffset}; use ui::prelude::*; @@ -12,6 +18,8 @@ use crate::{ AcceptEditPrediction, EditPrediction, MenuEditPredictionsPolicy, editor_tests::init_test, test::editor_test_context::EditorTestContext, }; +use rpc::proto::PeerId; +use workspace::CollaboratorId; #[gpui::test] async fn test_edit_prediction_insert(cx: &mut gpui::TestAppContext) { @@ -359,6 +367,60 @@ async fn test_edit_prediction_jump_disabled_for_non_zed_providers(cx: &mut gpui: }); } +#[gpui::test] +async fn test_edit_prediction_refresh_suppressed_while_following(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); + assign_editor_completion_provider(provider.clone(), &mut cx); + cx.set_state("let x = ˇ;"); + + propose_edits(&provider, vec![(8..8, "42")], &mut cx); + + cx.update_editor(|editor, window, cx| { + editor.refresh_edit_prediction(false, false, window, cx); + editor.update_visible_edit_prediction(window, cx); + }); + + assert_eq!( + provider.read_with(&cx.cx, |provider, _| { + provider.refresh_count.load(atomic::Ordering::SeqCst) + }), + 1 + ); + cx.editor(|editor, _, _| { + assert!(editor.active_edit_prediction.is_some()); + }); + + cx.update_editor(|editor, window, cx| { + editor.leader_id = Some(CollaboratorId::PeerId(PeerId::default())); + editor.refresh_edit_prediction(false, false, window, cx); + }); + + assert_eq!( + provider.read_with(&cx.cx, |provider, _| { + provider.refresh_count.load(atomic::Ordering::SeqCst) + }), + 1 + ); + cx.editor(|editor, _, _| { + assert!(editor.active_edit_prediction.is_none()); + }); + + cx.update_editor(|editor, window, cx| { + editor.leader_id = None; + editor.refresh_edit_prediction(false, false, window, cx); + }); + + assert_eq!( + provider.read_with(&cx.cx, |provider, _| { + provider.refresh_count.load(atomic::Ordering::SeqCst) + }), + 2 + ); +} + #[gpui::test] async fn test_edit_prediction_preview_cleanup_on_toggle_off(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); @@ -567,6 +629,7 @@ fn assign_editor_completion_provider_non_zed( #[derive(Default, Clone)] pub struct FakeEditPredictionDelegate { pub completion: Option, + pub refresh_count: Arc, } impl FakeEditPredictionDelegate { @@ -619,6 +682,7 @@ impl EditPredictionDelegate for FakeEditPredictionDelegate { _debounce: bool, _cx: &mut gpui::Context, ) { + self.refresh_count.fetch_add(1, atomic::Ordering::SeqCst); } fn accept(&mut self, _cx: &mut gpui::Context) {} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 8c2e03722c345a0f093572c336029a0eaa355537..fd830c254877463da84e98d21dd39b0e644ca433 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -7804,7 +7804,11 @@ impl Editor { window: &mut Window, cx: &mut Context, ) -> Option<()> { - let provider = self.edit_prediction_provider()?; + if self.leader_id.is_some() { + self.discard_edit_prediction(EditPredictionDiscardReason::Ignored, cx); + return None; + } + let cursor = self.selections.newest_anchor().head(); let (buffer, cursor_buffer_position) = self.buffer.read(cx).text_anchor_for_position(cursor, cx)?; @@ -7829,7 +7833,8 @@ impl Editor { return None; } - provider.refresh(buffer, cursor_buffer_position, debounce, cx); + self.edit_prediction_provider()? + .refresh(buffer, cursor_buffer_position, debounce, cx); Some(()) } @@ -7954,7 +7959,7 @@ impl Editor { cx: &App, ) -> bool { maybe!({ - if self.read_only(cx) { + if self.read_only(cx) || self.leader_id.is_some() { return Some(false); } let provider = self.edit_prediction_provider()?; diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 8dd4d88e2a89cadc39e1335b4bcdc18a0a144571..d79ded2b9781252855ef424e49247fc1cabd383f 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -2253,21 +2253,21 @@ pub mod hashline { Case { name: "insert_before_first_and_after_line", original: indoc! {" - a - b - "}, + a + b + "}, model_output: indoc! {" - <|insert|> - HEAD - <|insert|>0:61 - MID - "}, + <|insert|> + HEAD + <|insert|>0:61 + MID + "}, expected: indoc! {" - HEAD - a - MID - b - "}, + HEAD + a + MID + b + "}, }, ]; From ee8ecfa47c24ed67508737eaa49891a5aa671c1d Mon Sep 17 00:00:00 2001 From: Neel Date: Fri, 13 Mar 2026 20:18:30 +0000 Subject: [PATCH 12/43] language_models: Make subscription text exhaustive (#51524) Closes CLO-493. Release Notes: - N/A --- Cargo.lock | 1 - crates/language_models/Cargo.toml | 1 - crates/language_models/src/provider/cloud.rs | 47 +++++++++----------- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cbc83f81168b43049a6bd7bf2fcfd5514f3f3a77..21646eef97e2989bfe5c9f9cac9e53eb8b8ec11d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9444,7 +9444,6 @@ dependencies = [ "aws_http_client", "base64 0.22.1", "bedrock", - "chrono", "client", "cloud_api_types", "cloud_llm_client", diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index b37f783eb9213a3d1d4bb4cc1bb0011c24879b05..f9dc4266d69ae9164f6b187162ed32069de5c10c 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -20,7 +20,6 @@ aws-credential-types = { workspace = true, features = ["hardcoded-credentials"] aws_http_client.workspace = true base64.workspace = true bedrock = { workspace = true, features = ["schemars"] } -chrono.workspace = true client.workspace = true cloud_api_types.workspace = true cloud_llm_client.workspace = true diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index b871015826f36fb3dc9b727fb8b194c46c0ec05c..8f2b6c10f3434ed51e3908d0f9de93e54a12dae6 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -1,7 +1,6 @@ use ai_onboarding::YoungAccountBanner; use anthropic::AnthropicModelMode; use anyhow::{Context as _, Result, anyhow}; -use chrono::{DateTime, Utc}; use client::{Client, UserStore, zed_urls}; use cloud_api_types::{OrganizationId, Plan}; use cloud_llm_client::{ @@ -1091,7 +1090,6 @@ fn response_lines( struct ZedAiConfiguration { is_connected: bool, plan: Option, - subscription_period: Option<(DateTime, DateTime)>, eligible_for_trial: bool, account_too_young: bool, sign_in_callback: Arc, @@ -1099,31 +1097,34 @@ struct ZedAiConfiguration { impl RenderOnce for ZedAiConfiguration { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let is_pro = self.plan.is_some_and(|plan| plan == Plan::ZedPro); - let subscription_text = match (self.plan, self.subscription_period) { - (Some(Plan::ZedPro), Some(_)) => { - "You have access to Zed's hosted models through your Pro subscription." - } - (Some(Plan::ZedProTrial), Some(_)) => { - "You have access to Zed's hosted models through your Pro trial." - } - (Some(Plan::ZedFree), Some(_)) => { - if self.eligible_for_trial { - "Subscribe for access to Zed's hosted models. Start with a 14 day free trial." - } else { - "Subscribe for access to Zed's hosted models." - } - } - _ => { + let (subscription_text, has_paid_plan) = match self.plan { + Some(Plan::ZedPro) => ( + "You have access to Zed's hosted models through your Pro subscription.", + true, + ), + Some(Plan::ZedProTrial) => ( + "You have access to Zed's hosted models through your Pro trial.", + false, + ), + Some(Plan::ZedStudent) => ( + "You have access to Zed's hosted models through your Student subscription.", + true, + ), + Some(Plan::ZedBusiness) => ( + "You have access to Zed's hosted models through your Organization.", + true, + ), + Some(Plan::ZedFree) | None => ( if self.eligible_for_trial { "Subscribe for access to Zed's hosted models. Start with a 14 day free trial." } else { "Subscribe for access to Zed's hosted models." - } - } + }, + false, + ), }; - let manage_subscription_buttons = if is_pro { + let manage_subscription_buttons = if has_paid_plan { Button::new("manage_settings", "Manage Subscription") .full_width() .label_size(LabelSize::Small) @@ -1207,7 +1208,6 @@ impl Render for ConfigurationView { ZedAiConfiguration { is_connected: !state.is_signed_out(cx), plan: user_store.plan(), - subscription_period: user_store.subscription_period(), eligible_for_trial: user_store.trial_started_at().is_none(), account_too_young: user_store.account_too_young(), sign_in_callback: self.sign_in_callback.clone(), @@ -1238,9 +1238,6 @@ impl Component for ZedAiConfiguration { ZedAiConfiguration { is_connected, plan, - subscription_period: plan - .is_some() - .then(|| (Utc::now(), Utc::now() + chrono::Duration::days(7))), eligible_for_trial, account_too_young, sign_in_callback: Arc::new(|_, _| {}), From b7266ba31444be1932ff9c7666bc95551bd0ff7b Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 13 Mar 2026 14:28:48 -0600 Subject: [PATCH 13/43] Fix panic in crease folding (#51531) Fixes ZED-5BZ Release Notes: - N/A --- crates/project/src/lsp_command.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 67edd6c13ca5a850a99f28dee849718d9e7ec9ae..ebc5ea038e0726384bc7d677f6fc6aa8ce87661e 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -4857,9 +4857,14 @@ impl LspCommand for GetFoldingRanges { self, message: proto::GetFoldingRangesResponse, _: Entity, - _: Entity, - _: AsyncApp, + buffer: Entity, + mut cx: AsyncApp, ) -> Result { + buffer + .update(&mut cx, |buffer, _| { + buffer.wait_for_version(deserialize_version(&message.version)) + }) + .await?; message .ranges .into_iter() From 28fa9479163baa520190d9153073ec9ed6fb6c2b Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:33:57 -0300 Subject: [PATCH 14/43] agent_ui: Enable deleting a thread from the sidebar (#51532) Currently only available for native threads. Release Notes: - N/A --- crates/agent_ui/src/sidebar.rs | 48 ++++++++++++++++++++-- crates/ui/src/components/ai/thread_item.rs | 18 ++++++-- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index aed642ccc9987569fb3681ab93bb2c8fe6de2674..2586e3691278095ee177745e13c01b6e3d531145 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -243,6 +243,7 @@ pub struct Sidebar { selection: Option, focused_thread: Option, active_entry_index: Option, + hovered_thread_index: Option, collapsed_groups: HashSet, expanded_groups: HashMap, view: SidebarView, @@ -345,6 +346,7 @@ impl Sidebar { selection: None, focused_thread: None, active_entry_index: None, + hovered_thread_index: None, collapsed_groups: HashSet::new(), expanded_groups: HashMap::new(), view: SidebarView::default(), @@ -1582,11 +1584,23 @@ impl Sidebar { } } + fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context) { + let Some(thread_store) = ThreadStore::try_global(cx) else { + return; + }; + self.hovered_thread_index = None; + thread_store.update(cx, |store, cx| { + store + .delete_thread(session_id.clone(), cx) + .detach_and_log_err(cx); + }); + } + fn render_thread( &self, ix: usize, thread: &ThreadEntry, - is_selected: bool, + is_focused: bool, docked_right: bool, cx: &mut Context, ) -> AnyElement { @@ -1602,6 +1616,11 @@ impl Sidebar { let session_info = thread.session_info.clone(); let thread_workspace = thread.workspace.clone(); + let is_hovered = self.hovered_thread_index == Some(ix); + let is_selected = self.focused_thread.as_ref() == Some(&session_info.session_id); + let can_delete = thread.agent == Agent::NativeAgent; + let session_id_for_delete = thread.session_info.session_id.clone(); + let id = SharedString::from(format!("thread-entry-{}", ix)); let timestamp = thread @@ -1648,9 +1667,32 @@ impl Sidebar { .when(thread.diff_stats.lines_removed > 0, |this| { this.removed(thread.diff_stats.lines_removed as usize) }) - .selected(self.focused_thread.as_ref() == Some(&session_info.session_id)) - .focused(is_selected) + .selected(is_selected) + .focused(is_focused) .docked_right(docked_right) + .hovered(is_hovered) + .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| { + if *is_hovered { + this.hovered_thread_index = Some(ix); + } else if this.hovered_thread_index == Some(ix) { + this.hovered_thread_index = None; + } + cx.notify(); + })) + .when((is_hovered || is_selected) && can_delete, |this| { + this.action_slot( + IconButton::new("delete-thread", IconName::Trash) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Delete Thread")) + .on_click({ + let session_id = session_id_for_delete.clone(); + cx.listener(move |this, _, _window, cx| { + this.delete_thread(&session_id, cx); + }) + }), + ) + }) .on_click({ let agent = thread.agent.clone(); cx.listener(move |this, _, window, cx| { diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 35aa3487a39c69795545b646666840743cfd8526..51ed3d4e01b5dd469a29d3b969a10be2f3d88c12 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -283,8 +283,20 @@ impl RenderOnce for ThreadItem { .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)), ) .child(gradient_overlay) - .when(self.hovered, |this| { - this.when_some(self.action_slot, |this, slot| this.child(slot)) + .when(self.hovered || self.selected, |this| { + this.when_some(self.action_slot, |this, slot| { + let overlay = GradientFade::new( + base_bg, + color.element_hover, + color.element_active, + ) + .width(px(64.0)) + .right(px(6.)) + .gradient_stop(0.75) + .group_name("thread-item"); + + this.child(h_flex().relative().child(overlay).child(slot)) + }) }), ) .when_some(self.worktree, |this, worktree| { @@ -337,7 +349,7 @@ impl RenderOnce for ThreadItem { .when(has_diff_stats, |this| { this.child( DiffStat::new(diff_stat_id, added_count, removed_count) - .tooltip("Unreviewed changes"), + .tooltip("Unreviewed Changes"), ) }) .when(has_diff_stats && has_timestamp, |this| { From a39201a96e498ac3a0c6ed9528b07f73397993ad Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 13 Mar 2026 22:32:18 +0100 Subject: [PATCH 15/43] acp_thread: Stream in agent text in a more continous manner (#51499) Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/acp_thread/src/acp_thread.rs | 174 +++++++++++++++++++++++++++- 1 file changed, 173 insertions(+), 1 deletion(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 95030443f642b019b27758f53fd413c5146857b1..7b6c198e5d77f6f962c6d259929d065feb76e48d 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -976,6 +976,30 @@ pub struct AcpThread { draft_prompt: Option>, /// The initial scroll position for the thread view, set during session registration. ui_scroll_position: Option, + /// Buffer for smooth text streaming. Holds text that has been received from + /// the model but not yet revealed in the UI. A timer task drains this buffer + /// gradually to create a fluid typing effect instead of choppy chunk-at-a-time + /// updates. + streaming_text_buffer: Option, +} + +struct StreamingTextBuffer { + /// Text received from the model but not yet appended to the Markdown source. + pending: String, + /// The number of bytes to reveal per timer turn. + bytes_to_reveal_per_tick: usize, + /// The Markdown entity being streamed into. + target: Entity, + /// Timer task that periodically moves text from `pending` into `source`. + _reveal_task: Task<()>, +} + +impl StreamingTextBuffer { + /// The number of milliseconds between each timer tick, controlling how quickly + /// text is revealed. + const TASK_UPDATE_MS: u64 = 16; + /// The time in milliseconds to reveal the entire pending text. + const REVEAL_TARGET: f32 = 200.0; } impl From<&AcpThread> for ActionLogTelemetry { @@ -1137,6 +1161,7 @@ impl AcpThread { had_error: false, draft_prompt: None, ui_scroll_position: None, + streaming_text_buffer: None, } } @@ -1343,6 +1368,7 @@ impl AcpThread { }) = last_entry && *existing_indented == indented { + Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); *id = message_id.or(id.take()); content.append(chunk.clone(), &language_registry, path_style, cx); chunks.push(chunk); @@ -1379,8 +1405,20 @@ impl AcpThread { indented: bool, cx: &mut Context, ) { - let language_registry = self.project.read(cx).languages().clone(); let path_style = self.project.read(cx).path_style(cx); + + // For text chunks going to an existing Markdown block, buffer for smooth + // streaming instead of appending all at once which may feel more choppy. + if let acp::ContentBlock::Text(text_content) = &chunk { + if let Some(markdown) = self.streaming_markdown_target(is_thought, indented) { + let entries_len = self.entries.len(); + cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1)); + self.buffer_streaming_text(&markdown, text_content.text.clone(), cx); + return; + } + } + + let language_registry = self.project.read(cx).languages().clone(); let entries_len = self.entries.len(); if let Some(last_entry) = self.entries.last_mut() && let AgentThreadEntry::AssistantMessage(AssistantMessage { @@ -1391,6 +1429,7 @@ impl AcpThread { && *existing_indented == indented { let idx = entries_len - 1; + Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); cx.emit(AcpThreadEvent::EntryUpdated(idx)); match (chunks.last_mut(), is_thought) { (Some(AssistantMessageChunk::Message { block }), false) @@ -1425,7 +1464,134 @@ impl AcpThread { } } + fn streaming_markdown_target( + &self, + is_thought: bool, + indented: bool, + ) -> Option> { + let last_entry = self.entries.last()?; + if let AgentThreadEntry::AssistantMessage(AssistantMessage { + chunks, + indented: existing_indented, + .. + }) = last_entry + && *existing_indented == indented + && let [.., chunk] = chunks.as_slice() + { + match (chunk, is_thought) { + ( + AssistantMessageChunk::Message { + block: ContentBlock::Markdown { markdown }, + }, + false, + ) + | ( + AssistantMessageChunk::Thought { + block: ContentBlock::Markdown { markdown }, + }, + true, + ) => Some(markdown.clone()), + _ => None, + } + } else { + None + } + } + + /// Add text to the streaming buffer. If the target changed (e.g. switching + /// from thoughts to message text), flush the old buffer first. + fn buffer_streaming_text( + &mut self, + markdown: &Entity, + text: String, + cx: &mut Context, + ) { + if let Some(buffer) = &mut self.streaming_text_buffer { + if buffer.target.entity_id() == markdown.entity_id() { + buffer.pending.push_str(&text); + + buffer.bytes_to_reveal_per_tick = (buffer.pending.len() as f32 + / StreamingTextBuffer::REVEAL_TARGET + * StreamingTextBuffer::TASK_UPDATE_MS as f32) + .ceil() as usize; + return; + } + Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); + } + + let target = markdown.clone(); + let _reveal_task = self.start_streaming_reveal(cx); + let pending_len = text.len(); + let bytes_to_reveal = (pending_len as f32 / StreamingTextBuffer::REVEAL_TARGET + * StreamingTextBuffer::TASK_UPDATE_MS as f32) + .ceil() as usize; + self.streaming_text_buffer = Some(StreamingTextBuffer { + pending: text, + bytes_to_reveal_per_tick: bytes_to_reveal, + target, + _reveal_task, + }); + } + + /// Flush all buffered streaming text into the Markdown entity immediately. + fn flush_streaming_text( + streaming_text_buffer: &mut Option, + cx: &mut Context, + ) { + if let Some(buffer) = streaming_text_buffer.take() { + if !buffer.pending.is_empty() { + buffer + .target + .update(cx, |markdown, cx| markdown.append(&buffer.pending, cx)); + } + } + } + + /// Spawns a foreground task that periodically drains + /// `streaming_text_buffer.pending` into the target `Markdown` entity, + /// producing smooth, continuous text output. + fn start_streaming_reveal(&self, cx: &mut Context) -> Task<()> { + cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(StreamingTextBuffer::TASK_UPDATE_MS)) + .await; + + let should_continue = this + .update(cx, |this, cx| { + let Some(buffer) = &mut this.streaming_text_buffer else { + return false; + }; + + if buffer.pending.is_empty() { + return true; + } + + let pending_len = buffer.pending.len(); + + let byte_boundary = buffer + .pending + .ceil_char_boundary(buffer.bytes_to_reveal_per_tick) + .min(pending_len); + + buffer.target.update(cx, |markdown: &mut Markdown, cx| { + markdown.append(&buffer.pending[..byte_boundary], cx); + buffer.pending.drain(..byte_boundary); + }); + + true + }) + .unwrap_or(false); + + if !should_continue { + break; + } + } + }) + } + fn push_entry(&mut self, entry: AgentThreadEntry, cx: &mut Context) { + Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); self.entries.push(entry); cx.emit(AcpThreadEvent::NewEntry); } @@ -1970,6 +2136,8 @@ impl AcpThread { match response { Ok(r) => { + Self::flush_streaming_text(&mut this.streaming_text_buffer, cx); + if r.stop_reason == acp::StopReason::MaxTokens { this.had_error = true; cx.emit(AcpThreadEvent::Error); @@ -2022,6 +2190,8 @@ impl AcpThread { Ok(Some(r)) } Err(e) => { + Self::flush_streaming_text(&mut this.streaming_text_buffer, cx); + this.had_error = true; cx.emit(AcpThreadEvent::Error); log::error!("Error in run turn: {:?}", e); @@ -2039,6 +2209,7 @@ impl AcpThread { }; self.connection.cancel(&self.session_id, cx); + Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); self.mark_pending_tools_as_canceled(); // Wait for the send task to complete @@ -2103,6 +2274,7 @@ impl AcpThread { return Task::ready(Err(anyhow!("not supported"))); }; + Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); let telemetry = ActionLogTelemetry::from(&*self); cx.spawn(async move |this, cx| { cx.update(|cx| truncate.run(id.clone(), cx)).await?; From 2b39fba540c0309fc3d4f604dd60fb39c2a3a987 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Sat, 14 Mar 2026 06:37:18 +0800 Subject: [PATCH 16/43] agent_ui: Mask API key input in Add LLM provider modal (#50379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release Notes: - Added Mask API key input in Add LLM provider modal 截屏2026-02-28 17 35 22 --------- Signed-off-by: Xiaobo Liu Co-authored-by: Danilo Leal --- assets/icons/eye_off.svg | 6 +++ .../add_llm_provider_modal.rs | 19 ++++---- crates/icons/src/icons.rs | 1 + crates/ui_input/src/input_field.rs | 44 ++++++++++++++++++- 4 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 assets/icons/eye_off.svg diff --git a/assets/icons/eye_off.svg b/assets/icons/eye_off.svg new file mode 100644 index 0000000000000000000000000000000000000000..3057c3050c36c72be314f9b0646d44932c52e4ee --- /dev/null +++ b/assets/icons/eye_off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index 3d18d734af4890ef06a67dccec0c0e884a219a79..334aaf4026527938144cf12e25c9a7a23d5c28ac 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -68,14 +68,17 @@ impl AddLlmProviderInput { let provider_name = single_line_input("Provider Name", provider.name(), None, 1, window, cx); let api_url = single_line_input("API URL", provider.api_url(), None, 2, window, cx); - let api_key = single_line_input( - "API Key", - "000000000000000000000000000000000000000000000000", - None, - 3, - window, - cx, - ); + let api_key = cx.new(|cx| { + InputField::new( + window, + cx, + "000000000000000000000000000000000000000000000000", + ) + .label("API Key") + .tab_index(3) + .tab_stop(true) + .masked(true) + }); Self { provider_name, diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 17db6371114e1623280c22a23dd44e8efc6fa594..70bc0fc52784c4e50c715ddafab533beeccf3f93 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -114,6 +114,7 @@ pub enum IconName { ExpandUp, ExpandVertical, Eye, + EyeOff, FastForward, FastForwardOff, File, diff --git a/crates/ui_input/src/input_field.rs b/crates/ui_input/src/input_field.rs index 59a05497627838364b4037c44b236ab70c2b3c6b..16932b58e87cb9df83c14919b79bd048f33275fe 100644 --- a/crates/ui_input/src/input_field.rs +++ b/crates/ui_input/src/input_field.rs @@ -3,6 +3,7 @@ use component::{example_group, single_example}; use gpui::{App, FocusHandle, Focusable, Hsla, Length}; use std::sync::Arc; +use ui::Tooltip; use ui::prelude::*; use crate::ErasedEditor; @@ -38,6 +39,8 @@ pub struct InputField { tab_index: Option, /// Whether this field is a tab stop (can be focused via Tab key). tab_stop: bool, + /// Whether the field content is masked (for sensitive fields like passwords or API keys). + masked: Option, } impl Focusable for InputField { @@ -63,6 +66,7 @@ impl InputField { min_width: px(192.).into(), tab_index: None, tab_stop: true, + masked: None, } } @@ -96,6 +100,12 @@ impl InputField { self } + /// Sets this field as a masked/sensitive input (e.g., for passwords or API keys). + pub fn masked(mut self, masked: bool) -> Self { + self.masked = Some(masked); + self + } + pub fn is_empty(&self, cx: &App) -> bool { self.editor().text(cx).trim().is_empty() } @@ -115,12 +125,20 @@ impl InputField { pub fn set_text(&self, text: &str, window: &mut Window, cx: &mut App) { self.editor().set_text(text, window, cx) } + + pub fn set_masked(&self, masked: bool, window: &mut Window, cx: &mut App) { + self.editor().set_masked(masked, window, cx) + } } impl Render for InputField { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let editor = self.editor.clone(); + if let Some(masked) = self.masked { + self.editor.set_masked(masked, window, cx); + } + let theme_color = cx.theme().colors(); let style = InputFieldStyle { @@ -172,7 +190,31 @@ impl Render for InputField { this.gap_1() .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) }) - .child(self.editor.render(window, cx)), + .child(self.editor.render(window, cx)) + .when_some(self.masked, |this, is_masked| { + this.child( + IconButton::new( + "toggle-masked", + if is_masked { + IconName::Eye + } else { + IconName::EyeOff + }, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text(if is_masked { "Show" } else { "Hide" })) + .on_click(cx.listener( + |this, _, window, cx| { + if let Some(ref mut masked) = this.masked { + *masked = !*masked; + this.editor.set_masked(*masked, window, cx); + cx.notify(); + } + }, + )), + ) + }), ) } } From da8a7e8b506f13d6c3e677ad6c95e9c5c245af53 Mon Sep 17 00:00:00 2001 From: Kurian Jojo <67583328+polyesterswing@users.noreply.github.com> Date: Sat, 14 Mar 2026 04:39:19 +0530 Subject: [PATCH 17/43] markdown: Fix block quote continuation highlighting (#51465) There is no highlight for block quotes continued on multiple lines Currently, the ">" on lines 2 and 3 are not highlighted in the same way as line 1 image After this PR, image for this input ```md > abcd > > abcd ``` tree-sitter produces this ``` (document [0, 0] - [3, 0] (section [0, 0] - [3, 0] (block_quote [0, 0] - [3, 0] (block_quote_marker [0, 0] - [0, 2]) (paragraph [0, 2] - [1, 1] (inline [0, 2] - [0, 6]) (block_continuation [1, 0] - [1, 1])) (block_continuation [2, 0] - [2, 2]) (paragraph [2, 2] - [3, 0] (inline [2, 2] - [2, 6]))))) ``` the screenshots in #43043 also show this issue Release Notes: - Fixed highlighting of block quotes continued over multiple lines in markdown files --- crates/languages/src/markdown/highlights.scm | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/languages/src/markdown/highlights.scm b/crates/languages/src/markdown/highlights.scm index 1a471a848dfe0c9457ab23ba9dbf3fd9e8438f7d..76254c2472d98dc58a6efdccef41d9ec677a1b77 100644 --- a/crates/languages/src/markdown/highlights.scm +++ b/crates/languages/src/markdown/highlights.scm @@ -21,7 +21,10 @@ (list_marker_parenthesis) ] @punctuation.list_marker.markup -(block_quote_marker) @punctuation.markup +[ + (block_quote_marker) + (block_continuation) +] @punctuation.markup (pipe_table_header "|" @punctuation.markup) From 80acfff622552130fe7ea5a9ab86307cfc2f6ee7 Mon Sep 17 00:00:00 2001 From: Finn Eitreim <48069764+feitreim@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:29:49 -0400 Subject: [PATCH 18/43] which-key: Removed some keys from the filter list that were wrongly filtered (#51543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #49845 Follow up on #50992 Really simple, just removing some vim commands from the filter list that are useful enough to justify not being filtered out. tested to make sure the changes work: Screenshot 2026-03-13 at 11 23
52 PM Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - which-key: fixed filter list for some vim commands --- crates/which_key/src/which_key.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/which_key/src/which_key.rs b/crates/which_key/src/which_key.rs index 70889c100f33020a3ceaa8af1ba8812d5e7d4adb..d71bd646e70a4ede6047bd88416ea9314bddf12d 100644 --- a/crates/which_key/src/which_key.rs +++ b/crates/which_key/src/which_key.rs @@ -61,12 +61,8 @@ pub fn init(cx: &mut App) { pub static FILTERED_KEYSTROKES: LazyLock>> = LazyLock::new(|| { [ // Modifiers on normal vim commands - "g h", "g j", "g k", - "g l", - "g $", - "g ^", // Duplicate keys with "ctrl" held, e.g. "ctrl-w ctrl-a" is duplicate of "ctrl-w a" "ctrl-w ctrl-a", "ctrl-w ctrl-c", From e79429b51b626e0120278b694351cd34a83386be Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sat, 14 Mar 2026 02:14:25 -0300 Subject: [PATCH 19/43] agent_ui: Add more UI refinements to sidebar (#51545) - Move archive button to the header for simplicity - Hook up the delete button in the archive view - Improve how titles are displayed before summary is generated - Hook up keybinding for deleting threads in both the sidebar and archive view Release Notes: - N/A --- assets/keymaps/default-linux.json | 3 +- assets/keymaps/default-macos.json | 3 +- assets/keymaps/default-windows.json | 3 +- crates/acp_thread/src/acp_thread.rs | 4 + crates/agent/src/thread.rs | 8 ++ crates/agent_ui/src/agent_panel.rs | 59 +++++----- crates/agent_ui/src/sidebar.rs | 115 +++++++++++++------- crates/agent_ui/src/threads_archive_view.rs | 89 ++++++++++++++- crates/ui/src/components/ai/thread_item.rs | 25 ++++- 9 files changed, 230 insertions(+), 79 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 56a51843ca9da052e39450ba38d8afcda9d1166d..a79384ad0139b804f0ba7721e6f42260733c8e0c 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -671,13 +671,14 @@ }, }, { - "context": "WorkspaceSidebar", + "context": "ThreadsSidebar", "use_key_equivalents": true, "bindings": { "ctrl-n": "multi_workspace::NewWorkspaceInWindow", "left": "agents_sidebar::CollapseSelectedEntry", "right": "agents_sidebar::ExpandSelectedEntry", "enter": "menu::Confirm", + "shift-backspace": "agent::RemoveSelectedThread", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index a4aec7cfe8053f3f23b43652f7e58f319c9691f6..14804998a08de962b1849d7b1a728d1d9d6f9778 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -739,13 +739,14 @@ }, }, { - "context": "WorkspaceSidebar", + "context": "ThreadsSidebar", "use_key_equivalents": true, "bindings": { "cmd-n": "multi_workspace::NewWorkspaceInWindow", "left": "agents_sidebar::CollapseSelectedEntry", "right": "agents_sidebar::ExpandSelectedEntry", "enter": "menu::Confirm", + "shift-backspace": "agent::RemoveSelectedThread", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index c10054d5813c6deae33b7a790b3639e7f2c802aa..66896f43984dac73d7c098bfb46fb1a19568c14a 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -675,13 +675,14 @@ }, }, { - "context": "WorkspaceSidebar", + "context": "ThreadsSidebar", "use_key_equivalents": true, "bindings": { "ctrl-n": "multi_workspace::NewWorkspaceInWindow", "left": "agents_sidebar::CollapseSelectedEntry", "right": "agents_sidebar::ExpandSelectedEntry", "enter": "menu::Confirm", + "shift-backspace": "agent::RemoveSelectedThread", }, }, { diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 7b6c198e5d77f6f962c6d259929d065feb76e48d..99fe83a5c6f74c1989e2b5e2317d7c267d531eef 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -1207,6 +1207,10 @@ impl AcpThread { .unwrap_or_else(|| self.title.clone()) } + pub fn has_provisional_title(&self) -> bool { + self.provisional_title.is_some() + } + pub fn entries(&self) -> &[AgentThreadEntry] { &self.entries } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 02ffac47f120ee3ec4694b3a3be085af053c5909..55fdace2cfea1dd77be507cb06f0a9d4b6634cf7 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -2570,6 +2570,14 @@ impl Thread { .is_some() { _ = this.update(cx, |this, cx| this.set_title(title.into(), cx)); + } else { + // Emit TitleUpdated even on failure so that the propagation + // chain (agent::Thread → NativeAgent → AcpThread) fires and + // clears any provisional title that was set before the turn. + _ = this.update(cx, |_, cx| { + cx.emit(TitleUpdated); + cx.notify(); + }); } _ = this.update(cx, |this, _| this.pending_title_generation = None); })); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index d5c2942cf3528b94ad7d93271ef75e976bcbea56..10d24e61fe3e6bbf5d0a0d88e0f28ba3fbfa2b78 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -3266,48 +3266,49 @@ impl AgentPanel { let content = match &self.active_view { ActiveView::AgentThread { server_view } => { - let is_generating_title = server_view - .read(cx) - .as_native_thread(cx) - .map_or(false, |t| t.read(cx).is_generating_title()); + let server_view_ref = server_view.read(cx); + let is_generating_title = server_view_ref.as_native_thread(cx).is_some() + && server_view_ref.parent_thread(cx).map_or(false, |tv| { + tv.read(cx).thread.read(cx).has_provisional_title() + }); - if let Some(title_editor) = server_view - .read(cx) + if let Some(title_editor) = server_view_ref .parent_thread(cx) .map(|r| r.read(cx).title_editor.clone()) { - let container = div() - .w_full() - .on_action({ - let thread_view = server_view.downgrade(); - move |_: &menu::Confirm, window, cx| { - if let Some(thread_view) = thread_view.upgrade() { - thread_view.focus_handle(cx).focus(window, cx); - } - } - }) - .on_action({ - let thread_view = server_view.downgrade(); - move |_: &editor::actions::Cancel, window, cx| { - if let Some(thread_view) = thread_view.upgrade() { - thread_view.focus_handle(cx).focus(window, cx); - } - } - }) - .child(title_editor); - if is_generating_title { - container + Label::new("New Thread…") + .color(Color::Muted) + .truncate() .with_animation( "generating_title", Animation::new(Duration::from_secs(2)) .repeat() .with_easing(pulsating_between(0.4, 0.8)), - |div, delta| div.opacity(delta), + |label, delta| label.alpha(delta), ) .into_any_element() } else { - container.into_any_element() + div() + .w_full() + .on_action({ + let thread_view = server_view.downgrade(); + move |_: &menu::Confirm, window, cx| { + if let Some(thread_view) = thread_view.upgrade() { + thread_view.focus_handle(cx).focus(window, cx); + } + } + }) + .on_action({ + let thread_view = server_view.downgrade(); + move |_: &editor::actions::Cancel, window, cx| { + if let Some(thread_view) = thread_view.upgrade() { + thread_view.focus_handle(cx).focus(window, cx); + } + } + }) + .child(title_editor) + .into_any_element() } } else { Label::new(server_view.read(cx).title(cx)) diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs index 2586e3691278095ee177745e13c01b6e3d531145..333146bd7ac43f7a9c3851de1f4a7e6176609368 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/agent_ui/src/sidebar.rs @@ -1,5 +1,5 @@ use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent}; -use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread}; +use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread, RemoveSelectedThread}; use acp_thread::ThreadStatus; use action_log::DiffStats; use agent::ThreadStore; @@ -83,6 +83,7 @@ struct ActiveThreadInfo { icon: IconName, icon_from_external_svg: Option, is_background: bool, + is_title_generating: bool, diff_stats: DiffStats, } @@ -115,6 +116,7 @@ struct ThreadEntry { workspace: ThreadEntryWorkspace, is_live: bool, is_background: bool, + is_title_generating: bool, highlight_positions: Vec, worktree_name: Option, worktree_highlight_positions: Vec, @@ -453,6 +455,8 @@ impl Sidebar { let icon = thread_view_ref.agent_icon; let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone(); let title = thread.title(); + let is_native = thread_view_ref.as_native_thread(cx).is_some(); + let is_title_generating = is_native && thread.has_provisional_title(); let session_id = thread.session_id().clone(); let is_background = agent_panel_ref.is_background_thread(&session_id); @@ -476,6 +480,7 @@ impl Sidebar { icon, icon_from_external_svg, is_background, + is_title_generating, diff_stats, } }) @@ -593,6 +598,7 @@ impl Sidebar { workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: false, is_background: false, + is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), @@ -646,6 +652,7 @@ impl Sidebar { workspace: target_workspace.clone(), is_live: false, is_background: false, + is_title_generating: false, highlight_positions: Vec::new(), worktree_name: Some(worktree_name.clone()), worktree_highlight_positions: Vec::new(), @@ -676,6 +683,7 @@ impl Sidebar { thread.icon_from_external_svg = info.icon_from_external_svg.clone(); thread.is_live = true; thread.is_background = info.is_background; + thread.is_title_generating = info.is_title_generating; thread.diff_stats = info.diff_stats; } } @@ -1030,6 +1038,7 @@ impl Sidebar { .end_hover_gradient_overlay(true) .end_hover_slot( h_flex() + .gap_1() .when(workspace_count > 1, |this| { this.child( IconButton::new( @@ -1588,7 +1597,6 @@ impl Sidebar { let Some(thread_store) = ThreadStore::try_global(cx) else { return; }; - self.hovered_thread_index = None; thread_store.update(cx, |store, cx| { store .delete_thread(session_id.clone(), cx) @@ -1596,6 +1604,25 @@ impl Sidebar { }); } + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.selection else { + return; + }; + let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else { + return; + }; + if thread.agent != Agent::NativeAgent { + return; + } + let session_id = thread.session_info.session_id.clone(); + self.delete_thread(&session_id, cx); + } + fn render_thread( &self, ix: usize, @@ -1620,6 +1647,7 @@ impl Sidebar { let is_selected = self.focused_thread.as_ref() == Some(&session_info.session_id); let can_delete = thread.agent == Agent::NativeAgent; let session_id_for_delete = thread.session_info.session_id.clone(); + let focus_handle = self.focus_handle.clone(); let id = SharedString::from(format!("thread-entry-{}", ix)); @@ -1660,6 +1688,7 @@ impl Sidebar { .when_some(timestamp, |this, ts| this.timestamp(ts)) .highlight_positions(thread.highlight_positions.to_vec()) .status(thread.status) + .generating_title(thread.is_title_generating) .notified(has_notification) .when(thread.diff_stats.lines_added > 0, |this| { this.added(thread.diff_stats.lines_added as usize) @@ -1679,16 +1708,27 @@ impl Sidebar { } cx.notify(); })) - .when((is_hovered || is_selected) && can_delete, |this| { + .when(is_hovered && can_delete, |this| { this.action_slot( IconButton::new("delete-thread", IconName::Trash) .icon_size(IconSize::Small) .icon_color(Color::Muted) - .tooltip(Tooltip::text("Delete Thread")) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Delete Thread", + &RemoveSelectedThread, + &focus_handle, + cx, + ) + } + }) .on_click({ let session_id = session_id_for_delete.clone(); cx.listener(move |this, _, _window, cx| { this.delete_thread(&session_id, cx); + cx.stop_propagation(); }) }), ) @@ -1848,17 +1888,30 @@ impl Sidebar { this.child(self.render_sidebar_toggle_button(false, cx)) }) .child(self.render_filter_input()) - .when(has_query, |this| { - this.when(!docked_right, |this| this.pr_1p5()).child( - IconButton::new("clear_filter", IconName::Close) - .shape(IconButtonShape::Square) - .tooltip(Tooltip::text("Clear Search")) - .on_click(cx.listener(|this, _, window, cx| { - this.reset_filter_editor_text(window, cx); - this.update_entries(cx); - })), - ) - }) + .child( + h_flex() + .gap_0p5() + .when(!docked_right, |this| this.pr_1p5()) + .when(has_query, |this| { + this.child( + IconButton::new("clear_filter", IconName::Close) + .shape(IconButtonShape::Square) + .tooltip(Tooltip::text("Clear Search")) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_filter_editor_text(window, cx); + this.update_entries(cx); + })), + ) + }) + .child( + IconButton::new("archive", IconName::Archive) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Archive")) + .on_click(cx.listener(|this, _, window, cx| { + this.show_archive(window, cx); + })), + ), + ) .when(docked_right, |this| { this.pl_2() .pr_0p5() @@ -1866,27 +1919,6 @@ impl Sidebar { }) } - fn render_thread_list_footer(&self, cx: &mut Context) -> impl IntoElement { - h_flex() - .p_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Button::new("view-archive", "Archive") - .full_width() - .label_size(LabelSize::Small) - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Archive) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .on_click(cx.listener(|this, _, window, cx| { - this.show_archive(window, cx); - })), - ) - } - fn render_sidebar_toggle_button( &self, docked_right: bool, @@ -2064,7 +2096,7 @@ impl Render for Sidebar { v_flex() .id("workspace-sidebar") - .key_context("WorkspaceSidebar") + .key_context("ThreadsSidebar") .track_focus(&self.focus_handle) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_previous)) @@ -2076,6 +2108,7 @@ impl Render for Sidebar { .on_action(cx.listener(Self::expand_selected_entry)) .on_action(cx.listener(Self::collapse_selected_entry)) .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::remove_selected_thread)) .font(ui_font) .size_full() .bg(cx.theme().colors().surface_background) @@ -2097,8 +2130,7 @@ impl Render for Sidebar { ) .when_some(sticky_header, |this, header| this.child(header)) .vertical_scrollbar_for(&self.list_state, window, cx), - ) - .child(self.render_thread_list_footer(cx)), + ), SidebarView::Archive => { if let Some(archive_view) = &self.archive_view { this.child(archive_view.clone()) @@ -2641,6 +2673,7 @@ mod tests { workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: false, is_background: false, + is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), @@ -2663,6 +2696,7 @@ mod tests { workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: true, is_background: false, + is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), @@ -2685,6 +2719,7 @@ mod tests { workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: true, is_background: false, + is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), @@ -2707,6 +2742,7 @@ mod tests { workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: false, is_background: false, + is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), @@ -2729,6 +2765,7 @@ mod tests { workspace: ThreadEntryWorkspace::Open(workspace.clone()), is_live: true, is_background: true, + is_title_generating: false, highlight_positions: Vec::new(), worktree_name: None, worktree_highlight_positions: Vec::new(), diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index e1fd44b4d81280037404fa3f2415b39bdc2aade7..ce5cae4830be732cbc6ca0156d61eb3c48dae888 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -1,8 +1,12 @@ use std::sync::Arc; -use crate::{Agent, agent_connection_store::AgentConnectionStore, thread_history::ThreadHistory}; +use crate::{ + Agent, RemoveSelectedThread, agent_connection_store::AgentConnectionStore, + thread_history::ThreadHistory, +}; use acp_thread::AgentSessionInfo; use agent::ThreadStore; +use agent_client_protocol as acp; use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; use editor::Editor; use fs::Fs; @@ -109,6 +113,7 @@ pub struct ThreadsArchiveView { list_state: ListState, items: Vec, selection: Option, + hovered_index: Option, filter_editor: Entity, _subscriptions: Vec, selected_agent_menu: PopoverMenuHandle, @@ -152,6 +157,7 @@ impl ThreadsArchiveView { list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), items: Vec::new(), selection: None, + hovered_index: None, filter_editor, _subscriptions: vec![filter_editor_subscription], selected_agent_menu: PopoverMenuHandle::default(), @@ -272,6 +278,37 @@ impl ThreadsArchiveView { }); } + fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context) { + let Some(history) = &self.history else { + return; + }; + if !history.read(cx).supports_delete() { + return; + } + let session_id = session_id.clone(); + history.update(cx, |history, cx| { + history + .delete_session(&session_id, cx) + .detach_and_log_err(cx); + }); + } + + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.selection else { + return; + }; + let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else { + return; + }; + let session_id = session.session_id.clone(); + self.delete_thread(&session_id, cx); + } + fn is_selectable_item(&self, ix: usize) -> bool { matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. })) } @@ -377,9 +414,17 @@ impl ThreadsArchiveView { highlight_positions, } => { let is_selected = self.selection == Some(ix); + let hovered = self.hovered_index == Some(ix); + let supports_delete = self + .history + .as_ref() + .map(|h| h.read(cx).supports_delete()) + .unwrap_or(false); let title: SharedString = session.title.clone().unwrap_or_else(|| "Untitled".into()); let session_info = session.clone(); + let session_id_for_delete = session.session_id.clone(); + let focus_handle = self.focus_handle.clone(); let highlight_positions = highlight_positions.clone(); let timestamp = session.created_at.or(session.updated_at).map(|entry_time| { @@ -429,12 +474,45 @@ impl ThreadsArchiveView { .gap_2() .justify_between() .child(title_label) - .when_some(timestamp, |this, ts| { - this.child( - Label::new(ts).size(LabelSize::Small).color(Color::Muted), - ) + .when(!(hovered && supports_delete), |this| { + this.when_some(timestamp, |this, ts| { + this.child( + Label::new(ts).size(LabelSize::Small).color(Color::Muted), + ) + }) }), ) + .on_hover(cx.listener(move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_index = Some(ix); + } else if this.hovered_index == Some(ix) { + this.hovered_index = None; + } + cx.notify(); + })) + .end_slot::(if hovered && supports_delete { + Some( + IconButton::new("delete-thread", IconName::Trash) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + "Delete Thread", + &RemoveSelectedThread, + &focus_handle, + cx, + ) + } + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.delete_thread(&session_id_for_delete, cx); + cx.stop_propagation(); + })), + ) + } else { + None + }) .on_click(cx.listener(move |this, _, window, cx| { this.open_thread(session_info.clone(), window, cx); })) @@ -683,6 +761,7 @@ impl Render for ThreadsArchiveView { .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::remove_selected_thread)) .size_full() .bg(cx.theme().colors().surface_background) .child(self.render_header(cx)) diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 51ed3d4e01b5dd469a29d3b969a10be2f3d88c12..6ab137227a4699e38a90b530a5554e6fe66f1ee5 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -3,7 +3,8 @@ use crate::{ IconDecorationKind, prelude::*, }; -use gpui::{AnyView, ClickEvent, Hsla, SharedString}; +use gpui::{Animation, AnimationExt, AnyView, ClickEvent, Hsla, SharedString, pulsating_between}; +use std::time::Duration; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum AgentThreadStatus { @@ -23,6 +24,7 @@ pub struct ThreadItem { timestamp: SharedString, notified: bool, status: AgentThreadStatus, + generating_title: bool, selected: bool, focused: bool, hovered: bool, @@ -48,6 +50,7 @@ impl ThreadItem { timestamp: "".into(), notified: false, status: AgentThreadStatus::default(), + generating_title: false, selected: false, focused: false, hovered: false, @@ -89,6 +92,11 @@ impl ThreadItem { self } + pub fn generating_title(mut self, generating: bool) -> Self { + self.generating_title = generating; + self + } + pub fn selected(mut self, selected: bool) -> Self { self.selected = selected; self @@ -221,7 +229,18 @@ impl RenderOnce for ThreadItem { let title = self.title; let highlight_positions = self.highlight_positions; - let title_label = if highlight_positions.is_empty() { + let title_label = if self.generating_title { + Label::new("New Thread…") + .color(Color::Muted) + .with_animation( + "generating-title", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.alpha(delta), + ) + .into_any_element() + } else if highlight_positions.is_empty() { Label::new(title).into_any_element() } else { HighlightedLabel::new(title, highlight_positions).into_any_element() @@ -283,7 +302,7 @@ impl RenderOnce for ThreadItem { .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)), ) .child(gradient_overlay) - .when(self.hovered || self.selected, |this| { + .when(self.hovered, |this| { this.when_some(self.action_slot, |this, slot| { let overlay = GradientFade::new( base_bg, From e48230190816c5a0ca918d023ed30fd4f2b4197a Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Sat, 14 Mar 2026 15:17:09 +0100 Subject: [PATCH 20/43] http_client: Fix GitHub downloads failing if the destination path exists (#51548) cc https://github.com/zed-industries/zed/pull/45428#issuecomment-4060334728 Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/http_client/src/github_download.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/http_client/src/github_download.rs b/crates/http_client/src/github_download.rs index 642bbf11c11ce8816a1506c3c4989dce434552d8..2ef615ff64c2b564e5c254b9c6ef21413d18bcf2 100644 --- a/crates/http_client/src/github_download.rs +++ b/crates/http_client/src/github_download.rs @@ -155,6 +155,7 @@ async fn cleanup_staging_path(staging_path: &Path, asset_kind: AssetKind) { } async fn finalize_download(staging_path: &Path, destination_path: &Path) -> Result<()> { + _ = async_fs::remove_dir_all(destination_path).await; async_fs::rename(staging_path, destination_path) .await .with_context(|| format!("renaming {staging_path:?} to {destination_path:?}"))?; From 8a7daea560ac589e8627e61998af239943b2f7f5 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Sat, 14 Mar 2026 12:53:28 -0500 Subject: [PATCH 21/43] ep: Ensure prompt is always within token limit (#51529) Release Notes: - N/A *or* Added/Fixed/Improved ... --- .../src/edit_prediction_tests.rs | 1 + crates/edit_prediction/src/zeta.rs | 40 +++-- .../edit_prediction_cli/src/format_prompt.rs | 2 +- crates/zeta_prompt/src/zeta_prompt.rs | 154 ++++++++++-------- 4 files changed, 113 insertions(+), 84 deletions(-) diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index dc52ef6ab57428d6293cea126c695f7c659e2f53..74688f64effc4c4e371d4516b25c6ce55b317dbb 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -2270,6 +2270,7 @@ fn empty_response() -> PredictEditsV3Response { fn prompt_from_request(request: &PredictEditsV3Request) -> String { zeta_prompt::format_zeta_prompt(&request.input, zeta_prompt::ZetaFormat::default()) + .expect("default zeta prompt formatting should succeed in edit prediction tests") } fn assert_no_predict_request_ready( diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index fa93e681b66cb44a554f725d4a1c6dee11f0b1f1..fc3ed81c78737f4ba4c8b7aa5131232b2b007b87 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -130,13 +130,14 @@ pub fn request_prediction_with_zeta( return Err(anyhow::anyhow!("prompt contains special tokens")); } + let formatted_prompt = format_zeta_prompt(&prompt_input, zeta_version); + if let Some(debug_tx) = &debug_tx { - let prompt = format_zeta_prompt(&prompt_input, zeta_version); debug_tx .unbounded_send(DebugEvent::EditPredictionStarted( EditPredictionStartedDebugEvent { buffer: buffer.downgrade(), - prompt: Some(prompt), + prompt: formatted_prompt.clone(), position, }, )) @@ -145,11 +146,11 @@ pub fn request_prediction_with_zeta( log::trace!("Sending edit prediction request"); - let (request_id, output, model_version, usage) = - if let Some(custom_settings) = &custom_server_settings { + let Some((request_id, output, model_version, usage)) = + (if let Some(custom_settings) = &custom_server_settings { let max_tokens = custom_settings.max_output_tokens * 4; - match custom_settings.prompt_format { + Some(match custom_settings.prompt_format { EditPredictionPromptFormat::Zeta => { let ranges = &prompt_input.excerpt_ranges; let editable_range_in_excerpt = ranges.editable_350.clone(); @@ -186,7 +187,9 @@ pub fn request_prediction_with_zeta( (request_id, parsed_output, None, None) } EditPredictionPromptFormat::Zeta2 => { - let prompt = format_zeta_prompt(&prompt_input, zeta_version); + let Some(prompt) = formatted_prompt.clone() else { + return Ok((None, None)); + }; let prefill = get_prefill(&prompt_input, zeta_version); let prompt = format!("{prompt}{prefill}"); @@ -219,9 +222,11 @@ pub fn request_prediction_with_zeta( (request_id, output_text, None, None) } _ => anyhow::bail!("unsupported prompt format"), - } + }) } else if let Some(config) = &raw_config { - let prompt = format_zeta_prompt(&prompt_input, config.format); + let Some(prompt) = format_zeta_prompt(&prompt_input, config.format) else { + return Ok((None, None)); + }; let prefill = get_prefill(&prompt_input, config.format); let prompt = format!("{prompt}{prefill}"); let environment = config @@ -263,7 +268,7 @@ pub fn request_prediction_with_zeta( None }; - (request_id, output, None, usage) + Some((request_id, output, None, usage)) } else { // Use V3 endpoint - server handles model/version selection and suffix stripping let (response, usage) = EditPredictionStore::send_v3_request( @@ -284,8 +289,11 @@ pub fn request_prediction_with_zeta( range_in_excerpt: response.editable_range, }; - (request_id, Some(parsed_output), model_version, usage) - }; + Some((request_id, Some(parsed_output), model_version, usage)) + }) + else { + return Ok((None, None)); + }; let received_response_at = Instant::now(); @@ -296,7 +304,7 @@ pub fn request_prediction_with_zeta( range_in_excerpt: editable_range_in_excerpt, }) = output else { - return Ok(((request_id, None), None)); + return Ok((Some((request_id, None)), None)); }; let editable_range_in_buffer = editable_range_in_excerpt.start @@ -342,7 +350,7 @@ pub fn request_prediction_with_zeta( ); anyhow::Ok(( - ( + Some(( request_id, Some(Prediction { prompt_input, @@ -354,14 +362,16 @@ pub fn request_prediction_with_zeta( editable_range_in_buffer, model_version, }), - ), + )), usage, )) } }); cx.spawn(async move |this, cx| { - let (id, prediction) = handle_api_response(&this, request_task.await, cx)?; + let Some((id, prediction)) = handle_api_response(&this, request_task.await, cx)? else { + return Ok(None); + }; let Some(Prediction { prompt_input: inputs, diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index af955a05dce01fd34c37eb55d15b76b4a4592745..3a20fe0e9a5f89fa3325c1972721a836d60f7156 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -92,7 +92,7 @@ pub async fn run_format_prompt( zeta2_output_for_patch(prompt_inputs, patch, None, zeta_format).ok() }); - example.prompt = Some(ExamplePrompt { + example.prompt = prompt.map(|prompt| ExamplePrompt { input: prompt, expected_output, rejected_output, diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index d79ded2b9781252855ef424e49247fc1cabd383f..0dce7764e7b9c451b4360fb2177d9d3e0eb7315b 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -204,7 +204,7 @@ pub fn prompt_input_contains_special_tokens(input: &ZetaPromptInput, format: Zet .any(|token| input.cursor_excerpt.contains(token)) } -pub fn format_zeta_prompt(input: &ZetaPromptInput, format: ZetaFormat) -> String { +pub fn format_zeta_prompt(input: &ZetaPromptInput, format: ZetaFormat) -> Option { format_prompt_with_budget_for_format(input, format, MAX_PROMPT_TOKENS) } @@ -416,7 +416,7 @@ pub fn format_prompt_with_budget_for_format( input: &ZetaPromptInput, format: ZetaFormat, max_tokens: usize, -) -> String { +) -> Option { let (context, editable_range, context_range, cursor_offset) = resolve_cursor_region(input, format); let path = &*input.cursor_path; @@ -436,25 +436,24 @@ pub fn format_prompt_with_budget_for_format( input_related_files }; - match format { - ZetaFormat::V0211SeedCoder | ZetaFormat::V0304SeedNoEdits => { - seed_coder::format_prompt_with_budget( + let prompt = match format { + ZetaFormat::V0211SeedCoder + | ZetaFormat::V0304SeedNoEdits + | ZetaFormat::V0306SeedMultiRegions => { + let mut cursor_section = String::new(); + write_cursor_excerpt_section_for_format( + format, + &mut cursor_section, path, context, &editable_range, cursor_offset, - &input.events, - related_files, - max_tokens, - ) - } - ZetaFormat::V0306SeedMultiRegions => { - let cursor_prefix = - build_v0306_cursor_prefix(path, context, &editable_range, cursor_offset); + ); + seed_coder::assemble_fim_prompt( context, &editable_range, - &cursor_prefix, + &cursor_section, &input.events, related_files, max_tokens, @@ -497,7 +496,12 @@ pub fn format_prompt_with_budget_for_format( prompt.push_str(&cursor_section); prompt } + }; + let prompt_tokens = estimate_tokens(prompt.len()); + if prompt_tokens > max_tokens { + return None; } + return Some(prompt); } pub fn filter_redundant_excerpts( @@ -2707,8 +2711,8 @@ pub mod seed_coder { ) -> String { let suffix_section = build_suffix_section(context, editable_range); - let suffix_tokens = estimate_tokens(suffix_section.len()); - let cursor_prefix_tokens = estimate_tokens(cursor_prefix_section.len()); + let suffix_tokens = estimate_tokens(suffix_section.len() + FIM_PREFIX.len()); + let cursor_prefix_tokens = estimate_tokens(cursor_prefix_section.len() + FIM_MIDDLE.len()); let budget_after_cursor = max_tokens.saturating_sub(suffix_tokens + cursor_prefix_tokens); let edit_history_section = super::format_edit_history_within_budget( @@ -2718,8 +2722,9 @@ pub mod seed_coder { budget_after_cursor, max_edit_event_count_for_format(&ZetaFormat::V0211SeedCoder), ); - let edit_history_tokens = estimate_tokens(edit_history_section.len()); - let budget_after_edit_history = budget_after_cursor.saturating_sub(edit_history_tokens); + let edit_history_tokens = estimate_tokens(edit_history_section.len() + "\n".len()); + let budget_after_edit_history = + budget_after_cursor.saturating_sub(edit_history_tokens + "\n".len()); let related_files_section = super::format_related_files_within_budget( related_files, @@ -2741,6 +2746,7 @@ pub mod seed_coder { } prompt.push_str(cursor_prefix_section); prompt.push_str(FIM_MIDDLE); + prompt } @@ -4087,7 +4093,7 @@ mod tests { } } - fn format_with_budget(input: &ZetaPromptInput, max_tokens: usize) -> String { + fn format_with_budget(input: &ZetaPromptInput, max_tokens: usize) -> Option { format_prompt_with_budget_for_format(input, ZetaFormat::V0114180EditableRegion, max_tokens) } @@ -4102,7 +4108,7 @@ mod tests { ); assert_eq!( - format_with_budget(&input, 10000), + format_with_budget(&input, 10000).unwrap(), indoc! {r#" <|file_sep|>related.rs fn helper() {} @@ -4121,6 +4127,7 @@ mod tests { suffix <|fim_middle|>updated "#} + .to_string() ); } @@ -4132,18 +4139,18 @@ mod tests { 2, vec![make_event("a.rs", "-x\n+y\n")], vec![ - make_related_file("r1.rs", "a\n"), - make_related_file("r2.rs", "b\n"), + make_related_file("r1.rs", "aaaaaaa\n"), + make_related_file("r2.rs", "bbbbbbb\n"), ], ); assert_eq!( - format_with_budget(&input, 10000), + format_with_budget(&input, 10000).unwrap(), indoc! {r#" <|file_sep|>r1.rs - a + aaaaaaa <|file_sep|>r2.rs - b + bbbbbbb <|file_sep|>edit history --- a/a.rs +++ b/a.rs @@ -4156,15 +4163,18 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); assert_eq!( - format_with_budget(&input, 50), - indoc! {r#" - <|file_sep|>r1.rs - a - <|file_sep|>r2.rs - b + format_with_budget(&input, 55), + Some( + indoc! {r#" + <|file_sep|>edit history + --- a/a.rs + +++ b/a.rs + -x + +y <|file_sep|>test.rs <|fim_prefix|> <|fim_middle|>current @@ -4172,6 +4182,8 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() + ) ); } @@ -4207,7 +4219,7 @@ mod tests { ); assert_eq!( - format_with_budget(&input, 10000), + format_with_budget(&input, 10000).unwrap(), indoc! {r#" <|file_sep|>big.rs first excerpt @@ -4222,10 +4234,11 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); assert_eq!( - format_with_budget(&input, 50), + format_with_budget(&input, 50).unwrap(), indoc! {r#" <|file_sep|>big.rs first excerpt @@ -4237,6 +4250,7 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); } @@ -4275,7 +4289,7 @@ mod tests { // With large budget, both files included; rendered in stable lexicographic order. assert_eq!( - format_with_budget(&input, 10000), + format_with_budget(&input, 10000).unwrap(), indoc! {r#" <|file_sep|>file_a.rs low priority content @@ -4288,6 +4302,7 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); // With tight budget, only file_b (lower order) fits. @@ -4295,7 +4310,7 @@ mod tests { // file_b header (7) + excerpt (7) = 14 tokens, which fits. // file_a would need another 14 tokens, which doesn't fit. assert_eq!( - format_with_budget(&input, 52), + format_with_budget(&input, 52).unwrap(), indoc! {r#" <|file_sep|>file_b.rs high priority content @@ -4306,6 +4321,7 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); } @@ -4347,7 +4363,7 @@ mod tests { // With large budget, all three excerpts included. assert_eq!( - format_with_budget(&input, 10000), + format_with_budget(&input, 10000).unwrap(), indoc! {r#" <|file_sep|>mod.rs mod header @@ -4362,11 +4378,12 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); // With tight budget, only order<=1 excerpts included (header + important fn). assert_eq!( - format_with_budget(&input, 55), + format_with_budget(&input, 55).unwrap(), indoc! {r#" <|file_sep|>mod.rs mod header @@ -4380,6 +4397,7 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); } @@ -4394,7 +4412,7 @@ mod tests { ); assert_eq!( - format_with_budget(&input, 10000), + format_with_budget(&input, 10000).unwrap(), indoc! {r#" <|file_sep|>edit history --- a/old.rs @@ -4410,10 +4428,11 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); assert_eq!( - format_with_budget(&input, 55), + format_with_budget(&input, 60).unwrap(), indoc! {r#" <|file_sep|>edit history --- a/new.rs @@ -4426,6 +4445,7 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); } @@ -4439,25 +4459,19 @@ mod tests { vec![make_related_file("related.rs", "helper\n")], ); - assert_eq!( - format_with_budget(&input, 30), - indoc! {r#" - <|file_sep|>test.rs - <|fim_prefix|> - <|fim_middle|>current - fn <|user_cursor|>main() {} - <|fim_suffix|> - <|fim_middle|>updated - "#} - ); + assert!(format_with_budget(&input, 30).is_none()) } + #[track_caller] fn format_seed_coder(input: &ZetaPromptInput) -> String { format_prompt_with_budget_for_format(input, ZetaFormat::V0211SeedCoder, 10000) + .expect("seed coder prompt formatting should succeed") } + #[track_caller] fn format_seed_coder_with_budget(input: &ZetaPromptInput, max_tokens: usize) -> String { format_prompt_with_budget_for_format(input, ZetaFormat::V0211SeedCoder, max_tokens) + .expect("seed coder prompt formatting should succeed") } #[test] @@ -4542,17 +4556,22 @@ mod tests { <[fim-middle]>"#} ); - // With tight budget, context is dropped but cursor section remains assert_eq!( - format_seed_coder_with_budget(&input, 30), + format_prompt_with_budget_for_format(&input, ZetaFormat::V0211SeedCoder, 24), + None + ); + + assert_eq!( + format_seed_coder_with_budget(&input, 40), indoc! {r#" <[fim-suffix]> <[fim-prefix]>test.rs <<<<<<< CURRENT co<|user_cursor|>de ======= - <[fim-middle]>"#} - ); + <[fim-middle]>"# + } + ) } #[test] @@ -4603,21 +4622,20 @@ mod tests { <[fim-middle]>"#} ); - // With tight budget, only high_prio included. - // Cursor sections cost 25 tokens, so budget 44 leaves 19 for related files. - // high_prio header (7) + excerpt (3) = 10, fits. low_prio would add 10 more = 20 > 19. + // With tight budget under the generic heuristic, context is dropped but the + // minimal cursor section still fits. assert_eq!( - format_seed_coder_with_budget(&input, 44), - indoc! {r#" - <[fim-suffix]> - <[fim-prefix]>high_prio.rs - high prio - - test.rs - <<<<<<< CURRENT - co<|user_cursor|>de - ======= - <[fim-middle]>"#} + format_prompt_with_budget_for_format(&input, ZetaFormat::V0211SeedCoder, 44), + Some( + indoc! {r#" + <[fim-suffix]> + <[fim-prefix]>test.rs + <<<<<<< CURRENT + co<|user_cursor|>de + ======= + <[fim-middle]>"#} + .to_string() + ) ); } From cbc39669b414c2601f86ece9faffe164a33b5ad7 Mon Sep 17 00:00:00 2001 From: Alex Mihaiuc <69110671+foxmsft@users.noreply.github.com> Date: Sun, 15 Mar 2026 01:02:34 +0100 Subject: [PATCH 22/43] Remove std::fs::read_link in fs (#50974) Closes #46307 Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [X] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Improved compatibility with mounted VHDs on Windows. --------- Co-authored-by: John Tur --- Cargo.lock | 1 + Cargo.toml | 1 + crates/fs/Cargo.toml | 1 + crates/fs/src/fs.rs | 109 +++++++++++++---------------------------- crates/util/Cargo.toml | 2 +- 5 files changed, 39 insertions(+), 75 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 21646eef97e2989bfe5c9f9cac9e53eb8b8ec11d..8d86e9fa9b82978a4309ff4a77dd8388b4626d0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6583,6 +6583,7 @@ dependencies = [ "async-trait", "cocoa 0.26.0", "collections", + "dunce", "fs", "futures 0.3.31", "git", diff --git a/Cargo.toml b/Cargo.toml index 754860cc43f5b841e45316a0434b37886e901a0f..e1f5b4ffeb9711e00ae698205b5e9312a49adfe8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -548,6 +548,7 @@ derive_more = { version = "2.1.1", features = [ dirs = "4.0" documented = "0.9.1" dotenvy = "0.15.0" +dunce = "1.0" ec4rs = "1.1" emojis = "0.6.1" env_logger = "0.11" diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml index 04cae2dd2ad18f85a7c2ed663c1c3482febb22d3..371057c3f8abfd50eea34f0edfcc3e3f7d52df7b 100644 --- a/crates/fs/Cargo.toml +++ b/crates/fs/Cargo.toml @@ -48,6 +48,7 @@ cocoa = "0.26" [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true +dunce.workspace = true [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] ashpd.workspace = true diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 311992d20d9947d189ff5026a73620090a8579c4..51757cc5a16b301facd465c356a984f0f41af388 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -431,82 +431,43 @@ impl RealFs { #[cfg(target_os = "windows")] fn canonicalize(path: &Path) -> Result { - let mut strip_prefix = None; + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use windows::Win32::Storage::FileSystem::GetVolumePathNameW; + use windows::core::HSTRING; - let mut new_path = PathBuf::new(); - for component in path.components() { - match component { - std::path::Component::Prefix(_) => { - let component = component.as_os_str(); - let canonicalized = if component - .to_str() - .map(|e| e.ends_with("\\")) - .unwrap_or(false) - { - std::fs::canonicalize(component) - } else { - let mut component = component.to_os_string(); - component.push("\\"); - std::fs::canonicalize(component) - }?; - - let mut strip = PathBuf::new(); - for component in canonicalized.components() { - match component { - Component::Prefix(prefix_component) => { - match prefix_component.kind() { - std::path::Prefix::Verbatim(os_str) => { - strip.push(os_str); - } - std::path::Prefix::VerbatimUNC(host, share) => { - strip.push("\\\\"); - strip.push(host); - strip.push(share); - } - std::path::Prefix::VerbatimDisk(disk) => { - strip.push(format!("{}:", disk as char)); - } - _ => strip.push(component), - }; - } - _ => strip.push(component), - } - } - strip_prefix = Some(strip); - new_path.push(component); - } - std::path::Component::RootDir => { - new_path.push(component); - } - std::path::Component::CurDir => { - if strip_prefix.is_none() { - // unrooted path - new_path.push(component); - } - } - std::path::Component::ParentDir => { - if strip_prefix.is_some() { - // rooted path - new_path.pop(); - } else { - new_path.push(component); - } - } - std::path::Component::Normal(_) => { - if let Ok(link) = std::fs::read_link(new_path.join(component)) { - let link = match &strip_prefix { - Some(e) => link.strip_prefix(e).unwrap_or(&link), - None => &link, - }; - new_path.extend(link); - } else { - new_path.push(component); - } - } - } - } + // std::fs::canonicalize resolves mapped network paths to UNC paths, which can + // confuse some software. To mitigate this, we canonicalize the input, then rebase + // the result onto the input's original volume root if both paths are on the same + // volume. This keeps the same drive letter or mount point the caller used. - Ok(new_path) + let abs_path = if path.is_relative() { + std::env::current_dir()?.join(path) + } else { + path.to_path_buf() + }; + + let path_hstring = HSTRING::from(abs_path.as_os_str()); + let mut vol_buf = vec![0u16; abs_path.as_os_str().len() + 2]; + unsafe { GetVolumePathNameW(&path_hstring, &mut vol_buf)? }; + let volume_root = { + let len = vol_buf + .iter() + .position(|&c| c == 0) + .unwrap_or(vol_buf.len()); + PathBuf::from(OsString::from_wide(&vol_buf[..len])) + }; + + let resolved_path = dunce::canonicalize(&abs_path)?; + let resolved_root = dunce::canonicalize(&volume_root)?; + + if let Ok(relative) = resolved_path.strip_prefix(&resolved_root) { + let mut result = volume_root; + result.push(relative); + Ok(result) + } else { + Ok(resolved_path) + } } } diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index 9f4c391ed01cc21e6e334d37407c8206ff1b3409..4f317e79e0cfc92087250182531ae33a591b1f48 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -21,7 +21,7 @@ test-support = ["git2", "rand", "util_macros"] anyhow.workspace = true async_zip.workspace = true collections.workspace = true -dunce = "1.0" +dunce.workspace = true futures-lite.workspace = true futures.workspace = true globset.workspace = true From e5a69d89497e37cca1a7e7407225d3b0c142d5b0 Mon Sep 17 00:00:00 2001 From: ozacod <47009516+ozacod@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:35:29 +0300 Subject: [PATCH 23/43] lsp: Add clangd readonly token modifier to semantic token rules to highlight constant variables as constant (#49065) Clangd uses the "readonly" token modifier instead of "constant". Therefore, "readonly" should be mapped to highlight constant variables as constants. Before: before After: after - [x] Code Reviewed - [x] Manual QA Release Notes: - Added clangd readonly token modifier to semantic token rules to highlight constant variables as constant. Co-authored-by: ozacod --- crates/languages/src/cpp.rs | 12 ++++++++++++ crates/languages/src/cpp/semantic_token_rules.json | 7 +++++++ crates/languages/src/lib.rs | 1 + 3 files changed, 20 insertions(+) create mode 100644 crates/languages/src/cpp/semantic_token_rules.json diff --git a/crates/languages/src/cpp.rs b/crates/languages/src/cpp.rs index 85a3fb5045275648282c7a8cbad58779491ad7dc..3207b492f4b11be345cd67a989f9667d025d6660 100644 --- a/crates/languages/src/cpp.rs +++ b/crates/languages/src/cpp.rs @@ -1,3 +1,15 @@ +use settings::SemanticTokenRules; + +use crate::LanguageDir; + +pub(crate) fn semantic_token_rules() -> SemanticTokenRules { + let content = LanguageDir::get("cpp/semantic_token_rules.json") + .expect("missing cpp/semantic_token_rules.json"); + let json = std::str::from_utf8(&content.data).expect("invalid utf-8 in semantic_token_rules"); + settings::parse_json_with_comments::(json) + .expect("failed to parse cpp semantic_token_rules.json") +} + #[cfg(test)] mod tests { use gpui::{AppContext as _, BorrowAppContext, TestAppContext}; diff --git a/crates/languages/src/cpp/semantic_token_rules.json b/crates/languages/src/cpp/semantic_token_rules.json new file mode 100644 index 0000000000000000000000000000000000000000..627a5c5f187b47918e6a56069c5ed1bda8583aa6 --- /dev/null +++ b/crates/languages/src/cpp/semantic_token_rules.json @@ -0,0 +1,7 @@ +[ + { + "token_type": "variable", + "token_modifiers": ["readonly"], + "style": ["constant"] + } +] diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 275b8c58ecde831c8f89ae688dc236583b135c07..240935d2f817b43b2aae03dfdff4321de6522bf3 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -125,6 +125,7 @@ pub fn init(languages: Arc, fs: Arc, node: NodeRuntime LanguageInfo { name: "cpp", adapters: vec![c_lsp_adapter], + semantic_token_rules: Some(cpp::semantic_token_rules()), ..Default::default() }, LanguageInfo { From a93c66e7947591db60a84bb02c1ddb0238dda4db Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 16 Mar 2026 08:43:56 +0100 Subject: [PATCH 24/43] markdown_preview: Prevent stackoverflows in markdown parsing (#51637) Fixes ZED-5TR Release Notes: - Fixed a stack overflow when parsing deeply nested html in markdown files --- Cargo.lock | 1 + crates/markdown_preview/Cargo.toml | 1 + crates/markdown_preview/src/markdown_parser.rs | 3 +++ 3 files changed, 5 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 8d86e9fa9b82978a4309ff4a77dd8388b4626d0b..83606c7414b7ca323001d79a3aed690f2a57f554 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10247,6 +10247,7 @@ dependencies = [ "pretty_assertions", "pulldown-cmark 0.13.0", "settings", + "stacksafe", "theme", "ui", "urlencoding", diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index 4baa308f1088341aada1eb2917c2133b8df8c143..c72de7274a407c168e7a3cdd7a253070cc6f858a 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -30,6 +30,7 @@ markup5ever_rcdom.workspace = true pretty_assertions.workspace = true pulldown-cmark.workspace = true settings.workspace = true +stacksafe.workspace = true theme.workspace = true ui.workspace = true urlencoding.workspace = true diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs index ffd697d0e1bafc2feeccf3a3a7836a224d983860..29ea273f49578bd6ad408a8d57b891f572705c07 100644 --- a/crates/markdown_preview/src/markdown_parser.rs +++ b/crates/markdown_preview/src/markdown_parser.rs @@ -10,6 +10,7 @@ use language::LanguageRegistry; use markdown::parser::PARSE_OPTIONS; use markup5ever_rcdom::RcDom; use pulldown_cmark::{Alignment, Event, Parser, Tag, TagEnd}; +use stacksafe::stacksafe; use std::{ cell::RefCell, collections::HashMap, mem, ops::Range, path::PathBuf, rc::Rc, sync::Arc, vec, }; @@ -907,6 +908,7 @@ impl<'a> MarkdownParser<'a> { elements } + #[stacksafe] fn parse_html_node( &self, source_range: Range, @@ -1013,6 +1015,7 @@ impl<'a> MarkdownParser<'a> { } } + #[stacksafe] fn parse_paragraph( &self, source_range: Range, From ebd80d7995710ce7516fb09dc799bcc51578da96 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 16 Mar 2026 09:49:02 +0100 Subject: [PATCH 25/43] buffer_diff: Fix panic when staging hunks with stale buffer snapshot (#51641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the buffer is edited after the diff is computed but before staging, anchor positions shift while diff_base_byte_range values don't. If the primary (HEAD) hunk extends past the unstaged (index) hunk, an edit in the extension region causes the overshoot calculation to produce an index_end that exceeds index_text.len(), panicking in rope::Cursor::suffix. Fix by clamping index_end to index_text.len(). This is safe because the computed index text is an optimistic approximation — the real staging happens at the filesystem level via git add/git reset. Closes ZED-5R2 Release Notes: - Fixed a source of panics when staging diff hunks --- crates/buffer_diff/src/buffer_diff.rs | 55 +++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 82ab2736b8bc207aa30952ae9f79f161eb9db8db..c0f62ed8fc1c990b3bb4aaef5fff5ae23bebff86 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -843,6 +843,16 @@ impl BufferDiffInner> { .end .saturating_sub(prev_unstaged_hunk_buffer_end); let index_end = prev_unstaged_hunk_base_text_end + end_overshoot; + + // Clamp to the index text bounds. The overshoot mapping assumes that + // text between unstaged hunks is identical in the buffer and index. + // When the buffer has been edited since the diff was computed, anchor + // positions shift while diff_base_byte_range values don't, which can + // cause index_end to exceed index_text.len(). + // See `test_stage_all_with_stale_buffer` which would hit an assert + // without these min calls + let index_end = index_end.min(index_text.len()); + let index_start = index_start.min(index_end); let index_byte_range = index_start..index_end; let replacement_text = match new_status { @@ -2678,6 +2688,51 @@ mod tests { }); } + #[gpui::test] + async fn test_stage_all_with_stale_buffer(cx: &mut TestAppContext) { + // Regression test for ZED-5R2: when the buffer is edited after the diff is + // computed but before staging, anchor positions shift while diff_base_byte_range + // values don't. If the primary (HEAD) hunk extends past the unstaged (index) + // hunk, an edit in the extension region shifts the primary hunk end without + // shifting the unstaged hunk end. The overshoot calculation then produces an + // index_end that exceeds index_text.len(). + // + // Setup: + // HEAD: "aaa\nbbb\nccc\n" (primary hunk covers lines 1-2) + // Index: "aaa\nbbb\nCCC\n" (unstaged hunk covers line 1 only) + // Buffer: "aaa\nBBB\nCCC\n" (both lines differ from HEAD) + // + // The primary hunk spans buffer offsets 4..12, but the unstaged hunk only + // spans 4..8. The pending hunk extends 4 bytes past the unstaged hunk. + // An edit at offset 9 (inside "CCC") shifts the primary hunk end from 12 + // to 13 but leaves the unstaged hunk end at 8, making index_end = 13 > 12. + let head_text = "aaa\nbbb\nccc\n"; + let index_text = "aaa\nbbb\nCCC\n"; + let buffer_text = "aaa\nBBB\nCCC\n"; + + let mut buffer = Buffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + buffer_text.to_string(), + ); + + let unstaged_diff = cx.new(|cx| BufferDiff::new_with_base_text(index_text, &buffer, cx)); + let uncommitted_diff = cx.new(|cx| { + let mut diff = BufferDiff::new_with_base_text(head_text, &buffer, cx); + diff.set_secondary_diff(unstaged_diff); + diff + }); + + // Edit the buffer in the region between the unstaged hunk end (offset 8) + // and the primary hunk end (offset 12). This shifts the primary hunk end + // but not the unstaged hunk end. + buffer.edit([(9..9, "Z")]); + + uncommitted_diff.update(cx, |diff, cx| { + diff.stage_or_unstage_all_hunks(true, &buffer, true, cx); + }); + } + #[gpui::test] async fn test_toggling_stage_and_unstage_same_hunk(cx: &mut TestAppContext) { let head_text = " From ec2ab12a063d7a5a1551fc3f057c8b70e8f68897 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 16 Mar 2026 10:03:22 +0100 Subject: [PATCH 26/43] fs: Fix wrong windows cfg (#51644) Causes releases on windows to fail due to unused imports Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/fs/src/fs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 51757cc5a16b301facd465c356a984f0f41af388..662e5c286315e543e361d16f5bedc9a8d7a3150d 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -37,7 +37,7 @@ use is_executable::IsExecutable; use rope::Rope; use serde::{Deserialize, Serialize}; use smol::io::AsyncWriteExt; -#[cfg(any(target_os = "windows", feature = "test-support"))] +#[cfg(feature = "test-support")] use std::path::Component; use std::{ io::{self, Write}, From be4d38a56e1334967e67b72e5b2c3021f3c45f1d Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Mon, 16 Mar 2026 10:47:36 +0100 Subject: [PATCH 27/43] livekit: Use our build of libwebrtc.a (#51433) Closes #51339 This should address issues with too new libstdc++.so on older/more conservative distros such as RHEL9. Release Notes: - Relaxed requirement for libstdc++.so available on Linux distros. --- .cargo/ci-config.toml | 10 ---------- .cargo/config.toml | 4 ++++ .github/workflows/autofix_pr.yml | 2 -- .github/workflows/compare_perf.yml | 2 -- .github/workflows/deploy_collab.yml | 4 ---- .github/workflows/release.yml | 8 -------- .github/workflows/release_nightly.yml | 4 ---- .github/workflows/run_agent_evals.yml | 2 -- .github/workflows/run_bundling.yml | 4 ---- .github/workflows/run_cron_unit_evals.yml | 2 -- .github/workflows/run_tests.yml | 10 ---------- .github/workflows/run_unit_evals.yml | 2 -- Cargo.lock | 14 +++++++------- Cargo.toml | 4 ++-- script/bundle-linux | 20 +++++++++++++++----- script/linux | 2 ++ tooling/xtask/src/tasks/workflows/steps.rs | 8 +------- 17 files changed, 31 insertions(+), 71 deletions(-) diff --git a/.cargo/ci-config.toml b/.cargo/ci-config.toml index b31b79a59b262a5cc18cf1d2b32124a97bab4fc7..6a5feece648a39be39e99fa3eb5807713b911348 100644 --- a/.cargo/ci-config.toml +++ b/.cargo/ci-config.toml @@ -15,14 +15,4 @@ rustflags = ["-D", "warnings"] [profile.dev] debug = "limited" -# Use Mold on Linux, because it's faster than GNU ld and LLD. -# -# We no longer set this in the default `config.toml` so that developers can opt in to Wild, which -# is faster than Mold, in their own ~/.cargo/config.toml. -[target.x86_64-unknown-linux-gnu] -linker = "clang" -rustflags = ["-C", "link-arg=-fuse-ld=mold"] -[target.aarch64-unknown-linux-gnu] -linker = "clang" -rustflags = ["-C", "link-arg=-fuse-ld=mold"] diff --git a/.cargo/config.toml b/.cargo/config.toml index 9b2e6f51c96e3ae98a54bbb11524210911d0e262..a9bf1f9cc975cf812605e88379def0ab334f76ad 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -16,5 +16,9 @@ rustflags = [ "target-feature=+crt-static", # This fixes the linking issue when compiling livekit on Windows ] +# We need lld to link libwebrtc.a successfully on aarch64-linux +[target.aarch64-unknown-linux-gnu] +rustflags = ["-C", "link-arg=-fuse-ld=lld"] + [env] MACOSX_DEPLOYMENT_TARGET = "10.15.7" diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml index 1fa271d168a8c3d1744439647ff50b793a854d1d..1f9e6320700d14cab69662e317c30fa7206eb655 100644 --- a/.github/workflows/autofix_pr.yml +++ b/.github/workflows/autofix_pr.yml @@ -37,8 +37,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_pnpm diff --git a/.github/workflows/compare_perf.yml b/.github/workflows/compare_perf.yml index f7d78dbbf6a6d04bc47212b6842f894850288fcc..03113f2aa0be4dc794f8f5edec18df22fb0daa31 100644 --- a/.github/workflows/compare_perf.yml +++ b/.github/workflows/compare_perf.yml @@ -30,8 +30,6 @@ jobs: cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: compare_perf::run_perf::install_hyperfine diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index 0d98438c9e3029f85cc37cb4e57f6c9e24df43b0..7fe06460f752599513c79b71bb01636d69d20e6c 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -32,8 +32,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_fmt @@ -65,8 +63,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8adad5cfba278dc68dd227b86455510278c7a1ae..07a0a6d672a0a66c9c1609e82a22af9034dc936e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,8 +72,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_node @@ -199,8 +197,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_sccache @@ -318,8 +314,6 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: ./script/bundle-linux @@ -360,8 +354,6 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: ./script/bundle-linux diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 46d8732b08ea658275e1fb21117a09b9e0668933..093a17e8760e52fc4278d56dd6144b6a0432f3c5 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -122,8 +122,6 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: ./script/bundle-linux @@ -170,8 +168,6 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: ./script/bundle-linux diff --git a/.github/workflows/run_agent_evals.yml b/.github/workflows/run_agent_evals.yml index c506039ce7c1863bd3c60091beb78d5239110bbd..56cbd17a197200a6764ed1e28c87e90740cd7deb 100644 --- a/.github/workflows/run_agent_evals.yml +++ b/.github/workflows/run_agent_evals.yml @@ -34,8 +34,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_cargo_config diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index 7cb1665f9d0bd4fe3b0f3c05527bf39aab5f610a..5a93cf074e2a2d7f2f3cf8418ed508c5ad359d9e 100644 --- a/.github/workflows/run_bundling.yml +++ b/.github/workflows/run_bundling.yml @@ -32,8 +32,6 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: ./script/bundle-linux @@ -73,8 +71,6 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: ./script/bundle-linux diff --git a/.github/workflows/run_cron_unit_evals.yml b/.github/workflows/run_cron_unit_evals.yml index 2a204a9d40d78bf52f38825b4db060216e348a87..6af46e678d3d629cc2f7973b8b31ee99477dfefc 100644 --- a/.github/workflows/run_cron_unit_evals.yml +++ b/.github/workflows/run_cron_unit_evals.yml @@ -35,8 +35,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index fed05e00459b3c688c4244ddb9ea29ec1dbfd564..fd7fecb4eb0309b7cc53c6efe0d2f2ece5f2a228 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -218,8 +218,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_sccache @@ -331,8 +329,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_node @@ -430,8 +426,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_cargo_config @@ -480,8 +474,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_sccache @@ -606,8 +598,6 @@ jobs: jobSummary: false - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: ./script/generate-action-metadata diff --git a/.github/workflows/run_unit_evals.yml b/.github/workflows/run_unit_evals.yml index 2259d2498b76f3627e6784f55023e2fbfe855cbb..44f12a1886bdac2fa1da8c870d223dd358285658 100644 --- a/.github/workflows/run_unit_evals.yml +++ b/.github/workflows/run_unit_evals.yml @@ -38,8 +38,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest diff --git a/Cargo.lock b/Cargo.lock index 83606c7414b7ca323001d79a3aed690f2a57f554..36f5d5d5ab4b19e4746052e57ba0bf2823660d7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9758,7 +9758,7 @@ dependencies = [ [[package]] name = "libwebrtc" version = "0.3.26" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=37835f840d0070d45ac8b31cce6a6ae7aca3f459#37835f840d0070d45ac8b31cce6a6ae7aca3f459" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=cf4375b244ebb51702968df7fc36e192d0f45ad5#cf4375b244ebb51702968df7fc36e192d0f45ad5" dependencies = [ "cxx", "glib", @@ -9856,7 +9856,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "livekit" version = "0.7.32" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=37835f840d0070d45ac8b31cce6a6ae7aca3f459#37835f840d0070d45ac8b31cce6a6ae7aca3f459" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=cf4375b244ebb51702968df7fc36e192d0f45ad5#cf4375b244ebb51702968df7fc36e192d0f45ad5" dependencies = [ "base64 0.22.1", "bmrng", @@ -9882,7 +9882,7 @@ dependencies = [ [[package]] name = "livekit-api" version = "0.4.14" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=37835f840d0070d45ac8b31cce6a6ae7aca3f459#37835f840d0070d45ac8b31cce6a6ae7aca3f459" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=cf4375b244ebb51702968df7fc36e192d0f45ad5#cf4375b244ebb51702968df7fc36e192d0f45ad5" dependencies = [ "base64 0.21.7", "futures-util", @@ -9909,7 +9909,7 @@ dependencies = [ [[package]] name = "livekit-protocol" version = "0.7.1" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=37835f840d0070d45ac8b31cce6a6ae7aca3f459#37835f840d0070d45ac8b31cce6a6ae7aca3f459" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=cf4375b244ebb51702968df7fc36e192d0f45ad5#cf4375b244ebb51702968df7fc36e192d0f45ad5" dependencies = [ "futures-util", "livekit-runtime", @@ -9925,7 +9925,7 @@ dependencies = [ [[package]] name = "livekit-runtime" version = "0.4.0" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=37835f840d0070d45ac8b31cce6a6ae7aca3f459#37835f840d0070d45ac8b31cce6a6ae7aca3f459" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=cf4375b244ebb51702968df7fc36e192d0f45ad5#cf4375b244ebb51702968df7fc36e192d0f45ad5" dependencies = [ "tokio", "tokio-stream", @@ -19953,7 +19953,7 @@ dependencies = [ [[package]] name = "webrtc-sys" version = "0.3.23" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=37835f840d0070d45ac8b31cce6a6ae7aca3f459#37835f840d0070d45ac8b31cce6a6ae7aca3f459" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=cf4375b244ebb51702968df7fc36e192d0f45ad5#cf4375b244ebb51702968df7fc36e192d0f45ad5" dependencies = [ "cc", "cxx", @@ -19967,7 +19967,7 @@ dependencies = [ [[package]] name = "webrtc-sys-build" version = "0.3.13" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=37835f840d0070d45ac8b31cce6a6ae7aca3f459#37835f840d0070d45ac8b31cce6a6ae7aca3f459" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=cf4375b244ebb51702968df7fc36e192d0f45ad5#cf4375b244ebb51702968df7fc36e192d0f45ad5" dependencies = [ "anyhow", "fs2", diff --git a/Cargo.toml b/Cargo.toml index e1f5b4ffeb9711e00ae698205b5e9312a49adfe8..4ce16aa36a52428fe81bf31391a30d16cecb9824 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -845,8 +845,8 @@ notify = { git = "https://github.com/zed-industries/notify.git", rev = "ce58c24c notify-types = { git = "https://github.com/zed-industries/notify.git", rev = "ce58c24cad542c28e04ced02e20325a4ec28a31d" } windows-capture = { git = "https://github.com/zed-industries/windows-capture.git", rev = "f0d6c1b6691db75461b732f6d5ff56eed002eeb9" } calloop = { git = "https://github.com/zed-industries/calloop" } -livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "37835f840d0070d45ac8b31cce6a6ae7aca3f459" } -libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "37835f840d0070d45ac8b31cce6a6ae7aca3f459" } +livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "cf4375b244ebb51702968df7fc36e192d0f45ad5" } +libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "cf4375b244ebb51702968df7fc36e192d0f45ad5" } [profile.dev] split-debuginfo = "unpacked" diff --git a/script/bundle-linux b/script/bundle-linux index c89d21082dd6c33a11ffcfc908ef87a91554dc18..3487feaf32b9e8258a88a7a1b14c2aafccc37942 100755 --- a/script/bundle-linux +++ b/script/bundle-linux @@ -74,7 +74,15 @@ fi export CC=${CC:-$(which clang)} # Build binary in release mode -export RUSTFLAGS="${RUSTFLAGS:-} -C link-args=-Wl,--disable-new-dtags,-rpath,\$ORIGIN/../lib" +# We need lld to link libwebrtc.a successfully on aarch64-linux. +# NOTE: Since RUSTFLAGS env var overrides all .cargo/config.toml rustflags +# (see https://github.com/rust-lang/cargo/issues/5376), the +# [target.aarch64-unknown-linux-gnu] section in config.toml has no effect here. +if [[ "$(uname -m)" == "aarch64" ]]; then + export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-fuse-ld=lld -C link-args=-Wl,--disable-new-dtags,-rpath,\$ORIGIN/../lib" +else + export RUSTFLAGS="${RUSTFLAGS:-} -C link-args=-Wl,--disable-new-dtags,-rpath,\$ORIGIN/../lib" +fi cargo build --release --target "${target_triple}" --package zed --package cli # Build remote_server in separate invocation to prevent feature unification from other crates # from influencing dynamic libraries required by it. @@ -111,10 +119,12 @@ else fi fi -# Strip debug symbols and save them for upload to DigitalOcean -objcopy --strip-debug "${target_dir}/${target_triple}/release/zed" -objcopy --strip-debug "${target_dir}/${target_triple}/release/cli" -objcopy --strip-debug "${target_dir}/${remote_server_triple}/release/remote_server" +# Strip debug symbols and save them for upload to DigitalOcean. +# We use llvm-objcopy because GNU objcopy on older distros (e.g. Ubuntu 20.04) +# doesn't understand CREL sections produced by newer LLVM. +llvm-objcopy --strip-debug "${target_dir}/${target_triple}/release/zed" +llvm-objcopy --strip-debug "${target_dir}/${target_triple}/release/cli" +llvm-objcopy --strip-debug "${target_dir}/${remote_server_triple}/release/remote_server" # Ensure that remote_server does not depend on libssl nor libcrypto, as we got rid of these deps. if ldd "${target_dir}/${remote_server_triple}/release/remote_server" | grep -q 'libcrypto\|libssl'; then diff --git a/script/linux b/script/linux index c7922355342a7776202f81abf9e471cf32854085..808841aeb39262f148399c643cc17314a9727fef 100755 --- a/script/linux +++ b/script/linux @@ -39,6 +39,8 @@ if [[ -n $apt ]]; then make cmake clang + lld + llvm jq git curl diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index fbe7ef66a331e2e7b84c1b4be7af3482f2b1ce95..27d3819ec72d9117347284610742a0de96d005f3 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -262,18 +262,12 @@ pub fn setup_linux() -> Step { named::bash("./script/linux") } -fn install_mold() -> Step { - named::bash("./script/install-mold") -} - fn download_wasi_sdk() -> Step { named::bash("./script/download-wasi-sdk") } pub(crate) fn install_linux_dependencies(job: Job) -> Job { - job.add_step(setup_linux()) - .add_step(install_mold()) - .add_step(download_wasi_sdk()) + job.add_step(setup_linux()).add_step(download_wasi_sdk()) } pub fn script(name: &str) -> Step { From 949944acb4b33595f329126060132a2cdaca1de9 Mon Sep 17 00:00:00 2001 From: Atchyut Preetham Pulavarthi Date: Mon, 16 Mar 2026 15:22:57 +0530 Subject: [PATCH 28/43] editor: Fix jumbled auto-imports when completing with multiple cursors (#50320) When accepting an autocomplete suggestion with multiple active cursors using `CMD+D`, Zed applies the primary completion edit to all cursors. However, the overlap check for LSP `additionalTextEdits` only verifies the replace range of the newest cursor. If user has a cursor inside an existing import statement at the top of the file and another cursor further down, Zed fails to detect the overlap at the top of the file. When the user auto-completes the import statement ends up jumbled. This fix updates the completion logic to calculate the commit ranges for all active cursors and passes them to the LSP store. The overlap check now iterates over all commit ranges to ensure auto-imports are correctly discarded if they intersect with any of the user's multi-cursor edits. Closes https://github.com/zed-industries/zed/issues/50314 ### Before https://github.com/user-attachments/assets/8d0f71ec-37ab-4714-a318-897d9ee5e56b ### After https://github.com/user-attachments/assets/4c978167-3065-48c0-bc3c-547a2dd22ac3 Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Fixed an issue where accepting an autocomplete suggestion with multiple cursors could result in duplicated or jumbled text in import statements. --- crates/editor/src/editor.rs | 13 ++++- crates/editor/src/editor_tests.rs | 94 +++++++++++++++++++++++++++++++ crates/project/src/lsp_store.rs | 32 ++++++++--- crates/proto/proto/lsp.proto | 1 + 4 files changed, 129 insertions(+), 11 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index fd830c254877463da84e98d21dd39b0e644ca433..2512c362f9c06dc94b231a2ea56168df9e13bf7e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -6481,6 +6481,7 @@ impl Editor { .selections .all::(&self.display_snapshot(cx)); let mut ranges = Vec::new(); + let mut all_commit_ranges = Vec::new(); let mut linked_edits = LinkedEdits::new(); let text: Arc = new_text.clone().into(); @@ -6506,10 +6507,12 @@ impl Editor { ranges.push(range.clone()); + let start_anchor = snapshot.anchor_before(range.start); + let end_anchor = snapshot.anchor_after(range.end); + let anchor_range = start_anchor.text_anchor..end_anchor.text_anchor; + all_commit_ranges.push(anchor_range.clone()); + if !self.linked_edit_ranges.is_empty() { - let start_anchor = snapshot.anchor_before(range.start); - let end_anchor = snapshot.anchor_after(range.end); - let anchor_range = start_anchor.text_anchor..end_anchor.text_anchor; linked_edits.push(&self, anchor_range, text.clone(), cx); } } @@ -6596,6 +6599,7 @@ impl Editor { completions_menu.completions.clone(), candidate_id, true, + all_commit_ranges, cx, ); @@ -26575,6 +26579,7 @@ pub trait CompletionProvider { _completions: Rc>>, _completion_index: usize, _push_to_history: bool, + _all_commit_ranges: Vec>, _cx: &mut Context, ) -> Task>> { Task::ready(Ok(None)) @@ -26943,6 +26948,7 @@ impl CompletionProvider for Entity { completions: Rc>>, completion_index: usize, push_to_history: bool, + all_commit_ranges: Vec>, cx: &mut Context, ) -> Task>> { self.update(cx, |project, cx| { @@ -26952,6 +26958,7 @@ impl CompletionProvider for Entity { completions, completion_index, push_to_history, + all_commit_ranges, cx, ) }) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index f497881531bf4ba39cb22aca4cf90923f7d10b81..683995e8ff0817e9f11c276fba1e85eef29eee7a 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -19888,6 +19888,100 @@ async fn test_completions_with_additional_edits(cx: &mut TestAppContext) { cx.assert_editor_state("fn main() { let a = Some(2)ˇ; }"); } +#[gpui::test] +async fn test_completions_with_additional_edits_and_multiple_cursors(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_typescript( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state( + "import { «Fooˇ» } from './types';\n\nclass Bar {\n method(): «Fooˇ» { return new Foo(); }\n}", + ); + + cx.simulate_keystroke("F"); + cx.simulate_keystroke("o"); + + let completion_item = lsp::CompletionItem { + label: "FooBar".into(), + kind: Some(lsp::CompletionItemKind::CLASS), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 3, + character: 14, + }, + end: lsp::Position { + line: 3, + character: 16, + }, + }, + new_text: "FooBar".to_string(), + })), + additional_text_edits: Some(vec![lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 9, + }, + end: lsp::Position { + line: 0, + character: 11, + }, + }, + new_text: "FooBar".to_string(), + }]), + ..Default::default() + }; + + let closure_completion_item = completion_item.clone(); + let mut request = cx.set_request_handler::(move |_, _, _| { + let task_completion_item = closure_completion_item.clone(); + async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + task_completion_item, + ]))) + } + }); + + request.next().await; + + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }); + + cx.assert_editor_state( + "import { FooBarˇ } from './types';\n\nclass Bar {\n method(): FooBarˇ { return new Foo(); }\n}", + ); + + cx.set_request_handler::(move |_, _, _| { + let task_completion_item = completion_item.clone(); + async move { Ok(task_completion_item) } + }) + .next() + .await + .unwrap(); + + apply_additional_edits.await.unwrap(); + + cx.assert_editor_state( + "import { FooBarˇ } from './types';\n\nclass Bar {\n method(): FooBarˇ { return new Foo(); }\n}", + ); +} + #[gpui::test] async fn test_completions_resolve_updates_labels_if_filter_text_matches(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 8b4f3d7e8e1a6f68a1263fc11dc2e61c4a4890aa..25a614052789c85b8c418086e803b9b5cb9e6fae 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -6643,6 +6643,7 @@ impl LspStore { completions: Rc>>, completion_index: usize, push_to_history: bool, + all_commit_ranges: Vec>, cx: &mut Context, ) -> Task>> { if let Some((client, project_id)) = self.upstream_client() { @@ -6659,6 +6660,11 @@ impl LspStore { new_text: completion.new_text, source: completion.source, })), + all_commit_ranges: all_commit_ranges + .iter() + .cloned() + .map(language::proto::serialize_anchor_range) + .collect(), } }; @@ -6752,12 +6758,15 @@ impl LspStore { let has_overlap = if is_file_start_auto_import { false } else { - let start_within = primary.start.cmp(&range.start, buffer).is_le() - && primary.end.cmp(&range.start, buffer).is_ge(); - let end_within = range.start.cmp(&primary.end, buffer).is_le() - && range.end.cmp(&primary.end, buffer).is_ge(); - let result = start_within || end_within; - result + all_commit_ranges.iter().any(|commit_range| { + let start_within = + commit_range.start.cmp(&range.start, buffer).is_le() + && commit_range.end.cmp(&range.start, buffer).is_ge(); + let end_within = + range.start.cmp(&commit_range.end, buffer).is_le() + && range.end.cmp(&commit_range.end, buffer).is_ge(); + start_within || end_within + }) }; //Skip additional edits which overlap with the primary completion edit @@ -10418,13 +10427,19 @@ impl LspStore { envelope: TypedEnvelope, mut cx: AsyncApp, ) -> Result { - let (buffer, completion) = this.update(&mut cx, |this, cx| { + let (buffer, completion, all_commit_ranges) = this.update(&mut cx, |this, cx| { let buffer_id = BufferId::new(envelope.payload.buffer_id)?; let buffer = this.buffer_store.read(cx).get_existing(buffer_id)?; let completion = Self::deserialize_completion( envelope.payload.completion.context("invalid completion")?, )?; - anyhow::Ok((buffer, completion)) + let all_commit_ranges = envelope + .payload + .all_commit_ranges + .into_iter() + .map(language::proto::deserialize_anchor_range) + .collect::, _>>()?; + anyhow::Ok((buffer, completion, all_commit_ranges)) })?; let apply_additional_edits = this.update(&mut cx, |this, cx| { @@ -10444,6 +10459,7 @@ impl LspStore { }]))), 0, false, + all_commit_ranges, cx, ) }); diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index 226373a111b6e29e4731edd638a5317dcd244273..813f9e9ec652a7b97281bea29f368b0dcf37d537 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -230,6 +230,7 @@ message ApplyCompletionAdditionalEdits { uint64 project_id = 1; uint64 buffer_id = 2; Completion completion = 3; + repeated AnchorRange all_commit_ranges = 4; } message ApplyCompletionAdditionalEditsResponse { From 923b5122af34289d19ec8a5706775cbd78d129e1 Mon Sep 17 00:00:00 2001 From: Finn Eitreim <48069764+feitreim@users.noreply.github.com> Date: Mon, 16 Mar 2026 06:05:14 -0400 Subject: [PATCH 29/43] vim: Fix dot repeat ignoring recorded register (#50753) When a command used an explicit register (e.g. `"_dd` or `"add`), the subsequent dot repeat (`.`) was ignoring that register and using the default instead. Store the register at recording start in `recording_register_for_dot`, persist it to `recorded_register_for_dot` when recording stops, and restore it in `Vim::repeat` when no explicit register is supplied for `.`. An explicit register on `.` (e.g. `"b.`) still takes precedence. This commit also updates the dot-repeat logic to closely follow Neovim's when using numbered registers, where each dot repeat increments the register. For example, after using `"1p`, using `.` will repeat the command using `"2p`, `"3p`, etc. Closes #49867 Release Notes: - Fixed vim's repeat . to preserve the register the recorded command used - Updated vim's repeat . to increment the recorded register when using numbered registers --------- Co-authored-by: dino --- crates/vim/src/normal.rs | 7 +- crates/vim/src/normal/paste.rs | 16 +- crates/vim/src/normal/repeat.rs | 219 ++++++++++++++++++ crates/vim/src/state.rs | 10 + crates/vim/src/vim.rs | 16 +- .../test_data/test_dot_repeat_registers.json | 125 ++++++++++ .../test_dot_repeat_registers_paste.json | 105 +++++++++ 7 files changed, 490 insertions(+), 8 deletions(-) create mode 100644 crates/vim/test_data/test_dot_repeat_registers.json create mode 100644 crates/vim/test_data/test_dot_repeat_registers_paste.json diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 1501d29c7b9b712f3f8edc25025545d0fa0baa08..6763c5cddb8bf2cda6aa4fa0988ff6be67119d3c 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -949,17 +949,16 @@ impl Vim { let current_line = point.row; let percentage = current_line as f32 / lines as f32; let modified = if buffer.is_dirty() { " [modified]" } else { "" }; - vim.status_label = Some( + vim.set_status_label( format!( "{}{} {} lines --{:.0}%--", filename, modified, lines, percentage * 100.0, - ) - .into(), + ), + cx, ); - cx.notify(); }); } diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index ec964ec9ae3af08b108aa027a0aa62883dbcbcc5..fab9b353e3e9bb5b5d00d9d415783b4a5a31ae95 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -50,6 +50,10 @@ impl Vim { }) .filter(|reg| !reg.text.is_empty()) else { + vim.set_status_label( + format!("Nothing in register {}", selected_register.unwrap_or('"')), + cx, + ); return; }; let clipboard_selections = clipboard_selections @@ -249,7 +253,7 @@ impl Vim { ) { self.stop_recording(cx); let selected_register = self.selected_register.take(); - self.update_editor(cx, |_, editor, cx| { + self.update_editor(cx, |vim, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { @@ -262,6 +266,10 @@ impl Vim { globals.read_register(selected_register, Some(editor), cx) }) .filter(|reg| !reg.text.is_empty()) else { + vim.set_status_label( + format!("Nothing in register {}", selected_register.unwrap_or('"')), + cx, + ); return; }; editor.insert(&text, window, cx); @@ -286,7 +294,7 @@ impl Vim { ) { self.stop_recording(cx); let selected_register = self.selected_register.take(); - self.update_editor(cx, |_, editor, cx| { + self.update_editor(cx, |vim, editor, cx| { let text_layout_details = editor.text_layout_details(window, cx); editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); @@ -306,6 +314,10 @@ impl Vim { globals.read_register(selected_register, Some(editor), cx) }) .filter(|reg| !reg.text.is_empty()) else { + vim.set_status_label( + format!("Nothing in register {}", selected_register.unwrap_or('"')), + cx, + ); return; }; editor.insert(&text, window, cx); diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index 8a4bfc241d1b0c62b17464bfb1dd5076015ac638..387bca0912be303fbe86bf947446fe85a50d6022 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -291,6 +291,24 @@ impl Vim { }) else { return; }; + + // Dot repeat always uses the recorded register, ignoring any "X + // override, as the register is an inherent part of the recorded action. + // For numbered registers, Neovim increments on each dot repeat so after + // using `"1p`, using `.` will equate to `"2p", the next `.` to `"3p`, + // etc.. + let recorded_register = cx.global::().recorded_register_for_dot; + let next_register = recorded_register + .filter(|c| matches!(c, '1'..='9')) + .map(|c| ((c as u8 + 1).min(b'9')) as char); + + self.selected_register = next_register.or(recorded_register); + if let Some(next_register) = next_register { + Vim::update_globals(cx, |globals, _| { + globals.recorded_register_for_dot = Some(next_register) + }) + }; + if mode != Some(self.mode) { if let Some(mode) = mode { self.switch_mode(mode, false, window, cx) @@ -441,6 +459,207 @@ mod test { cx.shared_state().await.assert_eq("THE QUICK ˇbrown fox"); } + #[gpui::test] + async fn test_dot_repeat_registers_paste(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // basic paste repeat uses the unnamed register + cx.set_shared_state("ˇhello\n").await; + cx.simulate_shared_keystrokes("y y p").await; + cx.shared_state().await.assert_eq("hello\nˇhello\n"); + cx.simulate_shared_keystrokes(".").await; + cx.shared_state().await.assert_eq("hello\nhello\nˇhello\n"); + + // "_ (blackhole) is recorded and replayed, so the pasted text is still + // the original yanked line. + cx.set_shared_state(indoc! {" + ˇone + two + three + four + "}) + .await; + cx.simulate_shared_keystrokes("y y j \" _ d d . p").await; + cx.shared_state().await.assert_eq(indoc! {" + one + four + ˇone + "}); + + // the recorded register is replayed, not whatever is in the unnamed register + cx.set_shared_state(indoc! {" + ˇone + two + "}) + .await; + cx.simulate_shared_keystrokes("y y j \" a y y \" a p .") + .await; + cx.shared_state().await.assert_eq(indoc! {" + one + two + two + ˇtwo + "}); + + // `"X.` ignores the override and always uses the recorded register. + // Both `dd` calls go into register `a`, so register `b` is empty and + // `"bp` pastes nothing. + cx.set_shared_state(indoc! {" + ˇone + two + three + "}) + .await; + cx.simulate_shared_keystrokes("\" a d d \" b .").await; + cx.shared_state().await.assert_eq(indoc! {" + ˇthree + "}); + cx.simulate_shared_keystrokes("\" a p \" b p").await; + cx.shared_state().await.assert_eq(indoc! {" + three + ˇtwo + "}); + + // numbered registers cycle on each dot repeat: "1p . . uses registers 2, 3, … + // Since the cycling behavior caps at register 9, the first line to be + // deleted `1`, is no longer in any of the registers. + cx.set_shared_state(indoc! {" + ˇone + two + three + four + five + six + seven + eight + nine + ten + "}) + .await; + cx.simulate_shared_keystrokes("d d . . . . . . . . .").await; + cx.shared_state().await.assert_eq(indoc! {"ˇ"}); + cx.simulate_shared_keystrokes("\" 1 p . . . . . . . . .") + .await; + cx.shared_state().await.assert_eq(indoc! {" + + ten + nine + eight + seven + six + five + four + three + two + ˇtwo"}); + + // unnamed register repeat: dd records None, so . pastes the same + // deleted text + cx.set_shared_state(indoc! {" + ˇone + two + three + "}) + .await; + cx.simulate_shared_keystrokes("d d p .").await; + cx.shared_state().await.assert_eq(indoc! {" + two + one + ˇone + three + "}); + + // After `"1p` cycles to `2`, using `"ap` resets recorded_register to `a`, + // so the next `.` uses `a` and not 3. + cx.set_shared_state(indoc! {" + one + two + ˇthree + "}) + .await; + cx.simulate_shared_keystrokes("\" 2 y y k k \" a y y j \" 1 y y k \" 1 p . \" a p .") + .await; + cx.shared_state().await.assert_eq(indoc! {" + one + two + three + one + ˇone + two + three + "}); + } + + // This needs to be a separate test from `test_dot_repeat_registers_paste` + // as Neovim doesn't have support for using registers in replace operations + // by default. + #[gpui::test] + async fn test_dot_repeat_registers_replace(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {" + line ˇone + line two + line three + "}, + Mode::Normal, + ); + + // 1. Yank `one` into register `a` + // 2. Move down and yank `two` into the default register + // 3. Replace `two` with the contents of register `a` + cx.simulate_keystrokes("\" a y w j y w \" a g R w"); + cx.assert_state( + indoc! {" + line one + line onˇe + line three + "}, + Mode::Normal, + ); + + // 1. Move down to `three` + // 2. Repeat the replace operation + cx.simulate_keystrokes("j ."); + cx.assert_state( + indoc! {" + line one + line one + line onˇe + "}, + Mode::Normal, + ); + + // Similar test, but this time using numbered registers, as those should + // automatically increase on successive uses of `.` . + cx.set_state( + indoc! {" + line ˇone + line two + line three + line four + "}, + Mode::Normal, + ); + + // 1. Yank `one` into register `1` + // 2. Yank `two` into register `2` + // 3. Move down and yank `three` into the default register + // 4. Replace `three` with the contents of register `1` + // 5. Move down and repeat + cx.simulate_keystrokes("\" 1 y w j \" 2 y w j y w \" 1 g R w j ."); + cx.assert_state( + indoc! {" + line one + line two + line one + line twˇo + "}, + Mode::Normal, + ); + } + #[gpui::test] async fn test_repeat_ime(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 4e71a698ff0789a462e5ec2e83d673421621c884..9ba744de6855e101a1871ddcf0a84cc3fc931830 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -232,7 +232,15 @@ pub struct VimGlobals { pub recorded_actions: Vec, pub recorded_selection: RecordedSelection, + /// The register being written to by the active `q{register}` macro + /// recording. pub recording_register: Option, + /// The register that was selected at the start of the current + /// dot-recording, for example, `"ap`. + pub recording_register_for_dot: Option, + /// The register from the last completed dot-recording. Used when replaying + /// with `.`. + pub recorded_register_for_dot: Option, pub last_recorded_register: Option, pub last_replayed_register: Option, pub replayer: Option, @@ -919,6 +927,7 @@ impl VimGlobals { self.dot_recording = false; self.recorded_actions = std::mem::take(&mut self.recording_actions); self.recorded_count = self.recording_count.take(); + self.recorded_register_for_dot = self.recording_register_for_dot.take(); self.stop_recording_after_next_action = false; } } @@ -946,6 +955,7 @@ impl VimGlobals { self.dot_recording = false; self.recorded_actions = std::mem::take(&mut self.recording_actions); self.recorded_count = self.recording_count.take(); + self.recorded_register_for_dot = self.recording_register_for_dot.take(); self.stop_recording_after_next_action = false; } } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 3085dc5b3763222eb4b06d2ee551e026feba0002..c1058f5738915359b107865bf99d9f2c73f2085d 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -996,7 +996,14 @@ impl Vim { cx: &mut Context, f: impl Fn(&mut Vim, &A, &mut Window, &mut Context) + 'static, ) { - let subscription = editor.register_action(cx.listener(f)); + let subscription = editor.register_action(cx.listener(move |vim, action, window, cx| { + if !Vim::globals(cx).dot_replaying { + if vim.status_label.take().is_some() { + cx.notify(); + } + } + f(vim, action, window, cx); + })); cx.on_release(|_, _| drop(subscription)).detach(); } @@ -1155,7 +1162,6 @@ impl Vim { let last_mode = self.mode; let prior_mode = self.last_mode; let prior_tx = self.current_tx; - self.status_label.take(); self.last_mode = last_mode; self.mode = mode; self.operator_stack.clear(); @@ -1586,6 +1592,7 @@ impl Vim { globals.dot_recording = true; globals.recording_actions = Default::default(); globals.recording_count = None; + globals.recording_register_for_dot = self.selected_register; let selections = self.editor().map(|editor| { editor.update(cx, |editor, cx| { @@ -2092,6 +2099,11 @@ impl Vim { editor.selections.set_line_mode(state.line_mode); editor.set_edit_predictions_hidden_for_vim_mode(state.hide_edit_predictions, window, cx); } + + fn set_status_label(&mut self, label: impl Into, cx: &mut Context) { + self.status_label = Some(label.into()); + cx.notify(); + } } struct VimEditorSettingsState { diff --git a/crates/vim/test_data/test_dot_repeat_registers.json b/crates/vim/test_data/test_dot_repeat_registers.json new file mode 100644 index 0000000000000000000000000000000000000000..76ca1af20fe14cacb23482cd6988dea16cfb9194 --- /dev/null +++ b/crates/vim/test_data/test_dot_repeat_registers.json @@ -0,0 +1,125 @@ +{"Put":{"state":"ˇhello\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"p"} +{"Get":{"state":"hello\nˇhello\n","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"hello\nhello\nˇhello\n","mode":"Normal"}} +{"Put":{"state":"ˇtocopytext\n1\n2\n3\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"_"} +{"Key":"d"} +{"Key":"d"} +{"Key":"."} +{"Key":"p"} +{"Get":{"state":"tocopytext\n3\nˇtocopytext\n","mode":"Normal"}} +{"Put":{"state":"ˇtocopytext\n1\n2\n3\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"1"} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"j"} +{"Key":"\""} +{"Key":"1"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"tocopytext\n1\n2\n3\nˇ1\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\n"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"d"} +{"Key":"d"} +{"Key":"\""} +{"Key":"b"} +{"Key":"."} +{"Get":{"state":"ˇthree\n","mode":"Normal"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"\""} +{"Key":"b"} +{"Key":"p"} +{"Get":{"state":"three\nˇtwo\n","mode":"Normal"}} +{"Put":{"state":"ˇline one\nline two\n"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"."} +{"Key":"\""} +{"Key":"b"} +{"Key":"."} +{"Get":{"state":"line one\nline two\nline one\nline one\nˇline one\n","mode":"Normal"}} +{"Put":{"state":"ˇ1\n2\n3\n4\n5\n6\n7\n8\n9\n"}} +{"Key":"d"} +{"Key":"d"} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"ˇ","mode":"Normal"}} +{"Key":"\""} +{"Key":"1"} +{"Key":"p"} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"\n9\n8\n7\n6\n5\n4\n3\n2\n1\nˇ1","mode":"Normal"}} +{"Put":{"state":"ˇa\nb\nc\n"}} +{"Key":"\""} +{"Key":"9"} +{"Key":"y"} +{"Key":"y"} +{"Key":"\""} +{"Key":"9"} +{"Key":"p"} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"a\na\na\nˇa\nb\nc\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\n"}} +{"Key":"d"} +{"Key":"d"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"two\none\nˇone\nthree\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\n"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"1"} +{"Key":"y"} +{"Key":"y"} +{"Key":"k"} +{"Key":"\""} +{"Key":"1"} +{"Key":"p"} +{"Key":"."} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"one\ntwo\n9\none\nˇone\ntwo\nthree\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_dot_repeat_registers_paste.json b/crates/vim/test_data/test_dot_repeat_registers_paste.json new file mode 100644 index 0000000000000000000000000000000000000000..f5a08d432d0b1fda8ec1bfe71d7401ec8769d8d2 --- /dev/null +++ b/crates/vim/test_data/test_dot_repeat_registers_paste.json @@ -0,0 +1,105 @@ +{"Put":{"state":"ˇhello\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"p"} +{"Get":{"state":"hello\nˇhello\n","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"hello\nhello\nˇhello\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\nfour\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"_"} +{"Key":"d"} +{"Key":"d"} +{"Key":"."} +{"Key":"p"} +{"Get":{"state":"one\nfour\nˇone\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"a"} +{"Key":"y"} +{"Key":"y"} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"one\ntwo\ntwo\nˇtwo\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\n"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"d"} +{"Key":"d"} +{"Key":"\""} +{"Key":"b"} +{"Key":"."} +{"Get":{"state":"ˇthree\n","mode":"Normal"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"\""} +{"Key":"b"} +{"Key":"p"} +{"Get":{"state":"three\nˇtwo\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n"}} +{"Key":"d"} +{"Key":"d"} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"ˇ","mode":"Normal"}} +{"Key":"\""} +{"Key":"1"} +{"Key":"p"} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"\nten\nnine\neight\nseven\nsix\nfive\nfour\nthree\ntwo\nˇtwo","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\n"}} +{"Key":"d"} +{"Key":"d"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"two\none\nˇone\nthree\n","mode":"Normal"}} +{"Put":{"state":"one\ntwo\nˇthree\n"}} +{"Key":"\""} +{"Key":"2"} +{"Key":"y"} +{"Key":"y"} +{"Key":"k"} +{"Key":"k"} +{"Key":"\""} +{"Key":"a"} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"1"} +{"Key":"y"} +{"Key":"y"} +{"Key":"k"} +{"Key":"\""} +{"Key":"1"} +{"Key":"p"} +{"Key":"."} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"one\ntwo\nthree\none\nˇone\ntwo\nthree\n","mode":"Normal"}} From 357ee0fa3f51f0558f7c7c7e2e22d4518fff5177 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 16 Mar 2026 11:14:26 +0100 Subject: [PATCH 30/43] vim: Fix helix select next match panic when search wraps around (#51642) Fixes ZED-4YP Sort and deduplicate anchor ranges in do_helix_select before passing them to select_anchor_ranges. When the search wraps past the end of the document back to the beginning, the new selection is at a lower offset than the accumulated prior selections, producing unsorted anchors that crash the rope cursor with 'cannot summarize backward'. Release Notes: - Fixed a panic in helix mode with search selecting wrapping around the document end --- crates/vim/src/helix.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 06630d18edfe0d1f3e643f02a1f50e5a1f4a0682..60d87572eb3151f8e36c06f91501921ea9affb3b 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -12,6 +12,7 @@ use editor::{ }; use gpui::actions; use gpui::{Context, Window}; +use itertools::Itertools as _; use language::{CharClassifier, CharKind, Point}; use search::{BufferSearchBar, SearchOptions}; use settings::Settings; @@ -876,11 +877,22 @@ impl Vim { self.update_editor(cx, |_vim, editor, cx| { let snapshot = editor.snapshot(window, cx); editor.change_selections(SelectionEffects::default(), window, cx, |s| { + let buffer = snapshot.buffer_snapshot(); + s.select_anchor_ranges( prior_selections .iter() .cloned() - .chain(s.all_anchors(&snapshot).iter().map(|s| s.range())), + .chain(s.all_anchors(&snapshot).iter().map(|s| s.range())) + .sorted_by(|a, b| { + a.start + .cmp(&b.start, buffer) + .then_with(|| a.end.cmp(&b.end, buffer)) + }) + .dedup_by(|a, b| { + a.start.cmp(&b.start, buffer).is_eq() + && a.end.cmp(&b.end, buffer).is_eq() + }), ); }) }); @@ -1670,6 +1682,25 @@ mod test { cx.assert_state("hello two «oneˇ» two «oneˇ» two «oneˇ»", Mode::HelixSelect); } + #[gpui::test] + async fn test_helix_select_next_match_wrapping(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // Three occurrences of "one". After selecting all three with `n n`, + // pressing `n` again wraps the search to the first occurrence. + // The prior selections (at higher offsets) are chained before the + // wrapped selection (at a lower offset), producing unsorted anchors + // that cause `rope::Cursor::summary` to panic with + // "cannot summarize backward". + cx.set_state("ˇhello two one two one two one", Mode::HelixSelect); + cx.simulate_keystrokes("/ o n e"); + cx.simulate_keystrokes("enter"); + cx.simulate_keystrokes("n n n"); + // Should not panic; all three occurrences should remain selected. + cx.assert_state("hello two «oneˇ» two «oneˇ» two «oneˇ»", Mode::HelixSelect); + } + #[gpui::test] async fn test_helix_substitute(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; From 2a7bd8464b14f3fdf82ae948d8f2545edc369491 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:50:44 +0100 Subject: [PATCH 31/43] audio: Remove unbounded input queue in favor of 1-element queue (#51647) Co-authored-by: Jakub Konka Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Improve recovery of audio latency after CPU-intensive period. Co-authored-by: Jakub Konka --- .../src/livekit_client/playback.rs | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index 88ebdfd389498ae00ad434eb22726a84a5fe1e01..d6fc061321acd8d40a7df0e615bad0b8ecbb1f26 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -3,7 +3,7 @@ use anyhow::{Context as _, Result}; use audio::{AudioSettings, CHANNEL_COUNT, LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE, SAMPLE_RATE}; use cpal::DeviceId; use cpal::traits::{DeviceTrait, StreamTrait as _}; -use futures::channel::mpsc::UnboundedSender; +use futures::channel::mpsc::Sender; use futures::{Stream, StreamExt as _}; use gpui::{ AsyncApp, BackgroundExecutor, Priority, ScreenCaptureFrame, ScreenCaptureSource, @@ -201,7 +201,7 @@ impl AudioStack { let apm = self.apm.clone(); - let (frame_tx, mut frame_rx) = futures::channel::mpsc::unbounded(); + let (frame_tx, mut frame_rx) = futures::channel::mpsc::channel(1); let transmit_task = self.executor.spawn_with_priority(Priority::RealtimeAudio, { async move { while let Some(frame) = frame_rx.next().await { @@ -344,7 +344,7 @@ impl AudioStack { async fn capture_input( executor: BackgroundExecutor, apm: Arc>, - frame_tx: UnboundedSender>, + frame_tx: Sender>, sample_rate: u32, num_channels: u32, input_audio_device: Option, @@ -354,7 +354,7 @@ impl AudioStack { let (device, config) = crate::default_device(true, input_audio_device.as_ref())?; let (end_on_drop_tx, end_on_drop_rx) = std::sync::mpsc::channel::<()>(); let apm = apm.clone(); - let frame_tx = frame_tx.clone(); + let mut frame_tx = frame_tx.clone(); let mut resampler = audio_resampler::AudioResampler::default(); executor @@ -408,7 +408,7 @@ impl AudioStack { .log_err(); buf.clear(); frame_tx - .unbounded_send(AudioFrame { + .try_send(AudioFrame { data: Cow::Owned(sampled), sample_rate, num_channels, @@ -445,7 +445,7 @@ pub struct Speaker { pub sends_legacy_audio: bool, } -fn send_to_livekit(frame_tx: UnboundedSender>, mut microphone: impl Source) { +fn send_to_livekit(mut frame_tx: Sender>, mut microphone: impl Source) { use cpal::Sample; let sample_rate = microphone.sample_rate().get(); let num_channels = microphone.channels().get() as u32; @@ -458,17 +458,19 @@ fn send_to_livekit(frame_tx: UnboundedSender>, mut microphon .map(|s| s.to_sample()) .collect(); - if frame_tx - .unbounded_send(AudioFrame { - sample_rate, - num_channels, - samples_per_channel: sampled.len() as u32 / num_channels, - data: Cow::Owned(sampled), - }) - .is_err() - { - // must rx has dropped or is not consuming - break; + match frame_tx.try_send(AudioFrame { + sample_rate, + num_channels, + samples_per_channel: sampled.len() as u32 / num_channels, + data: Cow::Owned(sampled), + }) { + Ok(_) => {} + Err(err) => { + if !err.is_full() { + // must rx has dropped or is not consuming + break; + } + } } } } From 603e6d6742ef0c9e5f9ea98af9aaae0e6d1b3311 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Mon, 16 Mar 2026 13:13:23 +0100 Subject: [PATCH 32/43] agent_ui: Disable editing and regeneration for subagent messages (#51654) We had inconsistent handling of this read-only behavior. Now subagents should behave the same as agents that don't support editing Release Notes: - agent_ui: Fix inconsistent behavior for subagent views when focusing on previous messages. --- .../src/connection_view/thread_view.rs | 100 ++++++++---------- 1 file changed, 46 insertions(+), 54 deletions(-) diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index eed8de86c841350d507b040287088989ae23c023..c5e44a582cefba92161760711ebd5ba6bf0d1936 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -630,6 +630,7 @@ impl ThreadView { if let Some(AgentThreadEntry::UserMessage(user_message)) = self.thread.read(cx).entries().get(event.entry_index) && user_message.id.is_some() + && !self.is_subagent() { self.editing_message = Some(event.entry_index); cx.notify(); @@ -639,6 +640,7 @@ impl ThreadView { if let Some(AgentThreadEntry::UserMessage(user_message)) = self.thread.read(cx).entries().get(event.entry_index) && user_message.id.is_some() + && !self.is_subagent() { if editor.read(cx).text(cx).as_str() == user_message.content.to_markdown(cx) { self.editing_message = None; @@ -648,7 +650,9 @@ impl ThreadView { } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::SendImmediately) => {} ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => { - self.regenerate(event.entry_index, editor.clone(), window, cx); + if !self.is_subagent() { + self.regenerate(event.entry_index, editor.clone(), window, cx); + } } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => { self.cancel_editing(&Default::default(), window, cx); @@ -3792,14 +3796,12 @@ impl ThreadView { .as_ref() .is_some_and(|checkpoint| checkpoint.show); - let agent_name = self.agent_name.clone(); let is_subagent = self.is_subagent(); - - let non_editable_icon = || { - IconButton::new("non_editable", IconName::PencilUnavailable) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .style(ButtonStyle::Transparent) + let is_editable = message.id.is_some() && !is_subagent; + let agent_name = if is_subagent { + "subagents".into() + } else { + self.agent_name.clone() }; v_flex() @@ -3820,8 +3822,8 @@ impl ThreadView { .gap_1p5() .w_full() .children(rules_item) - .children(message.id.clone().and_then(|message_id| { - message.checkpoint.as_ref()?.show.then(|| { + .when(is_editable && has_checkpoint_button, |this| { + this.children(message.id.clone().map(|message_id| { h_flex() .px_3() .gap_2() @@ -3837,8 +3839,8 @@ impl ThreadView { })) ) .child(Divider::horizontal()) - }) - })) + })) + }) .child( div() .relative() @@ -3854,8 +3856,11 @@ impl ThreadView { }) .border_color(cx.theme().colors().border) .map(|this| { - if is_subagent { - return this.border_dashed(); + if !is_editable { + if is_subagent { + return this.border_dashed(); + } + return this; } if editing && editor_focus { return this.border_color(focus_border); @@ -3863,12 +3868,9 @@ impl ThreadView { if editing && !editor_focus { return this.border_dashed() } - if message.id.is_some() { - return this.shadow_md().hover(|s| { - s.border_color(focus_border.opacity(0.8)) - }); - } - this + this.shadow_md().hover(|s| { + s.border_color(focus_border.opacity(0.8)) + }) }) .text_xs() .child(editor.clone().into_any_element()) @@ -3886,20 +3888,7 @@ impl ThreadView { .overflow_hidden(); let is_loading_contents = self.is_loading_contents; - if is_subagent { - this.child( - base_container.border_dashed().child( - non_editable_icon().tooltip(move |_, cx| { - Tooltip::with_meta( - "Unavailable Editing", - None, - "Editing subagent messages is currently not supported.", - cx, - ) - }), - ), - ) - } else if message.id.is_some() { + if is_editable { this.child( base_container .child( @@ -3938,26 +3927,29 @@ impl ThreadView { this.child( base_container .border_dashed() - .child( - non_editable_icon() - .tooltip(Tooltip::element({ - move |_, _| { - v_flex() - .gap_1() - .child(Label::new("Unavailable Editing")).child( - div().max_w_64().child( - Label::new(format!( - "Editing previous messages is not available for {} yet.", - agent_name.clone() - )) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .into_any_element() - } - })) - ) + .child(IconButton::new("non_editable", IconName::PencilUnavailable) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .style(ButtonStyle::Transparent) + .tooltip(Tooltip::element({ + let agent_name = agent_name.clone(); + move |_, _| { + v_flex() + .gap_1() + .child(Label::new("Unavailable Editing")) + .child( + div().max_w_64().child( + Label::new(format!( + "Editing previous messages is not available for {} yet.", + agent_name + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .into_any_element() + } + }))), ) } }), From 491ff012f7c8f4f5a8c72431eacfb6896b6907be Mon Sep 17 00:00:00 2001 From: Om Chillure Date: Mon, 16 Mar 2026 18:26:29 +0530 Subject: [PATCH 33/43] Fix outline filtering always selecting last match (#50594) Fixes #29774 When filtering the outline panel, matches with equal fuzzy scores previously defaulted to the last item due to iterator `max_by_key` semantics. This caused the bottommost match (e.g. `C::f`) to always be pre-selected regardless of cursor position. Changes: - Select the match nearest to the cursor when scores are tied, using a multi-criteria comparison: score -> cursor containment depth -> proximity to cursor -> earlier index - Move outline search off the UI thread (`smol::block_on` -> async `cx.spawn_in`) to avoid blocking during filtering - Wrap `Outline` in `Arc` for cheap cloning into the async task - Add `match_update_count` to discard results from stale queries Tests : Adds a regression test: `test_outline_filtered_selection_prefers_cursor_proximity_over_last_tie` which passes Video : [Screencast from 2026-03-03 17-01-32.webm](https://github.com/user-attachments/assets/7a27eaed-82a0-4990-85af-08c5a781f269) Release Notes: Fixed the outline filtering always select last match --------- Co-authored-by: Kirill Bulatov --- crates/outline/src/outline.rs | 387 ++++++++++++++++++++++++++++------ 1 file changed, 320 insertions(+), 67 deletions(-) diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 454f6f0b578ce25785f0a356251c8af64776772f..4fb30cec9898534c8c72a83eb7634588ab78f73f 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -1,8 +1,5 @@ use std::ops::Range; -use std::{ - cmp::{self, Reverse}, - sync::Arc, -}; +use std::{cmp, sync::Arc}; use editor::scroll::ScrollOffset; use editor::{Anchor, AnchorRangeExt, Editor, scroll::Autoscroll}; @@ -183,11 +180,10 @@ impl OutlineView { struct OutlineViewDelegate { outline_view: WeakEntity, active_editor: Entity, - outline: Outline, + outline: Arc>, selected_match_index: usize, prev_scroll_position: Option>, matches: Vec, - last_query: String, } enum OutlineRowHighlights {} @@ -202,12 +198,11 @@ impl OutlineViewDelegate { ) -> Self { Self { outline_view, - last_query: Default::default(), matches: Default::default(), selected_match_index: 0, prev_scroll_position: Some(editor.update(cx, |editor, cx| editor.scroll_position(cx))), active_editor: editor, - outline, + outline: Arc::new(outline), } } @@ -280,67 +275,73 @@ impl PickerDelegate for OutlineViewDelegate { window: &mut Window, cx: &mut Context>, ) -> Task<()> { - let selected_index; - if query.is_empty() { + let is_query_empty = query.is_empty(); + if is_query_empty { self.restore_active_editor(window, cx); - self.matches = self - .outline - .items - .iter() - .enumerate() - .map(|(index, _)| StringMatch { - candidate_id: index, - score: Default::default(), - positions: Default::default(), - string: Default::default(), - }) - .collect(); - - let (buffer, cursor_offset) = self.active_editor.update(cx, |editor, cx| { - let buffer = editor.buffer().read(cx).snapshot(cx); - let cursor_offset = editor - .selections - .newest::(&editor.display_snapshot(cx)) - .head(); - (buffer, cursor_offset) - }); - selected_index = self - .outline - .items - .iter() - .enumerate() - .map(|(ix, item)| { - let range = item.range.to_offset(&buffer); - let distance_to_closest_endpoint = cmp::min( - (range.start.0 as isize - cursor_offset.0 as isize).abs(), - (range.end.0 as isize - cursor_offset.0 as isize).abs(), - ); - let depth = if range.contains(&cursor_offset) { - Some(item.depth) - } else { - None - }; - (ix, depth, distance_to_closest_endpoint) - }) - .max_by_key(|(_, depth, distance)| (*depth, Reverse(*distance))) - .map(|(ix, _, _)| ix) - .unwrap_or(0); - } else { - self.matches = smol::block_on( - self.outline - .search(&query, cx.background_executor().clone()), - ); - selected_index = self - .matches - .iter() - .enumerate() - .max_by_key(|(_, m)| OrderedFloat(m.score)) - .map(|(ix, _)| ix) - .unwrap_or(0); } - self.last_query = query; - self.set_selected_index(selected_index, !self.last_query.is_empty(), cx); - Task::ready(()) + + let outline = self.outline.clone(); + cx.spawn_in(window, async move |this, cx| { + let matches = if is_query_empty { + outline + .items + .iter() + .enumerate() + .map(|(index, _)| StringMatch { + candidate_id: index, + score: Default::default(), + positions: Default::default(), + string: Default::default(), + }) + .collect() + } else { + outline + .search(&query, cx.background_executor().clone()) + .await + }; + + let _ = this.update(cx, |this, cx| { + this.delegate.matches = matches; + let selected_index = if is_query_empty { + let (buffer, cursor_offset) = + this.delegate.active_editor.update(cx, |editor, cx| { + let snapshot = editor.display_snapshot(cx); + let cursor_offset = editor + .selections + .newest::(&snapshot) + .head(); + (snapshot.buffer().clone(), cursor_offset) + }); + this.delegate + .matches + .iter() + .enumerate() + .filter_map(|(ix, m)| { + let item = &this.delegate.outline.items[m.candidate_id]; + let range = item.range.to_offset(&buffer); + range.contains(&cursor_offset).then_some((ix, item.depth)) + }) + .max_by_key(|(ix, depth)| (*depth, cmp::Reverse(*ix))) + .map(|(ix, _)| ix) + .unwrap_or(0) + } else { + this.delegate + .matches + .iter() + .enumerate() + .max_by(|(ix_a, a), (ix_b, b)| { + OrderedFloat(a.score) + .cmp(&OrderedFloat(b.score)) + .then(ix_b.cmp(ix_a)) + }) + .map(|(ix, _)| ix) + .unwrap_or(0) + }; + + this.delegate + .set_selected_index(selected_index, !is_query_empty, cx); + }); + }) } fn confirm( @@ -586,6 +587,246 @@ mod tests { assert_single_caret_at_row(&editor, expected_first_highlighted_row, cx); } + #[gpui::test] + async fn test_outline_empty_query_prefers_deepest_containing_symbol_else_first( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "a.rs": indoc! {" + // display line 0 + struct Outer { // display line 1 + fn top(&self) {// display line 2 + let _x = 1;// display line 3 + } // display line 4 + } // display line 5 + + struct Another; // display line 7 + "} + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + project.read_with(cx, |project, _| { + project.languages().add(language::rust_lang()) + }); + + let (workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = cx.read(|cx| workspace.read(cx).workspace().clone()); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + let _buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + set_single_caret_at_row(&editor, 3, cx); + let outline_view = open_outline_view(&workspace, cx); + cx.run_until_parked(); + let (selected_candidate_id, expected_deepest_containing_candidate_id) = outline_view + .update(cx, |outline_view, cx| { + let delegate = &outline_view.delegate; + let selected_candidate_id = + delegate.matches[delegate.selected_match_index].candidate_id; + let (buffer, cursor_offset) = delegate.active_editor.update(cx, |editor, cx| { + let buffer = editor.buffer().read(cx).snapshot(cx); + let cursor_offset = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); + (buffer, cursor_offset) + }); + let deepest_containing_candidate_id = delegate + .outline + .items + .iter() + .enumerate() + .filter_map(|(ix, item)| { + item.range + .to_offset(&buffer) + .contains(&cursor_offset) + .then_some((ix, item.depth)) + }) + .max_by(|(ix_a, depth_a), (ix_b, depth_b)| { + depth_a.cmp(depth_b).then(ix_b.cmp(ix_a)) + }) + .map(|(ix, _)| ix) + .unwrap(); + (selected_candidate_id, deepest_containing_candidate_id) + }); + assert_eq!( + selected_candidate_id, expected_deepest_containing_candidate_id, + "Empty query should select the deepest symbol containing the cursor" + ); + + cx.dispatch_action(menu::Cancel); + cx.run_until_parked(); + + set_single_caret_at_row(&editor, 0, cx); + let outline_view = open_outline_view(&workspace, cx); + cx.run_until_parked(); + let selected_candidate_id = outline_view.read_with(cx, |outline_view, _| { + let delegate = &outline_view.delegate; + delegate.matches[delegate.selected_match_index].candidate_id + }); + assert_eq!( + selected_candidate_id, 0, + "Empty query should fall back to the first symbol when cursor is outside all symbol ranges" + ); + } + + #[gpui::test] + async fn test_outline_filtered_selection_prefers_first_match_on_score_ties( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "a.rs": indoc! {" + struct A; + impl A { + fn f(&self) {} + fn g(&self) {} + } + + struct B; + impl B { + fn f(&self) {} + fn g(&self) {} + } + + struct C; + impl C { + fn f(&self) {} + fn g(&self) {} + } + "} + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + project.read_with(cx, |project, _| { + project.languages().add(language::rust_lang()) + }); + + let (workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = cx.read(|cx| workspace.read(cx).workspace().clone()); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + let _buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + assert_single_caret_at_row(&editor, 0, cx); + let outline_view = open_outline_view(&workspace, cx); + let match_ids = |outline_view: &Entity>, + cx: &mut VisualTestContext| { + outline_view.read_with(cx, |outline_view, _| { + let delegate = &outline_view.delegate; + let selected_match = &delegate.matches[delegate.selected_match_index]; + let scored_ids = delegate + .matches + .iter() + .filter(|m| m.score > 0.0) + .map(|m| m.candidate_id) + .collect::>(); + ( + selected_match.candidate_id, + *scored_ids.first().unwrap(), + *scored_ids.last().unwrap(), + scored_ids.len(), + ) + }) + }; + + outline_view + .update_in(cx, |outline_view, window, cx| { + outline_view + .delegate + .update_matches("f".to_string(), window, cx) + }) + .await; + let (selected_id, first_scored_id, last_scored_id, scored_match_count) = + match_ids(&outline_view, cx); + + assert!( + scored_match_count > 1, + "Expected multiple scored matches for `f` in outline filtering" + ); + assert_eq!( + selected_id, first_scored_id, + "Filtered query should pick the first scored match when scores tie" + ); + assert_ne!( + selected_id, last_scored_id, + "Selection should not default to the last scored match" + ); + + set_single_caret_at_row(&editor, 12, cx); + outline_view + .update_in(cx, |outline_view, window, cx| { + outline_view + .delegate + .update_matches("f".to_string(), window, cx) + }) + .await; + let (selected_id, first_scored_id, last_scored_id, scored_match_count) = + match_ids(&outline_view, cx); + + assert!( + scored_match_count > 1, + "Expected multiple scored matches for `f` in outline filtering" + ); + assert_eq!( + selected_id, first_scored_id, + "Filtered selection should stay score-ordered and not switch based on cursor proximity" + ); + assert_ne!( + selected_id, last_scored_id, + "Selection should not default to the last scored match" + ); + } + fn open_outline_view( workspace: &Entity, cx: &mut VisualTestContext, @@ -634,6 +875,18 @@ mod tests { }) } + fn set_single_caret_at_row( + editor: &Entity, + buffer_row: u32, + cx: &mut VisualTestContext, + ) { + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([rope::Point::new(buffer_row, 0)..rope::Point::new(buffer_row, 0)]) + }); + }); + } + fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); From 93f3286faeaf664be178447329c624c2072bcbf6 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Mon, 16 Mar 2026 13:03:33 +0000 Subject: [PATCH 34/43] gpui: Add Unicode range for Bengali (#51659) --- crates/gpui/src/text_system/line_wrapper.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 07df35472b0bd3f91b8096439ed82cf811b45c77..9a7d10133bb9bd57b86c3e08e1a21e47fec38b96 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -236,6 +236,9 @@ impl LineWrapper { matches!(c, '\u{1E00}'..='\u{1EFF}') || // Latin Extended Additional matches!(c, '\u{0300}'..='\u{036F}') || // Combining Diacritical Marks + // Bengali (https://en.wikipedia.org/wiki/Bengali_(Unicode_block)) + matches!(c, '\u{0980}'..='\u{09FF}') || + // Some other known special characters that should be treated as word characters, // e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`, // `2^3`, `a~b`, `a=1`, `Self::new`, etc. @@ -856,6 +859,10 @@ mod tests { assert_word("АБВГДЕЖЗИЙКЛМНОП"); // Vietnamese (https://github.com/zed-industries/zed/issues/23245) assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng"); + // Bengali + assert_word("গিয়েছিলেন"); + assert_word("ছেলে"); + assert_word("হচ্ছিল"); // non-word characters assert_not_word("你好"); From 3d7d2cac4b167dc02a8ac796302cf8b02e62fe5f Mon Sep 17 00:00:00 2001 From: Lena <241371603+zelenenka@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:21:27 +0100 Subject: [PATCH 35/43] Add autolabeling the guild PRs (#51663) Quick-and-dirty version as we're trying this out with the first cohort. Release Notes: - N/A --- .github/workflows/pr_labeler.yml | 53 +++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr_labeler.yml b/.github/workflows/pr_labeler.yml index cc9c4a9eefd4aa75ba69fb18b353efa6a32778c5..ce5b5f39e6769de8f793b2effd0dee73b2c7d2b8 100644 --- a/.github/workflows/pr_labeler.yml +++ b/.github/workflows/pr_labeler.yml @@ -1,5 +1,6 @@ # Labels pull requests by author: 'bot' for bot accounts, 'staff' for -# staff team members, 'first contribution' for first-time external contributors. +# staff team members, 'guild' for guild members, 'first contribution' for +# first-time external contributors. name: PR Labeler on: @@ -29,8 +30,47 @@ jobs: script: | const BOT_LABEL = 'bot'; const STAFF_LABEL = 'staff'; + const GUILD_LABEL = 'guild'; const FIRST_CONTRIBUTION_LABEL = 'first contribution'; const STAFF_TEAM_SLUG = 'staff'; + const GUILD_MEMBERS = [ + '11happy', + 'AidanV', + 'AmaanBilwar', + 'OmChillure', + 'Palanikannan1437', + 'Shivansh-25', + 'SkandaBhat', + 'TwistingTwists', + 'YEDASAVG', + 'Ziqi-Yang', + 'alanpjohn', + 'arjunkomath', + 'austincummings', + 'ayushk-1801', + 'claiwe', + 'criticic', + 'dongdong867', + 'emamulandalib', + 'eureka928', + 'iam-liam', + 'iksuddle', + 'ishaksebsib', + 'lingyaochu', + 'marcocondrache', + 'mchisolm0', + 'nairadithya', + 'nihalxkumar', + 'notJoon', + 'polyesterswing', + 'prayanshchh', + 'razeghi71', + 'sarmadgulzar', + 'seanstrom', + 'th0jensen', + 'tommyming', + 'virajbhartiya', + ]; const pr = context.payload.pull_request; const author = pr.user.login; @@ -71,6 +111,17 @@ jobs: return; } + if (GUILD_MEMBERS.includes(author)) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: [GUILD_LABEL] + }); + console.log(`PR #${pr.number} by ${author}: labeled '${GUILD_LABEL}' (guild member)`); + // No early return: guild members can also get 'first contribution' + } + // We use inverted logic here due to a suspected GitHub bug where first-time contributors // get 'NONE' instead of 'FIRST_TIME_CONTRIBUTOR' or 'FIRST_TIMER'. // https://github.com/orgs/community/discussions/78038 From b0fd10134f27690d449fad18323f6da299b3db31 Mon Sep 17 00:00:00 2001 From: Giorgi Merebashvili Date: Mon, 16 Mar 2026 17:27:41 +0400 Subject: [PATCH 36/43] file_finder: Fix project root appearing in file paths while searching when hide_root=true (#51530) Closes #45135 Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Fixed project root name appearing in file paths while searching in file finder. --- crates/file_finder/src/file_finder.rs | 58 +++++----- crates/file_finder/src/file_finder_tests.rs | 113 ++++++++++++++++++++ 2 files changed, 143 insertions(+), 28 deletions(-) diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 7e0c584c739caa9c71f87be9673a04bd9b9b840f..3dcd052c34acc3a28650c58079c82499b7e94c85 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -563,18 +563,21 @@ impl Matches { .extend(history_items.into_iter().map(path_to_entry)); return; }; - // If several worktress are open we have to set the worktree root names in path prefix - let several_worktrees = worktree_store.read(cx).worktrees().count() > 1; - let worktree_name_by_id = several_worktrees.then(|| { - worktree_store - .read(cx) - .worktrees() - .map(|worktree| { - let snapshot = worktree.read(cx).snapshot(); - (snapshot.id(), snapshot.root_name().into()) - }) - .collect() - }); + + let worktree_name_by_id = if should_hide_root_in_entry_path(&worktree_store, cx) { + None + } else { + Some( + worktree_store + .read(cx) + .worktrees() + .map(|worktree| { + let snapshot = worktree.read(cx).snapshot(); + (snapshot.id(), snapshot.root_name().into()) + }) + .collect(), + ) + }; let new_history_matches = matching_history_items( history_items, currently_opened, @@ -797,6 +800,16 @@ fn matching_history_items<'a>( matching_history_paths } +fn should_hide_root_in_entry_path(worktree_store: &Entity, cx: &App) -> bool { + let multiple_worktrees = worktree_store + .read(cx) + .visible_worktrees(cx) + .filter(|worktree| !worktree.read(cx).is_single_file()) + .nth(1) + .is_some(); + ProjectPanelSettings::get_global(cx).hide_root && !multiple_worktrees +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] struct FoundPath { project: ProjectPath, @@ -902,14 +915,12 @@ impl FileFinderDelegate { .currently_opened_path .as_ref() .map(|found_path| Arc::clone(&found_path.project.path)); - let worktrees = self - .project - .read(cx) - .worktree_store() + let worktree_store = self.project.read(cx).worktree_store(); + let worktrees = worktree_store .read(cx) .visible_worktrees_and_single_files(cx) .collect::>(); - let include_root_name = worktrees.len() > 1; + let include_root_name = !should_hide_root_in_entry_path(&worktree_store, cx); let candidate_sets = worktrees .into_iter() .map(|worktree| { @@ -1135,17 +1146,8 @@ impl FileFinderDelegate { if let Some(panel_match) = panel_match { self.labels_for_path_match(&panel_match.0, path_style) } else if let Some(worktree) = worktree { - let multiple_folders_open = self - .project - .read(cx) - .visible_worktrees(cx) - .filter(|worktree| !worktree.read(cx).is_single_file()) - .nth(1) - .is_some(); - - let full_path = if ProjectPanelSettings::get_global(cx).hide_root - && !multiple_folders_open - { + let worktree_store = self.project.read(cx).worktree_store(); + let full_path = if should_hide_root_in_entry_path(&worktree_store, cx) { entry_path.project.path.clone() } else { worktree.read(cx).root_name().join(&entry_path.project.path) diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index da9fd4b87b045a6321a291cb7128a051d977815b..cd9f22ef9e9c09a828ceced449ebafb9c3c2e12b 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -400,6 +400,18 @@ async fn test_absolute_paths(cx: &mut TestAppContext) { #[gpui::test] async fn test_complex_path(cx: &mut TestAppContext) { let app_state = init_test(cx); + + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() @@ -1413,6 +1425,18 @@ async fn test_create_file_no_focused_with_multiple_worktrees(cx: &mut TestAppCon #[gpui::test] async fn test_path_distance_ordering(cx: &mut TestAppContext) { let app_state = init_test(cx); + + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() @@ -1648,6 +1672,17 @@ async fn test_query_history(cx: &mut gpui::TestAppContext) { async fn test_history_match_positions(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() @@ -2148,6 +2183,17 @@ async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) { async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() @@ -2253,6 +2299,17 @@ async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) { async fn test_search_sorts_history_items(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() @@ -2736,6 +2793,17 @@ async fn test_selected_history_item_stays_selected_on_worktree_updated(cx: &mut async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() @@ -2784,6 +2852,17 @@ async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppCo async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() @@ -3183,6 +3262,17 @@ async fn test_history_items_uniqueness_for_multiple_worktree_open_all_files( async fn test_selected_match_stays_selected_after_matches_refreshed(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state.fs.as_fake().insert_tree("/src", json!({})).await; app_state @@ -3779,6 +3869,17 @@ fn assert_match_at_position( async fn test_filename_precedence(cx: &mut TestAppContext) { let app_state = init_test(cx); + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() @@ -3823,6 +3924,18 @@ async fn test_filename_precedence(cx: &mut TestAppContext) { #[gpui::test] async fn test_paths_with_starting_slash(cx: &mut TestAppContext) { let app_state = init_test(cx); + + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() From 70a5669842378e9259eabf24c6b5d89c0b950769 Mon Sep 17 00:00:00 2001 From: Om Chillure Date: Mon, 16 Mar 2026 19:11:55 +0530 Subject: [PATCH 37/43] agent_servers: Fix Claude Agent model resetting to default (#51587) #### Fixes #51082 #### Description : When a user configures `default_config_options` (e.g. a specific model) for an ACP agent, the setting was only applied when creating a new session. Resuming or loading an existing session (e.g. from history) skipped this step, causing the model dropdown to revert to "Default (recommended)". #### Fix : Extract the config options defaulting logic into a shared helper apply_default_config_options and call it consistently across all three session entry points: new_session, resume_session, and load_session Release Notes: #### Release notes : Fixed model dropdown resetting to "Default (recommended)" when opening a previous conversation from history. Configured models (via default_config_options) are now correctly applied when resuming or loading existing sessions. #### Video : [Screencast from 2026-03-15 12-55-58.webm](https://github.com/user-attachments/assets/977747b9-390c-4f78-91fc-91feace444e1) --- crates/agent_servers/src/acp.rs | 194 ++++++++++++++++++-------------- 1 file changed, 109 insertions(+), 85 deletions(-) diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index ba0851565e4ee84e1eb4360a6391a1ad442602cf..8f7f7c94535453f0c2c0598de2b86bf51cd79a9d 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -361,6 +361,102 @@ impl AcpConnection { pub fn prompt_capabilities(&self) -> &acp::PromptCapabilities { &self.agent_capabilities.prompt_capabilities } + + fn apply_default_config_options( + &self, + session_id: &acp::SessionId, + config_options: &Rc>>, + cx: &mut AsyncApp, + ) { + let name = self.server_name.clone(); + let defaults_to_apply: Vec<_> = { + let config_opts_ref = config_options.borrow(); + config_opts_ref + .iter() + .filter_map(|config_option| { + let default_value = self.default_config_options.get(&*config_option.id.0)?; + + let is_valid = match &config_option.kind { + acp::SessionConfigKind::Select(select) => match &select.options { + acp::SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .any(|opt| &*opt.value.0 == default_value.as_str()), + acp::SessionConfigSelectOptions::Grouped(groups) => { + groups.iter().any(|g| { + g.options + .iter() + .any(|opt| &*opt.value.0 == default_value.as_str()) + }) + } + _ => false, + }, + _ => false, + }; + + if is_valid { + let initial_value = match &config_option.kind { + acp::SessionConfigKind::Select(select) => { + Some(select.current_value.clone()) + } + _ => None, + }; + Some(( + config_option.id.clone(), + default_value.clone(), + initial_value, + )) + } else { + log::warn!( + "`{}` is not a valid value for config option `{}` in {}", + default_value, + config_option.id.0, + name + ); + None + } + }) + .collect() + }; + + for (config_id, default_value, initial_value) in defaults_to_apply { + cx.spawn({ + let default_value_id = acp::SessionConfigValueId::new(default_value.clone()); + let session_id = session_id.clone(); + let config_id_clone = config_id.clone(); + let config_opts = config_options.clone(); + let conn = self.connection.clone(); + async move |_| { + let result = conn + .set_session_config_option(acp::SetSessionConfigOptionRequest::new( + session_id, + config_id_clone.clone(), + default_value_id, + )) + .await + .log_err(); + + if result.is_none() { + if let Some(initial) = initial_value { + let mut opts = config_opts.borrow_mut(); + if let Some(opt) = opts.iter_mut().find(|o| o.id == config_id_clone) { + if let acp::SessionConfigKind::Select(select) = &mut opt.kind { + select.current_value = initial; + } + } + } + } + } + }) + .detach(); + + let mut opts = config_options.borrow_mut(); + if let Some(opt) = opts.iter_mut().find(|o| o.id == config_id) { + if let acp::SessionConfigKind::Select(select) = &mut opt.kind { + select.current_value = acp::SessionConfigValueId::new(default_value); + } + } + } + } } impl Drop for AcpConnection { @@ -471,89 +567,7 @@ impl AgentConnection for AcpConnection { } if let Some(config_opts) = config_options.as_ref() { - let defaults_to_apply: Vec<_> = { - let config_opts_ref = config_opts.borrow(); - config_opts_ref - .iter() - .filter_map(|config_option| { - let default_value = self.default_config_options.get(&*config_option.id.0)?; - - let is_valid = match &config_option.kind { - acp::SessionConfigKind::Select(select) => match &select.options { - acp::SessionConfigSelectOptions::Ungrouped(options) => { - options.iter().any(|opt| &*opt.value.0 == default_value.as_str()) - } - acp::SessionConfigSelectOptions::Grouped(groups) => groups - .iter() - .any(|g| g.options.iter().any(|opt| &*opt.value.0 == default_value.as_str())), - _ => false, - }, - _ => false, - }; - - if is_valid { - let initial_value = match &config_option.kind { - acp::SessionConfigKind::Select(select) => { - Some(select.current_value.clone()) - } - _ => None, - }; - Some((config_option.id.clone(), default_value.clone(), initial_value)) - } else { - log::warn!( - "`{}` is not a valid value for config option `{}` in {}", - default_value, - config_option.id.0, - name - ); - None - } - }) - .collect() - }; - - for (config_id, default_value, initial_value) in defaults_to_apply { - cx.spawn({ - let default_value_id = acp::SessionConfigValueId::new(default_value.clone()); - let session_id = response.session_id.clone(); - let config_id_clone = config_id.clone(); - let config_opts = config_opts.clone(); - let conn = self.connection.clone(); - async move |_| { - let result = conn - .set_session_config_option( - acp::SetSessionConfigOptionRequest::new( - session_id, - config_id_clone.clone(), - default_value_id, - ), - ) - .await - .log_err(); - - if result.is_none() { - if let Some(initial) = initial_value { - let mut opts = config_opts.borrow_mut(); - if let Some(opt) = opts.iter_mut().find(|o| o.id == config_id_clone) { - if let acp::SessionConfigKind::Select(select) = - &mut opt.kind - { - select.current_value = initial; - } - } - } - } - } - }) - .detach(); - - let mut opts = config_opts.borrow_mut(); - if let Some(opt) = opts.iter_mut().find(|o| o.id == config_id) { - if let acp::SessionConfigKind::Select(select) = &mut opt.kind { - select.current_value = acp::SessionConfigValueId::new(default_value); - } - } - } + self.apply_default_config_options(&response.session_id, config_opts, cx); } let action_log = cx.new(|_| ActionLog::new(project.clone())); @@ -641,7 +655,7 @@ impl AgentConnection for AcpConnection { }, ); - cx.spawn(async move |_| { + cx.spawn(async move |cx| { let response = match self .connection .load_session( @@ -658,6 +672,11 @@ impl AgentConnection for AcpConnection { let (modes, models, config_options) = config_state(response.modes, response.models, response.config_options); + + if let Some(config_opts) = config_options.as_ref() { + self.apply_default_config_options(&session_id, config_opts, cx); + } + if let Some(session) = self.sessions.borrow_mut().get_mut(&session_id) { session.session_modes = modes; session.models = models; @@ -716,7 +735,7 @@ impl AgentConnection for AcpConnection { }, ); - cx.spawn(async move |_| { + cx.spawn(async move |cx| { let response = match self .connection .resume_session( @@ -734,6 +753,11 @@ impl AgentConnection for AcpConnection { let (modes, models, config_options) = config_state(response.modes, response.models, response.config_options); + + if let Some(config_opts) = config_options.as_ref() { + self.apply_default_config_options(&session_id, config_opts, cx); + } + if let Some(session) = self.sessions.borrow_mut().get_mut(&session_id) { session.session_modes = modes; session.models = models; From 0e2ce490656687ac1dce16fd581898517cf0534f Mon Sep 17 00:00:00 2001 From: Dong Date: Mon, 16 Mar 2026 21:45:58 +0800 Subject: [PATCH 38/43] markdown_preview: Fix not re-rendering issue when editing by agent (#50583) Closes #47900 ## Root cause The current markdown preview only re-renders on `EditorEvent::Edited, DirtyChanged, ExcerptsEdited`, but agent edits are implemented via [`buffer.edit()`](https://github.com/dongdong867/zed/blob/eb3f92708b6dc67bb534c1c44a200c0cb5c4997b/crates/agent/src/edit_agent.rs#L375) which does not guaranty to emit the `EditorEvent::Edited` event. Causing the markdown preview stuck on the last received parsed markdown. ## Applied fix Subscribing to `EditorEvent::BufferEdited` when initializing the markdown preview view. This will cause the view to update when received `BufferEdited` event including agent edits to the file. ## As is/ To be As is | To be --- | ---