Compare commits

...

61 commits

Author SHA1 Message Date
Pranav Nedungadi
60099a06df
git_ui: Add restore buttons to unstaged diff view (#60639)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
The unstaged diff view introduced in the partially staged changes commit
only had a Stage button but no Restore option. This adds:

- A per-hunk Restore button in the inline hunk controls (disabled for
new files)
- A Restore button in the toolbar for selected hunks
- A Restore All button in the toolbar to discard all unstaged changes
- A restore method override on UnstagedDiffDelegate for the Restore
action

Release Notes:

- Added restore buttons to the unstaged diff view for discarding
unstaged changes.

# Objective
New unstaged and staged diffs were added in #46541 . However for
unstaged changes, only option available is to stage a change. It would
also be a common use case to restore the changes. (Like how it is done
in uncommitted changes).

## Solution
- A per-hunk Restore button in the inline hunk controls (disabled for
new files)
- A Restore button in the toolbar for selected hunks
- A Restore All button in the toolbar to discard all unstaged changes
- A restore method override on UnstagedDiffDelegate for the Restore
action

## Testing
Tested in locally and ensured both features work

## Self-Review Checklist:

- [ ] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [ ] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [ ] Tests cover the new/changed behavior
- [ ] Performance impact has been considered and is acceptable

## Showcase
<img width="1141" height="635" alt="Screenshot 2026-07-09 at 9 00 47 AM"
src="https://github.com/user-attachments/assets/b1d3f091-9f1d-49c6-87e6-9efad25e62dd"
/>

## Note
I am still learning rust bit by bit. Please let me know if something is
massively wrong. This is assisted by AI but reviewed by me with best of
my knowledge.

---

Release Notes:

- Added restore buttons to the unstaged diff view for discarding
unstaged changes.

---------

Signed-off-by: Pranav <pranav10121@gmail.com>
Co-authored-by: Christopher Biscardi <chris@christopherbiscardi.com>
2026-07-12 13:39:09 +00:00
Mikayla Maki
49ad06c1b4
gpui: Add container_query element (#60774)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
Adds a `container_query` element to GPUI, in the spirit of CSS container
queries: the element's own size is determined solely by its style and
the space offered by its parent, and once that size is known the
provided closure is called with the measured size to build the contents.

```rust
container_query(|size, _window, _cx| {
    if size.width < px(240.) {
        div().child("Narrow layout")
    } else {
        div().child("Wide layout")
    }
});
```

Implementation notes:

- Defaults to filling its parent (`size_full()` semantics), overridable
via `Styled` since contents can't influence the container's size (the
same constraint CSS container queries impose).
- Reworks the `grid_layout` example (the Holy Grail layout) to
demonstrate it: the three-column grid collapses to a stacked column when
the window is narrower than 400px, and the header shows the live
measured width.

Release Notes:

- N/A

---------

Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
2026-07-12 03:38:24 +00:00
Mikayla Maki
b0da438545
gpui: Fill auto-sized window roots to the viewport (#60739)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
Window roots whose size is `auto` now stretch to fill the window, the
way the root element on the web fills the initial containing block.
Previously a window root with no explicit size shrink-wrapped its
content (flex/grid roots collapsed to content size in both axes), which
is why every root view needed `size_full()`.

Mechanically: `draw_roots` requests layout for the window root (and
prompt roots), then `TaffyLayoutEngine::stretch_auto_size_to_fill`
rewrites any `auto` dimension on the root node to the viewport size
before layout runs. Explicitly styled root dimensions are preserved, and
tooltips, drags, and anchored/deferred draws keep their shrink-wrap
semantics.

Includes regression tests covering both the auto-fill and explicit-size
cases (the auto case collapses to 0×0 without the fix).

Release Notes:

- N/A

---------

Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
2026-07-11 23:02:10 +00:00
Mikayla Maki
9c3273201c
gpui: Add mobile/touch API surface (#60496)
TODO:

- [ ] Remove slop comments
- [ ] Review APIs in detail

# Objective

- This PR aims to add official mobile API surfaces to GPUI

## Solution

- This PR cross references several internal experiments to come up with
a general cross-platform abstraction for core input mechanisms, but
defers actual implementations to later. These are intended to be a
common base to build off of.

## Testing

- Yes, as mentioned above with external experiments

## Self-Review Checklist:

- [ ] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

---

Release Notes:

- N/A
2026-07-11 22:14:52 +00:00
Bing Wang
df37766060
terminal: Fix terminal jitter on resize (#56715)
Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #55934

Release Notes:

- Fixed terminal jitter when resizing vertically with content that
doesn't fill the available height.

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>
2026-07-11 20:03:03 +00:00
Sathwik Chirivelli
65e1c5af25
git_panel: Add group by staging view option (#59884)
Some checks are pending
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
# Objective

- Add a view option for group by staging.

## Solution

- Add a new option for group_by under git_panel view options, with 2
sections "Staged" and "Unstaged", with buttons (+/-) to stage and
unstage

## Testing

- cargo check -p git_ui

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

## Showcase

<img width="1679" height="1140" alt="Screenshot 2026-06-25 at 2 09
44 PM"
src="https://github.com/user-attachments/assets/1b605cad-7792-4823-983c-ada41be25504"
/>


---

Release Notes:

- Added group by staging view option

---------

Co-authored-by: Christopher Biscardi <chris@christopherbiscardi.com>
2026-07-11 13:36:18 +00:00
Eli Stark
bc99075373
Guard OpenCode bell plugin in ACP mode (#60507)
## Summary

- Add an ACP-mode guard to the documented OpenCode bell plugin snippet
- Preserve terminal bell notifications for Terminal Threads while
avoiding writes to stdout when OpenCode is used as an ACP External Agent

## Rationale

The OpenCode bell plugin writes BEL to stdout for Terminal Thread
notifications. When the plugin is installed globally and OpenCode runs
in ACP mode, stdout is the JSON-RPC transport. OpenCode sets
`OPENCODE_CLIENT=acp` in this mode, so the guard prevents BEL bytes from
corrupting ACP JSON-RPC messages, including usage updates and permission
flows.

Release Notes:

- N/A
2026-07-11 12:46:17 +00:00
morgankrey
5f8a7413a3
Update Zed-hosted model documentation (#60771)
Some checks are pending
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
## Summary

- Document pricing and Zed-hosted context limits for Claude Fable 5,
Claude Sonnet 5, and GPT-5.6 Sol, Terra, and Luna.
- Record recent Gemini and xAI model retirements and replacement
guidance.
- Put the Fable safety-retention warning on the hosted-model reference
and align the privacy docs with the current Anthropic Covered Models
terminology and retention period.

## Sources

- zed-industries/cloud origin/main at
f5d109a868a303241e89ea30d6da8da19699ead4
- OpenAI GPT-5.6 model documentation
- Anthropic Covered Models retention policy
- xAI May 15, 2026 model retirement guide

## Testing

- pnpm dlx prettier@3.5.0 . --check
- mdbook build docs

Release Notes:

- N/A
2026-07-10 23:24:52 +00:00
Cameron Mcloughlin
8f92822cbf
agent: Sandbox security review and docs update (#60291)
Closes security loopholes and updates docs:
- installs seccomp filter for blocking naughty syscalls
- tightens macos seatbelt profile
- fetch tool responses that redirect are now constrained by allowed
domains list

Also adds a few "Learn More" buttons that link to the new docs.

Also fixes a bug where the agent would try to create a
`~/.config/zed/AGENTS.md` directory

Also adds unicode confusable detection to URL/path privilege escalation
prompts.

---

Release Notes:

- N/A or Added/Fixed/Improved ...

---------

Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
2026-07-10 22:51:41 +00:00
MartinYe1234
7344a31ddc
agent: Fix edit_file corrupting indentation when old_text omits first-line indent (#60613)
The `edit_file` tool re-indents `new_text` by computing a single indent
delta from the first line of `old_text` versus the matched buffer line,
then applying that delta to every line of the replacement. When a model
omits the leading indentation on only the first line of
`old_text`/`new_text` (a common pattern when copying from mid-line
context), the delta computed from the first line was wrongly applied to
the remaining, already-correctly-indented lines, doubling their
indentation.

This PR:

- Tracks the `(query_row, buffer_row)` line pairs aligned by the
streaming fuzzy matcher so the indent of each `old_text` line can be
compared against the buffer line it actually matched.
- Computes a separate indent delta for the lines after the first: when
those lines agree on a consistent delta, it's used for the rest of the
replacement; otherwise the previous uniform behavior is preserved.
- Keeps `query_lines`/line pairs in sync when `finish()` extends a match
with a trailing incomplete line.
- Adds an end-to-end regression test reproducing the issue, plus unit
tests for the new re-indentation logic.

Closes https://github.com/zed-industries/zed/issues/60302

Release Notes:

- Fixed the agent's `edit_file` tool corrupting indentation when a
replacement omitted leading whitespace on only its first line.
2026-07-10 20:21:26 +00:00
Lukas Wirth
2b9b3c7ea2
worktree: Watch .git/refs subdirectories for external ref updates (#60660)
On Linux and FreeBSD the native file watcher is non-recursive, so a
watch on the .git directory itself does not report changes to files
nested below it. Loose refs live in nested directories under refs, so
external git commit, fetch, branch, and update-ref operations that don't
also touch a direct child of .git (like the index) went entirely
unnoticed.

Watch every directory in the refs tree when a repository is inserted,
and watch directories subsequently created under refs (new remotes,
slash-named branches) as their creation events arrive. On platforms with
recursive watchers these registrations dedupe against the existing
recursive watch, making them free.


---

Release Notes:

- N/A or Added/Fixed/Improved ...
2026-07-10 17:29:20 +00:00
Zephiris Evergreen
01f915d6be
picker: Automatically switch layout depending on window dimensions (#60559)
# Objective

Fixes #59820. 

## Solution

At a high level, I approached this by conditionally rendering a `Below`
preview layout only when the preview was at or below the min width of
the `Right` when the layout is `Right`. Since there's one breakpoint at
`SizeBounds::min_width()` and hitting that breakpoint only changes
rendering (and doesn't change the state), there's no need to persist to
storage or memory that the view is forced to be Below.

I toggled between the two rendering modes by defining
`Picker::preview_layout_rendered()` that would return `Below` if the
window was equal to or smaller than its min width. Then I replaced all
calls of `preview_layout()` that used the response purely for rendering
purposes with `preview_layout_rendered()`. I avoided replacing
`preview_layout()` calls that were responsible for persisting the
current layout and window dimensions to disk to ensure that changes to
the window dimensions were properly synced to the correct layout. This
way changes to the window while in the `BelowAuto` rendering mode edit
the `Right` window dimensions on disk.

## Testing

I tested these changes manually with the following actions on my local
machine (an Apple Silicon Macbook Pro):
- resizing the popover
- resizing the window
- adjusting the split
- making sure the popover size and split percent remains the same after:
  - closing and re-opening the popover
- closing and re-opening the application and making sure the window size
and split percent remains the same
  
Reviewers can test my changes by performing the same manual actions.

## Self-Review Checklist:

- [X] I've reviewed my own diff for quality, security, and reliability
- [X] Unsafe blocks (if any) have justifying comments
- [X] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [X] Tests cover the new/changed behavior
- [X] Performance impact has been considered and is acceptable

## Showcase

### Before


https://github.com/user-attachments/assets/a4524065-d1a3-4595-bc9e-50c9715b4e66

### After


https://github.com/user-attachments/assets/fede7eb0-5d73-423c-8df0-8192caee8d26

---

Release Notes:

- fix(picker): Automatically switch from Right split to Down split when
picker gets too narrow
2026-07-10 16:52:14 +00:00
Gabriele Ancillai
04d03d1fe5
lmstudio: Fix context wheel by including token usage in streaming responses (#57861)
Some checks are pending
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
LM Studio doesn't show the context token wheel (#53790) because token
usage is
never reported in streaming responses.

Causes:

1. `stream_options` was missing from the request. Without
`stream_options: { include_usage: true }`, the LM Studio API omits
`usage`
   from every streaming chunk entirely.

2. The event mapper discarded usage data in the final chunk.
OpenAI-compatible
servers send the usage summary in a trailing chunk that has an empty
`choices`
array. The old guard treated that as an error, so even when usage was
present
   it was thrown away before emitting a `UsageUpdate` event.

Fix:
- Add `StreamOptions { include_usage: bool }` and `stream_options` to
`ChatCompletionRequest`, and always set it to `true` for streaming
requests.
- Move usage handling in `LmStudioEventMapper::map_event` to run
*before* the
  empty-choices guard, mirroring the OpenAI provider's approach.
- Add four unit tests for `map_event` covering the fixed behavior.

Release Notes:

- Fixed LM Studio not showing the context window usage wheel. 

<img width="1184" height="1080" alt="Screenshot_20260527_130449"
src="https://github.com/user-attachments/assets/97eb8500-39dd-4824-aaf8-f0422b62119d"
/>

---------

Co-authored-by: Gabriele Ancillai <gabriele.ancillai@sofka.com.co>
Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2026-07-10 15:32:21 +00:00
Nakano Kenji
bd2e879213
anthropic: Allow configured models to opt into fast mode (#58225)
Adds support for enabling Anthropic fast mode on configured models.

Previously, Anthropic models configured through
`language_models.anthropic.available_models` were always marked as not
supporting fast mode, so `speed: "fast"` would be stripped before
sending requests even when the configured model supported Anthropic fast
mode.

This adds an optional `supports_fast_mode` field to configured Anthropic
models. When enabled, the model is marked as supporting fast mode and
the required Anthropic beta header is added automatically. Built-in
fast-mode model detection remains the fallback when the setting is
omitted.

Testing:
- `cargo fmt --package anthropic --package language_models --package
settings_content`
- `cargo test -p language_models available_model --lib`
- `cargo test -p anthropic from_listed_enables_fast_mode --lib`
- `cargo check -p language_models`
- Manual: verified in a local build that a configured Anthropic model
can send fast mode requests correctly.

Release Notes:

- agent: Allow specifying if fast mode is supported for custom anthropic
models

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
2026-07-10 15:01:47 +00:00
Tyler Benfield
503292376e
Fix remote worktree picker hides "Create from origin/main" option (#59134)
This passes the `include_remote_name` flag through the remote
`GetDefaultBranch` RPC instead of dropping it at the client/host
boundary. That lets remote repositories resolve default branches as
`origin/main` when needed, so worktree creation uses a valid remote
branch name instead of falling back to `main`.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #59121

Release Notes:

- Fixed remote worktree creation from the default branch when the
default branch requires its remote name.

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2026-07-10 12:11:57 +00:00
Roman Cinis
c5383ce5d4
extension_api: Expose hard_tabs in LanguageSettings (#52560)
Closes #21822
The issue is closed already, but it was requested to provide the
`hard_tabs` boolean param.

Follow-up to #52175
Reviewed by @maxdeviant (it already bumped the settings path to v0.8.0,
so this `hard_tabs` change only needs 2 files, 2-3 lines of code
additions).


## Context

Exposes `hard_tabs` from `AllLanguageSettings` to the Extension
API's `LanguageSettings` struct. Currently, only `tab_size` and
`preferred_line_length` are available to extensions, which prevents
language extensions (e.g., Go, C++) from reading the user's hard tabs
preference and forwarding it to their language server or formatter
(e.g., as rustfmt.hard_tabs or clang-format.UseTab).

Related:
https://github.com/zed-industries/zed/issues/21822#issuecomment-4139809956

## How to Review

Small change — follow how `tab_size` is plumbed through:
1. WIT definition (`language-settings` record)
2. `extension_api` Rust struct
3. Host-side bridge conversion

The new field follows the exact same pattern.

## Self-Review Checklist

<!-- Check before requesting review: -->
- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
  - Compile-time verified via WIT bindings; no runtime behavior change
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A
2026-07-10 11:51:16 +00:00
Sergey Borovikov
0953838ec2
javascript, typescript: Inject GLSL and WGSL syntax in comment-labeled template literals (#55341)
Extends the existing ECMAScript comment-label injection system to
support GLSL and WGSL shader languages inside JS/TS strings and template
literals.

The pattern `/* language */` before a string is already used for `html`,
`sql`, `graphql`, and `css`. This adds the same support for two shader
languages commonly embedded as strings in WebGL/WebGPU code:

before:
<img width="460" height="300" alt="Screenshot 2026-04-30 at 23 07 05"
src="https://github.com/user-attachments/assets/8952eddc-2f53-42ea-b086-7a0b63dceb10"
/>

after:
<img width="460" height="300" alt="Screenshot 2026-04-30 at 23 07 39"
src="https://github.com/user-attachments/assets/93bf536a-9df4-438b-af7b-382efda196ca"
/>

Syntax highlighting activates when the corresponding extension is
installed:
- GLSL: the built-in `glsl` extension
- WGSL: the community extension
[`wgsl-wesl-zed`](https://github.com/lucascompython/wgsl-wesl-zed)
(language name `WGSL/WESL`)

Both `/*glsl*/` and `/* glsl */` forms are accepted (same behavior as
the existing patterns).

Release Notes:

- Added `/*glsl*/` and `/*wgsl*/` comment-label syntax injection for
JavaScript and TypeScript template literals
2026-07-10 11:45:13 +00:00
Bennet Bo Fenner
585e4aab08
agent: Fix cmd-f not working when buffer is open (#60750)
Turns out I only tested it with all tabs closed, the keybinding would
only work when all tabs were closed and you focused the agent panel. If
a file was opened in the center pane, cmd-f would open a search in that
file instead of searching in the agent panel, even if the thread view as
focused.

Closes #60686

Release Notes:

- agent: Fixed an issue where cmd-f would not work if file is open in
center pane
2026-07-10 11:38:53 +00:00
saberoueslati
5cf706ceeb
terminal_view: Forward ctrl-q to PTY when terminal is focused on Linux (#58879)
## Context

On Linux, `ctrl-q` is globally bound to `zed::Quit`. When a
`TerminalView` is focused, pressing `ctrl-q` quit the application
instead of forwarding the keycode to the shell, breaking programs like
`ftp`, `tig`, and any app that uses XON/XOFF flow control.

Windows already had the fix: its `Terminal` keymap context overrides
`ctrl-q` with `["terminal::SendKeystroke", "ctrl-q"]`. The Linux keymap
was simply missing that override.

Closes #58809

Manual test after fix below :

[Screencast from 2026-06-09
00-46-37.webm](https://github.com/user-attachments/assets/3d103b2a-bff1-4559-af1d-2a52d57a6b18)


## How to Review

- **`assets/keymaps/default-linux.json`** : One-line addition in the
`Terminal` context under the "Overrides for conflicting keybindings"
comment, mirroring the existing Windows entry.
- **`crates/terminal_view/src/terminal_view.rs`** : Regression test
`ctrl_q_is_forwarded_to_terminal_not_quit` (Linux-only, `#[cfg(target_os
= "linux")]`): loads the default keymap, focuses a display-only
terminal, simulates `ctrl-q`, and asserts the PTY receives byte `0x11`
instead of the quit action firing.

## Self-Review Checklist

- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the UI/UX checklist
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- Fixed `ctrl-q` quitting Zed instead of being forwarded to the shell
when a terminal is focused on Linux
2026-07-10 11:29:50 +00:00
Ben Brandt
fbceb2823b
acp: Enable ACP elicitations (#60749)
Removes the feature flag. The RFD is in Preview and I am confident it
will be stable without major changes by the time this hits Zed Stable.

Release Notes:

- acp: Allow ACP agents to use Elicitation capturing structure user
input.
2026-07-10 11:22:16 +00:00
David Wu
60314a7416
Open non-writeable files in Capability::Read mode (#57202)
Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #57174

Release Notes:

- Open non-writeable files in Capability::Read mode

Co-authored-by: Lukas Wirth <lukas@zed.dev>
2026-07-10 09:50:19 +00:00
Richard Boisvert
af56cb75a8
agent_ui: Fix duplicate terminal when starting a new agent thread (#59586)
# Objective

- Fixes #58097.
- Opening a new project and clicking `+` in the agent panel to start a
terminal thread creates two terminals instead of one. I am able to
replicate the issue on version 1.8.0 on macOS 27

## Solution

The new-thread action creates the terminal and then focuses the agent
panel. Focusing re-activates the panel (`Panel::set_active` then
`ensure_thread_initialized`) before the terminal, which is spawned
asynchronously, has registered. The panel still looks uninitialized at
that moment, so it spawns its own "initial" terminal too, and that is
the duplicate.

`spawn_terminal` now marks the spawn as in-flight
(`pending_terminal_spawn`) the moment it starts, the same way the
restore and initial-terminal paths already do, so the existing guard in
`ensure_thread_initialized` skips the redundant terminal.

This only affected new (unrestored) projects, since existing ones
restore their previous entry instead of auto-creating one. The auto-init
behavior was introduced in #57150.

## Testing

- Verified in a local dev build on macOS: opening a fresh project and
clicking `+` now creates one terminal, and clicking `+` again creates a
second, as expected. Reopening an existing project still restores a
single terminal.
- Added `test_explicit_terminal_blocks_redundant_auto_init`, which fails
without the fix.
- The change is platform-agnostic (no platform-specific code); I wasn't
able to test on Linux/Windows.

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

---

Release Notes:

- Fixed a duplicate terminal being created when starting an agent
terminal thread in a new project
2026-07-10 09:36:11 +00:00
Om Chillure
b2db24e58a
Fix MCP servers in multi root workspaces (#52849)
### Closes #51951

## Self-Review Checklist

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the UI/UX checklist
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

#### Note : Reopens previous work from closed PR
https://github.com/zed-industries/zed/pull/52161 (fork was deleted)

## Video 

[Screencast from 2026-03-22
23-26-06.webm](https://github.com/user-attachments/assets/ab68e47a-7e74-4f1e-991d-8ca4fed7952c)


## Release Notes:

- Fixed MCP servers from `.zed/settings.json` not being discovered when
multiple project folders are open in a workspace.

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Christopher Biscardi <chris@christopherbiscardi.com>
Co-authored-by: Bennet Bo Fenner <bennet@zed.dev>
2026-07-10 09:13:45 +00:00
Bennet Bo Fenner
9db37846a6
agent: Add GPT 5.6 Sol/Terra for ChatGPT subscription (#60743)
Some checks are pending
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
Release Notes:

- agent: Added GPT 5.6 Sol & Terra for ChatGPT subscription. Note: GPT
5.6 Luna is not available yet, since OpenAI has not unlocked access for
third-party clients
2026-07-10 08:10:51 +00:00
Joseph T. Lyons
76c93968da
Fix workspace double-lease panic when reusing Git Graph (#60717)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / orchestrate (push) Waiting to run
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
Release Notes:

- Fixed a panic when running “Show in Git Graph” while the Git Graph was
already open.
2026-07-09 23:51:04 +00:00
Dino
a5f696bfa3
agent_ui: Remove duplicate MCP Servers menu item (#60712)
# Objective

Fixes https://github.com/zed-industries/zed/issues/60709 by removing the
duplicate menu section for "MCP Servers".

## Solution

Update `agent_ui::agent_panel::AgentPanel::render_panel_options_menu` to
ensure the "MCP Servers" section is only rendered once, if not using a
Terminal Thread.

## Testing

Tested manually, comparing against the stable release, as this bug is
present in Preview. Screenshot is shown in the "Showcase" section.

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [ ] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [ ] Tests cover the new/changed behavior
- [ ] Performance impact has been considered and is acceptable

## Showcase

<details>
  <summary>Before</summary>
<img width="2736" height="1586" alt="CleanShot 2026-07-09 at 23 14
34@2x"
src="https://github.com/user-attachments/assets/ce2014db-bd72-46fb-bfa1-247cd5471daf"
/>

</details>

<details>
  <summary>After</summary>
<img width="2736" height="1586" alt="CleanShot 2026-07-09 at 23 15
13@2x"
src="https://github.com/user-attachments/assets/bbba9965-3f6a-4137-94e5-17704efd2cce"
/>

</details>


---

Release Notes:

- N/A
2026-07-09 22:19:08 +00:00
John Tur
2c4e44704c
Fix "Task polled after completion" panic (#60693)
Dropping a scheduled runnable cancels its task and makes the next poll
of any awaiter panic with "Task polled after completion." The only paths
where we drop these runnables seem to be during shutdown or extreme
resource exhaustion, so, let's leak the runnables instead of crashing.

On Windows, we also moved to calling the Win32 thread pool API directly,
because 1) WinRT thread pool API is just a wrapper that adds overhead we
don't need, and 2) the closure we pass to the `WorkItemHandler` object
takes ownership of the runnable object, so if the WinRT thread pool
releases the delegate, it can free the runnable without our control.

Release Notes:

- N/A
2026-07-09 18:06:00 +00:00
Revantark
35ffa8f480
git_ui: Fix history tab empty and detached HEAD states (#57959)
This PR fixes a few History tab edge cases in the Git Panel.

For a fresh repo with no commits, the History tab now finishes loading
and shows No commits yet instead
of sitting on Loading… indefinitely or falling into a misleading
empty/error state.

It also fixes detached HEAD history loading. In that case, the Git Panel
asks the backend to load history
from the current commit SHA. The local git backend was accidentally
treating the raw object ID bytes as a
string instead of formatting them as a normal hex SHA, so git log could
fail before returning any
commits. The backend now passes the SHA in the format git expects.

**Repro for empty repo:**

mkdir /tmp/zed-empty-history
cd /tmp/zed-empty-history
git init
zed .

Open Git Panel → History.

Before: History could stay stuck on Loading….
After: History shows No commits yet.

**Repro for detached HEAD:**

mkdir /tmp/zed-detached-history
cd /tmp/zed-detached-history
git init
echo hi > file
git add file
git commit -m initial
git checkout --detach HEAD
zed .

Open Git Panel → History.

Before: History could fail to load commits.
After: History shows the commit history normally.




Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable


Release Notes:

- Fixed Git history tab states for empty repositories and detached HEAD
history.

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2026-07-09 17:51:16 +00:00
Bennet Bo Fenner
d8c8040462
open_ai: Add GPT 5.6 models (#60697)
Co-authored-by: Christopher Biscardi <chris@christopherbiscardi.com>

Add new GPT 5.6 models

Release Notes:

- open_ai: Added support for GPT 5.6 Sol/Terra/Luna

---------

Co-authored-by: Christopher Biscardi <chris@christopherbiscardi.com>
2026-07-09 17:37:43 +00:00
Bennet Bo Fenner
3c45bfb205
agent_ui: Allow expanding running MCP tool calls (#60695)
Closes #57144

Release Notes:

- agent: Allow expanding in-progress MCP tool calls

Co-authored-by: Christopher Biscardi <chris@christopherbiscardi.com>
2026-07-09 16:49:57 +00:00
Danilo Leal
717e1c0590
Make agent diff tab consistent with commit view tab (#60470)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
This makes them more consistent with regards to limiting the label size.
These two tabs are similar because they frequently house a label that's
pretty big, and the agent diff tab didn't truncate it in any way.

Release Notes:

- N/A
2026-07-09 13:41:58 +00:00
Lena
da4703be85
Dedupe first-responder Slack notifications (#60666)
What looks like a single event of applying a few labels is actually
multiple events, and the raciness is racy as we've recently experienced.
One solution would've been adding a “first responders notified” label
for enforcing consistency but issues already have enough labels (and we
could take it off by mistake), so a bot reaction will instead serve as a
marker for any later runs.

So if we're applying multiple labels or changing our mind about, say, an
area label after the notification has already been sent, this should
work fine now, without duplicate notifications.

Release Notes:

- N/A
2026-07-09 13:39:43 +00:00
Lukas Wirth
11d216d8bf
worktree: Refresh all repositories sharing a changed git directory (#60664)
update_git_repositories mapped a changed .git path to a repository with
find_map, so when several repositories share a git directory - a main
checkout plus one of its linked worktrees in the same project worktree -
a ref update under the shared common dir only bumped git_dir_scan_id on
whichever repository iterated first, leaving the others stale until an
unrelated event happened to refresh them. Collect every matching
repository and bump each one.

---

Release Notes:

- N/A or Added/Fixed/Improved ...
2026-07-09 12:41:50 +00:00
Lukas Wirth
e2d41c477c
fs: Retry watch registrations skipped during the native watch-limit cooldown (#60662)
GlobalWatcher::add returns Ok(None) while the native watch-limit
cooldown is active, and FsWatcher::add_existing_path treated that as
success: the path was never watched, with no retry and no error. A
long-lived watch (like a repository's git directory) that happened to
register during a cooldown window silently never received events.

Route the skipped registration through the existing pending-path
machinery, which already polls until registration succeeds and emits a
rescan event for the path so that changes missed in the interim are
picked up.

---

Release Notes:

- N/A or Added/Fixed/Improved ...
2026-07-09 12:39:43 +00:00
Elliot Thomas
8230cb16d1
project_panel: Restore worktree drag-and-drop reordering (#55755)
Reordering worktree roots by drag-and-drop had silently broken: a
worktree-root filter added to `disjoint_entries` for delete-safety
stripped roots before the drop handler could see them, so root-to-root
drops never reached the existing `move_worktree` reorder path.

This PR is the minimal regression fix:

- Move the worktree-root filter out of `disjoint_entries` and into
`disjoint_effective_entries` (used by cut/copy/delete), so drag-and-drop
keeps seeing roots and single-root reorder works again via the existing
`move_worktree` path.
- Filter worktree roots out of `drag_onto`'s copy branch, so holding the
copy modifier over a drag that contains a root no longer returns `None`
from `create_paste_path` and silently cancels the whole copy.
- Add `test_drag_worktree_root_reorders_worktrees` exercising the
drag-onto reorder flow end to end.

The larger feature work (multi-root group reordering, blank-area "send
to end", copy-mode drag feedback, and syncing worktree order to
collaborators) has been split into a separate follow-up PR so this fix
can land quickly. Note that worktree order was intentionally not synced
during collaboration, so that change is discussed separately.

Closes #46699

Release Notes:

- Fixed drag and drop to reorder worktrees

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2026-07-09 12:32:00 +00:00
Ben Kunkle
7828463fcb
edit_prediction: Report patch apply failures (#60637)
# Objective

- Report V4 edit prediction patch apply failures as cloud rejection
events.
- Closes EP-210

## Solution

- Added `PatchApplyFailed` as an edit prediction rejection reason.
- Converted `prediction_edits_for_single_file_diff` errors into rejected
prediction results so the existing rejection pipeline posts them to
`/predict_edits/reject`.
- Kept interpolation failures reported as `InterpolateFailed`.

## Testing

- Ran `cargo fmt --check && cargo check -p edit_prediction`.

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A
2026-07-09 12:28:59 +00:00
Ben Kunkle
3e976e89bc
language_model: Add OpenAI Responses API custom tool support (#60638)
# Objective

Allow zed's language model stack to express OpenAI Responses API custom
tools (freeform text-input tools with an optional lark/regex grammar),
so downstream consumers can offer tools like a freeform `apply_patch` to
GPT models.
## Solution

- `LanguageModelRequestTool` now carries a Function-vs-Custom input
variant; `LanguageModelCustomToolFormat` models text/grammar formats.
- `LanguageModelToolUse.input` becomes a typed
`LanguageModelToolUseInput::{Json, Text}`. Serialization is tagged so
persisted Text inputs round-trip losslessly; legacy plain JSON values
still deserialize as `Json`.
- `open_ai` gains the custom tool wire types (tool definition,
`custom_tool_call`/`custom_tool_call_output` input items with
string-or-content-part outputs, output item, and
`custom_tool_call_input` delta/done stream events). The Responses event
mapper accumulates raw text deltas into `ToolUse` events, and history
replay derives custom-vs-function tool results from the matching
`ToolUse` by id.
- All non-OpenAI providers and the Chat Completions path error
explicitly when a request contains custom tools — no silent drops or
empty-schema coercions.

Release Notes:

- N/A
2026-07-09 12:05:50 +00:00
Finn Evers
55b776183d
ci: Fix push permissions for cherry-pick workflow (#60673)
Release Notes:

- N/A
2026-07-09 11:34:57 +00:00
Finn Evers
9b4598f708
ci: Fix insufficient permissions for asset validation step (#60625)
Release Notes:

- N/A
2026-07-09 11:19:07 +00:00
Smit Barmase
8f907c5ee1
markdown: Improve selection copying to always yield valid markdown (#60657)
Closes https://github.com/zed-industries/zed/issues/59083
Closes https://github.com/zed-industries/zed/issues/42958
Closes https://github.com/zed-industries/zed/issues/59612

This PR improve selection copy of Agent Panel and Markdown Preview such
that copied text is always valid markdown, following how VSCode handles
it. The partial selection of styled text would not copy broken syntax
like `bold**` or ```inline code` ``` like before.

Now we follow simple rule:

> Selecting any part, no matter from where, copies it as its markdown.
Except when the selection sits entirely inside a single inline code
span, in which case we copy plain text, for terminal and code use cases.

Examples:

This is **bold** text, this is *italic* text, and this is `code` all `in
one` sentence.

- selecting only bold → `**bold**`
- selecting normal text and partial bold → `is is **bo**`
- selecting a single code span completely → `code`
- selecting partial code → `od`
- selecting partial text and code → `` his is `cod` ``
- selecting multiple code spans partially → `` `ode` all `in o` ``
- selecting multiple code spans end to end → `` `code` all `in one` ``

Nested spans, like **bold with `code` inside**:

- partial text in bold → `**ld wi**`
- partial code in bold → ``**`od`**``
- full code in bold → ``**`code`**``
- the whole sentence → ``**bold with `code` inside**``

Links, like [Visit Rust's website](https://rust.org):

- partial link text → `[bsite](https://rust.org)`
- full link text → `[Visit Rust's website](https://rust.org)`

How it works:

Selection boundaries that land inside delimiter syntax (`**`, backticks,
etc.) first snap out so no delimiter is left half-selected. Then any
spans the selection cuts through get their delimiters re-added,
outermost first, so nested styling stays balanced. Only the root blocks
containing the two selection boundaries are inspected, everything in
between is copied right as is, which also keeps this cheap on large
documents.

Release Notes:

- Improved copying selected text in Agent Panel and Markdown Preview.
Partial selections of styled text now copy as well-formed markdown, and
selections within a single inline code span copy as plain text.
2026-07-09 11:05:04 +00:00
Lukas Wirth
9cdaff1492
editor: Fix panic removing blocks in split diff with companion edit at EOF (#60445)
BlockMap::sync unwrapped the transform cursor for every edit, assuming
each edit's old.start lands strictly inside the old transform tree. The
companion (split-diff) branch of sync can compose an edit anchored at
the trailing boundary of the old transforms, leaving the cursor past the
end of the tree and aborting the process on the None unwrap. Only bind
the transform when there are rows preceding the edit.

Fixes ZED-9V4.
Closes FR-113


Release Notes:

- N/A or Added/Fixed/Improved ...
2026-07-09 09:20:39 +00:00
Lukas Wirth
0bc9c99f00
Invalidate hover state when a multibuffers paths change (#60443)
Fixes a panic when multibuffer paths change under a cursor hover
Fixes ZED-9W1
Closes FR-112

Release Notes:

- N/A or Added/Fixed/Improved ...
2026-07-09 09:20:36 +00:00
Bennet Bo Fenner
4d6762ad6b
cli: Restore workspace by default when cli_default_open_behavior="new_window" (#60652)
Closes #60325, Follow up to #59415

Fixes an issue where launching Zed from the cli (just `zed`, no path)
would not restore the previous window when using
`cli_default_open_behavior: new_window`

Release Notes:

- cli: Fixed an issue where the previous workspace would not be restored
when using `cli_default_open_behavior: new_window` and no path was
provided
2026-07-09 08:20:29 +00:00
Vitaly Slobodin
c93b4cd9cc
ruby: Mention kanayago and fuzzy-ruby-server in the documentation (#60640)
# Objective

Hi! This PR updates the Ruby doc to mention 2 language servers
`kanayago` and `fuzzy-ruby-server`.

## Testing

N/A

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

## Showcase

N/A

---

Release Notes:

- N/A
2026-07-09 07:54:36 +00:00
Todd L Smith
57261fef89
editor: Add actions to move between comment paragraphs (#58353)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
Congratsbot / congrats (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
Jump the caret straight to the next/previous block of prose comments —
like paragraph motion, but for comments.

The motivating workflow: reflowing a file's comments with `editor:
rewrap`. Today that means reaching for the mouse to click into each
comment block scattered through the file, or using other cursor motions
and undershooting or overshooting the target. With these two actions you
can step from one comment paragraph to the next, `rewrap`, and repeat —
reflowing every comment in a file without ever touching the mouse. It's
also handy for skimming a heavily-documented file: hop from doc comment
to doc comment without manually scrolling past the code in between.

Adds two editor actions:

- `editor::MoveToNextCommentParagraph`
- `editor::MoveToPreviousCommentParagraph`

Both move the caret to the first non-whitespace character of the
next/previous *comment paragraph*. They have no default keybinding and
are available from the command palette ("editor: move to next/previous
comment paragraph").

### What counts as a comment paragraph

A comment paragraph is a run of consecutive comment lines. A line is a
comment line when its **first non-whitespace character is in a `comment`
syntax scope** and the line contains prose (at least one alphanumeric
character). This is determined from the syntax tree
(`language_scope_at(...).override_name()`), the same mechanism `rewrap`
and comment folding already use, so it behaves correctly without
per-language string matching:

- **End-of-line comments preceded by code are ignored** — on `let x = 1;
// note` the first non-whitespace character is code, not a comment, so
the line is not a paragraph line.
- **`//` inside a string literal is ignored** — its scope is `string`,
not `comment`.
- **Blank/divider comment lines separate paragraphs** — a bare `//` or
`// -----` (no prose) acts as a separator, so you can hop between
paragraphs *within* one comment block as well as across blocks.

Both directions always move to a paragraph *other* than the one the
caret is in: when the caret is inside a paragraph, the whole current
paragraph is skipped, so `Prev` lands on the previous paragraph's start
rather than the current paragraph's own start.

### On the autoscroll

These two actions scroll the destination near the top of the viewport
(`Autoscroll::top_relative`) rather than using the default `Fit`
strategy that sibling motions use. This is deliberate and specific to
the feature: you are jumping to the **start** of a comment paragraph
that extends *downward*, so biasing the destination toward the top keeps
the whole paragraph visible after the jump. This matters for the rewrap
workflow above — you want to see the full comment you are about to
reflow, and the reflow itself changes the paragraph's line count. With
the default `Fit`, repeated forward jumps creep the caret to the bottom
edge and leave long paragraphs cut off below the fold — the opposite of
what this motion is for. Happy to revisit the exact strategy/offset if
you'd prefer consistency with the other motions.

### Tests

Two tests in `editor_tests.rs` (using a real grammar + comment override
query):

- `test_move_to_next_and_previous_comment_paragraph` — full
forward/backward round trip, covering blank comment-line separators,
code separators, trailing comments, and the no-more-paragraphs stop.
- `test_move_to_previous_comment_paragraph_skips_current_paragraph` —
`Prev` from mid-paragraph skips to the previous paragraph, and stays put
when there is no previous paragraph.

---

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments — N/A, no unsafe
- [x] The content is consistent with the UI/UX checklist
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- Added `editor::MoveToNextCommentParagraph` and
`editor::MoveToPreviousCommentParagraph` actions to move the caret
between comment paragraphs
2026-07-09 05:49:09 +00:00
Richard Feldman
f281770034
gpui: Store GPU-facing bools as PaddedBool32 to avoid uninitialized padding (#60482)
`PolychromeSprite` in `crates/gpui/src/scene.rs` is `#[repr(C)]` and had
a `grayscale: bool` field followed by 3 compiler-inserted padding bytes
that were never written. The wgpu renderer's `instance_bytes`
reinterprets `&[PolychromeSprite]` as `&[u8]` via
`slice::from_raw_parts` and passes it to `queue.write_buffer`, so those
uninitialized padding bytes were exposed behind a shared `&[u8]` on
every frame that draws an image or emoji, which is undefined behavior.

Rather than widening the field to a raw `u32` (which would suggest
values other than 0 and 1 are meaningful), this introduces
`PaddedBool32`: a `#[repr(transparent)]` wrapper around `u32` whose only
public constructor is `From<bool>`, so the 0-or-1 invariant is enforced
by the type while the layout has no padding. `Underline.wavy`, which was
already a raw `u32` for the same reason, is converted too.

cbindgen emits the wrapper as `typedef uint32_t PaddedBool32;`, so the
generated Metal header and shaders are unchanged. The WGSL and HLSL
shaders already declared these fields as `u32`/`uint`; their `& 0xFFu`
masks, which existed to ignore the garbage padding bytes, are now
simplified to plain comparisons.

Release Notes:

- N/A
2026-07-09 05:37:35 +00:00
morgankrey
10504e3ce1
Improve docs AI readiness (#59577)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
Context

This PR makes the Zed docs easier for AI tools, search crawlers, and
users to consume without changing the visible docs content. The current
production docs are primarily optimized for browser navigation. They do
not expose first-class Markdown URLs, an `llms.txt` index, page-level
copy affordances, or machine-readable freshness metadata that let users
and agents grab clean, current page content.

Changes

- Generate Markdown copies for docs pages during the mdBook postprocess
step, including `/docs/index.md` as an alias for Getting Started.
- Generate `/docs/llms.txt` from the mdBook chapter list, grouped by
`SUMMARY.md` sections and annotated with page frontmatter descriptions.
- Generate `/docs/sitemap.xml` with `<lastmod>` values for every docs
page.
- Emit machine-readable freshness metadata in HTML via `last-modified`
and `article:modified_time` meta tags.
- Generate Cloudflare Pages `_redirects` for `.html`, extensionless, and
`.md` redirect variants, with channel-aware docs destinations.
- Add discovery hints for agents and crawlers: `rel="llms.txt"`,
`rel="alternate" type="text/markdown"`, and a short generated `llms.txt`
directive in copied Markdown pages.
- Update the docs proxy so `Accept: text/markdown`, `/docs.md`, and
direct `.md` requests can resolve to the generated Markdown artifacts.
- Move primary docs content earlier in the HTML source while preserving
the visible layout, so crawlers and agent scorers encounter the article
before sidebar chrome.
- Move the existing copy-as-Markdown control from the top navigation
into the page-title row, using the generated Markdown alternate link as
the source of truth.
- Split AI-discovery artifact generation out of
`docs_preprocessor/src/main.rs` into a focused module.

Best Practices Adopted

- Use `llms.txt` as a concise navigation index, not a dump of full page
content.
- Link to absolute, canonical Markdown URLs from `llms.txt`.
- Preserve the docs hierarchy in `llms.txt` instead of emitting a flat
sitemap-like list.
- Include short per-link descriptions from existing metadata rather than
inventing summaries.
- Keep `llms.txt`, Markdown copies, sitemap data, redirects, and
freshness metadata generated from the same mdBook source to avoid drift.
- Advertise Markdown alternates with standard HTML metadata and
same-origin URLs.
- Support both explicit Markdown URLs and content negotiation for
clients that prefer Markdown.
- Keep browser copy behavior pointed at generated Markdown alternate
links instead of duplicating route inference in JavaScript.
- Keep the copy-as-Markdown affordance in the page title row without
duplicating header chrome controls.

Validation

- `cargo check -p docs_preprocessor`
- `cargo test -p docs_preprocessor`
- `./script/clippy -p docs_preprocessor`
- `mdbook build ./docs --dest-dir=../target/deploy/docs/`
- `node --check docs/theme/plugins.js`
- `pnpm dlx prettier@3.5.0 docs/theme/plugins.js --check`
- `git diff --check`
- Local artifact checks confirmed generated Markdown pages, `llms.txt`,
`sitemap.xml` lastmod values, HTML freshness metadata, Markdown
alternate links, redirect targets, and preprocessed action/keybinding
tags resolve as expected.
- Worker URL rewrite mock covered `/docs/`, `/docs.md`,
`/docs/index.md`, extensionless docs routes, `.html` routes,
`/docs/llms.txt`, and `/docs/sitemap.xml`.
- High-effort adversarial subagent review found blockers around
channel-aware redirects, shallow-checkout date fallback, file size,
duplicated Markdown path inference, and process spawning. Those were
addressed.

Remaining Notes

- Local `python -m http.server` does not emulate Cloudflare Pages pretty
URLs, `_redirects`, or the docs-proxy Worker, so full local `afdocs`
still cannot prove content negotiation end to end.
- Existing production remains unchanged until this PR is deployed
through the docs workflow.
- Production baseline `npx afdocs check https://zed.dev/docs/ --fixes
--verbose` still reports the original failures before this PR is
deployed: 12 passed, 8 failed, 3 skipped.

Release Notes:

- Improved docs AI-readiness by adding machine-readable discovery,
Markdown access, and freshness metadata.

---------

Co-authored-by: Katie Geer <katie@zed.dev>
Co-authored-by: Ben Kunkle <ben@zed.dev>
2026-07-08 20:54:51 +00:00
Richard Feldman
dd68454633
Update wayland-backend to fix Wayland file dialog crash (#60621)
On Wayland, closing a window in the brief gap between requesting a
portal file dialog and ashpd exporting the window's surface (used to
parent the dialog) crashed Zed with `Unknown opcode 0 for object
<anonymous>@0`
([ZED-9KB](https://zed-dev.sentry.io/issues/7568720776/)). When the
export request fails because the surface is already dead,
wayland-scanner's generated code silently returns an inert proxy, and
ashpd's `Drop` impl later sends `destroy` on it — which wayland-backend
0.3.11 answers with a panic, because it looks up the request opcode
before checking whether the object is null. wayland-backend 0.3.15 fixes
this
([Smithay/wayland-rs#890](https://github.com/Smithay/wayland-rs/issues/890))
by returning an error instead, which the generated destructor discards,
so the drop becomes a harmless no-op. This bumps the lockfile to 0.3.15
and raises the version floor in `gpui_linux` so the fix can't silently
regress via a fresh lockfile.

Closes FR-100

Release Notes:

- Fixed a crash on Linux (Wayland) when a window was closed just as a
file dialog was being opened.
2026-07-08 20:37:57 +00:00
Philipp Schaffrath
664b7ecb40
gpui: Fix hover state not clearing when mouse leaves window (#60275)
# Objective

Fix two gaps in element hover tracking at window boundaries. Hover was
only re-evaluated on `MouseMove`, so when the pointer left the window no
event fired `on_hover(false)` and the element stayed hovered.
Symmetrically on Wayland, no `Motion` follows `Enter` until the pointer
moves again, so hover was not established at the entry pixel. Both cases
are easy to miss since most hover-styled elements don't sit flush
against the window edge, but they surfaced while implementing
layer_shell popups with input_regions, which should close when stop
hovering.

## Solution

The hover compare-and-fire logic in `div` is refactored into a shared
`update_hover` closure, and a second listener on `MouseExitEvent` clears
hover when the pointer leaves the window. It clears unconditionally
because `MouseExited` doesn't update the tracked mouse position, so a
hit test during that dispatch would still report the element as hovered.

On Wayland, a `MouseMove` is synthesized at the entry position on
`wl_pointer.enter`, mirroring the `MouseExited` already dispatched on
`Leave`.

## Testing

Tested manually on Wayland/Linux: hover on a window-edge element clears
when the pointer leaves the window, and hover is established immediately
when the pointer enters a surface with an element under the entry pixel.

Not tested on other platforms. The `div` change relies on each
platform's existing `MouseExited` dispatch: macOS and X11 emit it, so
they get the exit fix too. Windows never dispatches `MouseExited`
(`WM_MOUSELEAVE` only flips the window-level hover flag), so the
stuck-hover case might remain there, unchanged from before.

Before:


https://github.com/user-attachments/assets/6af83bb3-de9d-40e2-a64c-bdefc98fc96d

After:


https://github.com/user-attachments/assets/721a9b5c-108a-499f-867e-da111836a34a


Here is an example application to test this:

```rs
#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{
    App, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size,
};
use gpui_platform::application;

struct HoverExit {
    hovered: bool,
}

impl Render for HoverExit {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        // Fills the whole window so its edge is the window edge: moving the mouse
        // out of the window is what exercises the MouseExited path.
        div()
            .id("hover-exit")
            .size_full()
            .flex()
            .justify_center()
            .items_center()
            .text_xl()
            .text_color(rgb(0xffffff))
            .bg(if self.hovered {
                rgb(0x585f58)
            } else {
                rgb(0x505050)
            })
            .child(if self.hovered { "HOVERED" } else { "not hovered" })
            .on_hover(cx.listener(|this, hovered, _, cx| {
                this.hovered = *hovered;
                cx.notify();
            }))
    }
}

fn run_example() {
    application().run(|cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(240.), px(160.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                app_id: Some("gpui-hover-exit".to_string()),
                ..Default::default()
            },
            |_, cx| cx.new(|_| HoverExit { hovered: false }),
        )
        .unwrap();
        cx.activate(true);
    });
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run_example();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    run_example();
}
```


## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

---

Release Notes:

- Fixed element hover state not clearing when the mouse leaves the
window
2026-07-08 20:27:47 +00:00
Richard Feldman
23c0080d1d
gpui_windows: Check composition attribute lookup (#60122)
Checks that SetWindowCompositionAttribute was found before transmuting
and calling the function pointer, making the composition setup a no-op
when the undocumented export is unavailable.

Release Notes:

- N/A
2026-07-08 19:27:01 +00:00
Philipp Schaffrath
546a16d64f
gpui: Add parent-anchored native popup windows (with wayland xdg_popup implementation only so far) (#60232)
# Objective

gpui can't show UI that extends past the window it belongs to. Menus,
dropdowns and tooltips are drawn as elements inside the window, so they
clip at its edges. This PR adds a window kind for platform-native popups
anchored to a parent window, as groundwork for real native menus,
dropdowns and tooltips.

## Solution

`WindowKind::AnchoredPopup(PopupOptions)` opens a popup positioned
relative to a parent window. Instead of giving the popup an absolute
position, you describe where it should go and the platform figures out
the rest:

- `parent`: the window to anchor to
- `anchor_rect`: a rectangle in the parent, e.g. the button that opened
the menu
- `anchor` and `gravity`: which point of that rect to attach to, and
which direction to grow
- `constraint_adjustment`: what the platform may do if the popup would
leave the screen (slide, flip, resize)
- `grab`: menu behavior, the popup takes focus and is dismissed when
clicking outside the app

The popup's size comes from `WindowOptions::window_bounds`.

This model mirrors Wayland's `xdg_positioner`, where the compositor owns
positioning and the client can only describe intent. Since that's the
most restrictive case, the other platforms can implement the same
description later with simple math against screen bounds.

Only Wayland is implemented so far, via `xdg_popup` on top of the
existing surface implementation. Popups can be parented to toplevels,
layer-shell surfaces (a menu opened from a panel) and other popups
(nested menus). macOS, Windows, X11 and web reject the kind with
`PopupNotSupportedError`, so callers can detect that and fall back to
in-window popovers.

Some Wayland details that might help during review:

- Anchor rects are translated from gpui coordinates into the parent's
window geometry space and clamped to it. A rect outside the geometry, or
with zero size, is a fatal protocol error
- Resizing a mapped popup goes through `xdg_popup.reposition`
- Mouse press serials are now recorded on press only, not release.
Compositors decline grabs and interactive moves that reference a release
serial

## Testing

Tested manually on Wayland with an example app: the menu opens anchored
below its button, extends past the parent window, flips above the button
near the bottom of the screen, and a grabbing popup is dismissed when
clicking into another application.

Nested menus were tested in one of my projects (ignore that they are
ugly, that's just a prototype 😛):


https://github.com/user-attachments/assets/2cd3e2e9-87f7-4b02-986f-48e5633e205c




I also have a complete runnable example demonstrating it. I did not add
it to the PR, because this might give the impression that
`WindowKind::AnchoredPopup` are a complete implementation, despite only
working on wayland so far:

<details>
  <summary>Click to view example</summary>

```rust
//! Example and manual test for platform-native popups (`WindowKind::AnchoredPopup`).
//!
//! A native popup is a real, parent-anchored window that can extend beyond its parent onto the
//! screen, unlike gpui's in-window popovers. Run it, open the menu, and confirm the points listed
//! in the window. On a platform without an implementation the button reports that popups are not
//! supported instead of opening anything.
//!
//! Run with: cargo run -p gpui --example popup

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{
    AnyWindowHandle, App, Bounds, Context, MouseButton, SharedString, Window, WindowBounds,
    WindowHandle, WindowKind, WindowOptions, div, point, popup::*, prelude::*, px, rgb, size,
};
use gpui_platform::application;

/// The trigger button, at a fixed position so the popup can anchor to a known rectangle. Real code
/// would anchor to the measured bounds of whatever element opens the popup.
const BUTTON_BOUNDS: Bounds<gpui::Pixels> = Bounds {
    origin: point(px(24.), px(24.)),
    size: size(px(200.), px(32.)),
};

const POPUP_SIZE: gpui::Size<gpui::Pixels> = size(px(260.), px(320.));

struct Menu;

impl Render for Menu {
    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
        let item = |label: &str| {
            div()
                .id(label.to_string())
                .px_3()
                .py_1()
                .rounded_sm()
                .hover(|this| this.bg(rgb(0x3a3a3a)))
                .cursor_pointer()
                .child(label.to_string())
                .on_click(|_, window, _| window.remove_window())
        };

        div()
            .id("menu-root")
            .size_full()
            .p_1()
            .flex()
            .flex_col()
            .gap_0p5()
            .bg(rgb(0x2a2a2a))
            .text_color(gpui::white())
            .rounded_md()
            .border_1()
            .border_color(rgb(0x454545))
            .child(item("Foo"))
            .child(item("Bar"))
            .child(item("Baz"))
            .child(item("Qux"))
            .child(item("Alice"))
            .child(item("Bob"))
    }
}

struct PopupExample {
    menu: Option<WindowHandle<Menu>>,
    status: SharedString,
}

impl Default for PopupExample {
    fn default() -> Self {
        Self {
            menu: None,
            status: "Click \"Open menu\" to open a native popup.".into(),
        }
    }
}

impl PopupExample {
    /// Closes the menu if it is open. Returns true if a menu was actually open.
    fn close_menu(&mut self, cx: &mut App) -> bool {
        match self.menu.take() {
            Some(menu) => menu
                .update(cx, |_, window, _| window.remove_window())
                .is_ok(),
            None => false,
        }
    }

    fn toggle_menu(&mut self, parent: AnyWindowHandle, cx: &mut App) {
        if self.close_menu(cx) {
            return;
        }
        match open_menu(parent, cx) {
            Ok(menu) => {
                self.menu = Some(menu);
                self.status = "Menu open. Dismiss it by selecting an item, clicking elsewhere in \
                    this window, or clicking another application."
                    .into();
            }
            // A real application would fall back to an in-window popover here.
            Err(error) => {
                self.status = format!("Failed to open a native popup: {error}").into();
                log::error!("failed to open popup: {error}");
            }
        }
    }
}

impl Render for PopupExample {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let bullet = |text: &str| div().child(format!("• {text}"));

        div()
            .id("root")
            .size_full()
            .bg(rgb(0xf7f7f7))
            .text_color(rgb(0x222222))
            // Same-app clicks don't auto-dismiss a grabbing popup (see `PopupOptions::grab`).
            .on_mouse_down(
                MouseButton::Left,
                cx.listener(|this, _, _window, cx| {
                    this.close_menu(cx);
                }),
            )
            .child(
                div()
                    .size_full()
                    .p_5()
                    .pt(px(76.))
                    .flex()
                    .flex_col()
                    .gap_3()
                    .child(div().text_xl().child("Native popup test"))
                    .child(div().text_sm().child(
                        "WindowKind::AnchoredPopup opens a real, parent-anchored window that can \
                         extend past this window onto the screen. Only some platforms implement \
                         it so far.",
                    ))
                    .child(
                        div()
                            .flex()
                            .flex_col()
                            .gap_1()
                            .text_sm()
                            .text_color(rgb(0x555555))
                            .child(div().child("Verify:"))
                            .child(bullet("The menu opens anchored below the button."))
                            .child(bullet(
                                "The menu extends past the bottom edge of this window.",
                            ))
                            .child(bullet(
                                "Near the bottom of the screen, the menu flips above the button.",
                            ))
                            .child(bullet("Clicking another application dismisses the menu."))
                            .child(bullet(
                                "Selecting an item or clicking in this window dismisses it.",
                            )),
                    )
                    .child(
                        div()
                            .text_sm()
                            .text_color(rgb(0x333333))
                            .child(self.status.clone()),
                    ),
            )
            .child(
                div()
                    .absolute()
                    .left(BUTTON_BOUNDS.origin.x)
                    .top(BUTTON_BOUNDS.origin.y)
                    .w(BUTTON_BOUNDS.size.width)
                    .h(BUTTON_BOUNDS.size.height)
                    .flex()
                    .items_center()
                    .justify_center()
                    .bg(rgb(0xffffff))
                    .border_1()
                    .border_color(rgb(0xd0d0d0))
                    .rounded_md()
                    .cursor_pointer()
                    .id("open-menu")
                    .active(|this| this.bg(rgb(0xeeeeee)))
                    .child("Open menu ▾")
                    // Open on mouse-down, not on click, so the grab is taken while the button is still held.
                    .on_mouse_down(
                        MouseButton::Left,
                        cx.listener(|this, _, window, cx| {
                            // Don't let the window handler above close the menu we are opening.
                            cx.stop_propagation();
                            this.toggle_menu(window.window_handle(), cx);
                        }),
                    ),
            )
    }
}

fn open_menu(parent: AnyWindowHandle, cx: &mut App) -> anyhow::Result<WindowHandle<Menu>> {
    cx.open_window(
        WindowOptions {
            titlebar: None,
            // Sizes the popup. The platform decides the position, so the origin is ignored.
            window_bounds: Some(WindowBounds::Windowed(Bounds {
                origin: point(px(0.), px(0.)),
                size: POPUP_SIZE,
            })),
            kind: WindowKind::AnchoredPopup(PopupOptions {
                parent,
                anchor_rect: BUTTON_BOUNDS,
                // Anchor to the button's bottom-left and grow down-right so the menu drops beneath it.
                anchor: PopupAnchor::BottomLeft,
                gravity: PopupGravity::BottomRight,
                // Slide horizontally and flip vertically if the menu would leave the screen.
                constraint_adjustment: PopupConstraintAdjustment::SLIDE_X
                    | PopupConstraintAdjustment::FLIP_Y,
                offset: point(px(0.), px(4.)),
                // Grab input so the compositor dismisses the popup on clicks into other applications.
                grab: true,
            }),
            ..Default::default()
        },
        |_, cx| cx.new(|_| Menu),
    )
}

fn run_example() {
    application().run(|cx: &mut App| {
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(Bounds {
                    origin: point(px(100.), px(100.)),
                    size: size(px(420.), px(300.)),
                })),
                ..Default::default()
            },
            |_, cx| cx.new(|_| PopupExample::default()),
        )
        .unwrap();
        cx.activate(true);
    });
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run_example();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    run_example();
}
```

</details>

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

---

Release Notes:

- N/A
2026-07-08 19:22:23 +00:00
Keith Hall
6b733d1058
search: Bump fancy-regex dependency and enable CRLF mode (#55471)
Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #43396

Release Notes:

- Project search now supports CRLF line endings correctly, as well as
other regex features like subroutine calls
2026-07-08 16:56:45 +00:00
G36maid
b9f3396b68
Add support for format_on_save only changed lines (#49964)
## Summary

Introduces `format_on_save` variants that format only modified lines,
limiting formatting to lines changed since the last commit. This
prevents massive diffs when editing legacy codebases. Aligns with VS
Code's `editor.formatOnSaveMode` naming:

- `"modifications"` — formats only git-diffed lines. Requires source
control; skips formatting if no diff is available.
- `"modifications_if_available"` — formats only git-diffed lines,
falling back to full-file formatting for untracked files, when source
control is unavailable, or when the LSP does not support range
formatting.

Also supports importing equivalent VS Code settings
(`formatOnSaveMode`).

This PR uses the range-based whitespace/newline infrastructure from the
dependency PR above.

## Changes


<details>
<summary><b>New settings, modified ranges computation, VS Code
import</b></summary>

### New settings (`language.rs`, `default.json`, `all-settings.md`)

Two new `FormatOnSave` variants: `Modifications` and
`ModificationsIfAvailable`.

### Modified ranges computation (`items.rs`)

- `compute_format_decision()` — reads `format_on_save` setting across
all buffers, determines whether to use ranged formatting (`Ranges`),
full formatting (`Full`), or skip (`Skip`).
- `compute_modified_ranges()` — extracts modified line ranges from git
diff hunks via `BufferDiffSnapshot`.
- `is_empty_range()` — helper to detect deletion-only hunks that produce
no formatable content.

The `save()` method calls `compute_format_decision()` and dispatches
accordingly.

### Modifications mode in `lsp_store.rs`

- Adds `FormatOnSave::Modifications |
FormatOnSave::ModificationsIfAvailable` to the formatter selection match
arm.
- `ModificationsIfAvailable` falls back to full-file formatting when
ranged formatting produces no results.

### VS Code settings import (`vscode_import.rs`, `settings_store.rs`)

Imports `editor.formatOnSaveMode` mapping:
- `"modifications"` → `FormatOnSave::Modifications`
- `"modificationsIfAvailable"` →
`FormatOnSave::ModificationsIfAvailable`
- `"file"` → `FormatOnSave::On`

</details>

## Tests

- `test_modifications_format_on_save` — basic modifications mode
formatting with dirty buffer
- `test_modifications_format_no_changes` — clean buffer triggers no
formatting
- `test_modifications_format_lsp_no_range_support` — LSP without range
formatting skips entirely for `Modifications`
- `test_modifications_format_lsp_returns_empty_edits` — empty edits
handled gracefully
- `test_modifications_format_multiple_hunks` — two non-adjacent edits
produce two separate range formatting requests

---

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable


Closes #16509
Depends on #53942

Release Notes:

- Added `modifications` and `modifications_if_available` options to
`format_on_save`, which format only git-changed lines instead of the
entire file
- When using modifications mode, `remove_trailing_whitespace_on_save`
and `ensure_final_newline_on_save` are also scoped to changed lines,
preventing unwanted diff jitter in legacy codebases
- Added support for importing VS Code's `editor.formatOnSaveMode`
setting

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
2026-07-08 16:53:11 +00:00
Lena
7dc634124c
Switch Guild board automation to role checks (#60606)
Outside collaborators can't be added to GitHub teams, team membership is
restricted to org members.

https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-outside-collaborators/adding-outside-collaborators-to-repositories-in-your-organization#:~:text=Outside%20collaborators%20cannot%20be%20added%20to%20a%20team%2C%20team%20membership%20is%20restricted%20to%20members%20of%20the%20organization

Release Notes:

- N/A
2026-07-08 16:52:33 +00:00
TwoClocks
029bf2f284
Make editor::LineUp & editor::LineDown honor vertical_scroll_margin like vim::LineUp & vim::LineDown (#52057)
## Context

This is an implementation of this feature:
https://github.com/zed-industries/zed/discussions/49821

Although, I'd argue it's a bug fix, not a feature.

Either way : This copies the window scroll logic from the `vim` mode
versions with out all the extra logic for visual mode.

Could refactor the common logic out of the vim code and make it common.
But that seems like a bigger PR. Happy to take a stab at it if that's
what you prefer.
Could also add new commands for the new behavior if you prefer. I didn't
do that, because it seems like more clutter in the commands, and my
belief that the existing behavior is a bug. But happy to do that if you
prefer.

## How to Review

creates new function `scroll_screen_with_cursor_margin` in `scroll.rs`
wires up editor::LineUp/LineDown to new function in `elements.rs`
adds a test in  `editor_tests.rs`

## Self-Review Checklist

<!-- Check before requesting review: -->
- [X] I've reviewed my own diff for quality, security, and reliability
- [X] Unsafe blocks (if any) have justifying comments
- [X] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [X] Tests cover the new/changed behavior
- [X] Performance impact has been considered and is acceptable

Release Notes:

- editor::LineUp/LineDown commands now honor vertical_scroll_margin
(same as vim::LineUp/LineDown)

---------

Co-authored-by: Kirill Bulatov <mail4score@gmail.com>
2026-07-08 16:37:08 +00:00
Jiby Jose
7f5cf583dc
Fix worktree entry IDs for symlinked files (#57846)
Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #55792

Release Notes:

- Fixed files in pnpm workspaces moving to symlinked `node_modules`
paths after saving.

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
2026-07-08 16:37:02 +00:00
zed-zippy[bot]
6979d7281f
Bump Zed to v1.12.0 (#60600)
Release Notes:

- N/A

Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
2026-07-08 16:06:44 +00:00
Oleksandr Kholiavko
d9e35975c2
csv_preview: Add row filtering feature (#60339)
# Objective

CSV feature needs row filtering feature by column values. This PR
provides base implementation of it with barebones ui.

> NOTE: Sleek UI with search & proper scrolling hanling is implemented
in next PR. It's stacked on top to reduce review scope

## Solution

- New `FilterEntry` / `FilterEntryState` model in
`table_data_engine/filtering_by_column.rs` tracking per-column
applied/candidate filter values
- Filtering runs in the background (`feat: Implement background
filtering`) so large CSVs don't block the UI thread while a filter is
applied
- Filter menu entries reflect live counts and support a configurable
sort order (`FilterSortOrder`, added in `renderer/settings.rs` /
`settings.rs`)
- Filter/sort trigger buttons on column headers are hidden until hover,
using `GradientFade` (new in `ui/src/components/gradient_fade.rs`) to
fade content behind them

## Testing

Filter chain tested on csv fixtures with multiple filters applied
sequentially columns.

## Self-Review Checklist: (todo)

- [ ] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [ ] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines) (out of scope of this pr)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

## Showcase

<img width="664" height="249" alt="image"
src="https://github.com/user-attachments/assets/0e9b0a91-1a27-4e0f-a8d4-fdce36735131"
/>

<img width="663" height="205" alt="image"
src="https://github.com/user-attachments/assets/0428f5c6-6aaa-4891-b010-ca79803f6613"
/>

---

Release Notes:

- Added initial row filtering UI & logic
2026-07-08 15:54:36 +00:00
Mikayla Maki
74b5207744
Unify Render and RenderOnce into View (#58087)
This allows us to build powerful and flexible Input and TextArea
components

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:52:48 +00:00
soddygo
c80020231b
agent_ui: Fix expanded message editor staying auto-height during streaming (#55916)
Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments (Unsafe: none)
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable


## Summary
When the agent is streaming output, expanding the message editor can
appear to work (the container grows) but the input editor itself remains
constrained to auto-height (only a few lines). Toggling minimize →
expand makes it render correctly.

This PR ensures the message editor stays in full mode while expanded,
preventing automatic editor mode syncing on thread updates from
overriding the user's explicit expand action during streaming.

## Steps to reproduce
1. Start an agent thread and send a message that causes streaming output
2. While streaming, click “Expand Message Editor”
3. Observe the input editor still shows only a few lines (auto-height)
4. Click “Minimize Message Editor” and then expand again; it becomes
fully expanded

## Test plan
- Start an agent thread and let it stream
- Click “Expand Message Editor” while streaming
- Verify the editor actually expands (not limited to the auto-height max
lines)
- Toggle minimize/expand multiple times during streaming; verify it
remains correct

## Additional context
Repro video: 


https://github.com/user-attachments/assets/6e328fa8-d431-456a-b70a-864ae617ea88



Release Notes:

- Fixed message editor not fully expanding during agent generation

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2026-07-08 15:52:26 +00:00
Mikayla Maki
74798c68d5
gpui: Add run_embedded and ApplicationHandle for externally driven run loops (#60574)
On ordinary platforms, `Platform::run` blocks for the lifetime of the
app, and `Application::run`'s stack frame keeps the app state alive.
Embedded platforms invert that: the run loop belongs to someone else.

`Application::run_embedded` supports that shape: it starts the app
exactly like `run()`, but returns an `ApplicationHandle` holding the
strong app handle.

Release Notes:

- N/A
2026-07-08 15:46:41 +00:00
216 changed files with 15441 additions and 2398 deletions

View file

@ -1,6 +1,11 @@
export default {
async fetch(request, _env, _ctx) {
const url = new URL(request.url);
const acceptHeader = request.headers.get("Accept") || "";
const wantsMarkdown = acceptHeader
.split(",")
.map((mediaType) => mediaType.split(";")[0].trim().toLowerCase())
.includes("text/markdown");
if (url.pathname === "/docs/nightly") {
url.hostname = "docs-nightly.pages.dev";
@ -18,6 +23,14 @@ export default {
url.hostname = "docs-anw.pages.dev";
}
if (url.pathname === "/docs.md") {
url.pathname = "/docs/getting-started.md";
}
if (wantsMarkdown) {
url.pathname = markdownPathFor(url.pathname);
}
let res = await fetch(url, request);
if (res.status === 404) {
@ -27,3 +40,31 @@ export default {
return res;
},
};
function markdownPathFor(pathname) {
if (pathname === "/docs" || pathname === "/docs/") {
return "/docs/getting-started.md";
}
if (pathname.endsWith("/index.md")) {
return pathname.replace(/\/index\.md$/, "/getting-started.md");
}
if (pathname.endsWith(".md")) {
return pathname;
}
if (pathname.endsWith(".html")) {
return pathname.replace(/\.html$/, ".md");
}
if (pathname.split("/").pop().includes(".")) {
return pathname;
}
if (pathname.endsWith("/")) {
return `${pathname}getting-started.md`;
}
return `${pathname}.md`;
}

View file

@ -27,10 +27,6 @@ jobs:
run_cherry_pick:
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- name: steps::checkout_repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with:
clean: false
- id: generate-token
name: steps::authenticate_as_zippy
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859
@ -40,6 +36,11 @@ jobs:
permission-contents: write
permission-workflows: write
permission-pull-requests: write
- name: steps::checkout_repo
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with:
clean: false
token: ${{ steps.generate-token.outputs.token }}
- name: cherry_pick::run_cherry_pick::cherry_pick
run: ./script/cherry-pick "$BRANCH" "$COMMIT" "$CHANNEL"
env:

View file

@ -40,18 +40,23 @@ jobs:
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const GUILD_TEAM_SLUG = 'guild-cohort-2';
// Guild cohort members are outside collaborators holding this custom
// repository role, not members of an org team.
const GUILD_ROLE_NAME = 'Guild Assign issues/PRs';
const pr = context.payload.pull_request;
const author = pr.user.login;
const isGuildMember = async (username) => {
try {
const response = await github.rest.teams.getMembershipForUserInOrg({
org: 'zed-industries',
team_slug: GUILD_TEAM_SLUG,
const response = await github.rest.repos.getCollaboratorPermissionLevel({
owner: 'zed-industries',
repo: 'zed',
username
});
return response.data.state === 'active';
// role_name is the effective (highest) role; for cohort outside
// collaborators that is the custom role. Built-in roles come back
// lowercased and won't match.
return (response.data.role_name || '').toLowerCase() === GUILD_ROLE_NAME.toLowerCase();
} catch (error) {
if (error.status === 404) {
return false;

View file

@ -41,7 +41,9 @@ jobs:
const STAFF_TEAM_SLUG = 'staff';
const FIRST_CONTRIBUTION_LABEL = 'first contribution';
const GUILD_LABEL = 'guild';
const GUILD_TEAM_SLUG = 'guild-cohort-2';
// Guild cohort members are outside collaborators holding this custom
// repository role, not members of an org team.
const GUILD_ROLE_NAME = 'Guild Assign issues/PRs';
const COMMUNITY_CHAMPION_LABEL = 'community champion';
const COMMUNITY_CHAMPIONS = [
'0x2CA',
@ -138,7 +140,25 @@ jobs:
};
const isStaffMember = (author) => isTeamMember(STAFF_TEAM_SLUG, author);
const isGuildMember = (author) => isTeamMember(GUILD_TEAM_SLUG, author);
const isGuildMember = async (author) => {
try {
const response = await github.rest.repos.getCollaboratorPermissionLevel({
owner: 'zed-industries',
repo: 'zed',
username: author
});
// role_name is the effective (highest) role; for cohort outside
// collaborators that is the custom role. Built-in roles come back
// lowercased and won't match.
return (response.data.role_name || '').toLowerCase() === GUILD_ROLE_NAME.toLowerCase();
} catch (error) {
if (error.status !== 404) {
throw error;
}
return false;
}
};
const getIssueLabels = () => {
if (listIncludesAuthor(COMMUNITY_CHAMPIONS, author)) {

View file

@ -760,6 +760,8 @@ jobs:
needs:
- upload_release_assets
runs-on: namespace-profile-2x4-ubuntu-2404
permissions:
contents: write
steps:
- name: release::validate_release_assets
run: |

View file

@ -11,28 +11,41 @@ env:
PRIORITY_LABELS: '["priority:P0", "priority:P1"]'
REPRODUCIBLE_LABEL: 'state:reproducible'
FREQUENCY_LABELS: '["frequency:always", "frequency:common"]'
MARKER_REACTION: 'eyes'
jobs:
notify-slack:
if: github.repository_owner == 'zed-industries' && github.event.issue.state == 'open'
runs-on: namespace-profile-2x4-ubuntu-2404
# Serialize per-issue so concurrent `labeled` events can't both observe
# the trifecta and double-notify.
permissions:
contents: read
# For the issue reaction used to dedupe notifications.
issues: write
# Serialize per issue so the reaction claim below can't race.
concurrency:
group: slack-notify-first-responders-${{ github.event.issue.number }}
cancel-in-progress: false
steps:
- name: Check if label combination requires first responder notification
- name: Check label combination and claim the notification
id: check-label
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
LABEL_NAME: ${{ github.event.label.name }}
ISSUE_LABELS_JSON: ${{ toJson(github.event.issue.labels.*.name) }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
set -euo pipefail
# Gate on the just-added label so unrelated labeling on an
# already-qualifying issue doesn't re-fire the notification.
api() {
curl -sS \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$@"
}
# Ignore labels outside the trifecta so unrelated later edits don't re-fire.
TRIGGER_LABELS=$(jq -cn \
--argjson priority "$PRIORITY_LABELS" \
--arg repro "$REPRODUCIBLE_LABEL" \
@ -45,19 +58,52 @@ jobs:
exit 0
fi
# The webhook's issue.labels snapshot is racy under bulk apply; read live.
ISSUE_LABELS_JSON=$(api -f "https://api.github.com/repos/$GH_REPO/issues/$ISSUE_NUMBER/labels?per_page=100" | jq '[.[].name]')
MATCHED_PRIORITY=$(echo "$ISSUE_LABELS_JSON" | jq -r --argjson priority "$PRIORITY_LABELS" 'map(select(. as $x | $priority | index($x) != null)) | first // ""')
HAS_REPRO=$(echo "$ISSUE_LABELS_JSON" | jq --arg l "$REPRODUCIBLE_LABEL" 'index($l) != null')
HAS_FREQ=$(echo "$ISSUE_LABELS_JSON" | jq --argjson freq "$FREQUENCY_LABELS" 'any(.[]; . as $x | $freq | index($x) != null)')
if [ -n "$MATCHED_PRIORITY" ] && [ "$HAS_REPRO" = "true" ] && [ "$HAS_FREQ" = "true" ]; then
echo "Confirmed high-frequency $MATCHED_PRIORITY, notifying"
echo "notify_reason=confirmed $MATCHED_PRIORITY" >> "$GITHUB_OUTPUT"
echo "should_notify=true" >> "$GITHUB_OUTPUT"
else
if [ -z "$MATCHED_PRIORITY" ] || [ "$HAS_REPRO" != "true" ] || [ "$HAS_FREQ" != "true" ]; then
echo "Combination not yet satisfied (priority=$MATCHED_PRIORITY, reproducible=$HAS_REPRO, frequency=$HAS_FREQ), skipping"
echo "should_notify=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# A bulk label apply emits one `labeled` event per label, so several
# runs reach this point. Creating the reaction is our atomic claim:
# one run gets 201 and notifies, the rest get 200. It's scoped to our
# own reaction, so a human's reaction can't suppress it.
REACTION_PAYLOAD=$(jq -cn --arg content "$MARKER_REACTION" '{content: $content}')
REACTION_RESPONSE=$(api -w "\n%{http_code}" -X POST \
"https://api.github.com/repos/$GH_REPO/issues/$ISSUE_NUMBER/reactions" \
-d "$REACTION_PAYLOAD")
REACTION_BODY=$(echo "$REACTION_RESPONSE" | sed '$d')
REACTION_STATUS=$(echo "$REACTION_RESPONSE" | tail -n1)
if [ "$REACTION_STATUS" = "200" ]; then
echo "First responders already notified for this issue (reaction present), skipping"
echo "should_notify=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$REACTION_STATUS" != "201" ]; then
echo "::error::Unexpected status $REACTION_STATUS creating reaction: $REACTION_BODY"
exit 1
fi
REACTION_ID=$(echo "$REACTION_BODY" | jq -r '.id')
LABELS=$(echo "$ISSUE_LABELS_JSON" | jq -r 'join(", ")')
echo "Confirmed high-frequency $MATCHED_PRIORITY, notifying"
{
echo "notify_reason=confirmed $MATCHED_PRIORITY"
echo "issue_labels=$LABELS"
echo "reaction_id=$REACTION_ID"
echo "should_notify=true"
} >> "$GITHUB_OUTPUT"
- name: Build Slack message payload
if: steps.check-label.outputs.should_notify == 'true'
env:
@ -65,10 +111,8 @@ jobs:
ISSUE_URL: ${{ github.event.issue.html_url }}
LABELED_BY: ${{ github.event.sender.login }}
NOTIFY_REASON: ${{ steps.check-label.outputs.notify_reason }}
LABELS_JSON: ${{ toJson(github.event.issue.labels.*.name) }}
LABELS: ${{ steps.check-label.outputs.issue_labels }}
run: |
LABELS=$(echo "$LABELS_JSON" | jq -r 'join(", ")')
jq -n \
--arg notify_reason "$NOTIFY_REASON" \
--arg issue_title "$ISSUE_TITLE" \
@ -111,9 +155,27 @@ jobs:
if: steps.check-label.outputs.should_notify == 'true'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_FIRST_RESPONDERS }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
REACTION_ID: ${{ steps.check-label.outputs.reaction_id }}
run: |
set -uo pipefail
release_claim() {
if [ -n "${REACTION_ID:-}" ]; then
echo "Releasing reaction claim so the notification can be retried"
curl -sS -X DELETE \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/$GH_REPO/issues/$ISSUE_NUMBER/reactions/$REACTION_ID" || true
fi
}
if [ -z "$SLACK_WEBHOOK_URL" ]; then
echo "::error::SLACK_WEBHOOK_FIRST_RESPONDERS secret is not set"
release_claim
exit 1
fi
@ -129,6 +191,7 @@ jobs:
if [ "$HTTP_STATUS" -ne 200 ]; then
echo "::error::Slack notification failed with status $HTTP_STATUS: $HTTP_BODY"
release_claim
exit 1
fi

37
Cargo.lock generated
View file

@ -494,6 +494,7 @@ dependencies = [
"heapless",
"html_to_markdown",
"http_client",
"idna",
"image",
"indoc",
"itertools 0.14.0",
@ -549,6 +550,7 @@ dependencies = [
"tree-sitter-md",
"ui",
"ui_input",
"unicode-script",
"unicode-segmentation",
"unindent",
"url",
@ -5344,9 +5346,9 @@ dependencies = [
[[package]]
name = "dlib"
version = "0.5.2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading",
]
@ -6465,9 +6467,9 @@ dependencies = [
[[package]]
name = "fancy-regex"
version = "0.17.0"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8"
checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277"
dependencies = [
"bit-set 0.8.0",
"regex-automata",
@ -14003,7 +14005,7 @@ dependencies = [
"dap",
"encoding_rs",
"extension",
"fancy-regex 0.17.0",
"fancy-regex 0.18.0",
"fs",
"futures 0.3.32",
"fuzzy",
@ -15066,9 +15068,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.13"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
@ -15995,6 +15997,7 @@ dependencies = [
"libc",
"log",
"nix 0.29.0",
"seccompiler",
"serde",
"serde_json",
"smol",
@ -16312,6 +16315,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "seccompiler"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884"
dependencies = [
"libc",
]
[[package]]
name = "secrecy"
version = "0.10.3"
@ -16724,6 +16736,7 @@ dependencies = [
"agent_skills",
"anyhow",
"audio",
"client",
"cloud_api_types",
"codestral",
"collections",
@ -20843,9 +20856,9 @@ dependencies = [
[[package]]
name = "wayland-backend"
version = "0.3.11"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35"
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
dependencies = [
"cc",
"downcast-rs",
@ -20929,9 +20942,9 @@ dependencies = [
[[package]]
name = "wayland-sys"
version = "0.31.7"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
dependencies = [
"dlib",
"log",
@ -22994,7 +23007,7 @@ dependencies = [
[[package]]
name = "zed"
version = "1.11.0"
version = "1.12.0"
dependencies = [
"acp_thread",
"acp_tools",

View file

@ -595,7 +595,7 @@ emojis = "0.6.1"
env_logger = "0.11"
encoding_rs = "0.8"
exec = "0.3.1"
fancy-regex = "0.17.0"
fancy-regex = "0.18.0"
fork = "0.4.0"
futures = "0.3.32"
futures-concurrency = "7.7.1"
@ -744,6 +744,7 @@ rustls-platform-verifier = "0.5.0"
# WARNING: If you change this, you must also publish a new version of zed-scap to crates.io
scap = { git = "https://github.com/zed-industries/scap", rev = "4afea48c3b002197176fb19cd0f9b180dd36eaac", default-features = false, package = "zed-scap", version = "0.0.8-zed" }
schemars = { version = "1.0", features = ["indexmap2"] }
seccompiler = "0.5"
semver = { version = "1.0", features = ["serde"] }
serde = { version = "1.0.221", features = ["derive", "rc"] }
serde_json = { version = "1.0.144", features = ["preserve_order", "raw_value"] }

View file

@ -298,13 +298,6 @@
"alt-r": "search::ToggleRegex",
},
},
{
"context": "AgentTerminalThread",
"use_key_equivalents": true,
"bindings": {
"ctrl-f": "agent::ToggleSearch",
},
},
{
"context": "AcpThreadSearchBar",
"use_key_equivalents": true,
@ -326,6 +319,7 @@
"context": "AcpThread > Editor",
"use_key_equivalents": true,
"bindings": {
"ctrl-f": "agent::ToggleSearch",
"ctrl-alt-pageup": "agent::ScrollOutputPageUp",
"ctrl-alt-pagedown": "agent::ScrollOutputPageDown",
"ctrl-alt-home": "agent::ScrollOutputToTop",
@ -1240,6 +1234,7 @@
"ctrl-c": ["terminal::SendKeystroke", "ctrl-c"],
"ctrl-e": ["terminal::SendKeystroke", "ctrl-e"],
"ctrl-o": ["terminal::SendKeystroke", "ctrl-o"],
"ctrl-q": ["terminal::SendKeystroke", "ctrl-q"],
"ctrl-w": ["terminal::SendKeystroke", "ctrl-w"],
"ctrl-r": ["terminal::SendKeystroke", "ctrl-r"],
"ctrl-backspace": ["terminal::SendKeystroke", "ctrl-w"],
@ -1270,7 +1265,9 @@
},
{
"context": "AgentPanel > Terminal",
"use_key_equivalents": true,
"bindings": {
"ctrl-f": "agent::ToggleSearch",
"ctrl-n": "agent::NewThread",
},
},

View file

@ -336,13 +336,6 @@
"alt-cmd-x": "search::ToggleRegex",
},
},
{
"context": "AgentTerminalThread",
"use_key_equivalents": true,
"bindings": {
"cmd-f": "agent::ToggleSearch",
},
},
{
"context": "AcpThreadSearchBar",
"use_key_equivalents": true,
@ -364,6 +357,7 @@
"context": "AcpThread > Editor",
"use_key_equivalents": true,
"bindings": {
"cmd-f": "agent::ToggleSearch",
"ctrl-pageup": "agent::ScrollOutputPageUp",
"ctrl-pagedown": "agent::ScrollOutputPageDown",
"ctrl-home": "agent::ScrollOutputToTop",
@ -1341,6 +1335,7 @@
"context": "AgentPanel > Terminal",
"use_key_equivalents": true,
"bindings": {
"cmd-f": "agent::ToggleSearch",
"cmd-n": "agent::NewThread",
},
},

View file

@ -299,13 +299,6 @@
"alt-r": "search::ToggleRegex",
},
},
{
"context": "AgentTerminalThread",
"use_key_equivalents": true,
"bindings": {
"ctrl-f": "agent::ToggleSearch",
},
},
{
"context": "AcpThreadSearchBar",
"use_key_equivalents": true,
@ -327,6 +320,7 @@
"context": "AcpThread > Editor",
"use_key_equivalents": true,
"bindings": {
"ctrl-f": "agent::ToggleSearch",
"ctrl-alt-pageup": "agent::ScrollOutputPageUp",
"ctrl-alt-pagedown": "agent::ScrollOutputPageDown",
"ctrl-alt-home": "agent::ScrollOutputToTop",
@ -1285,6 +1279,7 @@
"context": "AgentPanel > Terminal",
"use_key_equivalents": true,
"bindings": {
"ctrl-f": "agent::ToggleSearch",
"ctrl-n": "agent::NewThread",
},
},

View file

@ -1458,7 +1458,13 @@
// The EditorConfig `end_of_line` property overrides this setting and behaves
// like `enforce_lf` or `enforce_crlf`.
"line_ending": "detect",
// Whether or not to perform a buffer format before saving: [on, off]
// Whether or not to perform a buffer format before saving:
// "on" format the whole buffer
// "off" do not format
// "modifications" format only lines with unstaged changes; skips formatting
// when no git diff is available or the language server lacks range formatting
// "modifications_if_available" same, but falls back to formatting the whole
// buffer when range formatting cannot be used
// Keep in mind, if the autosave with delay is enabled, format_on_save will be ignored
"format_on_save": "off",
// How to perform a buffer format. This setting can take multiple values:

View file

@ -224,6 +224,11 @@ pub struct SandboxFallbackAuthorizationDetails {
/// whether to run the command without a sandbox.
#[serde(default)]
pub reason: String,
/// Slug of the sandboxing docs section that best explains how to fix this
/// failure (see [`crate::LinuxWslSandboxError::docs_section`]), rendered as a
/// "Learn more" link. `None` when the cause is unknown.
#[serde(default)]
pub docs_section: Option<String>,
}
pub fn meta_with_sandbox_fallback_authorization(
@ -430,16 +435,7 @@ impl ElicitationStore {
&self.elicitations
}
fn validate_request(
request: &acp::CreateElicitationRequest,
cx: &App,
) -> Result<(), acp::Error> {
if !cx.has_flag::<AcpBetaFeatureFlag>() {
return Err(
acp::Error::invalid_params().data("elicitation support requires the ACP beta flag")
);
}
fn validate_request(request: &acp::CreateElicitationRequest) -> Result<(), acp::Error> {
if let acp::ElicitationMode::Url(mode) = &request.mode {
url::Url::parse(&mode.url)
.map_err(|_| acp::Error::invalid_params().data("invalid elicitation URL"))?;
@ -590,7 +586,7 @@ impl ElicitationStore {
request: acp::CreateElicitationRequest,
cx: &mut Context<Self>,
) -> Result<(ElicitationEntryId, Task<acp::CreateElicitationResponse>), acp::Error> {
Self::validate_request(&request, cx)?;
Self::validate_request(&request)?;
let (id, response_rx) = self.insert_pending_elicitation(request);
cx.emit(ElicitationStoreEvent::ElicitationRequested(id.clone()));
cx.notify();
@ -3485,7 +3481,7 @@ impl AcpThread {
request: acp::CreateElicitationRequest,
cx: &mut Context<Self>,
) -> Result<(ElicitationEntryId, Task<acp::CreateElicitationResponse>), acp::Error> {
ElicitationStore::validate_request(&request, cx)?;
ElicitationStore::validate_request(&request)?;
let (id, response_rx) = self.elicitations.insert_pending_elicitation(request);
self.push_entry(AgentThreadEntry::Elicitation(id.clone()), cx);
@ -7416,7 +7412,7 @@ mod tests {
}
#[gpui::test]
async fn test_elicitation_requires_acp_beta_flag(cx: &mut TestAppContext) {
async fn test_elicitation_is_available_without_acp_beta_flag(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
cx.update_flags(false, vec![]);
@ -7438,8 +7434,13 @@ mod tests {
)
});
assert!(result.is_err());
thread.read_with(cx, |thread, _| assert!(thread.entries().is_empty()));
assert!(result.is_ok());
thread.read_with(cx, |thread, _| {
assert!(matches!(
thread.entries(),
[AgentThreadEntry::Elicitation(_)]
));
});
}
#[gpui::test]

View file

@ -129,6 +129,31 @@ impl LinuxWslSandboxError {
LinuxWslSandboxError::Other(message) => message.clone(),
}
}
/// The slug of the sandboxing docs section that best explains how to resolve
/// this failure, for deep-linking from the UI. Pair with
/// `client::zed_urls::sandboxing_docs`.
pub fn docs_section(&self) -> &'static str {
match self {
// Both "no bwrap" and "only a setuid-root bwrap" are resolved by
// installing a non-setuid Bubblewrap.
LinuxWslSandboxError::BwrapNotFound | LinuxWslSandboxError::SetuidRejected => {
"installing-bubblewrap"
}
// A failed probe on Linux is almost always disabled unprivileged
// user namespaces, which the Ubuntu-specific section covers.
LinuxWslSandboxError::SandboxProbeFailed => "installing-bubblewrap-ubuntu",
// Catch-all (includes WSL/Windows messages): point at the platform
// overview for the current OS.
LinuxWslSandboxError::Other(_) => {
if cfg!(target_os = "windows") {
"windows"
} else {
"linux"
}
}
}
}
}
impl SandboxWrap {
@ -151,6 +176,15 @@ impl SandboxWrap {
/// grant as a [`sandbox::HostFilesystemLocation`] (pinning the inode / canonical
/// path) rather than passing a re-resolvable path. A location that can't be
/// captured (e.g. it doesn't exist) is dropped from the grant — fail-closed.
///
/// This function has **no filesystem side effects**: it never creates paths.
/// It is used both by the side-effect-free [`Self::can_create_sandbox`] probe
/// and by real sandbox construction, and must behave identically. On Linux a
/// writable grant that doesn't exist yet simply can't be captured (bwrap
/// can't bind a missing path), so it's dropped here — the sanctioned way to
/// get a grant to a new directory is the `create_directory` tool, which
/// creates it (pinning the inode) before the grant is recorded. On macOS a
/// missing leaf still canonicalizes, so such grants are captured directly.
fn to_policy(&self) -> sandbox::SandboxPolicy {
let protected_paths = self
.protected_paths
@ -164,12 +198,11 @@ impl SandboxWrap {
.writable_paths
.iter()
.chain(self.extra_write_paths.iter())
.filter_map(|path| {
// Create not-yet-existing writable grants (e.g. an approved
// scratch dir) so they can be captured and bound; best-effort.
let _ = std::fs::create_dir_all(path);
sandbox::HostFilesystemLocation::new(path).ok()
})
// Capture only — never create anything here (see the doc comment):
// materializing an approved-but-missing grant is deferred to
// `Sandbox::new` so it can never happen during the `can_create`
// probe, before the user has approved the grant.
.filter_map(|path| sandbox::HostFilesystemLocation::new(path).ok())
.collect();
sandbox::SandboxFsPolicy::Restricted {
writable_paths,

View file

@ -281,7 +281,9 @@ impl DbThread {
name: tool_use.name.into(),
raw_input: serde_json::to_string(&tool_use.input)
.unwrap_or_default(),
input: tool_use.input,
input: language_model::LanguageModelToolUseInput::Json(
tool_use.input,
),
is_input_complete: true,
thought_signature: None,
},

View file

@ -5,10 +5,9 @@
//! caller see the same answer (and so the `target_os` gate lives in one
//! place instead of scattered across the agent crate).
//!
//! The current policy is: enabled iff the user has the `sandboxing` feature
//! flag turned on, the project is local, the platform has an integration, and
//! the user has not persistently allowed unsandboxed execution (the
//! `allow_unsandboxed` sandbox setting). Setting `allow_unsandboxed`
//! The current policy is: enabled iff the project is local, the platform has an
//! integration, and the user has not persistently allowed unsandboxed execution
//! (the `allow_unsandboxed` sandbox setting). Setting `allow_unsandboxed`
//! persistently turns sandboxing off for the model-facing surface entirely:
//! the plain (non-sandboxed) `terminal` tool is exposed and the system prompt
//! omits the sandbox section, since every command would run without a wrap
@ -19,14 +18,12 @@
//!
//! macOS (Seatbelt), Linux (Bubblewrap), and Windows (Bubblewrap via WSL)
//! have real sandbox integrations; on platforms without one the per-command
//! wrap is a no-op, so commands run with the agent's ambient permissions even
//! when the flag is on.
//! wrap is a no-op, so commands run with the agent's ambient permissions.
//!
//! Naming note: this module is about agent terminal sandboxing specifically.
//! Other agent operations (e.g. file edits) are gated separately.
use agent_settings::{AgentSettings, SandboxPermissions};
use feature_flags::{FeatureFlagAppExt as _, SandboxingFeatureFlag};
use gpui::App;
use http_proxy::HostPattern;
use project::Project;
@ -176,12 +173,6 @@ pub fn settings_sandbox_policy(persistent: &SandboxPermissions) -> SandboxPolicy
SandboxPolicy { fs, network }
}
/// Whether agent-run terminal commands should be wrapped in an OS-level
/// sandbox for this process. See module docs for the policy.
pub(crate) fn sandboxing_enabled(cx: &App) -> bool {
cx.has_flag::<SandboxingFeatureFlag>()
}
/// Whether the sandboxed terminal can be exposed for this project.
///
/// The persistent `allow_unsandboxed` setting turns sandboxing off for the
@ -193,20 +184,19 @@ pub(crate) fn sandboxing_enabled(cx: &App) -> bool {
/// prompt in place, since the model is still operating in the sandbox model and
/// only escaping individual commands (tracked in `ThreadSandboxGrants`).
pub(crate) fn sandboxing_enabled_for_project(project: &Project, cx: &App) -> bool {
sandboxing_available_for_project(project, cx)
sandboxing_available_for_project(project)
&& !AgentSettings::get_global(cx)
.sandbox_permissions
.allow_unsandboxed
}
/// Whether sandboxing is *applicable* for this project at all — the feature is
/// enabled, the project is local, and the platform has a sandbox integration —
/// independent of the persistent `allow_unsandboxed` setting. Used by the UI to
/// distinguish "sandboxing isn't relevant here" (don't show the indicator) from
/// "sandboxing is available but turned off in settings" (show it, struck out).
pub(crate) fn sandboxing_available_for_project(project: &Project, cx: &App) -> bool {
sandboxing_enabled(cx)
&& project.is_local()
/// Whether sandboxing is *applicable* for this project at all — the project is
/// local and the platform has a sandbox integration — independent of the
/// persistent `allow_unsandboxed` setting. Used by the UI to distinguish
/// "sandboxing isn't relevant here" (don't show the indicator) from "sandboxing
/// is available but turned off in settings" (show it, struck out).
pub(crate) fn sandboxing_available_for_project(project: &Project) -> bool {
project.is_local()
&& cfg!(any(
target_os = "macos",
target_os = "linux",

View file

@ -198,7 +198,7 @@ You can request elevated permissions on individual `terminal` calls:
- `allow_hosts: ["github.com", "*.npmjs.org"]` — allow outbound HTTP/HTTPS to specific hosts (exact hostnames or leading-`*.` subdomain wildcards; no IP literals). Prefer this whenever you know which hosts the command needs.
- `allow_all_hosts: true` — lift the network restriction entirely: outbound access to any host over any protocol, so SSH, FTP, and raw sockets work too (unlike `allow_hosts`, which is HTTP/HTTPS-only). Use only when the specific hosts can't be enumerated up front.
{{/if}}
- `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write. Git metadata paths cannot be requested and will never be made writable while sandboxed.
- `fs_write_paths: ["/abs/or/worktree-relative/path", ...]` — allow writes to specific paths (each directory grants its whole subtree). Prefer this whenever you know which paths the command needs to write. Each path must be an existing directory. To write into a directory that doesn't exist yet, first create it with the `create_directory` tool (which creates it and grants write access to exactly that directory) rather than requesting write access to a broad existing parent. Git metadata paths cannot be requested and will never be made writable while sandboxed.
- `allow_fs_write_all: true` — allow unrestricted filesystem writes except protected Git metadata. Only use this when the specific paths can't be enumerated up front.
- `unsandboxed: true` — run the command with no sandbox at all. Use only when none of the above suffice, including when a command must write Git metadata.

View file

@ -689,7 +689,7 @@ async fn test_prompt_caching(cx: &mut TestAppContext) {
id: "tool_1".into(),
name: EchoTool::NAME.into(),
raw_input: json!({"text": "test"}).to_string(),
input: json!({"text": "test"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})),
is_input_complete: true,
thought_signature: None,
};
@ -840,17 +840,25 @@ async fn test_streaming_tool_calls(cx: &mut TestAppContext) {
assert_eq!(last_tool_use.name.as_ref(), "word_list");
if tool_call.status == acp::ToolCallStatus::Pending {
if !last_tool_use.is_input_complete
&& last_tool_use.input.get("g").is_none()
&& last_tool_use
.input
.as_json()
.and_then(|input| input.get("g"))
.is_none()
{
saw_partial_tool_use = true;
}
} else {
last_tool_use
.input
.as_json()
.expect("tool input should be JSON")
.get("a")
.expect("'a' has streamed because input is now complete");
last_tool_use
.input
.as_json()
.expect("tool input should be JSON")
.get("g")
.expect("'g' has streamed because input is now complete");
}
@ -884,7 +892,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
id: "tool_id_1".into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@ -894,7 +902,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
id: "tool_id_2".into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@ -951,7 +959,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
id: "tool_id_3".into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@ -990,7 +998,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
id: "tool_id_4".into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@ -1029,7 +1037,7 @@ async fn test_tool_hallucination(cx: &mut TestAppContext) {
id: "tool_id_1".into(),
name: "nonexistent_tool".into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@ -1533,7 +1541,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) {
id: "tool_1".into(),
name: "echo".into(),
raw_input: json!({"text": "test"}).to_string(),
input: json!({"text": "test"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})),
is_input_complete: true,
thought_signature: None,
},
@ -1577,7 +1585,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) {
id: "tool_2".into(),
name: "test_server_echo".into(),
raw_input: json!({"text": "mcp"}).to_string(),
input: json!({"text": "mcp"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "mcp"})),
is_input_complete: true,
thought_signature: None,
},
@ -1587,7 +1595,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) {
id: "tool_3".into(),
name: "echo".into(),
raw_input: json!({"text": "native"}).to_string(),
input: json!({"text": "native"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "native"})),
is_input_complete: true,
thought_signature: None,
},
@ -1697,7 +1705,7 @@ async fn test_mcp_tool_names_are_sanitized_for_providers(cx: &mut TestAppContext
id: "tool_1".into(),
name: "snake_case_PascalCase".into(),
raw_input: json!({}).to_string(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@ -1786,7 +1794,7 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) {
id: "tool_1".into(),
name: "screenshot".into(),
raw_input: json!({}).to_string(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@ -1936,7 +1944,9 @@ async fn test_mcp_tool_result_displayed_when_server_disconnected(cx: &mut TestAp
name: "issue_read".into(),
raw_input: json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"})
.to_string(),
input: json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}),
input: language_model::LanguageModelToolUseInput::Json(
json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}),
),
is_input_complete: true,
thought_signature: None,
},
@ -2351,7 +2361,9 @@ async fn test_terminal_tool_cancellation_captures_output(cx: &mut TestAppContext
id: "terminal_tool_1".into(),
name: TerminalTool::NAME.into(),
raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(),
input: json!({"command": "sleep 1000", "cd": "."}),
input: language_model::LanguageModelToolUseInput::Json(
json!({"command": "sleep 1000", "cd": "."}),
),
is_input_complete: true,
thought_signature: None,
},
@ -2446,7 +2458,7 @@ async fn test_cancellation_aware_tool_responds_to_cancellation(cx: &mut TestAppC
id: "cancellation_aware_1".into(),
name: "cancellation_aware".into(),
raw_input: r#"{}"#.into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@ -2632,7 +2644,9 @@ async fn test_truncate_while_terminal_tool_running(cx: &mut TestAppContext) {
id: "terminal_tool_1".into(),
name: TerminalTool::NAME.into(),
raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(),
input: json!({"command": "sleep 1000", "cd": "."}),
input: language_model::LanguageModelToolUseInput::Json(
json!({"command": "sleep 1000", "cd": "."}),
),
is_input_complete: true,
thought_signature: None,
},
@ -2697,7 +2711,9 @@ async fn test_cancel_multiple_concurrent_terminal_tools(cx: &mut TestAppContext)
id: "terminal_tool_1".into(),
name: TerminalTool::NAME.into(),
raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(),
input: json!({"command": "sleep 1000", "cd": "."}),
input: language_model::LanguageModelToolUseInput::Json(
json!({"command": "sleep 1000", "cd": "."}),
),
is_input_complete: true,
thought_signature: None,
},
@ -2707,7 +2723,9 @@ async fn test_cancel_multiple_concurrent_terminal_tools(cx: &mut TestAppContext)
id: "terminal_tool_2".into(),
name: TerminalTool::NAME.into(),
raw_input: r#"{"command": "sleep 2000", "cd": "."}"#.into(),
input: json!({"command": "sleep 2000", "cd": "."}),
input: language_model::LanguageModelToolUseInput::Json(
json!({"command": "sleep 2000", "cd": "."}),
),
is_input_complete: true,
thought_signature: None,
},
@ -2811,7 +2829,9 @@ async fn test_terminal_tool_stopped_via_terminal_card_button(cx: &mut TestAppCon
id: "terminal_tool_1".into(),
name: TerminalTool::NAME.into(),
raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(),
input: json!({"command": "sleep 1000", "cd": "."}),
input: language_model::LanguageModelToolUseInput::Json(
json!({"command": "sleep 1000", "cd": "."}),
),
is_input_complete: true,
thought_signature: None,
},
@ -2907,7 +2927,9 @@ async fn test_terminal_tool_timeout_expires(cx: &mut TestAppContext) {
id: "terminal_tool_1".into(),
name: TerminalTool::NAME.into(),
raw_input: r#"{"command": "sleep 1000", "cd": ".", "timeout_ms": 100}"#.into(),
input: json!({"command": "sleep 1000", "cd": ".", "timeout_ms": 100}),
input: language_model::LanguageModelToolUseInput::Json(
json!({"command": "sleep 1000", "cd": ".", "timeout_ms": 100}),
),
is_input_complete: true,
thought_signature: None,
},
@ -3376,7 +3398,7 @@ async fn test_cumulative_token_usage(cx: &mut TestAppContext) {
id: "tool_1".into(),
name: EchoTool::NAME.into(),
raw_input: json!({"text": "hello"}).to_string(),
input: json!({"text": "hello"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})),
is_input_complete: true,
thought_signature: None,
},
@ -3786,7 +3808,7 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) {
id: "tool_id_1".into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
};
@ -3794,7 +3816,7 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) {
id: "tool_id_2".into(),
name: EchoTool::NAME.into(),
raw_input: json!({"text": "test"}).to_string(),
input: json!({"text": "test"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})),
is_input_complete: true,
thought_signature: None,
};
@ -3992,7 +4014,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
id: "1".into(),
name: EchoTool::NAME.into(),
raw_input: input.to_string(),
input,
input: language_model::LanguageModelToolUseInput::Json(input),
is_input_complete: false,
thought_signature: None,
},
@ -4005,7 +4027,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
id: "1".into(),
name: "echo".into(),
raw_input: input.to_string(),
input,
input: language_model::LanguageModelToolUseInput::Json(input),
is_input_complete: true,
thought_signature: None,
},
@ -4175,7 +4197,7 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) {
id: "tool_1".into(),
name: EchoTool::NAME.into(),
raw_input: json!({"text": "test"}).to_string(),
input: json!({"text": "test"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})),
is_input_complete: true,
thought_signature: None,
};
@ -4323,7 +4345,7 @@ async fn test_streaming_tool_completes_when_llm_stream_ends_without_final_input(
id: "tool_1".into(),
name: "streaming_echo".into(),
raw_input: r#"{"text": "partial"}"#.into(),
input: json!({"text": "partial"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "partial"})),
is_input_complete: false,
thought_signature: None,
};
@ -4428,7 +4450,7 @@ async fn test_streaming_tool_json_parse_error_is_forwarded_to_running_tool(
id: "tool_1".into(),
name: StreamingJsonErrorContextTool::NAME.into(),
raw_input: r#"{"text": "partial"#.into(),
input: json!({"text": "partial"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "partial"})),
is_input_complete: false,
thought_signature: None,
};
@ -5228,7 +5250,9 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) {
id: "subagent_1".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
input: serde_json::to_value(&subagent_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&subagent_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@ -5362,7 +5386,9 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon
id: "subagent_1".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
input: serde_json::to_value(&subagent_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&subagent_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@ -5509,7 +5535,9 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp
id: "subagent_1".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
input: serde_json::to_value(&subagent_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&subagent_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@ -5638,7 +5666,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) {
id: "subagent_1".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
input: serde_json::to_value(&subagent_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&subagent_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@ -5699,7 +5729,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) {
id: "subagent_2".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&resume_tool_input).unwrap(),
input: serde_json::to_value(&resume_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&resume_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@ -6285,7 +6317,9 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) {
id: "subagent_1".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
input: serde_json::to_value(&subagent_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&subagent_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@ -6410,7 +6444,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu
id: "subagent_1".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
input: serde_json::to_value(&subagent_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&subagent_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@ -6476,7 +6512,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu
id: "subagent_2".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&resume_tool_input).unwrap(),
input: serde_json::to_value(&resume_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&resume_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@ -6583,7 +6621,9 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) {
id: "subagent_1".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
input: serde_json::to_value(&subagent_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&subagent_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@ -7350,6 +7390,194 @@ async fn test_fetch_tool_unsandboxed_lifts_restrictions(cx: &mut TestAppContext)
);
}
/// A granted host that redirects to a loopback target must not have that
/// redirect followed: loopback hosts can't be granted individually, so the hop
/// is refused just like a direct loopback fetch. This is the redirect variant of
/// the SSRF protection — the approved domain can't be used to bounce the request
/// onto the local machine.
#[gpui::test]
async fn test_fetch_tool_refuses_redirect_to_loopback(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
settings.tool_permissions.tools.insert(
FetchTool::NAME.into(),
agent_settings::ToolRules {
default: Some(settings::ToolPermissionMode::Allow),
always_allow: vec![],
always_deny: vec![],
always_confirm: vec![],
invalid_patterns: vec![],
},
);
settings
.sandbox_permissions
.network_hosts
.push("example.com".into());
agent_settings::AgentSettings::override_global(settings, cx);
});
let http_client = gpui::http_client::FakeHttpClient::create(|req| async move {
let uri = req.uri().to_string();
assert!(
uri.contains("example.com"),
"the loopback redirect target must never be requested, but saw {uri}"
);
Ok(gpui::http_client::Response::builder()
.status(302)
.header("location", "http://localhost:3000/internal")
.body("".into())
.unwrap())
});
#[allow(clippy::arc_with_non_send_sync)]
let tool = Arc::new(crate::FetchTool::new(http_client));
let (event_stream, _rx) = crate::ToolCallEventStream::test();
let input: crate::FetchToolInput =
serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap();
let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
let result = task.await;
assert!(
result.is_err(),
"expected a redirect to a loopback host to be refused"
);
assert!(
result.unwrap_err().contains("unsandboxed"),
"error should point at unsandboxed access as the way to reach loopback hosts"
);
}
/// A granted host that redirects to a *different*, ungranted host triggers a
/// fresh per-host authorization prompt for the redirect target — the redirect is
/// not silently followed to a host the user never approved.
#[gpui::test]
async fn test_fetch_tool_reauthorizes_redirect_to_new_host(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
settings.tool_permissions.tools.insert(
FetchTool::NAME.into(),
agent_settings::ToolRules {
default: Some(settings::ToolPermissionMode::Allow),
always_allow: vec![],
always_deny: vec![],
always_confirm: vec![],
invalid_patterns: vec![],
},
);
settings
.sandbox_permissions
.network_hosts
.push("example.com".into());
agent_settings::AgentSettings::override_global(settings, cx);
});
let http_client = gpui::http_client::FakeHttpClient::create(|req| async move {
let uri = req.uri().to_string();
assert!(
uri.contains("example.com"),
"the ungranted redirect target must not be requested before authorization, \
but saw {uri}"
);
Ok(gpui::http_client::Response::builder()
.status(302)
.header("location", "https://redirect-target.example/landing")
.body("".into())
.unwrap())
});
#[allow(clippy::arc_with_non_send_sync)]
let tool = Arc::new(crate::FetchTool::new(http_client));
let (event_stream, mut rx) = crate::ToolCallEventStream::test();
let input: crate::FetchToolInput =
serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap();
let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
cx.run_until_parked();
let authorization = rx.expect_authorization().await;
let details =
acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta)
.expect("a redirect to an ungranted host should request a sandbox network grant");
assert_eq!(
details.network_hosts,
vec!["redirect-target.example".to_string()]
);
assert!(!details.network_all_hosts);
}
/// Redirects between paths on an already-granted host are followed without any
/// additional prompt, so ordinary redirects (http→https upgrades, trailing-slash
/// canonicalization, etc.) keep working after the per-hop authorization change.
#[gpui::test]
async fn test_fetch_tool_follows_same_host_redirect(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
settings.tool_permissions.tools.insert(
FetchTool::NAME.into(),
agent_settings::ToolRules {
default: Some(settings::ToolPermissionMode::Allow),
always_allow: vec![],
always_deny: vec![],
always_confirm: vec![],
invalid_patterns: vec![],
},
);
settings
.sandbox_permissions
.network_hosts
.push("example.com".into());
agent_settings::AgentSettings::override_global(settings, cx);
});
let http_client = gpui::http_client::FakeHttpClient::create(|req| async move {
let uri = req.uri().to_string();
if uri.ends_with("/start") {
Ok(gpui::http_client::Response::builder()
.status(302)
.header("location", "https://example.com/final")
.body("".into())
.unwrap())
} else if uri.ends_with("/final") {
Ok(gpui::http_client::Response::builder()
.status(200)
.header("content-type", "text/plain")
.body("final content".into())
.unwrap())
} else {
panic!("unexpected request to {uri}");
}
});
#[allow(clippy::arc_with_non_send_sync)]
let tool = Arc::new(crate::FetchTool::new(http_client));
let (event_stream, mut rx) = crate::ToolCallEventStream::test();
let input: crate::FetchToolInput =
serde_json::from_value(json!({"url": "https://example.com/start"})).unwrap();
let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
let result = task.await;
assert_eq!(
result.expect("same-host redirect should succeed"),
"final content"
);
let event = rx.try_recv();
assert!(
!matches!(event, Ok(Ok(ThreadEvent::ToolCallAuthorization(_)))),
"expected no authorization prompt for a redirect to an already-granted host"
);
}
/// Approving one pending tool call with "Always for <tool>" auto-resolves
/// sibling pending authorizations for the same tool in the same turn.
#[gpui::test]
@ -7372,7 +7600,7 @@ async fn test_always_allow_resolves_pending_authorizations(cx: &mut TestAppConte
id: id.into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@ -7451,7 +7679,7 @@ async fn test_external_settings_edit_resolves_pending_authorization(cx: &mut Tes
id: "tool_id_1".into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@ -7522,7 +7750,7 @@ async fn test_external_deny_rule_resolves_pending_authorization(cx: &mut TestApp
id: "tool_id_1".into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@ -7598,7 +7826,7 @@ async fn test_unrelated_settings_change_does_not_resolve_pending_authorization(
id: "tool_id_1".into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@ -7668,7 +7896,7 @@ async fn test_always_allow_does_not_resolve_unrelated_tool_authorization(cx: &mu
id: id.into(),
name: name.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@ -7765,7 +7993,7 @@ async fn test_queued_message_ends_turn_at_boundary(cx: &mut TestAppContext) {
id: "tool_1".into(),
name: "echo".into(),
raw_input: r#"{"text": "hello"}"#.into(),
input: json!({"text": "hello"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})),
is_input_complete: true,
thought_signature: None,
},
@ -7847,7 +8075,7 @@ async fn test_queued_message_does_not_end_turn_without_boundary_flag(cx: &mut Te
id: "tool_1".into(),
name: "echo".into(),
raw_input: r#"{"text": "hello"}"#.into(),
input: json!({"text": "hello"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})),
is_input_complete: true,
thought_signature: None,
},
@ -7914,7 +8142,7 @@ async fn test_streaming_tool_error_breaks_stream_loop_immediately(cx: &mut TestA
id: "call_1".into(),
name: StreamingFailingEchoTool::NAME.into(),
raw_input: "hello".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: false,
thought_signature: None,
};
@ -7996,7 +8224,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te
id: "call_1".into(),
name: StreamingEchoTool::NAME.into(),
raw_input: "hello".into(),
input: json!({ "text": "hello" }),
input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello" })),
is_input_complete: false,
thought_signature: None,
},
@ -8005,7 +8233,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te
id: "call_1".into(),
name: StreamingEchoTool::NAME.into(),
raw_input: "hello world".into(),
input: json!({ "text": "hello world" }),
input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello world" })),
is_input_complete: true,
thought_signature: None,
};
@ -8015,7 +8243,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te
let second_tool_use = LanguageModelToolUse {
name: StreamingFailingEchoTool::NAME.into(),
raw_input: "hello".into(),
input: json!({ "text": "hello" }),
input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello" })),
is_input_complete: false,
thought_signature: None,
id: "call_2".into(),
@ -8144,7 +8372,7 @@ async fn test_mid_turn_model_and_settings_refresh(cx: &mut TestAppContext) {
id: "tool_1".into(),
name: "echo".into(),
raw_input: r#"{"text":"hello"}"#.into(),
input: json!({"text": "hello"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})),
is_input_complete: true,
thought_signature: None,
},

View file

@ -607,7 +607,7 @@ impl AgentMessage {
"{}\n",
MarkdownCodeBlock {
tag: "json",
text: &format!("{:#}", tool_use.input)
text: &format!("{:#}", tool_use.input.to_display_json())
}
));
}
@ -1593,7 +1593,7 @@ impl Thread {
.unbounded_send(Ok(ThreadEvent::ToolCall(
acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string())
.status(status)
.raw_input(tool_use.input.clone()),
.raw_input(tool_use.input.to_display_json()),
)))
.ok();
let mut fields = acp::ToolCallUpdateFields::new()
@ -1606,15 +1606,12 @@ impl Thread {
return;
};
let title = tool.initial_title(tool_use.input.clone(), cx);
let Ok(input) = tool_use.input.clone().into_json() else {
return;
};
let title = tool.initial_title(input.clone(), cx);
let kind = tool.kind();
stream.send_tool_call(
&tool_use.id,
&tool_use.name,
title,
kind,
tool_use.input.clone(),
);
stream.send_tool_call(&tool_use.id, &tool_use.name, title, kind, input.clone());
if let Some(content) = replay_content {
stream.update_tool_call_fields(
@ -1635,8 +1632,7 @@ impl Thread {
self.sandbox_grants.clone(),
Some(cx.weak_entity()),
);
tool.replay(tool_use.input.clone(), output, tool_event_stream, cx)
.log_err();
tool.replay(input, output, tool_event_stream, cx).log_err();
}
stream.update_tool_call_fields(
@ -1850,12 +1846,12 @@ impl Thread {
sandboxing_enabled_for_project(self.project.read(cx), cx)
}
/// Whether sandboxing is *applicable* for this thread's project (feature on,
/// local project, supported platform), regardless of whether it's been
/// turned off in settings. The UI shows the sandbox indicator whenever this
/// is true, drawing it struck-out when sandboxing is disabled.
/// Whether sandboxing is *applicable* for this thread's project (local
/// project, supported platform), regardless of whether it's been turned off
/// in settings. The UI shows the sandbox indicator whenever this is true,
/// drawing it struck-out when sandboxing is disabled.
pub fn sandboxing_available(&self, cx: &App) -> bool {
sandboxing_available_for_project(self.project.read(cx), cx)
sandboxing_available_for_project(self.project.read(cx))
}
/// The directory subtrees the sandbox always grants write access to for this
@ -3418,7 +3414,9 @@ impl Thread {
let mut title = SharedString::from(&tool_use.name);
let mut kind = acp::ToolKind::Other;
if let Some(tool) = tool.as_ref() {
title = tool.initial_title(tool_use.input.clone(), cx);
if let Ok(input) = tool_use.input.clone().into_json() {
title = tool.initial_title(input, cx);
}
kind = tool.kind();
}
@ -3435,16 +3433,33 @@ impl Thread {
}));
};
// Agent tools are JSON-schema tools. Custom text-tool deltas are rejected
// before considering partial-vs-complete input for these local tools.
let input = match tool_use.input.clone().into_json() {
Ok(input) => input,
Err(error) => {
return Some(Task::ready(LanguageModelToolResult {
content: vec![LanguageModelToolResultContent::Text(Arc::from(
error.to_string(),
))],
tool_use_id: tool_use.id,
tool_name: tool_use.name,
is_error: true,
output: None,
}));
}
};
if !tool_use.is_input_complete {
if tool.supports_input_streaming() {
let running_turn = self.running_turn.as_mut()?;
if let Some(sender) = running_turn.streaming_tool_inputs.get_mut(&tool_use.id) {
sender.send_partial(tool_use.input);
sender.send_partial(input);
return None;
}
let (mut sender, tool_input) = ToolInputSender::channel();
sender.send_partial(tool_use.input);
sender.send_partial(input);
running_turn
.streaming_tool_inputs
.insert(tool_use.id.clone(), sender);
@ -3471,12 +3486,12 @@ impl Thread {
.streaming_tool_inputs
.remove(&tool_use.id)
{
sender.send_full(tool_use.input);
sender.send_full(input);
return None;
}
log::debug!("Running tool {}", tool_use.name);
let tool_input = ToolInput::ready(tool_use.input);
let tool_input = ToolInput::ready(input);
Some(self.run_tool(
tool,
tool_input,
@ -3600,7 +3615,7 @@ impl Thread {
id: tool_use_id,
name: tool_name,
raw_input: raw_input.to_string(),
input: serde_json::json!({}),
input: language_model::LanguageModelToolUseInput::Json(serde_json::json!({})),
is_input_complete: true,
thought_signature: None,
};
@ -3676,7 +3691,7 @@ impl Thread {
&tool_use.name,
title,
kind,
tool_use.input.clone(),
tool_use.input.to_display_json(),
);
last_message
.content
@ -3687,7 +3702,7 @@ impl Thread {
acp::ToolCallUpdateFields::new()
.title(title.as_str())
.kind(kind)
.raw_input(tool_use.input.clone()),
.raw_input(tool_use.input.to_display_json()),
None,
);
}
@ -3927,12 +3942,12 @@ impl Thread {
.iter()
.filter_map(|(tool_name, tool)| {
log::trace!("Including tool: {}", tool_name);
Some(LanguageModelRequestTool {
name: tool_name.to_string(),
description: tool.description().to_string(),
input_schema: tool.input_schema(model.tool_input_format()).log_err()?,
use_input_streaming: tool.supports_input_streaming(),
})
Some(LanguageModelRequestTool::function(
tool_name.to_string(),
tool.description().to_string(),
tool.input_schema(model.tool_input_format()).log_err()?,
tool.supports_input_streaming(),
))
})
.collect::<Vec<_>>()
} else {
@ -5911,10 +5926,15 @@ impl ToolCallEventStream {
&self,
command: Option<String>,
reason: String,
docs_section: Option<String>,
retries: usize,
cx: &mut App,
) -> Task<Result<SandboxFallbackDecision>> {
let details = acp_thread::SandboxFallbackAuthorizationDetails { command, reason };
let details = acp_thread::SandboxFallbackAuthorizationDetails {
command,
reason,
docs_section,
};
let retry_label = if retries == 0 {
"Retry".to_string()
} else {
@ -7671,6 +7691,7 @@ mod tests {
event_stream.authorize_sandbox_fallback(
Some("cargo build".to_string()),
"bwrap not found on PATH".to_string(),
Some("installing-bubblewrap".to_string()),
0,
cx,
)
@ -7682,6 +7703,10 @@ mod tests {
.expect("fallback authorization should include details");
assert_eq!(details.command.as_deref(), Some("cargo build"));
assert_eq!(details.reason, "bwrap not found on PATH");
assert_eq!(
details.docs_section.as_deref(),
Some("installing-bubblewrap")
);
let acp_thread::PermissionOptions::Flat(options) = &authorization.options else {
panic!("expected flat fallback permission options");
@ -7722,6 +7747,7 @@ mod tests {
event_stream.authorize_sandbox_fallback(
None,
"probe failed".to_string(),
None,
retries,
cx,
)
@ -7766,6 +7792,7 @@ mod tests {
event_stream.authorize_sandbox_fallback(
Some("cargo build".to_string()),
"user namespaces are disabled".to_string(),
None,
0,
cx,
)
@ -7795,7 +7822,13 @@ mod tests {
let (event_stream, mut receiver) = ToolCallEventStream::test();
let authorize = cx.update(|cx| {
event_stream.authorize_sandbox_fallback(None, "bwrap probe failed".to_string(), 0, cx)
event_stream.authorize_sandbox_fallback(
None,
"bwrap probe failed".to_string(),
None,
0,
cx,
)
});
let authorization = receiver.expect_authorization().await;
authorization
@ -7870,7 +7903,7 @@ mod tests {
id: registered_tool_use_id.clone(),
name: ReplayImageTool::NAME.into(),
raw_input: "null".to_string(),
input: json!(null),
input: language_model::LanguageModelToolUseInput::Json(json!(null)),
is_input_complete: true,
thought_signature: None,
};
@ -7878,7 +7911,7 @@ mod tests {
id: missing_tool_use_id.clone(),
name: "missing_image_tool".into(),
raw_input: "{}".to_string(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
};
@ -8185,7 +8218,10 @@ mod tests {
assert_eq!(tool_use.raw_input, raw_input.to_string());
assert!(tool_use.is_input_complete);
// Should fall back to empty object for invalid JSON
assert_eq!(tool_use.input, json!({}));
assert_eq!(
tool_use.input,
language_model::LanguageModelToolUseInput::Json(json!({}))
);
}
_ => panic!("Expected ToolUse content"),
}

View file

@ -153,12 +153,12 @@ macro_rules! tools {
/// A list of all built-in tools
pub fn built_in_tools() -> impl Iterator<Item = LanguageModelRequestTool> {
fn language_model_tool<T: AgentTool>() -> LanguageModelRequestTool {
LanguageModelRequestTool {
name: T::NAME.to_string(),
description: T::description().to_string(),
input_schema: T::input_schema(LanguageModelToolSchemaFormat::JsonSchema).to_value(),
use_input_streaming: T::supports_input_streaming(),
}
LanguageModelRequestTool::function(
T::NAME.to_string(),
T::description().to_string(),
T::input_schema(LanguageModelToolSchemaFormat::JsonSchema).to_value(),
T::supports_input_streaming(),
)
}
[
$(

View file

@ -5,7 +5,7 @@ use super::tool_permissions::{
use agent_client_protocol::schema::v1 as acp;
use agent_settings::AgentSettings;
use futures::FutureExt as _;
use gpui::{App, Entity, SharedString, Task};
use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@ -17,12 +17,13 @@ use crate::{
AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision,
authorize_with_sensitive_settings, decide_permission_for_path,
};
use std::path::Path;
use std::path::{Path, PathBuf};
/// Creates a new directory at the specified path within the project. Returns confirmation that the directory was created.
/// Creates a new directory at the specified path, and all necessary parent directories. Returns confirmation that the directory was created.
///
/// This tool creates a directory and all necessary parent directories. It should be used whenever you need to create new directories within the project.
/// The only supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills.
/// Use this whenever you need to create new directories. Paths inside the project are created directly.
///
/// This tool can also create a directory **outside** the project. When agent terminal commands are sandboxed, doing so grants those commands write access to exactly that new directory — so, rather than requesting write access to a broad existing parent (e.g. your home directory) just to create something inside it, create the specific directory here first and then write into it. The only other supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct CreateDirectoryToolInput {
/// The path of the new directory.
@ -40,6 +41,13 @@ pub struct CreateDirectoryToolInput {
/// To create a global agent skill directory, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill`.
/// </example>
pub path: String,
/// Justification for creating a directory **outside** the project, shown to
/// the user (attributed to you) in the approval prompt that grants sandboxed
/// terminal commands write access to it. Required only for out-of-project
/// paths; ignored for paths inside the project or the global skills dir.
#[serde(default)]
pub reason: Option<String>,
}
pub struct CreateDirectoryTool {
@ -83,6 +91,28 @@ impl AgentTool for CreateDirectoryTool {
let project = self.project.clone();
cx.spawn(async move |cx| {
let input = input.recv().await.map_err(|e| e.to_string())?;
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
// Resolve where this directory lives. The global agent-skills dir is a
// special case allowed outside the project; anything else outside the
// project is handled as a narrow sandbox write grant below.
let global_skill_directory =
resolve_creatable_global_skill_path(Path::new(&input.path), fs.as_ref()).await;
let in_project = project.read_with(cx, |project, cx| {
project.find_project_path(&input.path, cx).is_some()
});
// A path outside the project (and not the global skills dir) can only
// be created as a narrow sandbox write grant: create the directory and
// grant sandboxed terminal commands write access to exactly it. The
// sandbox approval prompt — which shows the real, canonicalized target
// — fully replaces the normal permission and symlink-escape prompts
// here.
if global_skill_directory.is_none() && !in_project {
return create_out_of_project_directory(&project, &input, &event_stream, cx).await;
}
let decision = cx.update(|cx| {
decide_permission_for_path(Self::NAME, &input.path, AgentSettings::get_global(cx))
});
@ -93,7 +123,6 @@ impl AgentTool for CreateDirectoryTool {
let destination_path: Arc<str> = input.path.as_str().into();
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
let symlink_escape_target = project.read_with(cx, |project, cx| {
@ -149,9 +178,7 @@ impl AgentTool for CreateDirectoryTool {
authorize.await.map_err(|e| e.to_string())?;
}
if let Some(global_skill_directory) =
resolve_creatable_global_skill_path(Path::new(&input.path), fs.as_ref()).await
{
if let Some(global_skill_directory) = global_skill_directory {
futures::select! {
result = fs.create_dir(&global_skill_directory).fuse() => {
result.map_err(|e| format!("Creating directory {destination_path}: {e}"))?;
@ -185,6 +212,100 @@ impl AgentTool for CreateDirectoryTool {
}
}
/// Create a directory that lives **outside** the project by granting sandboxed
/// terminal commands write access to exactly it.
///
/// The directory is created (Linux: eagerly, pinning the inode; macOS: after
/// approval) and the user is shown the real, canonicalized target in the sandbox
/// approval prompt — which is what defends against a concurrent symlink swap: the
/// grant is always against the inode/path the user actually saw. On denial, only
/// the directories we created are removed.
async fn create_out_of_project_directory(
project: &Entity<Project>,
input: &CreateDirectoryToolInput,
event_stream: &ToolCallEventStream,
cx: &mut AsyncApp,
) -> Result<String, String> {
// Narrowing a grant to a brand-new directory only makes sense when the
// project's terminal commands are sandboxed, and only on platforms that can
// grant a not-yet-existing directory. Otherwise keep the historical
// "outside the project" rejection.
let sandboxing = project.read_with(cx, |project, cx| {
crate::sandboxing::sandboxing_enabled_for_project(project, cx)
});
let platform_supported = cfg!(any(target_os = "linux", target_os = "macos"));
if !sandboxing || !platform_supported {
return Err("Path to create was outside the project".to_string());
}
let Some(reason) = input
.reason
.as_deref()
.map(str::trim)
.filter(|reason| !reason.is_empty())
else {
return Err(
"Creating a directory outside the project grants sandboxed terminal commands write \
access to it, so a `reason` is required: briefly justify why the directory is needed, \
then try again."
.to_string(),
);
};
let reason = reason.to_string();
let absolute = resolve_absolute_path(project, &input.path, cx)
.ok_or_else(|| format!("Couldn't resolve `{}` to an absolute path.", input.path))?;
let prepared = cx
.background_spawn(async move { sandbox::GrantableWriteDir::prepare(&absolute) })
.await
.map_err(|error| format!("Creating directory {}: {error}", input.path))?;
let canonical = prepared.canonical_path().to_path_buf();
let request = crate::sandboxing::SandboxRequest {
write_paths: vec![canonical.clone()],
..Default::default()
};
let approve = cx.update(|cx| event_stream.authorize_sandbox(request, reason, cx));
match approve.await {
Ok(()) => {
let display = canonical.display().to_string();
cx.background_spawn(async move { prepared.finalize() })
.await
.map_err(|error| format!("Creating directory {display}: {error}"))?;
Ok(format!("Created directory {display}"))
}
Err(error) => {
// Roll back exactly what we created; leave the user no litter.
cx.background_spawn(async move { prepared.discard() }).await;
Err(format!("Create directory cancelled: {error}"))
}
}
}
/// Resolve a model-provided path to an absolute, lexically-normalized path.
/// Relative paths are joined onto the first worktree root.
fn resolve_absolute_path(
project: &Entity<Project>,
raw: &str,
cx: &mut AsyncApp,
) -> Option<PathBuf> {
let path = Path::new(raw);
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
let base = project.read_with(cx, |project, cx| {
project
.worktrees(cx)
.next()
.map(|worktree| worktree.read(cx).abs_path().to_path_buf())
})?;
base.join(path)
};
util::paths::normalize_lexically(&absolute).ok()
}
#[cfg(test)]
mod tests {
use super::*;
@ -231,7 +352,10 @@ mod tests {
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.run(
ToolInput::resolved(CreateDirectoryToolInput { path: input_path }),
ToolInput::resolved(CreateDirectoryToolInput {
path: input_path,
reason: None,
}),
event_stream,
cx,
)
@ -286,6 +410,7 @@ mod tests {
tool.run(
ToolInput::resolved(CreateDirectoryToolInput {
path: outside_path.to_string_lossy().into_owned(),
reason: None,
}),
event_stream,
cx,
@ -342,6 +467,7 @@ mod tests {
tool.run(
ToolInput::resolved(CreateDirectoryToolInput {
path: "project/link_to_external".into(),
reason: None,
}),
event_stream,
cx,
@ -404,6 +530,7 @@ mod tests {
tool.run(
ToolInput::resolved(CreateDirectoryToolInput {
path: "project/link_to_external".into(),
reason: None,
}),
event_stream,
cx,
@ -463,6 +590,7 @@ mod tests {
tool.run(
ToolInput::resolved(CreateDirectoryToolInput {
path: "project/link_to_external".into(),
reason: None,
}),
event_stream,
cx,
@ -545,6 +673,7 @@ mod tests {
tool.run(
ToolInput::resolved(CreateDirectoryToolInput {
path: "project/link_to_external".into(),
reason: None,
}),
event_stream,
cx,
@ -561,4 +690,107 @@ mod tests {
"Deny policy should not emit symlink authorization prompt",
);
}
/// Out-of-project creation goes through the sandbox write-grant prompt and,
/// on approval, creates the *specific* new directory (not its broad parent).
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[gpui::test]
async fn test_create_directory_out_of_project_creates_and_grants(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/root"), json!({ "project": { "src": {} } }))
.await;
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
// The sandbox create path operates on the *real* filesystem, so use a
// real directory outside the (fake) project.
let scratch = tempfile::tempdir().unwrap();
let target = scratch.path().join("new_grant_dir");
assert!(!target.exists());
let tool = Arc::new(CreateDirectoryTool::new(project));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let path_input = target.to_string_lossy().into_owned();
let task = cx.update(|cx| {
tool.run(
ToolInput::resolved(CreateDirectoryToolInput {
path: path_input,
reason: Some("scratch space for the build".into()),
}),
event_stream,
cx,
)
});
let auth = event_rx.expect_authorization().await;
let details = acp_thread::sandbox_authorization_details_from_meta(&auth.tool_call.meta)
.expect("out-of-project create should request a sandbox write grant");
// The grant is for exactly the new directory, not its parent.
assert_eq!(
details.write_paths,
vec![scratch.path().canonicalize().unwrap().join("new_grant_dir")]
);
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()),
acp::PermissionOptionKind::AllowAlways,
))
.unwrap();
let result = task.await;
assert!(result.is_ok(), "expected success, got {result:?}");
assert!(
target.is_dir(),
"the new directory should have been created"
);
}
/// Denying the grant removes the directory we eagerly created, leaving no
/// trace on the filesystem.
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[gpui::test]
async fn test_create_directory_out_of_project_denied_cleans_up(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/root"), json!({ "project": { "src": {} } }))
.await;
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let scratch = tempfile::tempdir().unwrap();
let target = scratch.path().join("denied_dir");
let tool = Arc::new(CreateDirectoryTool::new(project));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let path_input = target.to_string_lossy().into_owned();
let task = cx.update(|cx| {
tool.run(
ToolInput::resolved(CreateDirectoryToolInput {
path: path_input,
reason: Some("scratch space".into()),
}),
event_stream,
cx,
)
});
let auth = event_rx.expect_authorization().await;
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()),
acp::PermissionOptionKind::RejectOnce,
))
.unwrap();
let result = task.await;
assert!(result.is_err(), "denied create should fail");
assert!(
!target.exists(),
"denied create should leave no directory behind"
);
}
}

View file

@ -324,6 +324,77 @@ mod tests {
assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n");
}
#[gpui::test]
async fn test_streaming_edit_first_line_missing_indent(cx: &mut TestAppContext) {
// Reproduces https://github.com/zed-industries/zed/issues/60302: the
// first line of the multi-line `old_text` omits its leading
// indentation while subsequent lines include theirs, so the indent
// delta computed from the first line must not be applied to the
// following lines. `old_text` also omits the `self.extra` line, so
// the query lines don't correspond one-to-one to the matched buffer
// rows and the indent pairing must follow the fuzzy match's
// alignment instead of assuming equal line counts.
let content = concat!(
"class Outer:\n",
" def method(self):\n",
" self.kept = \"unchanged\"\n",
" self.target_a = \"before\"\n",
" self.extra = \"row\"\n",
" self.target_b = \"before\"\n",
" self.target_c = \"before\"\n",
" self.target_d = \"before\"\n",
" self.kept_2 = \"unchanged\"\n",
);
let (edit_tool, _project, _action_log, _fs, _thread) =
setup_test(cx, json!({"file.py": content})).await;
let result = cx
.update(|cx| {
edit_tool.clone().run(
ToolInput::resolved(EditFileToolInput {
path: "root/file.py".into(),
edits: vec![Edit {
old_text: concat!(
"self.target_a = \"before\"\n",
" self.target_b = \"before\"\n",
" self.target_c = \"before\"\n",
" self.target_d = \"before\"",
)
.into(),
new_text: concat!(
"self.target_a = \"after\"\n",
" self.target_b = \"after\"\n",
" self.target_c = \"after\"\n",
" self.target_d = \"after\"",
)
.into(),
}],
}),
ToolCallEventStream::test().0,
cx,
)
})
.await;
let EditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
panic!("expected success");
};
// The matched range includes the `self.extra` row, so it is replaced
// along with the rest of the match.
assert_eq!(
new_text,
concat!(
"class Outer:\n",
" def method(self):\n",
" self.kept = \"unchanged\"\n",
" self.target_a = \"after\"\n",
" self.target_b = \"after\"\n",
" self.target_c = \"after\"\n",
" self.target_d = \"after\"\n",
" self.kept_2 = \"unchanged\"\n",
)
);
}
#[gpui::test]
async fn test_streaming_edit_multiple_edits(cx: &mut TestAppContext) {
let (edit_tool, _project, _action_log, _fs, _thread) = setup_test(

View file

@ -16,7 +16,7 @@ use language::{Buffer, BufferEditSource, BufferEvent, LanguageRegistry};
use language_model::LanguageModelToolResultContent;
use project::lsp_store::{FormatTrigger, LspFormatTarget};
use project::{AgentLocation, Project, ProjectPath};
use reindent::{Reindenter, compute_indent_delta};
use reindent::{Reindenter, compute_indent_delta, compute_rest_indent_delta};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::ops::Range;
@ -527,15 +527,34 @@ impl EditPipeline {
);
let buffer_indent = snapshot.line_indent_for_row(line);
let query_lines = matcher.query_lines();
let query_indent = text::LineIndent::from_iter(
matcher
.query_lines()
query_lines
.first()
.map(|s| s.as_str())
.unwrap_or("")
.chars(),
);
let indent_delta = compute_indent_delta(buffer_indent, query_indent);
let first_line_delta = compute_indent_delta(buffer_indent, query_indent);
// Query row 0 is excluded: its delta is `first_line_delta`,
// which intentionally differs when the model stripped the
// first line's indentation.
let rest_delta = compute_rest_indent_delta(
first_line_delta,
matcher
.line_pairs(&range)
.unwrap_or(&[])
.iter()
.filter(|(query_row, _)| *query_row != 0)
.filter_map(|(query_row, buffer_row)| {
let query_line = query_lines.get(*query_row as usize)?;
Some((
snapshot.line_indent_for_row(*buffer_row),
text::LineIndent::from_iter(query_line.chars()),
))
}),
);
let old_text_in_buffer = snapshot.text_for_range(range.clone()).collect::<String>();
@ -551,7 +570,7 @@ impl EditPipeline {
self.current_edit = Some(EditPipelineEntry::StreamingNewText {
streaming_diff: StreamingDiff::new(old_text_in_buffer),
edit_cursor: range.start,
reindenter: Reindenter::new(indent_delta),
reindenter: Reindenter::with_deltas(first_line_delta, rest_delta),
original_snapshot: text_snapshot,
});

View file

@ -1,7 +1,7 @@
use language::LineIndent;
use std::{cmp, iter};
#[derive(Copy, Clone, Debug)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum IndentDelta {
Spaces(isize),
Tabs(isize),
@ -31,20 +31,60 @@ pub fn compute_indent_delta(buffer_indent: LineIndent, query_indent: LineIndent)
}
}
/// Computes the indent delta for the lines after the first, given per-line
/// `(buffer, query)` indents for those lines.
///
/// When the remaining lines agree on a consistent delta, that delta is
/// returned even if it differs from `first_line_delta`. This handles queries
/// where only the first line's indentation was stripped. When the remaining
/// lines are inconsistent (or all blank), falls back to `first_line_delta`,
/// preserving the uniform re-indentation behavior.
pub fn compute_rest_indent_delta(
first_line_delta: IndentDelta,
indent_pairs: impl IntoIterator<Item = (LineIndent, LineIndent)>,
) -> IndentDelta {
let mut rest_delta = None;
for (buffer_indent, query_indent) in indent_pairs {
if buffer_indent.line_blank || query_indent.line_blank {
continue;
}
let delta = compute_indent_delta(buffer_indent, query_indent);
match rest_delta {
None => rest_delta = Some(delta),
Some(existing) if existing == delta => {}
Some(_) => return first_line_delta,
}
}
rest_delta.unwrap_or(first_line_delta)
}
/// Synchronous re-indentation adapter. Buffers incomplete lines and applies
/// an `IndentDelta` to each line's leading whitespace before emitting it.
///
/// Models sometimes omit the leading indentation only on the first line of
/// `old_text`/`new_text` (e.g. when copying from mid-line context), so the
/// first line and the remaining lines can require different deltas.
pub struct Reindenter {
delta: IndentDelta,
first_line_delta: IndentDelta,
rest_delta: IndentDelta,
buffer: String,
in_leading_whitespace: bool,
on_first_line: bool,
}
impl Reindenter {
pub fn new(delta: IndentDelta) -> Self {
#[cfg(test)]
fn uniform(delta: IndentDelta) -> Self {
Self::with_deltas(delta, delta)
}
pub fn with_deltas(first_line_delta: IndentDelta, rest_delta: IndentDelta) -> Self {
Self {
delta,
first_line_delta,
rest_delta,
buffer: String::new(),
in_leading_whitespace: true,
on_first_line: true,
}
}
@ -70,14 +110,19 @@ impl Reindenter {
None => (self.buffer.len(), true),
};
let line = &self.buffer[start_ix..line_end];
let delta = if self.on_first_line {
self.first_line_delta
} else {
self.rest_delta
};
if self.in_leading_whitespace {
if let Some(non_whitespace_ix) = line.find(|c| self.delta.character() != c) {
if let Some(non_whitespace_ix) = line.find(|c| delta.character() != c) {
// We found a non-whitespace character, adjust indentation
// based on the delta.
let new_indent_len =
cmp::max(0, non_whitespace_ix as isize + self.delta.len()) as usize;
indented.extend(iter::repeat(self.delta.character()).take(new_indent_len));
cmp::max(0, non_whitespace_ix as isize + delta.len()) as usize;
indented.extend(iter::repeat(delta.character()).take(new_indent_len));
indented.push_str(&line[non_whitespace_ix..]);
self.in_leading_whitespace = false;
} else if is_pending_line && !is_final {
@ -97,6 +142,7 @@ impl Reindenter {
break;
} else {
self.in_leading_whitespace = true;
self.on_first_line = false;
indented.push('\n');
start_ix = line_end + 1;
}
@ -116,7 +162,7 @@ mod tests {
#[test]
fn test_indent_single_chunk() {
let mut r = Reindenter::new(IndentDelta::Spaces(2));
let mut r = Reindenter::uniform(IndentDelta::Spaces(2));
let out = r.push(" abc\n def\n ghi");
// All three lines are emitted: "ghi" starts with spaces but
// contains non-whitespace, so it's processed immediately.
@ -127,7 +173,7 @@ mod tests {
#[test]
fn test_outdent_tabs() {
let mut r = Reindenter::new(IndentDelta::Tabs(-2));
let mut r = Reindenter::uniform(IndentDelta::Tabs(-2));
let out = r.push("\t\t\t\tabc\n\t\tdef\n\t\t\t\t\t\tghi");
assert_eq!(out, "\t\tabc\ndef\n\t\t\t\tghi");
let out = r.finish();
@ -136,7 +182,7 @@ mod tests {
#[test]
fn test_incremental_chunks() {
let mut r = Reindenter::new(IndentDelta::Spaces(2));
let mut r = Reindenter::uniform(IndentDelta::Spaces(2));
// Feed " ab" — the `a` is non-whitespace, so the line is
// processed immediately even without a trailing newline.
let out = r.push(" ab");
@ -151,7 +197,7 @@ mod tests {
#[test]
fn test_zero_delta() {
let mut r = Reindenter::new(IndentDelta::Spaces(0));
let mut r = Reindenter::uniform(IndentDelta::Spaces(0));
let out = r.push(" hello\n world\n");
assert_eq!(out, " hello\n world\n");
let out = r.finish();
@ -160,7 +206,7 @@ mod tests {
#[test]
fn test_clamp_negative_indent() {
let mut r = Reindenter::new(IndentDelta::Spaces(-10));
let mut r = Reindenter::uniform(IndentDelta::Spaces(-10));
let out = r.push(" abc\n");
// max(0, 2 - 10) = 0, so no leading spaces.
assert_eq!(out, "abc\n");
@ -170,7 +216,7 @@ mod tests {
#[test]
fn test_whitespace_only_lines() {
let mut r = Reindenter::new(IndentDelta::Spaces(2));
let mut r = Reindenter::uniform(IndentDelta::Spaces(2));
let out = r.push(" \n code\n");
// First line is all whitespace — emitted verbatim. Second line is indented.
assert_eq!(out, " \n code\n");
@ -178,6 +224,95 @@ mod tests {
assert_eq!(out, "");
}
#[test]
fn test_distinct_first_line_delta() {
// First line's indentation was stripped in the query (delta +8),
// while the remaining lines are already correct (delta 0). Chunks
// split mid-line and mid-indentation to exercise the streaming path,
// and the blank line is passed through verbatim.
let mut r = Reindenter::with_deltas(IndentDelta::Spaces(8), IndentDelta::Spaces(0));
let mut out = r.push("self.target_a = ");
out.push_str(&r.push("\"after\"\n "));
out.push_str(&r.push(" self.target_b = \"after\"\n"));
out.push_str(&r.push("\n self.target_c = \"after\""));
out.push_str(&r.finish());
assert_eq!(
out,
concat!(
" self.target_a = \"after\"\n",
" self.target_b = \"after\"\n",
"\n",
" self.target_c = \"after\"",
)
);
}
fn line_indent(text: &str) -> LineIndent {
LineIndent::from_iter(text.chars())
}
#[test]
fn test_compute_rest_indent_delta() {
let first_line_delta = IndentDelta::Spaces(8);
// Remaining lines that agree on a delta override the first-line
// delta, and blank lines are skipped when forming the consensus.
assert_eq!(
compute_rest_indent_delta(
first_line_delta,
vec![
(line_indent(" b"), line_indent(" b")),
(line_indent(""), line_indent("")),
(line_indent(" c"), line_indent(" c")),
],
),
IndentDelta::Spaces(0)
);
assert_eq!(
compute_rest_indent_delta(
first_line_delta,
vec![
(line_indent(" b"), line_indent(" b")),
(line_indent(" "), line_indent("")),
(line_indent(" c"), line_indent(" c")),
],
),
IndentDelta::Spaces(4)
);
assert_eq!(
compute_rest_indent_delta(
first_line_delta,
vec![(line_indent("\t\tb"), line_indent("\tb"))],
),
IndentDelta::Tabs(1)
);
// Inconsistent remaining lines fall back to the first-line delta...
assert_eq!(
compute_rest_indent_delta(
first_line_delta,
vec![
(line_indent(" b"), line_indent(" b")),
(line_indent(" c"), line_indent(" c")),
],
),
first_line_delta
);
// ...and so do all-blank and empty pairings.
assert_eq!(
compute_rest_indent_delta(
first_line_delta,
vec![(line_indent(" "), line_indent(""))],
),
first_line_delta
);
assert_eq!(
compute_rest_indent_delta(first_line_delta, vec![]),
first_line_delta
);
}
#[test]
fn test_compute_indent_delta_spaces() {
let buffer = LineIndent {

View file

@ -12,10 +12,18 @@ pub struct StreamingFuzzyMatcher {
query_lines: Vec<String>,
line_hint: Option<u32>,
incomplete_line: String,
matches: Vec<Range<usize>>,
matches: Vec<SearchMatch>,
matrix: SearchMatrix,
}
/// A match candidate: the matched byte range plus the 0-based
/// `(query_row, buffer_row)` line pairs the search aligned to produce it.
#[derive(Clone, Debug)]
struct SearchMatch {
range: Range<usize>,
line_pairs: Vec<(u32, u32)>,
}
impl StreamingFuzzyMatcher {
pub fn new(snapshot: TextBufferSnapshot) -> Self {
let buffer_line_count = snapshot.max_point().row as usize + 1;
@ -34,6 +42,23 @@ impl StreamingFuzzyMatcher {
&self.query_lines
}
/// Returns the 0-based `(query_row, buffer_row)` line pairs that the
/// search aligned for the match with the given range. Lines that were
/// skipped on either side of the alignment are absent.
pub fn line_pairs(&self, range: &Range<usize>) -> Option<&[(u32, u32)]> {
self.matches
.iter()
.find(|search_match| search_match.range == *range)
.map(|search_match| search_match.line_pairs.as_slice())
}
fn match_ranges(&self) -> Vec<Range<usize>> {
self.matches
.iter()
.map(|search_match| search_match.range.clone())
.collect()
}
/// Push a new chunk of text and get the best match found so far.
///
/// This method accumulates text chunks and processes complete lines.
@ -62,7 +87,11 @@ impl StreamingFuzzyMatcher {
}
let best_match = self.select_best_match();
best_match.or_else(|| self.matches.first().cloned())
best_match.or_else(|| {
self.matches
.first()
.map(|search_match| search_match.range.clone())
})
}
/// Finish processing and return the final best match(es).
@ -72,26 +101,35 @@ impl StreamingFuzzyMatcher {
pub fn finish(&mut self) -> Vec<Range<usize>> {
// Process any remaining incomplete line
if !self.incomplete_line.is_empty() {
if self.matches.len() == 1 {
let range = &mut self.matches[0];
if let [only_match] = self.matches.as_mut_slice() {
let range = &mut only_match.range;
if range.end < self.snapshot.len()
&& self
.snapshot
.contains_str_at(range.end + 1, &self.incomplete_line)
{
range.end += 1 + self.incomplete_line.len();
return self.matches.clone();
// Record the line and its alignment so that `query_lines`
// and `line_pairs` stay in sync with the lines covered by
// the returned range.
let extended_row = self.snapshot.offset_to_point(range.end).row;
self.query_lines
.push(std::mem::take(&mut self.incomplete_line));
only_match
.line_pairs
.push(((self.query_lines.len() - 1) as u32, extended_row));
return self.match_ranges();
}
}
self.query_lines.push(self.incomplete_line.clone());
self.incomplete_line.clear();
self.query_lines
.push(std::mem::take(&mut self.incomplete_line));
self.matches = self.resolve_location_fuzzy();
}
self.matches.clone()
self.match_ranges()
}
fn resolve_location_fuzzy(&mut self) -> Vec<Range<usize>> {
fn resolve_location_fuzzy(&mut self) -> Vec<SearchMatch> {
let new_query_line_count = self.query_lines.len();
let old_query_line_count = self.matrix.rows.saturating_sub(1);
if new_query_line_count == old_query_line_count {
@ -167,7 +205,7 @@ impl StreamingFuzzyMatcher {
// Find ranges for the matches
let mut valid_matches = Vec::new();
for &buffer_row_end in &matches_with_best_cost {
let mut matched_lines = 0;
let mut line_pairs = Vec::new();
let mut query_row = new_query_line_count;
let mut buffer_row_start = buffer_row_end;
while query_row > 0 && buffer_row_start > 0 {
@ -176,7 +214,7 @@ impl StreamingFuzzyMatcher {
SearchDirection::Diagonal => {
query_row -= 1;
buffer_row_start -= 1;
matched_lines += 1;
line_pairs.push((query_row as u32, buffer_row_start));
}
SearchDirection::Up => {
query_row -= 1;
@ -186,9 +224,10 @@ impl StreamingFuzzyMatcher {
}
}
}
line_pairs.reverse();
let matched_buffer_row_count = buffer_row_end - buffer_row_start;
let matched_ratio = matched_lines as f32
let matched_ratio = line_pairs.len() as f32
/ (matched_buffer_row_count as f32).max(new_query_line_count as f32);
if matched_ratio >= 0.8 {
let buffer_start_ix = self
@ -198,11 +237,14 @@ impl StreamingFuzzyMatcher {
buffer_row_end - 1,
self.snapshot.line_len(buffer_row_end - 1),
));
valid_matches.push((buffer_row_start, buffer_start_ix..buffer_end_ix));
valid_matches.push(SearchMatch {
range: buffer_start_ix..buffer_end_ix,
line_pairs,
});
}
}
valid_matches.into_iter().map(|(_, range)| range).collect()
valid_matches
}
/// Return the best match with starting position close enough to line_hint.
@ -216,8 +258,8 @@ impl StreamingFuzzyMatcher {
return None;
}
if self.matches.len() == 1 {
return self.matches.first().cloned();
if let [only_match] = self.matches.as_slice() {
return Some(only_match.range.clone());
}
let Some(line_hint) = self.line_hint else {
@ -228,14 +270,14 @@ impl StreamingFuzzyMatcher {
let mut best_match = None;
let mut best_distance = u32::MAX;
for range in &self.matches {
let start_point = self.snapshot.offset_to_point(range.start);
for search_match in &self.matches {
let start_point = self.snapshot.offset_to_point(search_match.range.start);
let start_line = start_point.row;
let distance = start_line.abs_diff(line_hint);
if distance <= LINE_HINT_TOLERANCE && distance < best_distance {
best_distance = distance;
best_match = Some(range.clone());
best_match = Some(search_match.range.clone());
}
}
@ -831,6 +873,79 @@ mod tests {
}
}
#[test]
fn test_line_pairs_skip_unmatched_buffer_line() {
let text = indoc! {r#"
class Outer:
def method(self):
self.kept = "unchanged"
self.target_a = "before"
self.extra = "row"
self.target_b = "before"
self.target_c = "before"
self.target_d = "before"
self.kept_2 = "unchanged"
"#};
let buffer = TextBuffer::new(
ReplicaId::LOCAL,
BufferId::new(1).unwrap(),
text.to_string(),
);
let mut matcher = StreamingFuzzyMatcher::new(buffer.snapshot().clone());
// The query omits the `self.extra` row that sits between the matched
// buffer lines.
matcher.push(
concat!(
" self.target_a = \"before\"\n",
" self.target_b = \"before\"\n",
" self.target_c = \"before\"\n",
" self.target_d = \"before\"\n",
),
None,
);
let matches = matcher.finish();
assert_eq!(matches.len(), 1);
assert_eq!(
matcher.line_pairs(&matches[0]),
Some(&[(0, 3), (1, 5), (2, 6), (3, 7)][..])
);
}
#[test]
fn test_line_pairs_include_extended_incomplete_line() {
let text = indoc! {r#"
fn on_query_change(&mut self, cx: &mut Context<Self>) {
self.filter(cx);
}
fn render_search(&self, cx: &mut Context<Self>) -> Div {
div()
}
"#};
let buffer = TextBuffer::new(
ReplicaId::LOCAL,
BufferId::new(1).unwrap(),
text.to_string(),
);
let mut matcher = StreamingFuzzyMatcher::new(buffer.snapshot().clone());
// The last query line is incomplete and gets appended to the match by
// `finish` via verbatim comparison rather than the fuzzy search.
matcher.push("}\n\n\n\nfn render_search", None);
let matches = matcher.finish();
assert_eq!(matches.len(), 1);
assert_eq!(
matcher.line_pairs(&matches[0]),
Some(&[(0, 2), (1, 3), (2, 4), (3, 5), (4, 6)][..])
);
assert_eq!(matcher.query_lines().len(), 5);
}
fn to_random_chunks(rng: &mut StdRng, input: &str) -> Vec<String> {
let chunk_count = rng.random_range(1..=cmp::min(input.len(), 50));
let mut chunk_indices = (0..input.len()).choose_multiple(rng, chunk_count);

View file

@ -503,7 +503,9 @@ impl EditToolTest {
if tool_use.is_input_complete
&& tool_use.name.as_ref() == EditFileTool::NAME =>
{
let input: EditFileToolInput = serde_json::from_value(tool_use.input)
let input: EditFileToolInput = tool_use
.input
.parse()
.context("Failed to parse tool input as EditFileToolInput")?;
return Ok(input);
}
@ -607,7 +609,9 @@ fn tool_use(
id: LanguageModelToolUseId::from(id.into()),
name: name.into(),
raw_input: serde_json::to_string_pretty(&input).unwrap(),
input: serde_json::to_value(input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
})

View file

@ -327,7 +327,9 @@ async fn extract_tool_use(
Ok(LanguageModelCompletionEvent::ToolUse(tool_use))
if tool_use.is_input_complete && tool_use.name.as_ref() == TerminalTool::NAME =>
{
let input: TerminalToolInput = serde_json::from_value(tool_use.input)
let input: TerminalToolInput = tool_use
.input
.parse()
.context("Failed to parse tool input as TerminalToolInput")?;
return Ok(input);
}

View file

@ -323,7 +323,9 @@ impl WriteToolTest {
if tool_use.is_input_complete
&& tool_use.name.as_ref() == WriteFileTool::NAME =>
{
let input: WriteFileToolInput = serde_json::from_value(tool_use.input)
let input: WriteFileToolInput = tool_use
.input
.parse()
.context("Failed to parse tool input as WriteFileToolInput")?;
return Ok(input);
}
@ -410,7 +412,9 @@ fn tool_use(
id: LanguageModelToolUseId::from(id.into()),
name: name.into(),
raw_input: serde_json::to_string_pretty(&input).unwrap(),
input: serde_json::to_value(input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
})

View file

@ -23,12 +23,38 @@ enum ContentType {
Json,
}
/// The maximum number of HTTP redirects the fetch tool will follow. Each hop is
/// re-authorized against the shared network grants before being followed.
const MAX_REDIRECTS: usize = 20;
/// The outcome of a single (non-redirect-following) HTTP request.
enum FetchStep {
/// The server responded with a redirect to this absolute URL. Its host must
/// be authorized before the redirect is followed.
Redirect(String),
/// A terminal response was received and converted to Markdown.
Complete(String),
}
/// Prepends `https://` when the URL has no explicit HTTP(S) scheme, matching the
/// behavior the fetch tool has always had for user/model-supplied URLs.
fn normalize_url(url: &str) -> Cow<'_, str> {
if !url.starts_with("https://") && !url.starts_with("http://") {
Cow::Owned(format!("https://{url}"))
} else {
Cow::Borrowed(url)
}
}
/// Fetches a URL and returns the content as Markdown.
///
/// This tool is not run inside the terminal OS sandbox, but it still refuses to
/// reach any host that hasn't been granted network access. It shares the same
/// per-host grants as the `terminal` tool: approving a host for one authorizes
/// it for the other, whether the grant is for this thread or saved permanently.
/// HTTP redirects are followed one hop at a time, and each hop's host must be
/// granted the same way, so a granted host can't redirect the request to a host
/// that hasn't been approved.
/// When unsandboxed access has been granted, these restrictions are lifted
/// entirely, matching the terminal, which is also how loopback and IP-literal
/// hosts (which can't be granted individually) become reachable.
@ -47,14 +73,35 @@ impl FetchTool {
Self { http_client }
}
async fn build_message(http_client: Arc<HttpClientWithUrl>, url: &str) -> Result<String> {
let url = if !url.starts_with("https://") && !url.starts_with("http://") {
Cow::Owned(format!("https://{url}"))
} else {
Cow::Borrowed(url)
};
/// Performs a single HTTP GET *without* following redirects, so the tool can
/// re-authorize each hop against the shared network grants before following
/// it. Returns the redirect target when the server responds with a 3xx, or
/// the final content converted to Markdown otherwise.
async fn fetch_step(http_client: Arc<HttpClientWithUrl>, url: &str) -> Result<FetchStep> {
let normalized = normalize_url(url);
let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
let mut response = http_client
.get(&normalized, AsyncBody::default(), false)
.await?;
let status = response.status();
if status.is_redirection() {
let location = response
.headers()
.get("location")
.context("redirect response is missing a Location header")?
.to_str()
.context("redirect response has an invalid Location header")?;
let target = url::Url::parse(&normalized)
.with_context(|| format!("could not parse URL {normalized:?}"))?
.join(location)
.with_context(|| format!("invalid redirect target {location:?}"))?;
anyhow::ensure!(
matches!(target.scheme(), "http" | "https"),
"refusing to follow redirect to non-HTTP(S) URL {target}"
);
return Ok(FetchStep::Redirect(target.to_string()));
}
let mut body = Vec::new();
response
@ -63,12 +110,9 @@ impl FetchTool {
.await
.context("error reading response body")?;
if response.status().is_client_error() {
if status.is_client_error() {
let text = String::from_utf8_lossy(body.as_slice());
bail!(
"status error {}, response: {text:?}",
response.status().as_u16()
);
bail!("status error {}, response: {text:?}", status.as_u16());
}
let Some(content_type) = response.headers().get("content-type") else {
@ -86,7 +130,7 @@ impl FetchTool {
ContentType::Html
};
match content_type {
let text = match content_type {
ContentType::Html => {
let mut handlers: Vec<TagHandler> = vec![
Rc::new(RefCell::new(markdown::WebpageChromeRemover)),
@ -96,7 +140,7 @@ impl FetchTool {
Rc::new(RefCell::new(markdown::TableHandler::new())),
Rc::new(RefCell::new(markdown::StyledTextHandler)),
];
if url.contains("wikipedia.org") {
if normalized.contains("wikipedia.org") {
use html_to_markdown::structure::wikipedia;
handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
@ -108,30 +152,25 @@ impl FetchTool {
handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
}
convert_html_to_markdown(&body[..], &mut handlers)
convert_html_to_markdown(&body[..], &mut handlers)?
}
ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
ContentType::Plaintext => std::str::from_utf8(&body)?.to_owned(),
ContentType::Json => {
let json: serde_json::Value = serde_json::from_slice(&body)?;
Ok(format!(
"```json\n{}\n```",
serde_json::to_string_pretty(&json)?
))
format!("```json\n{}\n```", serde_json::to_string_pretty(&json)?)
}
}
};
Ok(FetchStep::Complete(text))
}
}
/// Extracts the host from a fetch URL as a [`http_proxy::HostPattern`] so it can
/// be matched against the shared network grants. Mirrors the scheme handling in
/// [`FetchTool::build_message`] (defaulting to `https://` when none is given).
/// [`normalize_url`] (defaulting to `https://` when none is given).
fn host_pattern_for_url(url: &str) -> Result<http_proxy::HostPattern> {
let normalized = if !url.starts_with("https://") && !url.starts_with("http://") {
Cow::Owned(format!("https://{url}"))
} else {
Cow::Borrowed(url)
};
let normalized = normalize_url(url);
let parsed =
url::Url::parse(&normalized).with_context(|| format!("could not parse URL {url:?}"))?;
let host = parsed
@ -211,36 +250,61 @@ impl AgentTool for FetchTool {
// already runs without isolation, so we drop fetch's restrictions
// too — including reaching hosts that can't be granted individually
// (loopback and IP literals).
//
// Crucially, this authorization is applied to every redirect hop as
// well as the initial URL, so a granted host can't 30x-redirect the
// fetch to a host the user never approved. We disable the HTTP
// client's own redirect following and re-run the grant for each hop
// before requesting it.
let unsandboxed = cx.update(|cx| event_stream.unsandboxed_access_granted(cx));
if !unsandboxed {
let host = host_pattern_for_url(&input.url).map_err(|e| e.to_string())?;
let authorize_host = cx.update(|cx| {
let request = SandboxRequest {
network: NetworkRequest::Hosts(vec![host]),
..Default::default()
let mut current_url = input.url.clone();
let mut redirects = 0;
let text = loop {
if !unsandboxed {
let host = host_pattern_for_url(&current_url).map_err(|e| e.to_string())?;
let authorize_host = cx.update(|cx| {
let request = SandboxRequest {
network: NetworkRequest::Hosts(vec![host]),
..Default::default()
};
event_stream.authorize_sandbox(request, String::new(), cx)
});
futures::select! {
result = authorize_host.fuse() => result.map_err(|e| e.to_string())?,
_ = event_stream.cancelled_by_user().fuse() => {
return Err("Fetch cancelled by user".to_string());
}
};
event_stream.authorize_sandbox(request, String::new(), cx)
}
let fetch_task = cx.background_spawn({
let http_client = http_client.clone();
let url = current_url.clone();
async move { Self::fetch_step(http_client, &url).await }
});
futures::select! {
result = authorize_host.fuse() => result.map_err(|e| e.to_string())?,
let step = futures::select! {
result = fetch_task.fuse() => result.map_err(|e| e.to_string())?,
_ = event_stream.cancelled_by_user().fuse() => {
return Err("Fetch cancelled by user".to_string());
}
};
}
let fetch_task = cx.background_spawn({
let http_client = http_client.clone();
let url = input.url.clone();
async move { Self::build_message(http_client, &url).await }
});
let text = futures::select! {
result = fetch_task.fuse() => result.map_err(|e| e.to_string())?,
_ = event_stream.cancelled_by_user().fuse() => {
return Err("Fetch cancelled by user".to_string());
match step {
FetchStep::Complete(text) => break text,
FetchStep::Redirect(target) => {
redirects += 1;
if redirects > MAX_REDIRECTS {
return Err(format!(
"exceeded the maximum of {MAX_REDIRECTS} redirects"
));
}
current_url = target;
}
}
};
if text.trim().is_empty() {
return Err("no textual content found".to_string());
}

View file

@ -558,8 +558,10 @@ async fn run_terminal_tool(
if !path.is_dir() {
return Err(format!(
"Cannot request sandbox write access to `{}`: on Linux, write access can only \
be granted to directories that already exist. To create or modify files, \
request write access to the existing directory that contains them, not the \
be granted to directories that already exist. To create a new directory to write \
into, use the `create_directory` tool (which creates it and grants write access to \
exactly that directory) rather than requesting its parent. To modify existing \
files, request write access to the existing directory that contains them, not the \
file path itself.",
path.display()
));
@ -683,6 +685,7 @@ async fn run_terminal_tool(
event_stream.authorize_sandbox_fallback(
Some(input.command.clone()),
error.user_facing_message(),
Some(error.docs_section().to_string()),
retries,
cx,
)
@ -788,6 +791,7 @@ async fn run_terminal_tool(
event_stream.authorize_sandbox_fallback(
Some(input.command.clone()),
sandbox_error.user_facing_message(),
Some(sandbox_error.docs_section().to_string()),
retries,
cx,
)

View file

@ -753,10 +753,7 @@ fn connect_client_future(
)
}
fn client_capabilities_for_agent(
agent_id: &AgentId,
supports_beta_features: bool,
) -> acp::ClientCapabilities {
fn client_capabilities_for_agent(agent_id: &AgentId) -> acp::ClientCapabilities {
let mut meta = acp::Meta::from_iter([
("terminal_output".into(), true.into()),
("terminal-auth".into(), true.into()),
@ -766,7 +763,7 @@ fn client_capabilities_for_agent(
meta.insert(PARAMETERIZED_MODEL_PICKER_META_KEY.into(), true.into());
}
let mut capabilities = acp::ClientCapabilities::new()
acp::ClientCapabilities::new()
.fs(acp::FileSystemCapabilities::new()
.read_text_file(true)
.write_text_file(true))
@ -778,17 +775,12 @@ fn client_capabilities_for_agent(
.boolean(acp::BooleanConfigOptionCapabilities::new()),
),
)
.meta(meta);
if supports_beta_features {
capabilities = capabilities.elicitation(
.elicitation(
acp::ElicitationCapabilities::new()
.form(acp::ElicitationFormCapabilities::new())
.url(acp::ElicitationUrlCapabilities::new()),
);
}
capabilities
)
.meta(meta)
}
impl AcpConnection {
@ -980,10 +972,7 @@ impl AcpConnection {
let initialize_response = connection
.send_request(
acp::InitializeRequest::new(ProtocolVersion::V1)
.client_capabilities(client_capabilities_for_agent(
&agent_id,
cx.update(|cx| cx.has_flag::<AcpBetaFeatureFlag>()),
))
.client_capabilities(client_capabilities_for_agent(&agent_id))
.client_info(
acp::Implementation::new("zed", version)
.title(release_channel.map(ToOwned::to_owned)),
@ -2698,7 +2687,6 @@ mod tests {
use super::*;
use feature_flags::FeatureFlag as _;
use gpui::UpdateGlobal as _;
use settings::Settings as _;
fn init_feature_flags_test(cx: &mut gpui::TestAppContext) {
@ -2710,50 +2698,15 @@ mod tests {
});
}
fn set_acp_beta_override(value: &str, cx: &mut gpui::TestAppContext) {
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |content| {
content
.feature_flags
.get_or_insert_default()
.insert(AcpBetaFeatureFlag::NAME.to_string(), value.to_string());
});
});
});
}
#[gpui::test]
async fn client_capabilities_omit_elicitation_without_acp_beta(cx: &mut gpui::TestAppContext) {
async fn client_capabilities_include_elicitation_without_acp_beta(
cx: &mut gpui::TestAppContext,
) {
init_feature_flags_test(cx);
set_acp_beta_override("off", cx);
let capabilities = cx.update(|cx| {
client_capabilities_for_agent(
&AgentId::new("codex-acp"),
cx.has_flag::<AcpBetaFeatureFlag>(),
)
});
assert!(capabilities.elicitation.is_none());
}
#[gpui::test]
async fn client_capabilities_include_elicitation_with_acp_beta(cx: &mut gpui::TestAppContext) {
init_feature_flags_test(cx);
cx.update(|cx| {
cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]);
});
let capabilities = cx.update(|cx| {
client_capabilities_for_agent(
&AgentId::new("codex-acp"),
cx.has_flag::<AcpBetaFeatureFlag>(),
)
});
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"));
let elicitation = capabilities
.elicitation
.expect("elicitation should be advertised when acp-beta is enabled");
.expect("elicitation should always be advertised");
assert!(elicitation.form.is_some());
assert!(elicitation.url.is_some());
@ -3016,7 +2969,7 @@ mod tests {
#[test]
fn cursor_client_capabilities_include_parameterized_model_picker_meta() {
let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID), false);
let capabilities = client_capabilities_for_agent(&AgentId::new(CURSOR_ID));
let meta = capabilities
.meta
.expect("expected client capabilities meta");
@ -3031,7 +2984,7 @@ mod tests {
#[test]
fn non_cursor_client_capabilities_do_not_include_parameterized_model_picker_meta() {
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false);
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"));
let meta = capabilities
.meta
.expect("expected client capabilities meta");
@ -3041,7 +2994,7 @@ mod tests {
#[test]
fn client_capabilities_include_boolean_config_options() {
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"), false);
let capabilities = client_capabilities_for_agent(&AgentId::new("codex-acp"));
assert!(
capabilities
@ -4566,13 +4519,6 @@ fn handle_create_elicitation(
cx: &mut AsyncApp,
ctx: &ClientContext,
) {
if !cx.update(|cx| cx.has_flag::<AcpBetaFeatureFlag>()) {
return respond_err(
responder,
acp::Error::invalid_params().data("elicitation support requires the ACP beta flag"),
);
}
match args.scope() {
acp::ElicitationScope::Session(scope) => {
let thread = match session_thread(ctx, &scope.session_id) {
@ -4661,10 +4607,6 @@ fn handle_complete_elicitation(
cx: &mut AsyncApp,
ctx: &ClientContext,
) {
if !cx.update(|cx| cx.has_flag::<AcpBetaFeatureFlag>()) {
return;
}
let threads = ctx
.sessions
.borrow()

View file

@ -414,7 +414,7 @@ impl Default for AgentProfileId {
/// combines them with the in-memory per-thread grants. `write_paths` are
/// stored as minimal, lexically-normalized subtrees (see
/// [`compile_sandbox_permissions`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SandboxPermissions {
/// Allow sandboxed commands to reach any host over the network.
pub allow_all_hosts: bool,
@ -432,6 +432,24 @@ pub struct SandboxPermissions {
/// tool/prompt in place — see `agent::sandboxing`.
pub allow_unsandboxed: bool,
pub write_paths: Vec<PathBuf>,
/// Whether sandbox escalation prompts warn about domains or write paths
/// that contain potentially confusable Unicode characters (homoglyphs,
/// invisible characters, or bidirectional overrides). Enabled by default.
pub warn_confusable_unicode: bool,
}
impl Default for SandboxPermissions {
fn default() -> Self {
Self {
allow_all_hosts: false,
network_hosts: Vec::new(),
allow_fs_write_all: false,
allow_unsandboxed: false,
write_paths: Vec::new(),
// The confusable-Unicode warning is a safety net, so it defaults on.
warn_confusable_unicode: true,
}
}
}
#[derive(Clone, Debug, Default)]
@ -821,6 +839,7 @@ fn compile_sandbox_permissions(
allow_fs_write_all: content.allow_fs_write_all.unwrap_or(false),
allow_unsandboxed: content.allow_unsandboxed.unwrap_or(false),
write_paths,
warn_confusable_unicode: content.warn_confusable_unicode.unwrap_or(true),
}
}
@ -1098,6 +1117,22 @@ mod tests {
fn test_sandbox_permissions_empty() {
let permissions = compile_sandbox_permissions(None);
assert_eq!(permissions, SandboxPermissions::default());
// The confusable-Unicode warning is a safety net, so it's on by default.
assert!(permissions.warn_confusable_unicode);
}
#[test]
fn test_sandbox_permissions_warn_confusable_unicode_can_be_disabled() {
let content: settings::SandboxPermissionsContent =
serde_json::from_value(json!({ "warn_confusable_unicode": false })).unwrap();
let permissions = compile_sandbox_permissions(Some(content));
assert!(!permissions.warn_confusable_unicode);
// Omitting the key keeps the warning enabled.
let content: settings::SandboxPermissionsContent =
serde_json::from_value(json!({})).unwrap();
let permissions = compile_sandbox_permissions(Some(content));
assert!(permissions.warn_confusable_unicode);
}
#[test]

View file

@ -63,6 +63,7 @@ gpui.workspace = true
gpui_tokio.workspace = true
html_to_markdown.workspace = true
http_client.workspace = true
idna.workspace = true
indoc.workspace = true
itertools.workspace = true
jsonschema.workspace = true
@ -108,6 +109,7 @@ theme_settings.workspace = true
time.workspace = true
ui.workspace = true
ui_input.workspace = true
unicode-script.workspace = true
unicode-segmentation.workspace = true
url.workspace = true
util.workspace = true

View file

@ -29,11 +29,11 @@ use std::{
sync::Arc,
};
use ui::{CommonAnimationExt, Divider, IconButtonShape, KeyBinding, Tooltip, prelude::*};
use util::ResultExt;
use util::{ResultExt, truncate_and_trailoff};
use workspace::{
Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
Workspace,
item::{ItemEvent, SaveOptions, TabContentParams},
item::{ItemEvent, SaveOptions, TabContentParams, TabTooltipContent},
searchable::SearchableItemHandle,
};
use zed_actions::assistant::ToggleFocus;
@ -528,23 +528,33 @@ impl Item for AgentDiffPane {
.update(cx, |editor, cx| editor.navigate(data, window, cx))
}
fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
Some("Agent Diff".into())
fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
let label_content = self.tab_content_text(params.detail.unwrap_or_default(), cx);
Label::new(label_content)
.when(!params.selected, |this| this.color(Color::Muted))
.into_any_element()
}
fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
fn tab_tooltip_content(&self, cx: &App) -> Option<TabTooltipContent> {
let title = self.thread.read(cx).title();
Label::new(if let Some(title) = title {
format!("Review: {}", title)
} else {
"Review".to_string()
})
.color(if params.selected {
Color::Default
} else {
Color::Muted
})
.into_any_element()
Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
let title = title.map(|title| title.to_string());
move |_, _| {
v_flex()
.child(Label::new(
title.clone().unwrap_or_else(|| "Review".to_string()),
))
.child(
Label::new("Agent Diff")
.color(Color::Muted)
.size(LabelSize::Small),
)
.into_any_element()
}
}))))
}
fn telemetry_event_text(&self) -> Option<&'static str> {
@ -666,8 +676,11 @@ impl Item for AgentDiffPane {
});
}
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
"Agent Diff".into()
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
match self.thread.read(cx).title() {
Some(title) => format!("Review: {}", truncate_and_trailoff(&title, 20)).into(),
None => "Review".into(),
}
}
}

View file

@ -2037,6 +2037,7 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.pending_terminal_spawn = Some(terminal_id);
let terminal_working_directory = working_directory.clone();
let init_command = Self::terminal_init_command(run_init_command, cx);
let terminal_task = self.project.update(cx, |project, cx| {
@ -5715,24 +5716,6 @@ impl AgentPanel {
}
menu = menu
.separator()
.header("MCP Servers")
.action(
"Add Server…",
Box::new(zed_actions::OpenSettingsAt {
path: "context_servers".to_string(),
target: None,
}),
)
.action(
"Install New Servers…",
Box::new(zed_actions::Extensions {
category_filter: Some(
zed_actions::ExtensionCategoryFilter::ContextServers,
),
id: None,
}),
)
.separator()
.action("Profiles", Box::new(ManageProfiles::default()));
}
@ -6502,7 +6485,6 @@ impl Render for AgentPanel {
.and_then(|terminal_id| self.terminals.get(&terminal_id))
.and_then(|terminal| terminal.search_bar.clone());
let terminal_content = v_flex()
.key_context("AgentTerminalThread")
.size_full()
.when_some(search_bar, |this, search_bar| {
this.when(!search_bar.read(cx).is_dismissed(), |this| {
@ -7382,6 +7364,34 @@ mod tests {
});
}
#[gpui::test]
async fn test_explicit_terminal_blocks_redundant_auto_init(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;
panel.update_in(&mut cx, |panel, window, cx| {
panel.last_created_entry_kind = AgentPanelEntryKind::Terminal;
assert!(
matches!(panel.base_view, BaseView::Uninitialized),
"precondition: the panel starts uninitialized"
);
assert!(
panel.pending_terminal_spawn.is_none(),
"precondition: no terminal spawn is in-flight yet"
);
panel.new_terminal(None, AgentThreadSource::AgentPanel, window, cx);
let pending = panel.pending_terminal_spawn;
assert!(
pending.is_some(),
"an explicit new terminal must mark a spawn in-flight"
);
panel.set_active(true, window, cx);
assert_eq!(
panel.pending_terminal_spawn, pending,
"activating the panel while a terminal spawn is in-flight must not schedule a second (auto-init) terminal"
);
});
}
#[gpui::test]
async fn test_restored_terminal_runs_init_command_once(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;

View file

@ -35,6 +35,7 @@ pub mod thread_worktree_archive;
pub mod threads_archive_view;
mod ui;
mod unicode_confusables;
use std::rc::Rc;
use std::sync::Arc;

View file

@ -525,18 +525,18 @@ impl CodegenAlternative {
messages.push(user_message);
let tools = vec![
LanguageModelRequestTool {
name: REWRITE_SECTION_TOOL_NAME.to_string(),
description: "Replaces text in <rewrite_this></rewrite_this> tags with your replacement_text.".to_string(),
input_schema: language_model::tool_schema::root_schema_for::<RewriteSectionInput>(tool_input_format).to_value(),
use_input_streaming: false,
},
LanguageModelRequestTool {
name: FAILURE_MESSAGE_TOOL_NAME.to_string(),
description: "Use this tool to provide a message to the user when you're unable to complete a task.".to_string(),
input_schema: language_model::tool_schema::root_schema_for::<FailureMessageInput>(tool_input_format).to_value(),
use_input_streaming: false,
},
LanguageModelRequestTool::function(
REWRITE_SECTION_TOOL_NAME.to_string(),
"Replaces text in <rewrite_this></rewrite_this> tags with your replacement_text.".to_string(),
language_model::tool_schema::root_schema_for::<RewriteSectionInput>(tool_input_format).to_value(),
false,
),
LanguageModelRequestTool::function(
FAILURE_MESSAGE_TOOL_NAME.to_string(),
"Use this tool to provide a message to the user when you're unable to complete a task.".to_string(),
language_model::tool_schema::root_schema_for::<FailureMessageInput>(tool_input_format).to_value(),
false,
),
];
LanguageModelRequest {
@ -1179,9 +1179,7 @@ impl CodegenAlternative {
let mut chars_read_by_tool_id = chars_read_by_tool_id.lock();
match tool_use.name.as_ref() {
REWRITE_SECTION_TOOL_NAME => {
let Ok(input) =
serde_json::from_value::<RewriteSectionInput>(tool_use.input)
else {
let Ok(input) = tool_use.input.parse::<RewriteSectionInput>() else {
return None;
};
let chars_read_so_far =
@ -1198,9 +1196,7 @@ impl CodegenAlternative {
})
}
FAILURE_MESSAGE_TOOL_NAME => {
let Ok(mut input) =
serde_json::from_value::<FailureMessageInput>(tool_use.input)
else {
let Ok(mut input) = tool_use.input.parse::<FailureMessageInput>() else {
return None;
};
Some(ToolUseOutput::Failure(std::mem::take(&mut input.message)))
@ -2011,7 +2007,9 @@ mod tests {
id: id.into(),
name: REWRITE_SECTION_TOOL_NAME.into(),
raw_input: serde_json::to_string(&input).unwrap(),
input: serde_json::to_value(&input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&input).unwrap(),
),
is_input_complete: is_complete,
thought_signature: None,
})

View file

@ -23,7 +23,6 @@ use editor::scroll::Autoscroll;
use editor::{
Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior,
};
use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
use file_icons::FileIcons;
use fs::Fs;
use futures::FutureExt as _;
@ -277,7 +276,7 @@ impl Conversation {
let session_id = thread.read(cx).session_id().clone();
let subscription = cx.subscribe(&thread, {
let session_id = session_id.clone();
move |this, _thread, event, cx| {
move |this, _thread, event, _cx| {
this.updated_at = Some(Instant::now());
match event {
AcpThreadEvent::ToolAuthorizationRequested(id) => {
@ -294,15 +293,12 @@ impl Conversation {
}
}
}
AcpThreadEvent::ElicitationRequested(id)
if cx.has_flag::<AcpBetaFeatureFlag>() =>
{
AcpThreadEvent::ElicitationRequested(id) => {
this.elicitation_requests
.entry(session_id.clone())
.or_default()
.push(id.clone());
}
AcpThreadEvent::ElicitationRequested(_) => {}
AcpThreadEvent::ElicitationResponded(id) => {
if let Some(elicitations) = this.elicitation_requests.get_mut(&session_id) {
elicitations.retain(|elicitation_id| elicitation_id != id);
@ -422,10 +418,6 @@ impl Conversation {
response: acp::CreateElicitationResponse,
cx: &mut Context<Self>,
) -> Option<()> {
if !cx.has_flag::<AcpBetaFeatureFlag>() {
return None;
}
let thread = self.threads.get(&session_id)?.clone();
thread.update(cx, |thread, cx| {
thread.respond_to_elicitation(&elicitation_id, response, cx);
@ -1614,7 +1606,7 @@ impl ConversationView {
});
active.update(cx, |active, cx| {
active.sync_elicitation_state_for_entry(index, window, cx);
active.sync_editor_mode_for_empty_state(cx);
active.sync_editor_mode(cx);
active.sync_generating_indicator(cx);
});
}
@ -1641,7 +1633,7 @@ impl ConversationView {
entry_view_state.update(cx, |view_state, _cx| view_state.remove(range.clone()));
list_state.splice(range.clone(), 0);
active.update(cx, |active, cx| {
active.sync_editor_mode_for_empty_state(cx);
active.sync_editor_mode(cx);
});
}
}
@ -1652,10 +1644,9 @@ impl ConversationView {
self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx);
}
AcpThreadEvent::ToolAuthorizationReceived(_) => {}
AcpThreadEvent::ElicitationRequested(_) if cx.has_flag::<AcpBetaFeatureFlag>() => {
AcpThreadEvent::ElicitationRequested(_) => {
self.notify_with_sound("Waiting for input", IconName::Info, window, cx);
}
AcpThreadEvent::ElicitationRequested(_) => {}
AcpThreadEvent::ElicitationResponded(_) => {}
AcpThreadEvent::Retry(retry) => {
if let Some(active) = self.thread_view(&session_id) {
@ -2347,11 +2338,6 @@ impl ConversationView {
}
fn sync_request_elicitation_states(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !cx.has_flag::<AcpBetaFeatureFlag>() {
self.request_elicitation_form_states.clear();
return;
}
let Some(store) = self.request_elicitation_store() else {
self.request_elicitation_form_states.clear();
return;
@ -2397,10 +2383,6 @@ impl ConversationView {
view: WeakEntity<Self>,
cx: &App,
) -> Vec<AnyElement> {
if !cx.has_flag::<AcpBetaFeatureFlag>() {
return Vec::new();
}
let Some(store) = connection.request_elicitations() else {
return Vec::new();
};
@ -2529,10 +2511,6 @@ impl ConversationView {
_window: &mut Window,
cx: &mut Context<Self>,
) {
if !cx.has_flag::<AcpBetaFeatureFlag>() {
return;
}
let Some(store) = self.request_elicitation_store() else {
return;
};
@ -2581,10 +2559,6 @@ impl ConversationView {
_window: &mut Window,
cx: &mut Context<Self>,
) {
if !cx.has_flag::<AcpBetaFeatureFlag>() {
return;
}
self.respond_to_request_elicitation(
elicitation_id,
acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
@ -2598,10 +2572,6 @@ impl ConversationView {
_window: &mut Window,
cx: &mut Context<Self>,
) {
if !cx.has_flag::<AcpBetaFeatureFlag>() {
return;
}
self.respond_to_request_elicitation(
elicitation_id,
acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel),
@ -3634,7 +3604,7 @@ pub(crate) mod tests {
use agent_servers::FakeAcpAgentServer;
use editor::MultiBufferOffset;
use editor::actions::Paste;
use feature_flags::{FeatureFlag as _, FeatureFlagAppExt as _};
use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _};
use fs::FakeFs;
use gpui::{ClipboardItem, EventEmitter, TestAppContext, VisualTestContext, point, size};
use parking_lot::Mutex;

View file

@ -20,12 +20,12 @@ use agent_settings::UserAgentsMd;
use agent_skills::MAX_SKILL_DESCRIPTION_LEN;
use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody};
use editor::actions::OpenExcerpts;
use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _};
use sandbox::{SandboxFsPolicy, SandboxNetPolicy, SandboxPolicy};
use crate::completion_provider::{AvailableSkill, PromptLocalCommand};
use crate::message_editor::SharedSessionCapabilities;
use crate::ui::{SandboxGroup, SandboxRow, SandboxSection, SandboxStatusTooltip};
use crate::unicode_confusables;
use db::kvp::KeyValueStore;
use gpui::List;
@ -39,8 +39,8 @@ use language_model::{
use notifications::status_toast::StatusToast;
use settings::{update_settings_file, update_settings_file_with_completion};
use ui::{
ButtonLike, CalloutBorderPosition, SpinnerLabel, SpinnerVariant, SplitButton, SplitButtonStyle,
Tab,
ButtonLike, CalloutBorderPosition, Checkbox, SpinnerLabel, SpinnerVariant, SplitButton,
SplitButtonStyle, Tab, ToggleState,
};
use workspace::{OpenOptions, SERIALIZATION_THROTTLE_TIME};
@ -589,6 +589,10 @@ pub struct ThreadView {
pub expanded_tool_call_raw_inputs: HashSet<acp::ToolCallId>,
collapsed_sandbox_authorization_details: HashSet<acp::ToolCallId>,
collapsed_sandbox_network_details: HashSet<acp::ToolCallId>,
/// Sandbox escalation prompts whose "surprising Unicode" warning the user
/// has explicitly acknowledged. Until a prompt's tool call is in this set,
/// its allow buttons stay disabled. See [`Self::sandbox_confusable_findings`].
acknowledged_confusable_warnings: HashSet<acp::ToolCallId>,
pub subagent_scroll_handles: RefCell<HashMap<acp::SessionId, ScrollHandle>>,
pub edits_expanded: bool,
pub plan_expanded: bool,
@ -996,6 +1000,7 @@ impl ThreadView {
expanded_tool_call_raw_inputs: HashSet::default(),
collapsed_sandbox_authorization_details: HashSet::default(),
collapsed_sandbox_network_details: HashSet::default(),
acknowledged_confusable_warnings: HashSet::default(),
subagent_scroll_handles: RefCell::new(HashMap::default()),
edits_expanded: false,
plan_expanded: false,
@ -1036,7 +1041,7 @@ impl ThreadView {
};
this.sync_generating_indicator(cx);
this.sync_editor_mode_for_empty_state(cx);
this.sync_editor_mode(cx);
this.sync_existing_elicitation_states(window, cx);
let list_state_for_scroll = this.list_state.clone();
let thread_view = cx.entity().downgrade();
@ -2373,27 +2378,7 @@ impl ThreadView {
pub fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context<Self>) {
self.editor_expanded = is_expanded;
self.message_editor.update(cx, |editor, cx| {
if is_expanded {
editor.set_mode(
EditorMode::Full {
scale_ui_elements_with_buffer_font_size: false,
show_active_line_background: false,
sizing_behavior: SizingBehavior::ExcludeOverscrollMargin,
},
cx,
)
} else {
let agent_settings = AgentSettings::get_global(cx);
editor.set_mode(
EditorMode::AutoHeight {
min_lines: agent_settings.message_editor_min_lines,
max_lines: Some(agent_settings.set_message_editor_max_lines()),
},
cx,
)
}
});
self.sync_editor_mode(cx);
cx.notify();
}
@ -2506,13 +2491,40 @@ impl ThreadView {
}
pub fn allow_always(&mut self, _: &AllowAlways, window: &mut Window, cx: &mut Context<Self>) {
if self.pending_allow_blocked_by_confusables(cx) {
return;
}
self.authorize_pending_tool_call(acp::PermissionOptionKind::AllowAlways, window, cx);
}
pub fn allow_once(&mut self, _: &AllowOnce, window: &mut Window, cx: &mut Context<Self>) {
if self.pending_allow_blocked_by_confusables(cx) {
return;
}
self.authorize_pending_with_granularity(true, window, cx);
}
/// Whether the currently pending permission prompt is blocked by an
/// unacknowledged surprising-Unicode warning, so the keyboard allow
/// shortcuts must be ignored (mirroring the disabled allow buttons).
fn pending_allow_blocked_by_confusables(&self, cx: &Context<Self>) -> bool {
let session_id = self.thread.read(cx).session_id().clone();
let Some((_, tool_call_id, _)) = self
.conversation
.read(cx)
.pending_tool_call(&session_id, cx)
else {
return false;
};
self.thread.read(cx).entries().iter().any(|entry| {
matches!(
entry,
AgentThreadEntry::ToolCall(call)
if call.id == tool_call_id && self.sandbox_confusables_block_allow(call, cx)
)
})
}
pub fn reject_once(&mut self, _: &RejectOnce, window: &mut Window, cx: &mut Context<Self>) {
self.authorize_pending_with_granularity(false, window, cx);
}
@ -2538,26 +2550,18 @@ impl ThreadView {
Some(())
}
fn is_waiting_for_confirmation(&self, entry: &AgentThreadEntry, cx: &Context<Self>) -> bool {
match entry {
AgentThreadEntry::ToolCall(tool_call) => {
matches!(
tool_call.status,
ToolCallStatus::WaitingForConfirmation { .. }
)
}
AgentThreadEntry::Elicitation(elicitation_id) => {
cx.has_flag::<AcpBetaFeatureFlag>()
&& self
.thread
.read(cx)
.elicitation(elicitation_id)
.is_some_and(|(_, elicitation)| {
fn has_pending_request_elicitation(&self, cx: &App) -> bool {
self.server_view
.read_with(cx, |server_view, cx| {
server_view
.request_elicitation_store()
.is_some_and(|store| {
store.read(cx).elicitations().iter().any(|elicitation| {
matches!(elicitation.status, ElicitationStatus::Pending { .. })
})
}
_ => false,
}
})
})
.unwrap_or(false)
}
pub fn sync_elicitation_state_for_entry(
@ -2575,11 +2579,6 @@ impl ThreadView {
elicitation_id.clone()
};
if !cx.has_flag::<AcpBetaFeatureFlag>() {
self.elicitation_form_states.remove(&elicitation_id);
return;
}
let thread = self.thread.read(cx);
let entry = thread.elicitation(&elicitation_id).map(|(_, elicitation)| {
(
@ -2625,10 +2624,6 @@ impl ThreadView {
_window: &mut Window,
cx: &mut Context<Self>,
) {
if !cx.has_flag::<AcpBetaFeatureFlag>() {
return;
}
let mode = self
.thread
.read(cx)
@ -2674,10 +2669,6 @@ impl ThreadView {
_window: &mut Window,
cx: &mut Context<Self>,
) {
if !cx.has_flag::<AcpBetaFeatureFlag>() {
return;
}
self.respond_to_elicitation(
elicitation_id,
acp::CreateElicitationResponse::new(acp::ElicitationAction::Decline),
@ -2691,10 +2682,6 @@ impl ThreadView {
_window: &mut Window,
cx: &mut Context<Self>,
) {
if !cx.has_flag::<AcpBetaFeatureFlag>() {
return;
}
self.respond_to_elicitation(
elicitation_id,
acp::CreateElicitationResponse::new(acp::ElicitationAction::Cancel),
@ -6030,9 +6017,8 @@ impl ThreadView {
let rendered = this.render_entry(index, entries.len(), entry, window, cx);
centered_container(rendered.into_any_element()).into_any_element()
} else if this.generating_indicator_in_list {
let confirmation = entries
.last()
.is_some_and(|entry| this.is_waiting_for_confirmation(entry, cx));
let confirmation = this.thread.read(cx).is_waiting_for_confirmation()
|| this.has_pending_request_elicitation(cx);
let rendered = this.render_generating(confirmation, cx);
centered_container(rendered.into_any_element()).into_any_element()
} else {
@ -6353,8 +6339,7 @@ impl ThreadView {
}
AgentThreadEntry::Elicitation(elicitation_id) => {
let thread = self.thread.read(cx);
if cx.has_flag::<AcpBetaFeatureFlag>()
&& let Some((_, elicitation)) = thread.elicitation(elicitation_id)
if let Some((_, elicitation)) = thread.elicitation(elicitation_id)
&& should_render_elicitation(elicitation)
{
let elicitation = self.render_elicitation(entry_ix, elicitation, window, cx);
@ -6446,7 +6431,8 @@ impl ThreadView {
primary
};
let needs_confirmation = self.is_waiting_for_confirmation(entry, cx);
let needs_confirmation = thread.read(cx).is_waiting_for_confirmation()
|| self.has_pending_request_elicitation(cx);
let comments_editor = self.thread_feedback.comments_editor.clone();
@ -7104,11 +7090,21 @@ impl ThreadView {
open_markdown_in_workspace(thread_title, markdown, workspace, window, cx)
}
pub(crate) fn sync_editor_mode_for_empty_state(&mut self, cx: &mut Context<Self>) {
pub(crate) fn sync_editor_mode(&mut self, cx: &mut Context<Self>) {
let has_messages = self.list_state.item_count() > 0;
let v2_empty_state = !has_messages;
let mode = if v2_empty_state {
if !has_messages {
self.editor_expanded = false;
}
let mode = if self.editor_expanded {
EditorMode::Full {
scale_ui_elements_with_buffer_font_size: false,
show_active_line_background: false,
sizing_behavior: SizingBehavior::ExcludeOverscrollMargin,
}
} else if v2_empty_state {
EditorMode::Full {
scale_ui_elements_with_buffer_font_size: false,
show_active_line_background: false,
@ -7412,7 +7408,7 @@ impl ThreadView {
block.markdown()
}
};
md.map_or(false, |m| m.read(cx).selected_text().is_some())
md.map_or(false, |m| m.read(cx).has_selection())
})
})
.unwrap_or(false);
@ -7931,6 +7927,7 @@ impl ThreadView {
})
.when_some(confirmation_options, |this, options| {
let is_first = self.is_first_tool_call(active_session_id, &tool_call.id, cx);
let allow_disabled = self.sandbox_confusables_block_allow(tool_call, cx);
this.child(self.render_permission_buttons(
self.thread.read(cx).session_id().clone(),
is_first,
@ -7938,6 +7935,7 @@ impl ThreadView {
entry_ix,
tool_call.id.clone(),
focus_handle,
allow_disabled,
cx,
))
})
@ -7951,34 +7949,44 @@ impl ThreadView {
reason: &SandboxNotAppliedReason,
cx: &Context<Self>,
) -> AnyElement {
let (title, detail): (SharedString, SharedString) = match reason {
SandboxNotAppliedReason::ErrorLinuxWsl(error) => (
"Couldn't create a sandbox".into(),
error.user_facing_message().into(),
),
SandboxNotAppliedReason::DisabledForThisThread => {
// The grant only exists because an earlier command failed to
// create a sandbox; surface that same explanation here.
let detail = self
.find_thread_sandbox_error(cx)
.map(|error| {
SharedString::from(format!(
"Allowed for this thread after the sandbox failed: {}",
error.user_facing_message()
))
})
.unwrap_or_else(|| {
"Unsandboxed execution is allowed for the rest of this thread.".into()
});
("Ran without sandbox".into(), detail)
}
};
// (title, detail line, docs section slug)
let (title, detail, docs_section): (SharedString, SharedString, Option<&'static str>) =
match reason {
SandboxNotAppliedReason::ErrorLinuxWsl(error) => (
"Couldn't create a sandbox".into(),
error.user_facing_message().into(),
Some(error.docs_section()),
),
SandboxNotAppliedReason::DisabledForThisThread => {
// The grant only exists because an earlier command failed to
// create a sandbox; surface that same explanation here.
let thread_error = self.find_thread_sandbox_error(cx);
let detail = thread_error
.as_ref()
.map(|error| {
SharedString::from(format!(
"Allowed for this thread after the sandbox failed: {}",
error.user_facing_message()
))
})
.unwrap_or_else(|| {
"Unsandboxed execution is allowed for the rest of this thread.".into()
});
let docs_section = thread_error.as_ref().map(|error| error.docs_section());
("Ran without sandbox".into(), detail, docs_section)
}
};
Callout::new()
.severity(Severity::Warning)
.icon(IconName::Warning)
.title(title)
.description(detail)
.actions_slot(self.render_sandbox_docs_link(
"sandbox-not-applied-docs-link",
docs_section,
cx,
))
.into_any_element()
}
@ -8126,7 +8134,13 @@ impl ThreadView {
let use_card_layout = needs_confirmation || is_edit || is_terminal_tool;
let has_image_content = tool_call.content.iter().any(|c| c.image().is_some());
let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content;
let has_content = !tool_call.content.is_empty()
|| (should_show_raw_input && tool_call.raw_input.is_some());
let is_collapsible = has_content && !needs_confirmation;
let mut is_open = self
.entry_view_state
.read(cx)
@ -8134,8 +8148,6 @@ impl ThreadView {
is_open |= needs_confirmation;
let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content;
let input_output_header = |label: SharedString| {
Label::new(label)
.size(LabelSize::XSmall)
@ -8173,6 +8185,7 @@ impl ThreadView {
entry_ix,
&tool_call.id,
details,
window,
cx,
))
},
@ -8281,6 +8294,7 @@ impl ThreadView {
entry_ix,
tool_call.id.clone(),
focus_handle,
self.sandbox_confusables_block_allow(tool_call, cx),
cx,
))
.into_any()
@ -8587,11 +8601,42 @@ impl ThreadView {
.children(tool_output_display)
}
/// A small "Learn more" link to the sandboxing docs, deep-linked to
/// `section` when provided. Shared by the sandbox warning and the two
/// sandbox approval prompts so the user can always reach an explanation of
/// what they're being asked about.
fn render_sandbox_docs_link(
&self,
id: &'static str,
section: Option<&str>,
cx: &Context<Self>,
) -> AnyElement {
let url = zed_urls::sandboxing_docs(section, cx);
let tooltip = format!("Opens {url}");
// Wrap in a row so the button shrinks to its content width instead of
// stretching to fill the enclosing column.
h_flex()
.child(
Button::new(id, "Learn more")
.label_size(LabelSize::Small)
.color(Color::Muted)
.end_icon(
Icon::new(IconName::ArrowUpRight)
.color(Color::Muted)
.size(IconSize::XSmall),
)
.tooltip(Tooltip::text(tooltip))
.on_click(move |_, _, cx| cx.open_url(&url)),
)
.into_any_element()
}
fn render_sandbox_authorization_details(
&self,
entry_ix: usize,
tool_call_id: &acp::ToolCallId,
details: &SandboxAuthorizationDetails,
window: &Window,
cx: &Context<Self>,
) -> AnyElement {
let has_network = details.network_all_hosts || !details.network_hosts.is_empty();
@ -8600,6 +8645,12 @@ impl ThreadView {
return Empty.into_any_element();
}
let confusable_findings = if Self::confusable_warning_enabled(cx) {
Self::sandbox_confusable_findings(details)
} else {
Vec::new()
};
let network_section = has_network.then(|| {
let summary = if details.network_all_hosts {
"any host".to_string()
@ -8827,10 +8878,186 @@ impl ThreadView {
v_flex()
.border_t_1()
.border_color(self.tool_card_border_color(cx))
.when(!confusable_findings.is_empty(), |this| {
this.child(self.render_sandbox_confusable_warning(
tool_call_id,
&confusable_findings,
window,
cx,
))
})
.children(network_section)
.children(write_section)
.children(unsandboxed_section)
.children(reason_section)
.child(
h_flex()
.px_1()
.py_0p5()
.child(self.render_sandbox_docs_link(
"sandbox-authorization-docs-link",
None,
cx,
)),
)
.into_any_element()
}
/// Scan the hosts and paths in a sandbox escalation request for surprising
/// Unicode characters (homoglyphs, invisible characters, bidi overrides).
/// Returns, for each offending value, the display string shown to the user
/// and the distinct suspicious characters it contains. Hosts are decoded from
/// Punycode first, so the display string is the Unicode form the user should
/// scrutinize. Empty when nothing is surprising.
fn sandbox_confusable_findings(
details: &SandboxAuthorizationDetails,
) -> Vec<(String, Vec<unicode_confusables::SuspiciousChar>)> {
let mut findings = Vec::new();
for host in &details.network_hosts {
let (decoded, suspicious) = unicode_confusables::scan_host(host);
if !suspicious.is_empty() {
findings.push((decoded, suspicious));
}
}
for path in &details.write_paths {
let display = path.display().to_string();
let suspicious = unicode_confusables::scan(&display);
if !suspicious.is_empty() {
findings.push((display, suspicious));
}
}
findings
}
/// Whether the surprising-Unicode warning is enabled in settings (on by
/// default). When off, prompts neither show the banner nor gate their allow
/// buttons on it.
fn confusable_warning_enabled(cx: &App) -> bool {
AgentSettings::get_global(cx)
.sandbox_permissions
.warn_confusable_unicode
}
/// Whether this tool call's sandbox escalation shows surprising Unicode that
/// the user hasn't acknowledged yet. While true, the prompt's allow buttons
/// stay disabled so the user can't grant access to a lookalike target
/// without first ticking the acknowledgement checkbox.
fn sandbox_confusables_block_allow(&self, tool_call: &ToolCall, cx: &App) -> bool {
if !Self::confusable_warning_enabled(cx) {
return false;
}
let Some(details) = tool_call.sandbox_authorization_details.as_ref() else {
return false;
};
if self
.acknowledged_confusable_warnings
.contains(&tool_call.id)
{
return false;
}
!Self::sandbox_confusable_findings(details).is_empty()
}
/// Red banner warning that a requested domain or path contains surprising
/// Unicode characters, with a checkbox the user must tick to unlock the
/// allow buttons. See [`Self::sandbox_confusables_block_allow`].
fn render_sandbox_confusable_warning(
&self,
tool_call_id: &acp::ToolCallId,
findings: &[(String, Vec<unicode_confusables::SuspiciousChar>)],
window: &Window,
cx: &Context<Self>,
) -> AnyElement {
let acknowledged = self.acknowledged_confusable_warnings.contains(tool_call_id);
let line_height = window.line_height();
v_flex()
.w_full()
.p_2()
.gap_2()
.border_t_1()
.border_color(cx.theme().status().error_border)
.bg(cx.theme().status().error_background.opacity(0.15))
.child(
h_flex()
.w_full()
.gap_1p5()
.items_start()
.child(
h_flex()
.h(line_height)
.flex_none()
.justify_center()
.child(
Icon::new(IconName::Warning)
.size(IconSize::Small)
.color(Color::Error),
),
)
.child(
v_flex().min_w_0().flex_1().gap_1().children(findings.iter().map(
|(value, suspicious)| {
v_flex()
.min_w_0()
.gap_0p5()
.child(
Label::new(format!(
"“{value}” contains potentially surprising Unicode characters"
))
.size(LabelSize::Small)
.color(Color::Error),
)
.child(v_flex().min_w_0().pl_2().children(
suspicious.iter().map(|character| {
Label::new(format!("{}", character.description()))
.size(LabelSize::XSmall)
.color(Color::Muted)
.buffer_font(cx)
}),
))
},
)),
)
.child(
IconButton::new("configure-confusable-warning", IconName::Settings)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.tooltip(Tooltip::text("Configure unicode confusables warning"))
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(zed_actions::OpenSettingsAt {
path: zed_actions::AGENT_SANDBOX_SETTINGS_PATH.to_string(),
target: None,
}),
cx,
);
}),
),
)
.child(
Checkbox::new(
SharedString::from(format!("confusable-ack-{}", tool_call_id.0)),
if acknowledged {
ToggleState::Selected
} else {
ToggleState::Unselected
},
)
.label("I understand and wish to proceed")
.label_size(LabelSize::Small)
.on_click(cx.listener({
let tool_call_id = tool_call_id.clone();
move |this, state: &ToggleState, _window, cx| {
if *state == ToggleState::Selected {
this.acknowledged_confusable_warnings
.insert(tool_call_id.clone());
} else {
this.acknowledged_confusable_warnings.remove(&tool_call_id);
}
cx.notify();
}
})),
)
.into_any_element()
}
@ -8866,7 +9093,12 @@ impl ThreadView {
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(Label::new(details.reason.clone()).size(LabelSize::Small)),
.child(Label::new(details.reason.clone()).size(LabelSize::Small))
.child(self.render_sandbox_docs_link(
"sandbox-fallback-docs-link",
details.docs_section.as_deref(),
cx,
)),
)
.into_any_element()
}
@ -8934,6 +9166,9 @@ impl ThreadView {
entry_ix: usize,
tool_call_id: acp::ToolCallId,
focus_handle: &FocusHandle,
// When true, the "allow" choices are disabled (e.g. an unacknowledged
// surprising-Unicode warning is showing). "Deny"/"Retry" stay enabled.
allow_disabled: bool,
cx: &Context<Self>,
) -> Div {
match options {
@ -8944,6 +9179,7 @@ impl ThreadView {
entry_ix,
tool_call_id,
focus_handle,
allow_disabled,
cx,
),
PermissionOptions::Dropdown(choices) => self.render_permission_buttons_with_dropdown(
@ -8954,6 +9190,7 @@ impl ThreadView {
session_id,
tool_call_id,
focus_handle,
allow_disabled,
cx,
),
PermissionOptions::DropdownWithPatterns {
@ -8968,6 +9205,7 @@ impl ThreadView {
session_id,
tool_call_id,
focus_handle,
allow_disabled,
cx,
),
}
@ -8982,6 +9220,7 @@ impl ThreadView {
session_id: acp::SessionId,
tool_call_id: acp::ToolCallId,
focus_handle: &FocusHandle,
allow_disabled: bool,
cx: &Context<Self>,
) -> Div {
let selection = self.permission_selections.get(&tool_call_id);
@ -9036,13 +9275,14 @@ impl ThreadView {
.gap_0p5()
.child(
Button::new(("allow-btn", entry_ix), "Allow")
.disabled(allow_disabled)
.start_icon(
Icon::new(IconName::Check)
.size(IconSize::XSmall)
.color(Color::Success),
)
.label_size(LabelSize::Small)
.when(is_first, |this| {
.when(is_first && !allow_disabled, |this| {
this.key_binding(
KeyBinding::for_action_in(
&AllowOnce as &dyn Action,
@ -9368,6 +9608,7 @@ impl ThreadView {
entry_ix: usize,
tool_call_id: acp::ToolCallId,
focus_handle: &FocusHandle,
allow_disabled: bool,
cx: &Context<Self>,
) -> Div {
let mut seen_kinds: ArrayVec<acp::PermissionOptionKind, 3, u8> = ArrayVec::new();
@ -9431,13 +9672,22 @@ impl ThreadView {
}
};
let this = this.start_icon(icon);
// An "allow" choice is disabled while a surprising-Unicode
// warning is unacknowledged; "deny"/"retry" stay enabled.
let is_allow = matches!(
option.kind,
acp::PermissionOptionKind::AllowOnce
| acp::PermissionOptionKind::AllowAlways
) && !is_retry;
let disabled = allow_disabled && is_allow;
let this = this.start_icon(icon).disabled(disabled);
let Some(action) = action else {
return this;
};
if !is_first || seen_kinds.contains(&option.kind) {
if !is_first || disabled || seen_kinds.contains(&option.kind) {
return this;
}

View file

@ -713,7 +713,6 @@ mod tests {
use agent_client_protocol::schema::v1 as acp;
use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
use editor::RowInfo;
use feature_flags::{AcpBetaFeatureFlag, FeatureFlag as _, FeatureFlagAppExt as _};
use fs::FakeFs;
use gpui::{AppContext as _, TestAppContext};
use parking_lot::RwLock;
@ -851,11 +850,8 @@ mod tests {
}
#[gpui::test]
async fn test_hidden_elicitation_preserves_entry_index(cx: &mut TestAppContext) {
async fn test_elicitation_preserves_entry_index(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project", json!({})).await;
@ -900,7 +896,6 @@ mod tests {
)),
cx,
);
cx.update_flags(false, vec![]);
});
let view_state = cx.new(|_cx| {

View file

@ -0,0 +1,244 @@
//! Detection of "surprising" Unicode characters in the domains and paths shown
//! in sandbox privilege-escalation prompts.
//!
//! Homoglyph/confusable attacks (a Cyrillic `а` standing in for a Latin `a`),
//! invisible characters (zero-width spaces), and bidirectional overrides can
//! make a requested domain or path look like something it is not, tricking the
//! user into granting access to the wrong target. Domains reach the prompt in
//! Punycode (`xn--…`) ASCII form, so a lookalike host is decoded back to
//! Unicode before scanning; paths are scanned as they are displayed.
use unicode_script::UnicodeScript as _;
/// Why a character in a domain or path is considered surprising.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SuspiciousKind {
/// A bidirectional control that can visually reorder surrounding text (for
/// example U+202E RIGHT-TO-LEFT OVERRIDE) — the classic "Trojan Source"
/// trick.
BidiControl,
/// A zero-width, invisible, or non-ASCII whitespace formatting character.
Invisible,
/// A visible non-ASCII character that can be confused with ASCII (a
/// homoglyph) or that mixes an unexpected script into otherwise-ASCII text.
Confusable,
}
/// A single surprising character discovered while scanning a domain or path.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SuspiciousChar {
pub character: char,
pub kind: SuspiciousKind,
}
impl SuspiciousChar {
/// A human-readable, one-line description for the approval banner, such as
/// `а (U+0430 Cyrillic)` or `U+202E right-to-left override`.
pub fn description(&self) -> String {
let codepoint = format!("U+{:04X}", self.character as u32);
match self.kind {
SuspiciousKind::Confusable => {
format!(
"{} ({codepoint} {})",
self.character,
self.character.script().full_name()
)
}
// Bidi controls and invisible characters have no meaningful glyph to
// show (and printing them could itself reorder the banner text), so
// we render only the codepoint and a name.
SuspiciousKind::BidiControl | SuspiciousKind::Invisible => {
match well_known_name(self.character) {
Some(name) => format!("{codepoint} {name}"),
None => codepoint,
}
}
}
}
}
/// Scan a raw string for surprising Unicode characters, returning each distinct
/// offending character once, in order of first appearance.
pub fn scan(text: &str) -> Vec<SuspiciousChar> {
let mut result: Vec<SuspiciousChar> = Vec::new();
for character in text.chars() {
if character.is_ascii() {
continue;
}
if result.iter().any(|found| found.character == character) {
continue;
}
result.push(SuspiciousChar {
character,
kind: classify(character),
});
}
result
}
/// Scan a host for surprising characters, first decoding any IDN/Punycode
/// (`xn--…`) labels back to Unicode so a lookalike domain that reaches us as
/// ASCII is still caught. Returns the decoded (Unicode) host — which is what the
/// banner shows the user — alongside the findings. When nothing is surprising
/// the returned host equals the input.
pub fn scan_host(host: &str) -> (String, Vec<SuspiciousChar>) {
// `domain_to_unicode` never fails destructively: on error it still returns a
// best-effort decoding, which is exactly what we want to scan and show.
let (decoded, _result) = idna::domain_to_unicode(host);
let findings = scan(&decoded);
(decoded, findings)
}
fn classify(character: char) -> SuspiciousKind {
if is_bidi_control(character) {
SuspiciousKind::BidiControl
} else if is_invisible(character) {
SuspiciousKind::Invisible
} else {
SuspiciousKind::Confusable
}
}
fn is_bidi_control(character: char) -> bool {
matches!(character,
'\u{061C}' // ARABIC LETTER MARK
| '\u{200E}' // LEFT-TO-RIGHT MARK
| '\u{200F}' // RIGHT-TO-LEFT MARK
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI
)
}
fn is_invisible(character: char) -> bool {
matches!(character,
'\u{00AD}' // SOFT HYPHEN
| '\u{180E}' // MONGOLIAN VOWEL SEPARATOR
| '\u{200B}' // ZERO WIDTH SPACE
| '\u{200C}' // ZERO WIDTH NON-JOINER
| '\u{200D}' // ZERO WIDTH JOINER
| '\u{2060}' // WORD JOINER
| '\u{2061}'..='\u{2064}' // invisible math operators
| '\u{FEFF}' // ZERO WIDTH NO-BREAK SPACE (BOM)
) || is_non_ascii_space(character)
// Any remaining control/format character (categories Cc/Cf) is
// invisible for our purposes.
|| character.is_control()
}
fn is_non_ascii_space(character: char) -> bool {
matches!(
character,
'\u{00A0}' // NO-BREAK SPACE
| '\u{1680}' // OGHAM SPACE MARK
| '\u{2000}'
..='\u{200A}' // EN QUAD … HAIR SPACE
| '\u{202F}' // NARROW NO-BREAK SPACE
| '\u{205F}' // MEDIUM MATHEMATICAL SPACE
| '\u{3000}' // IDEOGRAPHIC SPACE
)
}
/// Friendly names for the invisible/bidi characters most likely to show up in an
/// attack, so the banner reads better than a bare codepoint.
fn well_known_name(character: char) -> Option<&'static str> {
Some(match character {
'\u{00A0}' => "no-break space",
'\u{00AD}' => "soft hyphen",
'\u{061C}' => "arabic letter mark",
'\u{180E}' => "mongolian vowel separator",
'\u{200B}' => "zero-width space",
'\u{200C}' => "zero-width non-joiner",
'\u{200D}' => "zero-width joiner",
'\u{200E}' => "left-to-right mark",
'\u{200F}' => "right-to-left mark",
'\u{202A}' => "left-to-right embedding",
'\u{202B}' => "right-to-left embedding",
'\u{202C}' => "pop directional formatting",
'\u{202D}' => "left-to-right override",
'\u{202E}' => "right-to-left override",
'\u{2060}' => "word joiner",
'\u{2066}' => "left-to-right isolate",
'\u{2067}' => "right-to-left isolate",
'\u{2068}' => "first strong isolate",
'\u{2069}' => "pop directional isolate",
'\u{3000}' => "ideographic space",
'\u{FEFF}' => "zero-width no-break space",
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_ascii_is_never_flagged() {
assert!(scan("github.com").is_empty());
assert!(scan("/home/user/project/src/main.rs").is_empty());
assert!(scan("*.npmjs.org").is_empty());
}
#[test]
fn detects_cyrillic_homoglyph() {
// "gіthub.com" with a Cyrillic "і" (U+0456).
let findings = scan("g\u{0456}thub.com");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].character, '\u{0456}');
assert_eq!(findings[0].kind, SuspiciousKind::Confusable);
assert!(findings[0].description().contains("U+0456"));
assert!(findings[0].description().contains("Cyrillic"));
}
#[test]
fn detects_bidi_override() {
let findings = scan("safe\u{202E}txt.exe");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].kind, SuspiciousKind::BidiControl);
assert_eq!(findings[0].description(), "U+202E right-to-left override");
}
#[test]
fn detects_zero_width_space() {
let findings = scan("git\u{200B}hub.com");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].kind, SuspiciousKind::Invisible);
assert_eq!(findings[0].description(), "U+200B zero-width space");
}
#[test]
fn deduplicates_repeated_characters() {
// Two Cyrillic "а" (U+0430) should be reported once.
let findings = scan("\u{0430}bc\u{0430}");
assert_eq!(findings.len(), 1);
}
#[test]
fn scan_host_decodes_punycode_lookalike() {
// "аpple.com" (leading Cyrillic а, U+0430) encodes to this Punycode.
let (decoded, findings) = scan_host("xn--pple-43d.com");
assert_eq!(decoded, "\u{0430}pple.com");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].character, '\u{0430}');
assert_eq!(findings[0].kind, SuspiciousKind::Confusable);
}
#[test]
fn scan_host_leaves_plain_domains_alone() {
let (decoded, findings) = scan_host("github.com");
assert_eq!(decoded, "github.com");
assert!(findings.is_empty());
}
#[test]
fn scan_host_handles_wildcard_subdomain_patterns() {
// Host patterns can carry a leading `*.` wildcard; decoding must not
// choke on it, and a lookalike label behind it is still caught.
let (_decoded, findings) = scan_host("*.xn--pple-43d.com");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].character, '\u{0430}');
let (decoded, findings) = scan_host("*.github.com");
assert_eq!(decoded, "*.github.com");
assert!(findings.is_empty());
}
}

View file

@ -19,7 +19,14 @@ pub mod batches;
pub mod completion;
pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com";
const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01";
pub const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01";
pub fn supports_fast_mode(model_id: &str) -> bool {
matches!(
model_id,
"claude-opus-4-6" | "claude-opus-4-7" | "claude-opus-4-8"
)
}
pub const FABLE_MODEL_ID_PREFIX: &str = "claude-fable-5";
pub const FABLE_FALLBACK_MODEL_ID: &str = "claude-opus-4-8";
@ -156,10 +163,7 @@ impl Model {
AnthropicModelMode::Default
};
let supports_speed = matches!(
entry.id.as_str(),
"claude-opus-4-6" | "claude-opus-4-7" | "claude-opus-4-8"
);
let supports_speed = supports_fast_mode(&entry.id);
// <https://platform.claude.com/docs/en/build-with-claude/compaction#supported-models>
let supports_compaction = matches!(

View file

@ -3,9 +3,9 @@ use collections::HashMap;
use futures::{Stream, StreamExt};
use language_model_core::{
CompactionContent, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelProviderName, LanguageModelRequest, LanguageModelToolChoice,
LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, Role, StopReason,
TokenUsage,
LanguageModelProviderName, LanguageModelRequest, LanguageModelRequestToolInput,
LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse,
LanguageModelToolUseInput, MessageContent, Role, StopReason, TokenUsage,
util::{fix_streamed_json, parse_tool_arguments},
};
use std::pin::Pin;
@ -68,7 +68,7 @@ fn mark_last_cacheable_content(content: &mut [RequestContent], cache_control: Ca
}
}
fn to_anthropic_content(content: MessageContent) -> Option<RequestContent> {
fn to_anthropic_content(content: MessageContent) -> Result<Option<RequestContent>> {
match content {
MessageContent::Text(text) => {
let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) {
@ -77,12 +77,12 @@ fn to_anthropic_content(content: MessageContent) -> Option<RequestContent> {
text
};
if !text.is_empty() {
Some(RequestContent::Text {
Ok(Some(RequestContent::Text {
text,
cache_control: None,
})
}))
} else {
None
Ok(None)
}
}
MessageContent::Thinking {
@ -92,36 +92,41 @@ fn to_anthropic_content(content: MessageContent) -> Option<RequestContent> {
if let Some(signature) = signature
&& !thinking.is_empty()
{
Some(RequestContent::Thinking {
Ok(Some(RequestContent::Thinking {
thinking,
signature,
cache_control: None,
})
}))
} else {
None
Ok(None)
}
}
MessageContent::RedactedThinking(data) => {
if !data.is_empty() {
Some(RequestContent::RedactedThinking { data })
Ok(Some(RequestContent::RedactedThinking { data }))
} else {
None
Ok(None)
}
}
MessageContent::Image(image) => Some(RequestContent::Image {
MessageContent::Image(image) => Ok(Some(RequestContent::Image {
source: ImageSource {
source_type: "base64".to_string(),
media_type: "image/png".to_string(),
data: image.source.to_string(),
},
cache_control: None,
}),
MessageContent::ToolUse(tool_use) => Some(RequestContent::ToolUse {
id: tool_use.id.to_string(),
name: tool_use.name.to_string(),
input: tool_use.input,
cache_control: None,
}),
})),
MessageContent::ToolUse(tool_use) => match tool_use.input {
LanguageModelToolUseInput::Json(input) => Ok(Some(RequestContent::ToolUse {
id: tool_use.id.to_string(),
name: tool_use.name.to_string(),
input,
cache_control: None,
})),
LanguageModelToolUseInput::Text(_) => Err(anyhow::anyhow!(
"Anthropic does not support custom tool calls"
)),
},
MessageContent::ToolResult(tool_result) => {
let content = match tool_result.content.as_slice() {
[LanguageModelToolResultContent::Text(text)] => {
@ -147,24 +152,24 @@ fn to_anthropic_content(content: MessageContent) -> Option<RequestContent> {
ToolResultContent::Multipart(parts)
}
};
Some(RequestContent::ToolResult {
Ok(Some(RequestContent::ToolResult {
tool_use_id: tool_result.tool_use_id.to_string(),
is_error: tool_result.is_error,
content,
cache_control: None,
})
}))
}
MessageContent::Compaction(CompactionContent::Summary { content }) => {
Some(RequestContent::Compaction {
Ok(Some(RequestContent::Compaction {
content,
cache_control: None,
})
}))
}
// Encrypted compaction blocks come from other providers, and a
// Pending block is a streaming-only UI signal; neither is replayed.
MessageContent::Compaction(
CompactionContent::Encrypted { .. } | CompactionContent::Pending,
) => None,
) => Ok(None),
}
}
@ -175,7 +180,7 @@ pub fn into_anthropic(
max_output_tokens: u64,
mode: AnthropicModelMode,
cache_mode: AnthropicPromptCacheMode,
) -> crate::Request {
) -> Result<crate::Request> {
let mut new_messages: Vec<Message> = Vec::new();
let mut system_message = String::new();
let mut any_message_wants_cache = false;
@ -189,11 +194,12 @@ pub fn into_anthropic(
match message.role {
Role::User | Role::Assistant => {
let mut anthropic_message_content: Vec<RequestContent> = message
.content
.into_iter()
.filter_map(to_anthropic_content)
.collect();
let mut anthropic_message_content = Vec::new();
for content in message.content {
if let Some(content) = to_anthropic_content(content)? {
anthropic_message_content.push(content);
}
}
let anthropic_role = match message.role {
Role::User => crate::Role::User,
Role::Assistant => crate::Role::Assistant,
@ -261,21 +267,29 @@ pub fn into_anthropic(
let mut tools: Vec<Tool> = request
.tools
.into_iter()
.map(|tool| Tool {
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
eager_input_streaming: tool.use_input_streaming,
cache_control: None,
.map(|tool| match tool.input {
LanguageModelRequestToolInput::Function {
input_schema,
use_input_streaming,
} => Ok(Tool {
name: tool.name,
description: tool.description,
input_schema,
eager_input_streaming: use_input_streaming,
cache_control: None,
}),
LanguageModelRequestToolInput::Custom { .. } => {
Err(anyhow::anyhow!("Anthropic does not support custom tools"))
}
})
.collect();
.collect::<Result<_>>()?;
if let Some(cache_control) = long_lived_cache
&& let Some(last_tool) = tools.last_mut()
{
last_tool.cache_control = Some(cache_control);
}
crate::Request {
Ok(crate::Request {
model,
messages: new_messages,
max_tokens: max_output_tokens,
@ -339,7 +353,7 @@ pub fn into_anthropic(
trigger: Some(CompactionTrigger::InputTokens { value }),
}],
}),
}
})
}
pub struct AnthropicEventMapper {
@ -451,7 +465,7 @@ impl AnthropicEventMapper {
name: tool_use.name.clone().into(),
is_input_complete: false,
raw_input: tool_use.input_json.clone(),
input,
input: LanguageModelToolUseInput::Json(input),
thought_signature: None,
},
))];
@ -469,7 +483,7 @@ impl AnthropicEventMapper {
id: tool_use.id.into(),
name: tool_use.name.into(),
is_input_complete: true,
input,
input: LanguageModelToolUseInput::Json(input),
raw_input: tool_use.input_json.clone(),
thought_signature: None,
},
@ -597,12 +611,12 @@ mod tests {
intent: None,
stop: vec![],
temperature: None,
tools: vec![language_model_core::LanguageModelRequestTool {
name: "do_thing".into(),
description: "Does a thing.".into(),
input_schema: serde_json::json!({"type": "object"}),
use_input_streaming: false,
}],
tools: vec![language_model_core::LanguageModelRequestTool::function(
"do_thing".into(),
"Does a thing.".into(),
serde_json::json!({"type": "object"}),
false,
)],
tool_choice: None,
thinking_allowed: true,
thinking_effort: None,
@ -617,7 +631,8 @@ mod tests {
4096,
AnthropicModelMode::Default,
AnthropicPromptCacheMode::Automatic,
);
)
.unwrap();
// No message content block should carry cache_control anymore; the
// conversation breakpoint is set via top-level automatic caching.
@ -703,12 +718,12 @@ mod tests {
intent: None,
stop: vec![],
temperature: None,
tools: vec![language_model_core::LanguageModelRequestTool {
name: "do_thing".into(),
description: "Does a thing.".into(),
input_schema: serde_json::json!({"type": "object"}),
use_input_streaming: false,
}],
tools: vec![language_model_core::LanguageModelRequestTool::function(
"do_thing".into(),
"Does a thing.".into(),
serde_json::json!({"type": "object"}),
false,
)],
tool_choice: None,
thinking_allowed: true,
thinking_effort: None,
@ -723,7 +738,8 @@ mod tests {
4096,
AnthropicModelMode::Default,
AnthropicPromptCacheMode::Legacy,
);
)
.unwrap();
assert!(anthropic_request.cache_control.is_none());
assert!(matches!(
@ -781,7 +797,8 @@ mod tests {
128_000,
AnthropicModelMode::AdaptiveThinking,
AnthropicPromptCacheMode::Automatic,
);
)
.unwrap();
assert_eq!(
anthropic_request
@ -813,12 +830,12 @@ mod tests {
intent: None,
stop: vec![],
temperature: None,
tools: vec![language_model_core::LanguageModelRequestTool {
name: "do_thing".into(),
description: "Does a thing.".into(),
input_schema: serde_json::json!({"type": "object"}),
use_input_streaming: false,
}],
tools: vec![language_model_core::LanguageModelRequestTool::function(
"do_thing".into(),
"Does a thing.".into(),
serde_json::json!({"type": "object"}),
false,
)],
tool_choice: None,
thinking_allowed: true,
thinking_effort: None,
@ -833,7 +850,8 @@ mod tests {
4096,
AnthropicModelMode::Default,
AnthropicPromptCacheMode::Automatic,
);
)
.unwrap();
assert!(anthropic_request.cache_control.is_none());
assert!(matches!(
@ -879,6 +897,7 @@ mod tests {
},
AnthropicPromptCacheMode::Automatic,
)
.unwrap()
}
#[test]
@ -977,7 +996,8 @@ mod tests {
4096,
AnthropicModelMode::Default,
AnthropicPromptCacheMode::Disabled,
);
)
.unwrap();
assert_eq!(
serde_json::to_value(&anthropic_request.context_management).unwrap(),

View file

@ -69,6 +69,23 @@ pub fn skills_docs(cx: &App) -> String {
format!("{docs_url}/ai/skills", docs_url = docs_url(cx))
}
/// Returns the URL to Zed's Agent sandboxing documentation.
///
/// Pass `section` to deep-link to a specific section anchor on the page (for
/// example, `Some("installing-bubblewrap")`); pass `None` to link to the top of
/// the page.
///
/// Unlike the account/app links above, this targets `zed.dev/docs` (via
/// [`release_channel::docs_url`]) rather than the configured `server_url`: the
/// docs are a static site hosted on `zed.dev`, so pointing at a local dev
/// `server_url` would 404.
pub fn sandboxing_docs(section: Option<&str>, cx: &App) -> String {
let base = release_channel::docs_url("ai/sandboxing", cx);
match section {
Some(section) => format!("{base}#{section}"),
None => base,
}
}
pub fn llm_provider_docs(cx: &App) -> String {
format!("{docs_url}/ai/llm-providers", docs_url = docs_url(cx))
}

View file

@ -198,6 +198,8 @@ pub enum EditPredictionRejectReason {
InterpolatedEmpty,
/// Edits returned, but could not be interpolated after buffer changes
InterpolateFailed,
/// A patch was returned, but could not be applied to the buffer
PatchApplyFailed,
/// The new prediction was preferred over the current one
Replaced,
/// The current prediction was preferred over the new one

View file

@ -7310,6 +7310,20 @@ async fn test_remote_git_branches(
});
assert_eq!(host_branch.name(), "totally-new-branch");
let default_branch_b = cx_b
.update(|cx| repo_b.update(cx, |repository, _cx| repository.default_branch(false)))
.await
.unwrap()
.unwrap();
assert_eq!(default_branch_b.as_deref(), Some("main"));
let default_branch_with_remote_b = cx_b
.update(|cx| repo_b.update(cx, |repository, _cx| repository.default_branch(true)))
.await
.unwrap()
.unwrap();
assert_eq!(default_branch_with_remote_b.as_deref(), Some("origin/main"));
}
#[gpui::test]

View file

@ -8,7 +8,7 @@ use std::{
time::{Duration, Instant},
};
use crate::table_data_engine::TableDataEngine;
use crate::table_data_engine::{DisplayToDataMapping, TableDataEngine};
use ui::{
AbsoluteLength, ResizableColumnsState, SharedString, TableInteractionState,
TableResizeBehavior, prelude::*,
@ -41,6 +41,10 @@ pub struct CsvPreviewView {
pub(crate) table_interaction_state: Entity<TableInteractionState>,
pub(crate) column_widths: ColumnWidths,
pub(crate) parsing_task: Option<Task<anyhow::Result<()>>>,
pub(crate) is_parsing: bool,
/// Background task computing the display-to-data mapping after a filter/sort change.
/// Stored here so that a new change cancels the previous in-flight computation.
pub(crate) filter_sort_task: Option<Task<()>>,
pub(crate) settings: CsvPreviewSettings,
/// Performance metrics for debugging and monitoring CSV operations.
pub(crate) performance_metrics: PerformanceMetrics,
@ -178,9 +182,11 @@ impl CsvPreviewView {
table_interaction_state,
column_widths: ColumnWidths::new(cx, 1),
parsing_task: None,
is_parsing: false,
filter_sort_task: None,
performance_metrics: PerformanceMetrics::default(),
list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.))
.measure_all(),
.with_uniform_item_height(px(24.)),
settings: CsvPreviewSettings::default(),
last_parse_end_time: None,
engine: TableDataEngine::default(),
@ -194,22 +200,54 @@ impl CsvPreviewView {
pub(crate) fn editor_state(&self) -> &EditorState {
&self.active_editor_state
}
pub(crate) fn apply_sort(&mut self) {
self.performance_metrics.record("Sort", || {
self.engine.apply_sort();
});
pub(crate) fn apply_sort(&mut self, cx: &mut Context<Self>) {
self.apply_filter_sort(cx);
}
/// Update ordered indices when ordering or content changes
pub(crate) fn apply_filter_sort(&mut self) {
self.performance_metrics.record("Filter&sort", || {
self.engine.calculate_d2d_mapping();
});
pub fn clear_filters(&mut self, col: types::AnyColumn, cx: &mut Context<Self>) {
self.engine.clear_filters_for_col(col);
self.apply_filter_sort(cx);
}
// Update list state with filtered row count
let visible_rows = self.engine.d2d_mapping().visible_row_count();
self.list_state =
gpui::ListState::new(visible_rows, ListAlignment::Top, px(100.)).measure_all();
pub fn toggle_filter(
&mut self,
col: types::AnyColumn,
value: Option<SharedString>,
cx: &mut Context<Self>,
) {
if let Err(err) = self.engine.toggle_filter(col, value) {
log::error!("Failed to toggle filter: {err}");
return;
}
self.apply_filter_sort(cx);
}
/// Spawns a background task to recompute the display-to-data mapping after a filter or sort
/// change. Storing the task cancels any previous in-flight computation automatically.
pub(crate) fn apply_filter_sort(&mut self, cx: &mut Context<Self>) {
let contents = self.engine.contents.clone();
let filter_stack = self.engine.filter_stack.clone();
let sorting = self.engine.applied_sorting;
self.filter_sort_task = Some(cx.spawn(async move |this, cx| {
let mapping = cx
.background_spawn(async move {
DisplayToDataMapping::compute(&contents, &filter_stack, sorting)
})
.await;
this.update(cx, |view, cx| {
view.engine.set_d2d_mapping(mapping);
let visible_rows = view.engine.d2d_mapping().visible_row_count();
// Approximation of single csv table row height. Will be re-measured on scrolling.
// This cheap solution allow to render scrollbar with fraction of a cost compared to `.measure_all()` call
let approximate_height = px(24.);
view.list_state
.reset_with_uniform_height(visible_rows, approximate_height);
cx.notify();
})
.ok();
}));
}
pub fn resolve_active_item_as_csv_editor(
@ -301,7 +339,7 @@ impl PerformanceMetrics {
.map(|(name, (duration, time))| {
let took = duration.as_secs_f32() * 1000.;
let ago = time.elapsed().as_secs();
format!("{name}: {took:.2}ms {ago}s ago")
format!("{name}: {took:.3}ms {ago}s ago")
})
.collect::<Vec<_>>()
.join("\n")

View file

@ -1,3 +1,5 @@
use std::sync::Arc;
use crate::{
CsvPreviewView,
types::TableLikeContent,
@ -23,6 +25,7 @@ impl CsvPreviewView {
cx: &mut Context<Self>,
) {
let editor = self.active_editor_state.editor.clone();
self.is_parsing = true;
self.parsing_task = Some(self.parse_csv_in_background(wait_for_debounce, editor, cx));
}
@ -80,11 +83,13 @@ impl CsvPreviewView {
.insert("Parsing", (parse_duration, Instant::now()));
log::debug!("Parsed {} rows", parsed_csv.rows.len());
view.engine.contents = parsed_csv;
view.engine.contents = Arc::new(parsed_csv);
view.engine.calculate_available_filters();
view.sync_column_widths(cx);
view.last_parse_end_time = Some(parse_end_time);
view.apply_filter_sort();
view.is_parsing = false;
view.apply_filter_sort(cx);
cx.notify();
})
})

View file

@ -20,7 +20,7 @@ impl CsvPreviewView {
let children = div()
.absolute()
.top_24()
.bottom_8()
.right_4()
.px_3()
.py_2()

View file

@ -1,6 +1,6 @@
use std::time::Instant;
use ui::{div, prelude::*};
use ui::{SpinnerLabel, div, prelude::*};
use crate::CsvPreviewView;
@ -11,12 +11,12 @@ impl Render for CsvPreviewView {
let render_prep_start = Instant::now();
let table_with_settings = v_flex()
.size_full()
.p_4()
.bg(theme.colors().editor_background)
.track_focus(&self.focus_handle)
.child(self.render_settings_panel(window, cx))
.child({
if self.engine.contents.number_of_cols == 0 {
let is_parsing = self.is_parsing;
if is_parsing || self.engine.contents.number_of_cols == 0 {
div()
.flex()
.items_center()
@ -25,7 +25,15 @@ impl Render for CsvPreviewView {
.text_ui(cx)
.font_buffer(cx)
.text_color(cx.theme().colors().text_muted)
.child("No CSV content to display")
.when(is_parsing, |div| {
div.child(
h_flex()
.gap_2()
.child(SpinnerLabel::new())
.child("Loading…"),
)
})
.when(!is_parsing, |div| div.child("No CSV content to display"))
.into_any_element()
} else {
self.create_table(&self.column_widths.widths, cx)

View file

@ -171,6 +171,7 @@ impl CsvPreviewView {
.px_1()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.bg(cx.theme().colors().panel_background)
.h_full()
.text_ui(cx)
// Row identifiers are always centered

View file

@ -3,7 +3,10 @@ use ui::{
IntoElement as _, ParentElement as _, Styled as _, Tooltip, Window, div, h_flex,
};
use crate::{CsvPreviewView, settings::VerticalAlignment};
use crate::{
CsvPreviewView,
settings::{FilterSortOrder, VerticalAlignment},
};
///// Settings related /////
impl CsvPreviewView {
@ -18,6 +21,11 @@ impl CsvPreviewView {
VerticalAlignment::Center => "Center",
};
let current_filter_sort_text = match self.settings.filter_sort_order {
FilterSortOrder::AlphaThenCount => "A-Z, then Count",
FilterSortOrder::CountThenAlpha => "Count, then A-Z",
};
let view = cx.entity();
let alignment_dropdown_menu = ContextMenu::build(window, cx, |menu, _window, _cx| {
menu.entry("Top", None, {
@ -40,6 +48,27 @@ impl CsvPreviewView {
})
});
let filter_sort_dropdown_menu = ContextMenu::build(window, cx, |menu, _window, _cx| {
menu.entry("A-Z, then Count", None, {
let view = view.clone();
move |_window, cx| {
view.update(cx, |this, cx| {
this.settings.filter_sort_order = FilterSortOrder::AlphaThenCount;
cx.notify();
});
}
})
.entry("Count, then A-Z", None, {
let view = view.clone();
move |_window, cx| {
view.update(cx, |this, cx| {
this.settings.filter_sort_order = FilterSortOrder::CountThenAlpha;
cx.notify();
});
}
})
});
let panel = h_flex()
.gap_4()
.p_2()
@ -68,6 +97,28 @@ impl CsvPreviewView {
"Choose vertical text alignment within cells",
)),
),
)
.child(
h_flex()
.gap_2()
.items_center()
.child(
div()
.text_sm()
.text_color(cx.theme().colors().text_muted)
.child("Filter Sort:"),
)
.child(
DropdownMenu::new(
ElementId::Name("filter-sort-order-dropdown".into()),
current_filter_sort_text,
filter_sort_dropdown_menu,
)
.trigger_size(ButtonSize::Compact)
.trigger_tooltip(Tooltip::text(
"Choose how filter values are sorted in the filter menu",
)),
),
);
#[cfg(feature = "dev-tools")]

View file

@ -1,9 +1,13 @@
use gpui::ElementId;
use ui::{Tooltip, prelude::*};
use ui::{ContextMenu, PopoverMenu, Tooltip, prelude::*};
use crate::{
CsvPreviewView,
table_data_engine::sorting_by_column::{AppliedSorting, SortDirection},
settings::FilterSortOrder,
table_data_engine::{
filtering_by_column::{FilterEntry, FilterEntryState},
sorting_by_column::{AppliedSorting, SortDirection},
},
types::AnyColumn,
};
@ -22,7 +26,12 @@ impl CsvPreviewView {
.w_full()
.font_buffer(cx)
.child(div().child(header_text))
.child(h_flex().gap_1().child(self.create_sort_button(cx, col_idx)))
.child(
h_flex()
.gap_1()
.child(self.create_filter_button(cx, col_idx))
.child(self.create_sort_button(cx, col_idx)),
)
.into_any_element()
}
@ -82,9 +91,191 @@ impl CsvPreviewView {
};
this.engine.applied_sorting = new_sorting;
this.apply_sort();
this.apply_sort(cx);
cx.notify();
}));
sort_btn
}
fn create_filter_button(
&self,
cx: &mut Context<'_, CsvPreviewView>,
col: AnyColumn,
) -> PopoverMenu<ContextMenu> {
let has_active_filters = self.engine.has_active_filters(col);
PopoverMenu::new(ElementId::NamedInteger(
"filter-menu".into(),
col.get() as u64,
))
.trigger_with_tooltip(
Button::new(
ElementId::NamedInteger("filter-button".into(), col.get() as u64),
if has_active_filters { "" } else { "" },
)
.size(ButtonSize::Compact)
.style(if has_active_filters {
ButtonStyle::Filled
} else {
ButtonStyle::Subtle
}),
Tooltip::text(if has_active_filters {
"Column has active filters. Click to manage"
} else {
"No filters applied. Click to add filters"
}),
)
.menu({
let view_entity = cx.entity();
move |window, cx| {
let view = view_entity.read(cx);
let column_filters = match view.engine.get_filters_for_column(col) {
Ok(filters) => filters,
Err(err) => {
log::error!("Failed to get filters for column: {err}");
return None;
}
};
let filter_sort_order = view.settings.filter_sort_order;
let filter_menu = Self::create_filter_menu(
window,
cx,
view_entity.clone(),
col,
&column_filters,
has_active_filters,
filter_sort_order,
);
Some(filter_menu)
}
})
}
fn create_filter_menu(
window: &mut ui::Window,
cx: &mut ui::App,
view_entity: gpui::Entity<CsvPreviewView>,
col: AnyColumn,
column_filters: &[(FilterEntry, FilterEntryState)],
has_active_filters: bool,
sort_order: FilterSortOrder,
) -> gpui::Entity<ContextMenu> {
let mut available: Vec<&FilterEntry> = column_filters
.iter()
.filter_map(|(entry, state)| {
matches!(state, FilterEntryState::Available { .. }).then_some(entry)
})
.collect();
match sort_order {
FilterSortOrder::AlphaThenCount => available.sort_by(|a, b| {
a.content
.cmp(&b.content)
.then_with(|| b.occurred_times().cmp(&a.occurred_times()))
}),
FilterSortOrder::CountThenAlpha => available.sort_by(|a, b| {
b.occurred_times()
.cmp(&a.occurred_times())
.then_with(|| a.content.cmp(&b.content))
}),
}
let unavailable: Vec<(&FilterEntry, AnyColumn)> = column_filters
.iter()
.filter_map(|(entry, state)| {
if let FilterEntryState::Unavailable { blocked_by } = state {
Some((entry, *blocked_by))
} else {
None
}
})
.collect();
// Pre-build applied-state lookup before moving into the closure
let applied_states: Vec<(FilterEntry, bool)> = column_filters
.iter()
.filter_map(|(entry, state)| {
if let FilterEntryState::Available { is_applied } = state {
Some((entry.clone(), *is_applied))
} else {
None
}
})
.collect();
let available_cloned: Vec<FilterEntry> = available.iter().map(|e| (*e).clone()).collect();
let unavailable_cloned: Vec<(FilterEntry, AnyColumn)> = unavailable
.into_iter()
.map(|(e, col)| (e.clone(), col))
.collect();
ContextMenu::build(window, cx, move |menu, _, _| {
let mut menu = menu;
if has_active_filters {
menu = menu
.toggleable_entry("Clear all", false, ui::IconPosition::Start, None, {
let view_entity = view_entity.clone();
move |_window, cx| {
view_entity.update(cx, |view, cx| {
view.clear_filters(col, cx);
cx.notify();
});
}
})
.separator();
}
for entry in &available_cloned {
let is_applied = applied_states
.iter()
.find(|(e, _)| e.content == entry.content)
.map_or(false, |(_, applied)| *applied);
let label: SharedString =
format_filter_label(entry.content.as_ref(), entry.occurred_times()).into();
let entry_value = entry.content.clone();
menu = menu.toggleable_entry(&label, is_applied, ui::IconPosition::Start, None, {
let view_entity = view_entity.clone();
move |_window, cx| {
view_entity.update(cx, |view, cx| {
view.toggle_filter(col, entry_value.clone(), cx);
cx.notify();
});
}
});
}
if !unavailable_cloned.is_empty() {
menu = menu.separator().header("Hidden by other filters");
for (entry, _blocked_by) in &unavailable_cloned {
let label: SharedString =
format_filter_label(entry.content.as_ref(), entry.occurred_times()).into();
menu = menu.custom_entry(
{
let label = label.clone();
move |_window, cx| {
div()
.px_2()
.text_color(cx.theme().colors().text_muted)
.child(label.clone())
.into_any_element()
}
},
|_, _| {},
);
}
}
menu
})
}
}
fn format_filter_label(content: Option<&SharedString>, count: usize) -> String {
match content {
Some(s) => format!("{s} ({count})"),
None => format!("<null> ({count})"),
}
}

View file

@ -1,10 +1,10 @@
#[derive(Default, Clone, Copy, PartialEq)]
pub enum RowRenderMechanism {
/// More correct for multiline content, but slower.
#[allow(dead_code)] // Will be used when settings ui is added
#[default]
VariableList,
/// Default behaviour for now while resizable columns are being stabilized.
#[default]
#[allow(dead_code)] // Will be used when settings ui is added
UniformList,
}
@ -26,11 +26,21 @@ pub enum RowIdentifiers {
RowNum,
}
#[derive(Default, Clone, Copy, PartialEq)]
pub enum FilterSortOrder {
/// Sort alphabetically (A→Z), then by number of occurrences descending within ties
#[default]
AlphaThenCount,
/// Sort by number of occurrences descending, then alphabetically within ties
CountThenAlpha,
}
#[derive(Clone, Default)]
pub(crate) struct CsvPreviewSettings {
pub(crate) rendering_with: RowRenderMechanism,
pub(crate) vertical_alignment: VerticalAlignment,
pub(crate) numbering_type: RowIdentifiers,
pub(crate) filter_sort_order: FilterSortOrder,
pub(crate) show_debug_info: bool,
#[cfg(feature = "dev-tools")]
pub(crate) show_perf_metrics_overlay: bool,

View file

@ -5,22 +5,32 @@
//!
//! It's designed to contain core logic of operations without relying on `CsvPreviewView`, context or window handles.
use std::{collections::HashMap, sync::Arc};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use ui::table_row::TableRow;
use crate::{
table_data_engine::sorting_by_column::{AppliedSorting, sort_data_rows},
types::{DataRow, DisplayRow, TableCell, TableLikeContent},
table_data_engine::{
filtering_by_column::{FilterEntry, FilterStack, calculate_available_filters, retain_rows},
sorting_by_column::{AppliedSorting, sort_data_rows},
},
types::{AnyColumn, DataRow, DisplayRow, TableCell, TableLikeContent},
};
pub mod filtering_by_column;
pub mod sorting_by_column;
#[derive(Default)]
pub(crate) struct TableDataEngine {
pub filter_stack: FilterStack,
/// Pre-computed unique values per column, used to populate filter menus
all_filters: HashMap<AnyColumn, Vec<FilterEntry>>,
pub applied_sorting: Option<AppliedSorting>,
d2d_mapping: DisplayToDataMapping,
pub contents: TableLikeContent,
pub contents: Arc<TableLikeContent>,
}
impl TableDataEngine {
@ -28,32 +38,47 @@ impl TableDataEngine {
&self.d2d_mapping
}
pub(crate) fn apply_sort(&mut self) {
self.d2d_mapping
.apply_sorting(self.applied_sorting, &self.contents.rows);
self.d2d_mapping.merge_mappings();
pub(crate) fn set_d2d_mapping(&mut self, mapping: DisplayToDataMapping) {
self.d2d_mapping = mapping;
}
/// Applies sorting and filtering to the data and produces display to data mapping
pub(crate) fn calculate_d2d_mapping(&mut self) {
self.d2d_mapping
.apply_sorting(self.applied_sorting, &self.contents.rows);
self.d2d_mapping.merge_mappings();
/// Recomputes the unique filter entries for every column from the current table data.
/// Must be called after content changes (e.g. after parsing).
pub fn calculate_available_filters(&mut self) {
self.all_filters =
calculate_available_filters(&self.contents.rows, self.contents.number_of_cols);
}
}
/// Relation of Display (rendered) rows to Data (src) rows with applied transformations
/// Transformations applied:
/// - sorting by column
/// - filtering by column values
#[derive(Debug, Default)]
pub struct DisplayToDataMapping {
/// All rows sorted, regardless of applied filtering. Applied every time sorting changes
/// All rows sorted, regardless of applied filtering. Recomputed every time sorting changes
pub sorted_rows: Vec<DataRow>,
/// Filtered and sorted rows. Computed cheaply from `sorted_mapping` and `filtered_out_rows`
/// Rows that survive the active filters. Recomputed every time filters change
pub retained_rows: HashSet<DataRow>,
/// Merged result: sorted rows intersected with retained rows
pub mapping: Arc<HashMap<DisplayRow, DataRow>>,
}
impl DisplayToDataMapping {
/// Computes the full display-to-data mapping from owned inputs.
/// Intended to be called from a background thread.
pub(crate) fn compute(
contents: &Arc<TableLikeContent>,
filter_stack: &FilterStack,
sorting: Option<AppliedSorting>,
) -> Self {
let mut mapping = Self::default();
mapping.apply_sorting(sorting, &contents.rows);
mapping.apply_filtering(filter_stack, &contents.rows);
mapping.merge_mappings();
mapping
}
/// Get the data row for a given display row
pub fn get_data_row(&self, display_row: DisplayRow) -> Option<DataRow> {
self.mapping.get(&display_row).copied()
@ -77,11 +102,16 @@ impl DisplayToDataMapping {
self.sorted_rows = sorted_rows;
}
/// Take pre-computed sorting and filtering results, and apply them to the mapping
fn apply_filtering(&mut self, filter_stack: &FilterStack, rows: &[TableRow<TableCell>]) {
self.retained_rows = retain_rows(rows, filter_stack);
}
/// Merges pre-computed sorting and filtering into the final display mapping
fn merge_mappings(&mut self) {
self.mapping = Arc::new(
self.sorted_rows
.iter()
.filter(|data_row| self.retained_rows.contains(data_row))
.enumerate()
.map(|(display, data)| (DisplayRow(display), *data))
.collect(),

View file

@ -0,0 +1,250 @@
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use ui::{SharedString, table_row::TableRow};
use crate::{
table_data_engine::TableDataEngine,
types::{AnyColumn, DataRow, TableCell},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum FilterEntryState {
Available { is_applied: bool },
Unavailable { blocked_by: AnyColumn },
}
#[derive(Debug, Clone)]
pub struct FilterEntry {
/// Content to display. None if cell is virtual
pub content: Option<SharedString>,
/// List of rows in which this value occurs
pub rows: Vec<DataRow>,
}
impl FilterEntry {
pub(crate) fn occurred_times(&self) -> usize {
self.rows.len()
}
}
#[derive(Debug, Default, Clone)]
pub(crate) struct FilterStack {
/// Columns in the order their first filter was applied, used to compute cascade availability
activation_order: Vec<AnyColumn>,
/// Which cell values are currently allowed for each filtered column
retention_config: HashMap<AnyColumn, HashSet<Option<SharedString>>>,
}
impl TableDataEngine {
pub(crate) fn has_active_filters(&self, col: AnyColumn) -> bool {
self.filter_stack.retention_config.contains_key(&col)
}
/// Get available filters for a specific column with cascade behavior.
///
/// A filter entry is "unavailable" when all of its rows are hidden by a
/// filter on an earlier-activated column, meaning selecting it would show
/// zero rows. The cascade walk stops at `column` so that the column's own
/// current filter does not affect its own entry availability.
pub(crate) fn get_filters_for_column(
&self,
column: AnyColumn,
) -> anyhow::Result<Arc<Vec<(FilterEntry, FilterEntryState)>>> {
let all_column_entries = self
.all_filters
.get(&column)
.ok_or_else(|| anyhow::anyhow!("Expected {column:?} to have filter entries"))?;
let mut unavailable_entries: HashMap<Option<SharedString>, AnyColumn> = HashMap::new();
for &column_applied_previously in &self.filter_stack.activation_order {
if column_applied_previously == column {
break;
}
let retained_values = self
.filter_stack
.retention_config
.get(&column_applied_previously)
.ok_or_else(|| {
anyhow::anyhow!(
"Expected {column_applied_previously:?} to have retained entries \
as it is present in the filter stack"
)
})?;
// Rows that survive the filter on `column_applied_previously`
let retained_rows: HashSet<DataRow> = self
.contents
.rows
.iter()
.enumerate()
.filter(|(_, row)| {
let cell_value = row
.get(column_applied_previously)
.and_then(|cell| cell.display_value().cloned());
retained_values.contains(&cell_value)
})
.map(|(index, _)| DataRow(index))
.collect();
// An entry is unavailable when none of its rows survive the parent filter
for entry in all_column_entries {
if !entry.rows.iter().any(|row| retained_rows.contains(row)) {
unavailable_entries.insert(entry.content.clone(), column_applied_previously);
}
}
}
let empty = HashSet::new();
let active_column_filters = self
.filter_stack
.retention_config
.get(&column)
.unwrap_or(&empty);
Ok(Arc::new(
all_column_entries
.iter()
.map(|entry| {
let state = if let Some(&blocked_by) = unavailable_entries.get(&entry.content) {
FilterEntryState::Unavailable { blocked_by }
} else {
FilterEntryState::Available {
is_applied: active_column_filters.contains(&entry.content),
}
};
(entry.clone(), state)
})
.collect(),
))
}
pub(crate) fn clear_filters_for_col(&mut self, col: AnyColumn) {
self.filter_stack
.activation_order
.retain(|&entry| entry != col);
self.filter_stack.retention_config.remove(&col);
}
/// Toggle a filter value for a column. Returns `true` if the filter was
/// added, `false` if it was removed.
pub(crate) fn toggle_filter(
&mut self,
column: AnyColumn,
value: Option<SharedString>,
) -> anyhow::Result<bool> {
let is_currently_applied = self
.filter_stack
.retention_config
.get(&column)
.is_some_and(|filters| filters.contains(&value));
if is_currently_applied {
self.remove_filter(column, value)?;
Ok(false)
} else {
self.apply_filter(column, value);
Ok(true)
}
}
fn remove_filter(
&mut self,
column: AnyColumn,
value: Option<SharedString>,
) -> anyhow::Result<()> {
let entries = self
.filter_stack
.retention_config
.get_mut(&column)
.ok_or_else(|| {
anyhow::anyhow!("Expected {column:?} to be present in active filters")
})?;
debug_assert!(
entries.contains(&value),
"Expected value to be present in {column:?} active filters"
);
if entries.len() == 1 {
self.filter_stack.retention_config.remove(&column);
self.filter_stack
.activation_order
.retain(|&entry| entry != column);
} else {
entries.remove(&value);
}
Ok(())
}
fn apply_filter(&mut self, column: AnyColumn, value: Option<SharedString>) {
// Track the column only on its first activation to preserve cascade order
if !self.filter_stack.activation_order.contains(&column) {
self.filter_stack.activation_order.push(column);
}
self.filter_stack
.retention_config
.entry(column)
.or_default()
.insert(value);
}
}
/// Calculate available filter entries for each column from the table data.
pub fn calculate_available_filters(
content_rows: &[TableRow<TableCell>],
number_of_cols: usize,
) -> HashMap<AnyColumn, Vec<FilterEntry>> {
let mut available_filters = HashMap::new();
for col_idx in 0..number_of_cols {
let column = AnyColumn::new(col_idx);
let mut value_to_rows: HashMap<Option<SharedString>, Vec<DataRow>> = HashMap::new();
for (row_index, row) in content_rows.iter().enumerate() {
let cell_value = row
.get(column)
.and_then(|cell| cell.display_value().cloned());
value_to_rows
.entry(cell_value)
.or_default()
.push(DataRow(row_index));
}
let filter_entries: Vec<FilterEntry> = value_to_rows
.into_iter()
.map(|(content, rows)| FilterEntry { content, rows })
.collect();
available_filters.insert(column, filter_entries);
}
available_filters
}
/// Returns the set of data rows that survive all active filters in the stack.
pub fn retain_rows(
content_rows: &[TableRow<TableCell>],
filter_stack: &FilterStack,
) -> HashSet<DataRow> {
let config = &filter_stack.retention_config;
if config.is_empty() {
return (0..content_rows.len()).map(DataRow).collect();
}
content_rows
.iter()
.enumerate()
.filter(|(_, row)| {
config.iter().all(|(col, allowed_values)| {
let cell_value = row.get(*col).and_then(|cell| cell.display_value().cloned());
allowed_values.contains(&cell_value)
})
})
.map(|(index, _)| DataRow(index))
.collect()
}

View file

@ -27,4 +27,4 @@ workspace = true
[[bin]]
name = "docs_preprocessor"
path = "src/main.rs"
path = "src/main.rs"

View file

@ -0,0 +1,549 @@
use anyhow::{Context, Result};
use mdbook::BookItem;
use mdbook::book::Book;
use regex::Regex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use crate::FRONT_MATTER_COMMENT;
#[derive(Debug)]
pub(crate) struct DocsPage {
section: String,
title: String,
description: Option<String>,
pub(crate) source_path: PathBuf,
content: String,
}
pub(crate) fn write_ai_discovery_artifacts(
pages: &[DocsPage],
destination: &Path,
site_url: &str,
) -> Result<()> {
copy_markdown_sources(destination, site_url, pages)?;
write_llms_txt(destination, site_url, pages)?;
write_sitemap_xml(destination, site_url, pages)?;
Ok(())
}
pub(crate) fn docs_pages(book: &Book) -> Result<Vec<DocsPage>> {
let mut pages = Vec::new();
let mut section = "Docs".to_string();
for item in book.iter() {
let BookItem::Chapter(chapter) = item else {
if let BookItem::PartTitle(part_title) = item {
section.clone_from(part_title);
}
continue;
};
let Some(source_path) = chapter.source_path.as_ref() else {
continue;
};
if source_path == Path::new("SUMMARY.md") {
continue;
}
pages.push(DocsPage {
section: section.clone(),
title: chapter.name.clone(),
description: docs_page_description(&chapter.content),
source_path: source_path.clone(),
content: chapter.content.clone(),
});
}
Ok(pages)
}
fn copy_markdown_sources(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> {
for page in pages {
let destination = destination.join(&page.source_path);
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("failed to create markdown destination {}", parent.display())
})?;
}
let contents = rewrite_docs_links(&markdown_source_contents(&page.content), site_url);
std::fs::write(
&destination,
add_llms_markdown_directive(&contents, site_url),
)
.with_context(|| {
format!(
"failed to write markdown page {} to {}",
page.source_path.display(),
destination.display()
)
})?;
}
let getting_started = destination.join("getting-started.md");
if getting_started.exists() {
std::fs::copy(&getting_started, destination.join("index.md"))
.context("failed to write index.md markdown alias")?;
}
Ok(())
}
fn markdown_source_contents(contents: &str) -> String {
front_matter_comment_regex()
.replace(contents, "")
.trim_start()
.to_string()
}
fn docs_page_description(contents: &str) -> Option<String> {
docs_page_metadata(contents).and_then(|metadata| {
metadata
.get("description")
.map(|description| {
description
.trim()
.trim_matches('"')
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
})
.filter(|description| !description.is_empty())
})
}
fn docs_page_metadata(contents: &str) -> Option<HashMap<String, String>> {
let captures = front_matter_comment_regex().captures(contents)?;
serde_json::from_str(&captures[1]).ok()
}
fn front_matter_comment_regex() -> &'static Regex {
static FRONT_MATTER_COMMENT_REGEX: OnceLock<Regex> = OnceLock::new();
FRONT_MATTER_COMMENT_REGEX
.get_or_init(|| Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "([^\\n]*)")).unwrap())
}
fn write_llms_txt(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> {
let mut contents = String::new();
contents.push_str("# Zed Docs\n\n");
contents.push_str(
"> Official Zed documentation index with links to Markdown versions of each docs page.\n\n",
);
contents.push_str(
"Use these links for concise Markdown copies of Zed documentation pages. Each linked page mirrors the corresponding `/docs/*.html` page without site navigation or styling.\n\n",
);
let mut current_section = None;
for page in pages {
if current_section != Some(page.section.as_str()) {
if current_section.is_some() {
contents.push('\n');
}
contents.push_str("## ");
contents.push_str(&markdown_text(&page.section));
contents.push_str("\n\n");
current_section = Some(page.section.as_str());
}
contents.push_str("- [");
contents.push_str(&markdown_text(&page.title));
contents.push_str("](");
contents.push_str(&absolute_docs_url(site_url, &page.source_path));
contents.push(')');
if let Some(description) = &page.description {
contents.push_str(": ");
contents.push_str(&markdown_text(description));
}
contents.push('\n');
}
std::fs::write(destination.join("llms.txt"), contents).context("failed to write llms.txt")?;
Ok(())
}
fn markdown_text(text: &str) -> String {
text.replace('\\', "\\\\")
.replace('[', "\\[")
.replace(']', "\\]")
}
fn write_sitemap_xml(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> {
let mut contents = String::new();
contents.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
contents.push_str("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
for page in pages {
contents.push_str(" <url><loc>");
contents.push_str(&xml_escape(&absolute_docs_url(
site_url,
&page.source_path.with_extension("html"),
)));
contents.push_str("</loc>");
contents.push_str("</url>\n");
}
contents.push_str("</urlset>\n");
std::fs::write(destination.join("sitemap.xml"), contents)
.context("failed to write sitemap.xml")?;
Ok(())
}
pub(crate) fn write_pages_redirects(
destination: &Path,
redirects: &[(String, String)],
site_url: &str,
) -> Result<()> {
let Some(deploy_root) = destination.parent() else {
return Ok(());
};
let mut contents = String::new();
for (source, destination) in redirects {
write_redirect_line(
&mut contents,
&docs_path("/docs/", source),
&redirect_destination(site_url, destination),
);
if let Some(extensionless_source) = strip_html_suffix(source) {
write_redirect_line(
&mut contents,
&docs_path("/docs/", &extensionless_source),
&redirect_destination(
site_url,
&strip_html_suffix(destination).unwrap_or_else(|| destination.to_string()),
),
);
}
if let Some(markdown_source) = html_path_to_markdown(source) {
if let Some(markdown_destination) = html_path_to_markdown(destination) {
write_redirect_line(
&mut contents,
&docs_path("/docs/", &markdown_source),
&redirect_destination(site_url, &markdown_destination),
);
}
}
}
std::fs::write(deploy_root.join("_redirects"), contents)
.context("failed to write Cloudflare Pages _redirects")?;
Ok(())
}
pub(crate) fn write_markdown_redirect_aliases(
destination: &Path,
redirects: &[(String, String)],
site_url: &str,
) -> Result<()> {
for (source, redirect_destination_path) in redirects {
let Some(source_markdown) = html_path_to_markdown(source) else {
continue;
};
let Some(destination_markdown) = html_path_to_markdown(redirect_destination_path) else {
continue;
};
let source_markdown = destination.join(source_markdown.trim_start_matches('/'));
let destination_markdown =
destination.join(destination_markdown.trim_start_matches("/docs/"));
if !destination_markdown.exists() {
continue;
}
if let Some(parent) = source_markdown.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create markdown alias directory {}",
parent.display()
)
})?;
}
let contents = format!(
"# Moved\n\n> For the complete documentation index and Markdown links, see [llms.txt]({}).\n\nThis page moved to [the current docs page]({}).\n",
docs_url(site_url, Path::new("llms.txt")),
html_path_to_markdown(redirect_destination_path)
.map(|path| redirect_destination(site_url, &path))
.unwrap_or_else(|| redirect_destination(site_url, redirect_destination_path))
);
std::fs::write(&source_markdown, contents).with_context(|| {
format!(
"failed to write markdown redirect alias from {} to {}",
redirect_destination_path,
source_markdown.display()
)
})?;
}
Ok(())
}
fn write_redirect_line(contents: &mut String, source: &str, destination: &str) {
contents.push_str(source);
contents.push(' ');
contents.push_str(destination);
contents.push_str(" 301\n");
}
fn docs_path(site_url: &str, path: &str) -> String {
docs_url(site_url, Path::new(path.trim_start_matches('/')))
}
fn redirect_destination(site_url: &str, destination: &str) -> String {
if let Some(path) = destination.strip_prefix("/docs/") {
docs_url(site_url, Path::new(path))
} else if destination == "/docs" {
docs_url(site_url, Path::new(""))
} else {
destination.to_string()
}
}
fn strip_html_suffix(path: &str) -> Option<String> {
let (path, fragment) = split_fragment(path);
let path = path.strip_suffix(".html")?;
Some(format!("{path}{fragment}"))
}
fn html_path_to_markdown(path: &str) -> Option<String> {
let (path, fragment) = split_fragment(path);
if !path.starts_with("/docs/") && path != "/docs" && !path.ends_with(".html") {
return None;
}
let markdown_path = path.strip_suffix(".html").unwrap_or(path);
Some(format!("{markdown_path}.md{fragment}"))
}
fn split_fragment(path: &str) -> (&str, &str) {
match path.find('#') {
Some(index) => (&path[..index], &path[index..]),
None => (path, ""),
}
}
pub(crate) fn rewrite_docs_links(contents: &str, site_url: &str) -> String {
const STABLE_DOCS_PREFIX: &str = "https://zed.dev/docs/";
let channel_docs_prefix = absolute_docs_url(site_url, Path::new(""));
if channel_docs_prefix == STABLE_DOCS_PREFIX {
return contents.to_string();
}
let mut output = String::with_capacity(contents.len());
let mut remaining = contents;
while let Some(index) = remaining.find(STABLE_DOCS_PREFIX) {
output.push_str(&remaining[..index]);
let after_prefix = &remaining[index + STABLE_DOCS_PREFIX.len()..];
if after_prefix.starts_with("preview/") || after_prefix.starts_with("nightly/") {
output.push_str(STABLE_DOCS_PREFIX);
} else {
output.push_str(&channel_docs_prefix);
}
remaining = after_prefix;
}
output.push_str(remaining);
output
}
pub(crate) fn add_markdown_alternate_link(
contents: &str,
html_file: &Path,
root_dir: &Path,
site_url: &str,
) -> String {
let Ok(relative_path) = html_file.strip_prefix(root_dir) else {
return contents.to_string();
};
let markdown_path = relative_path.with_extension("md");
if !root_dir.join(&markdown_path).exists() {
return contents.to_string();
}
let markdown_url = docs_url(site_url, &markdown_path);
let link = format!(
" <link rel=\"alternate\" type=\"text/markdown\" href=\"{}\">\n",
markdown_url
);
contents.replacen("</head>", &(link + " </head>"), 1)
}
fn add_llms_markdown_directive(contents: &str, site_url: &str) -> String {
let directive = format!(
"> For the complete documentation index and Markdown links, see [llms.txt]({}).\n\n",
docs_url(site_url, Path::new("llms.txt")),
);
if let Some(rest) = contents.strip_prefix("---\n") {
if let Some(frontmatter_end) = rest.find("\n---\n") {
let split_at = "---\n".len() + frontmatter_end + "\n---\n".len();
let mut output = String::with_capacity(contents.len() + directive.len());
output.push_str(&contents[..split_at]);
output.push('\n');
output.push_str(&directive);
output.push_str(&contents[split_at..]);
return output;
}
}
let mut output = String::with_capacity(contents.len() + directive.len());
output.push_str(&directive);
output.push_str(contents);
output
}
fn docs_url(site_url: &str, path: &Path) -> String {
let mut url = site_url.to_string();
if !url.ends_with('/') {
url.push('/');
}
url.push_str(&path.to_string_lossy().replace('\\', "/"));
url
}
fn absolute_docs_url(site_url: &str, path: &Path) -> String {
let url = docs_url(site_url, path);
if url.starts_with("http://") || url.starts_with("https://") {
url
} else {
format!("https://zed.dev{}", url)
}
}
fn xml_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_llms_markdown_directive_inserts_after_frontmatter() {
let contents = "---\ntitle: Example\n---\n# Example\n";
let output = add_llms_markdown_directive(contents, "/docs/");
assert!(output.starts_with("---\ntitle: Example\n---\n\n"));
assert!(output.contains(
"> For the complete documentation index and Markdown links, see [llms.txt](/docs/llms.txt)."
));
}
#[test]
fn test_redirect_destination_uses_channel_site_url_for_docs_paths() {
assert_eq!(
redirect_destination("/docs/preview/", "/docs/ai/overview.html"),
"/docs/preview/ai/overview.html"
);
assert_eq!(
redirect_destination("/docs/preview/", "/community-links"),
"/community-links"
);
}
#[test]
fn test_rewrite_docs_links_uses_channel_site_url() {
assert_eq!(
rewrite_docs_links(
"See [Code Actions](https://zed.dev/docs/configuring-languages#code-actions) and [Preview](https://zed.dev/docs/preview/ai/overview.html).",
"/docs/preview/"
),
"See [Code Actions](https://zed.dev/docs/preview/configuring-languages#code-actions) and [Preview](https://zed.dev/docs/preview/ai/overview.html)."
);
}
#[test]
fn test_docs_path_uses_channel_site_url() {
assert_eq!(
docs_path("/docs/preview/", "/assistant.md"),
"/docs/preview/assistant.md"
);
}
#[test]
fn test_write_pages_redirects_keeps_sources_on_internal_pages_path() -> Result<()> {
let deploy_root = std::env::temp_dir().join(format!(
"docs_preprocessor_pages_redirects_test_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_nanos()
));
let destination = deploy_root.join("docs");
std::fs::create_dir_all(&destination)?;
let redirects = vec![
(
"/assistant.html".to_string(),
"/docs/ai/overview.html".to_string(),
),
(
"/community/feedback.html".to_string(),
"/community-links".to_string(),
),
];
write_pages_redirects(&destination, &redirects, "/docs/preview/")?;
assert_eq!(
std::fs::read_to_string(deploy_root.join("_redirects"))?,
"/docs/assistant.html /docs/preview/ai/overview.html 301\n\
/docs/assistant /docs/preview/ai/overview 301\n\
/docs/assistant.md /docs/preview/ai/overview.md 301\n\
/docs/community/feedback.html /community-links 301\n\
/docs/community/feedback /community-links 301\n"
);
std::fs::remove_dir_all(&deploy_root)?;
Ok(())
}
#[test]
fn test_write_ai_discovery_artifacts_generates_agent_facing_metadata() -> Result<()> {
let destination = std::env::temp_dir().join(format!(
"docs_preprocessor_ai_discovery_test_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_nanos()
));
std::fs::create_dir_all(&destination)?;
let pages = vec![
DocsPage {
section: "Docs".to_string(),
title: "Getting Started".to_string(),
description: Some("Start using Zed.".to_string()),
source_path: PathBuf::from("getting-started.md"),
content: format!(
"{}\n# Getting Started\n",
FRONT_MATTER_COMMENT.replace("{}", r#"{"description":"Start using Zed."}"#)
),
},
DocsPage {
section: "AI".to_string(),
title: "MCP".to_string(),
description: Some("Connect model context servers.".to_string()),
source_path: PathBuf::from("ai/mcp.md"),
content: format!(
"{}\n# MCP\n",
FRONT_MATTER_COMMENT
.replace("{}", r#"{"description":"Connect model context servers."}"#)
),
},
];
write_ai_discovery_artifacts(&pages, &destination, "/docs/")?;
let llms_txt = std::fs::read_to_string(destination.join("llms.txt"))?;
assert!(llms_txt.contains("## Docs"));
assert!(llms_txt.contains(
"- [Getting Started](https://zed.dev/docs/getting-started.md): Start using Zed."
));
assert!(llms_txt.contains("## AI"));
assert!(
llms_txt.contains(
"- [MCP](https://zed.dev/docs/ai/mcp.md): Connect model context servers."
)
);
let sitemap_xml = std::fs::read_to_string(destination.join("sitemap.xml"))?;
assert!(sitemap_xml.contains("<loc>https://zed.dev/docs/getting-started.html</loc>"));
assert!(sitemap_xml.contains("<loc>https://zed.dev/docs/ai/mcp.html</loc>"));
let mcp_markdown = std::fs::read_to_string(destination.join("ai/mcp.md"))?;
assert!(mcp_markdown.starts_with(
"> For the complete documentation index and Markdown links, see [llms.txt](/docs/llms.txt).\n\n# MCP"
));
assert!(!mcp_markdown.contains("ZED_META"));
let index_markdown = std::fs::read_to_string(destination.join("index.md"))?;
assert!(index_markdown.contains("# Getting Started"));
std::fs::remove_dir_all(&destination)?;
Ok(())
}
}

View file

@ -1,4 +1,10 @@
use anyhow::{Context, Result};
mod ai_discovery;
use ai_discovery::{
add_markdown_alternate_link, docs_pages, rewrite_docs_links, write_ai_discovery_artifacts,
write_markdown_redirect_aliases, write_pages_redirects,
};
use mdbook::BookItem;
use mdbook::book::{Book, Chapter};
use mdbook::preprocess::CmdPreprocessor;
@ -188,7 +194,7 @@ fn handle_preprocessing() -> Result<()> {
template_big_table_of_actions(&mut book);
template_and_validate_keybindings(&mut book, &mut errors);
template_and_validate_actions(&mut book, &mut errors);
template_and_validate_json_snippets(&mut book, &mut errors);
template_and_validate_json_snippets(&mut book, &mut errors)?;
if !errors.is_empty() {
const ANSI_RED: &str = "\x1b[31m";
@ -252,7 +258,7 @@ fn format_binding(binding: String) -> String {
}
fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
let regex = Regex::new(r"\{#kb(?::(\w+))?\s+(.*?)\}").unwrap();
let regex = Regex::new(r"(?s)\{#kb(?::(\w+))?\s+(.*?)\}").unwrap();
for_each_chapter_mut(book, |chapter| {
chapter.content = regex
@ -300,7 +306,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Prepr
}
fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
let regex = Regex::new(r"\{#action (.*?)\}").unwrap();
let regex = Regex::new(r"(?s)\{#action\s+(.*?)\}").unwrap();
for_each_chapter_mut(book, |chapter| {
chapter.content = regex
@ -379,7 +385,10 @@ fn find_binding_with_overlay(
.or_else(|| find_binding(os, action))
}
fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
fn template_and_validate_json_snippets(
book: &mut Book,
errors: &mut HashSet<PreprocessorError>,
) -> Result<()> {
let params = SettingsJsonSchemaParams {
language_names: &[],
font_names: &[],
@ -393,7 +402,7 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<Pre
};
let settings_schema = SettingsStore::json_schema(&params);
let settings_validator = jsonschema::validator_for(&settings_schema)
.expect("failed to compile settings JSON schema");
.context("failed to compile settings JSON schema")?;
// The keymap schema is built from the action manifest. When `actions.json`
// is unavailable (e.g. when running outside of CI without first generating
@ -405,7 +414,7 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<Pre
keymap_schema_for_actions(&ALL_ACTIONS.actions, &ALL_ACTIONS.schema_definitions);
Some(
jsonschema::validator_for(&keymap_schema)
.expect("failed to compile keymap JSON schema"),
.context("failed to compile keymap JSON schema")?,
)
} else {
None
@ -568,6 +577,8 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<Pre
};
Ok(())
});
Ok(())
}
/// Removes any configurable options from the stringified action if existing,
@ -679,6 +690,19 @@ fn handle_postprocessing() -> Result<()> {
.as_table_mut()
.expect("output is table");
let zed_html = output.remove("zed-html").expect("zed-html output defined");
let redirects = zed_html
.get("redirect")
.and_then(|redirects| redirects.as_table())
.map(|redirects| {
redirects
.iter()
.filter_map(|(source, destination)| {
destination
.as_str()
.map(|destination| (source.clone(), destination.to_string()))
})
.collect::<Vec<_>>()
});
let default_description = zed_html
.get("default-description")
.expect("Default description not found")
@ -694,6 +718,17 @@ fn handle_postprocessing() -> Result<()> {
let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default();
let consent_io_instance = std::env::var("DOCS_CONSENT_IO_INSTANCE").unwrap_or_default();
let docs_channel = std::env::var("DOCS_CHANNEL").unwrap_or_else(|_| "stable".to_string());
let site_url = std::env::var("MDBOOK_BOOK__SITE_URL")
.ok()
.filter(|site_url| !site_url.trim().is_empty())
.unwrap_or_else(|| {
match docs_channel.as_str() {
"nightly" => "/docs/nightly/",
"preview" => "/docs/preview/",
_ => "/docs/",
}
.to_string()
});
let noindex = if docs_channel == "nightly" || docs_channel == "preview" {
"<meta name=\"robots\" content=\"noindex, nofollow\">"
} else {
@ -733,8 +768,10 @@ fn handle_postprocessing() -> Result<()> {
}
zlog::info!(logger => "Processing {} `.html` files", files.len());
let pages = docs_pages(&ctx.book)?;
write_ai_discovery_artifacts(&pages, &root_dir, &site_url)?;
let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap();
for file in files {
for file in &files {
let contents = std::fs::read_to_string(&file)?;
let mut meta_description = None;
let mut meta_title = None;
@ -770,14 +807,19 @@ fn handle_postprocessing() -> Result<()> {
let contents = contents.replace("#amplitude_key#", &amplitude_key);
let contents = contents.replace("#consent_io_instance#", &consent_io_instance);
let contents = contents.replace("#noindex#", noindex);
let contents = rewrite_docs_links(&contents, &site_url);
let contents = add_markdown_alternate_link(&contents, file, &root_dir, &site_url);
let contents = title_regex()
.replace(&contents, |_: &regex::Captures| {
format!("<title>{}</title>", meta_title)
})
.to_string();
// let contents = contents.replace("#title#", &meta_title);
std::fs::write(file, contents)?;
}
if let Some(redirects) = redirects {
write_markdown_redirect_aliases(&root_dir, &redirects, &site_url)?;
write_pages_redirects(&root_dir, &redirects, &site_url)?;
}
return Ok(());
fn pretty_path<'a>(
@ -906,66 +948,4 @@ fn keymap_schema_for_actions(
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_find_binding_prefers_exact_match_over_parameterized() {
let keymap: KeymapFile = serde_json::from_value(json!([
{
"bindings": {
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
}
}
]))
.unwrap();
let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
assert_eq!(binding.as_deref(), Some("ctrl-tab"));
}
#[test]
fn test_find_binding_falls_back_to_parameterized_match() {
let keymap: KeymapFile = serde_json::from_value(json!([
{
"bindings": {
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
}
}
]))
.unwrap();
let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
assert_eq!(binding.as_deref(), Some("ctrl-shift-tab"));
}
#[test]
fn test_find_binding_prefers_exact_match_regardless_of_order() {
let keymap: KeymapFile = serde_json::from_value(json!([
{
"bindings": {
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher"
}
}
]))
.unwrap();
let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
assert_eq!(binding.as_deref(), Some("ctrl-tab"));
}
#[test]
fn test_find_binding_later_section_overrides_earlier() {
let keymap: KeymapFile = serde_json::from_value(json!([
{ "bindings": { "ctrl-a": "some::Action" } },
{ "bindings": { "ctrl-b": "some::Action" } }
]))
.unwrap();
let binding = find_binding_in_keymap(&keymap, "some::Action");
assert_eq!(binding.as_deref(), Some("ctrl-b"));
}
}
mod tests;

View file

@ -0,0 +1,61 @@
use super::*;
use serde_json::json;
#[test]
fn test_find_binding_prefers_exact_match_over_parameterized() {
let keymap: KeymapFile = serde_json::from_value(json!([
{
"bindings": {
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
}
}
]))
.unwrap();
let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
assert_eq!(binding.as_deref(), Some("ctrl-tab"));
}
#[test]
fn test_find_binding_falls_back_to_parameterized_match() {
let keymap: KeymapFile = serde_json::from_value(json!([
{
"bindings": {
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
}
}
]))
.unwrap();
let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
assert_eq!(binding.as_deref(), Some("ctrl-shift-tab"));
}
#[test]
fn test_find_binding_prefers_exact_match_regardless_of_order() {
let keymap: KeymapFile = serde_json::from_value(json!([
{
"bindings": {
"ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
"ctrl-tab": "agents_sidebar::ToggleThreadSwitcher"
}
}
]))
.unwrap();
let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
assert_eq!(binding.as_deref(), Some("ctrl-tab"));
}
#[test]
fn test_find_binding_later_section_overrides_earlier() {
let keymap: KeymapFile = serde_json::from_value(json!([
{ "bindings": { "ctrl-a": "some::Action" } },
{ "bindings": { "ctrl-b": "some::Action" } }
]))
.unwrap();
let binding = find_binding_in_keymap(&keymap, "some::Action");
assert_eq!(binding.as_deref(), Some("ctrl-b"));
}

View file

@ -104,6 +104,34 @@ impl EditPredictionResult {
e2e_latency,
}
}
pub fn new_rejected(
id: EditPredictionId,
edited_buffer: &Entity<Buffer>,
edited_buffer_snapshot: &BufferSnapshot,
inputs: EditPredictionInputs,
model_version: Option<String>,
trigger: PredictEditsRequestTrigger,
e2e_latency: std::time::Duration,
reject_reason: EditPredictionRejectReason,
) -> Self {
Self {
prediction: EditPrediction {
id,
edits: Arc::default(),
cursor_position: None,
editable_range: None,
snapshot: edited_buffer_snapshot.clone(),
edit_preview: EditPreview::unchanged(edited_buffer_snapshot),
inputs,
buffer: edited_buffer.clone(),
model_version,
trigger,
},
reject_reason: Some(reject_reason),
e2e_latency,
}
}
}
#[derive(Clone)]

View file

@ -9,7 +9,9 @@ use crate::{
udiff::prediction_edits_for_single_file_diff,
};
use anyhow::{Context as _, Result};
use cloud_llm_client::{AcceptEditPredictionBody, predict_edits_v3::RawCompletionRequest};
use cloud_llm_client::{
AcceptEditPredictionBody, EditPredictionRejectReason, predict_edits_v3::RawCompletionRequest,
};
use edit_prediction_types::PredictedCursorPosition;
use gpui::{App, AppContext as _, Entity, Task, TaskExt, WeakEntity, prelude::*};
use language::{
@ -516,26 +518,41 @@ pub(crate) fn request_prediction_with_zeta(
snapshot: fallback_snapshot,
patch,
} => {
let Some((buffer, snapshot, edits, cursor_position)) =
prediction_edits_for_single_file_diff(&patch, &project, cx).await?
else {
return Ok(Some(
EditPredictionResult::new(
id,
&fallback_buffer,
&fallback_snapshot,
Arc::new([]),
None,
None,
inputs,
model_version,
trigger,
request_duration,
cx,
)
.await,
));
};
let (buffer, snapshot, edits, cursor_position) =
match prediction_edits_for_single_file_diff(&patch, &project, cx).await {
Ok(Some(edits)) => edits,
Ok(None) => {
return Ok(Some(
EditPredictionResult::new(
id,
&fallback_buffer,
&fallback_snapshot,
Arc::new([]),
None,
None,
inputs,
model_version,
trigger,
request_duration,
cx,
)
.await,
));
}
Err(error) => {
log::error!("failed to apply edit prediction patch: {error:?}");
return Ok(Some(EditPredictionResult::new_rejected(
id,
&fallback_buffer,
&fallback_snapshot,
inputs,
model_version,
trigger,
request_duration,
EditPredictionRejectReason::PatchApplyFailed,
)));
}
};
let editable_range_in_buffer =
edits
.iter()

View file

@ -669,10 +669,14 @@ actions!(
MoveToEnd,
/// Moves cursor to the end of the paragraph.
MoveToEndOfParagraph,
/// Moves cursor to the start of the next comment paragraph.
MoveToNextCommentParagraph,
/// Moves cursor to the end of the next subword.
MoveToNextSubwordEnd,
/// Moves cursor to the end of the next word.
MoveToNextWordEnd,
/// Moves cursor to the start of the previous comment paragraph.
MoveToPreviousCommentParagraph,
/// Moves cursor to the start of the previous subword.
MoveToPreviousSubwordStart,
/// Moves cursor to the start of the previous word.

View file

@ -963,9 +963,14 @@ impl BlockMap {
// Ensure the edit starts at a transform boundary.
// If the edit starts within an isomorphic transform, preserve its prefix
// If the edit lands within a replacement block, expand the edit to include the start of the replaced input range
let transform = cursor.item().unwrap();
// The cursor can sit past the end of the tree when a companion edit
// is anchored at the new end-of-file and maps to `old_start` at the
// very end of the old transforms; in that case there is no transform
// preceding the edit and nothing to preserve.
let transform_rows_before_edit = old_start - *cursor.start();
if transform_rows_before_edit > RowDelta(0) {
if transform_rows_before_edit > RowDelta(0)
&& let Some(transform) = cursor.item()
{
if transform.block.is_none() {
// Preserve any portion of the old isomorphic transform that precedes this edit.
push_isomorphic(
@ -5019,6 +5024,61 @@ mod tests {
);
}
// Regression test for ZED-9V4: `BlockMap::sync` walks the (old) transform
// tree with a `WrapRow` cursor and used to `cursor.item().unwrap()` for
// every edit, assuming each edit's `old.start` lands strictly inside the
// tree. The companion (split-diff) branch of `sync` can compose an edit
// anchored at the trailing boundary of the old transforms
// (`old.start == input_rows`), at which point the cursor is past the end of
// the tree and `item()` is `None`. That used to abort the process.
#[gpui::test]
fn test_sync_edit_anchored_at_end_of_transforms(cx: &mut gpui::TestAppContext) {
cx.update(init_test);
let buffer = cx.update(|cx| MultiBuffer::build_simple("aaa\nbbb\nccc\n", cx));
let buffer_snapshot = cx.update(|cx| buffer.read(cx).snapshot(cx));
let subscription = buffer.update(cx, |buffer, _| buffer.subscribe());
let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot);
let (mut fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot);
let (mut tab_map, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap());
let (wrap_map, old_wrap_snapshot) =
cx.update(|cx| WrapMap::new(tab_snapshot, test_font(), px(14.0), None, cx));
let block_map = BlockMap::new(old_wrap_snapshot.clone(), 0, 0);
// The tree now spans exactly `old_end` input rows.
let old_end = old_wrap_snapshot.max_point().row() + RowDelta(1);
// Grow the buffer so the new snapshot is larger than the old transforms.
let buffer_snapshot = buffer.update(cx, |buffer, cx| {
buffer.edit(
[(Point::new(3, 0)..Point::new(3, 0), "ddd\neee\n")],
None,
cx,
);
buffer.snapshot(cx)
});
let (inlay_snapshot, inlay_edits) =
inlay_map.sync(buffer_snapshot, subscription.consume().into_inner());
let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits);
let (tab_snapshot, tab_edits) =
tab_map.sync(fold_snapshot, fold_edits, 4.try_into().unwrap());
let (new_wrap_snapshot, _) = wrap_map.update(cx, |wrap_map, cx| {
wrap_map.sync(tab_snapshot, tab_edits, cx)
});
let new_end = new_wrap_snapshot.max_point().row() + RowDelta(1);
// An edit anchored exactly at the end of the old transforms, of the shape
// the companion branch of `sync` can produce.
let edits = Patch::new(vec![text::Edit {
old: old_end..old_end,
new: old_end..new_end,
}]);
let snapshot = block_map.read(new_wrap_snapshot, edits, None);
assert_eq!(snapshot.snapshot.text(), "aaa\nbbb\nccc\nddd\neee\n");
}
fn init_test(cx: &mut gpui::App) {
let settings = SettingsStore::test(cx);
cx.set_global(settings);

View file

@ -9568,6 +9568,9 @@ impl Editor {
ranges,
path_key,
} => {
if let Some(hovered_link_state) = self.hovered_link_state.as_mut() {
hovered_link_state.symbol_range = None;
}
self.refresh_document_highlights(cx);
let buffer_id = buffer.read(cx).remote_id();
if self.buffer.read(cx).diff_for(buffer_id).is_none()

File diff suppressed because it is too large Load diff

View file

@ -287,22 +287,22 @@ impl EditorElement {
register_action(editor, window, Editor::scroll_cursor_bottom);
register_action(editor, window, Editor::scroll_cursor_center_top_bottom);
register_action(editor, window, |editor, _: &LineDown, window, cx| {
editor.scroll_screen(&ScrollAmount::Line(1.), window, cx)
editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(1.), window, cx)
});
register_action(editor, window, |editor, _: &LineUp, window, cx| {
editor.scroll_screen(&ScrollAmount::Line(-1.), window, cx)
editor.scroll_screen_with_cursor_margin(&ScrollAmount::Line(-1.), window, cx)
});
register_action(editor, window, |editor, _: &HalfPageDown, window, cx| {
editor.scroll_screen(&ScrollAmount::Page(0.5), window, cx)
editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(0.5), window, cx)
});
register_action(editor, window, |editor, _: &HalfPageUp, window, cx| {
editor.scroll_screen(&ScrollAmount::Page(-0.5), window, cx)
editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-0.5), window, cx)
});
register_action(editor, window, |editor, _: &PageDown, window, cx| {
editor.scroll_screen(&ScrollAmount::Page(1.), window, cx)
editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(1.), window, cx)
});
register_action(editor, window, |editor, _: &PageUp, window, cx| {
editor.scroll_screen(&ScrollAmount::Page(-1.), window, cx)
editor.scroll_screen_with_cursor_margin(&ScrollAmount::Page(-1.), window, cx)
});
register_action(editor, window, Editor::move_to_previous_word_start);
register_action(editor, window, Editor::move_to_previous_subword_start);
@ -312,6 +312,8 @@ impl EditorElement {
register_action(editor, window, Editor::move_to_end_of_line);
register_action(editor, window, Editor::move_to_start_of_paragraph);
register_action(editor, window, Editor::move_to_end_of_paragraph);
register_action(editor, window, Editor::move_to_next_comment_paragraph);
register_action(editor, window, Editor::move_to_previous_comment_paragraph);
register_action(editor, window, Editor::move_to_beginning);
register_action(editor, window, Editor::move_to_end);
register_action(editor, window, Editor::move_to_start_of_excerpt);

View file

@ -1050,11 +1050,7 @@ impl Editor {
);
}
pub(super) fn restore_diff_hunks(
&mut self,
hunks: Vec<ResolvedDiffHunks>,
cx: &mut Context<Self>,
) {
pub fn restore_diff_hunks(&mut self, hunks: Vec<ResolvedDiffHunks>, cx: &mut Context<Self>) {
let mut revert_changes = Vec::new();
for hunks in hunks {
let Some(buffer) = hunks.buffer else {
@ -2110,6 +2106,17 @@ impl Editor {
.detach_and_log_err(cx);
}
pub fn restore_diff_hunks_in_ranges(
&mut self,
ranges: Vec<Range<Anchor>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let snapshot = self.buffer.read(cx).snapshot(cx);
let hunks = self.diff_hunks_in_ranges(&ranges, &snapshot).collect();
self.apply_restore(hunks, window, cx);
}
fn toggle_diff_hunks_in_ranges(
&mut self,
ranges: Vec<Range<Anchor>>,

View file

@ -19,12 +19,14 @@ use gpui::{
};
use language::{
Bias, Buffer, BufferRow, CharKind, CharScopeContext, HighlightedText, LocalFile, PLAIN_TEXT,
Point, SelectionGoal, proto::serialize_anchor as serialize_text_anchor,
Point, SelectionGoal,
language_settings::{FormatOnSave, LanguageSettings},
proto::serialize_anchor as serialize_text_anchor,
};
use lsp::DiagnosticSeverity;
use multi_buffer::{BufferOffset, MultiBufferOffset, PathKey};
use project::{
File, Project, ProjectItem as _, ProjectPath, lsp_store::FormatTrigger,
File, Project, ProjectItem as _, ProjectPath, git_store::GitStore, lsp_store::FormatTrigger,
project_settings::ProjectSettings, search::SearchQuery,
};
use rope::TextSummary;
@ -39,7 +41,7 @@ use std::{
path::{Path, PathBuf},
sync::Arc,
};
use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection};
use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection, ToPoint as _};
use ui::{IconDecorationKind, prelude::*};
use util::{ResultExt, TryFutureExt, paths::PathExt, rel_path::RelPath};
use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams};
@ -974,16 +976,21 @@ impl Item for Editor {
cx.spawn_in(window, async move |this, cx| {
if options.format {
this.update_in(cx, |editor, window, cx| {
editor.perform_format(
project.clone(),
let format_task = this.update_in(cx, |editor, window, cx| {
let format_target = compute_format_target(
&buffers_to_save,
format_trigger,
FormatTarget::Buffers(buffers_to_save.clone()),
window,
editor.buffer(),
project.read(cx).git_store(),
cx,
)
})?
.await?;
);
format_target.map(|target| {
editor.perform_format(project.clone(), format_trigger, target, window, cx)
})
})?;
if let Some(format_task) = format_task {
format_task.await?;
}
}
if !buffers_to_save.is_empty() {
@ -2267,6 +2274,115 @@ fn chunk_search_range(
}))
}
/// Decides what to format based on the `format_on_save` settings of the saved buffers.
///
/// In the modifications modes, only lines with unstaged changes are formatted.
/// When no git diff is available for a buffer, `modifications` skips formatting while `modifications_if_available`
/// falls back to formatting entire buffers.
/// When a diff is available but empty, nothing is formatted in either mode.
fn compute_format_target(
buffers: &HashSet<Entity<Buffer>>,
trigger: FormatTrigger,
multi_buffer: &Entity<MultiBuffer>,
git_store: &Entity<GitStore>,
cx: &App,
) -> Option<FormatTarget> {
if trigger == FormatTrigger::Manual {
return Some(FormatTarget::Buffers(buffers.clone()));
}
let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
let git_store = git_store.read(cx);
let mut fall_back_to_full_format = false;
let mut modified_ranges: Vec<Range<Point>> = Vec::new();
for buffer_entity in buffers.iter() {
let buffer = buffer_entity.read(cx);
let settings = LanguageSettings::for_buffer(buffer, cx);
match settings.format_on_save {
FormatOnSave::On | FormatOnSave::Off => {
return Some(FormatTarget::Buffers(buffers.clone()));
}
FormatOnSave::Modifications | FormatOnSave::ModificationsIfAvailable => {}
}
let Some(diff_snapshot) = git_store
.get_unstaged_diff(buffer.remote_id(), cx)
.map(|diff| diff.read(cx).snapshot(cx))
else {
if settings.format_on_save == FormatOnSave::ModificationsIfAvailable {
fall_back_to_full_format = true;
}
continue;
};
let anchor_ranges = compute_modified_ranges(&buffer.snapshot(), &diff_snapshot);
let flat_anchors = anchor_ranges
.iter()
.flat_map(|range| [range.start, range.end])
.collect::<Vec<_>>();
let multi_buffer_anchors =
multi_buffer_snapshot.text_anchors_to_visible_anchors(flat_anchors);
for pair in multi_buffer_anchors.chunks_exact(2) {
let (Some(start), Some(end)) = (&pair[0], &pair[1]) else {
continue;
};
modified_ranges
.push(start.to_point(&multi_buffer_snapshot)..end.to_point(&multi_buffer_snapshot));
}
}
if fall_back_to_full_format {
Some(FormatTarget::Buffers(buffers.clone()))
} else if modified_ranges.is_empty() {
None
} else {
Some(FormatTarget::Ranges(modified_ranges))
}
}
/// Computes the buffer ranges that have unstaged changes, expanded to full lines and
/// with adjacent hunks merged, for use with format-on-save. An empty result means the
/// buffer has no formatable modifications.
fn compute_modified_ranges(
buffer_snapshot: &language::BufferSnapshot,
diff_snapshot: &buffer_diff::BufferDiffSnapshot,
) -> Vec<Range<text::Anchor>> {
let mut merged: Vec<Range<text::Anchor>> = Vec::new();
for hunk in diff_snapshot.hunks(buffer_snapshot) {
let range = hunk.buffer_range;
if range.start.cmp(&range.end, buffer_snapshot).is_eq() {
// Deletion-only hunks produce no buffer content to format.
continue;
}
let start_point = range.start.to_point(buffer_snapshot);
let end_point = range.end.to_point(buffer_snapshot);
let start_row = start_point.row;
let end_row = if end_point.column == 0 && end_point.row > start_point.row {
end_point.row - 1
} else {
end_point.row
};
let line_start = text::Point::new(start_row, 0);
let line_end = text::Point::new(end_row, buffer_snapshot.line_len(end_row));
let expanded =
buffer_snapshot.anchor_before(line_start)..buffer_snapshot.anchor_after(line_end);
if let Some(last) = merged.last_mut() {
let last_end_point = last.end.to_point(buffer_snapshot);
if start_row <= last_end_point.row + 1 {
if expanded.end.to_point(buffer_snapshot) > last_end_point {
last.end = expanded.end;
}
continue;
}
}
merged.push(expanded);
}
merged
}
#[cfg(test)]
mod tests {
use crate::editor_tests::init_test;
@ -2921,4 +3037,124 @@ mod tests {
"Editor::deserialize should not add items to panes as a side effect"
);
}
#[gpui::test]
fn test_compute_modified_ranges_git_diff(cx: &mut gpui::TestAppContext) {
let base_text = "line0\nline1\nline2\nline3\nline4\nline5\nline6\n";
// Modify line1 and line5 to create two non-adjacent hunks.
let buffer_text = "line0\nMOD1\nline2\nline3\nline4\nMOD5\nline6\n";
let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
let diff_snapshot = buffer.update(cx, |buffer, cx| {
let diff = cx.new(|cx| {
buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx)
});
diff.read(cx).snapshot(cx)
});
let ranges = buffer.update(cx, |buffer, _cx| {
compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
});
assert_eq!(ranges.len(), 2, "expected 2 non-adjacent ranges");
buffer.update(cx, |buffer, _cx| {
let text_snapshot: &text::BufferSnapshot = buffer;
let r0 = ranges[0].start.to_point(text_snapshot)..ranges[0].end.to_point(text_snapshot);
let r1 = ranges[1].start.to_point(text_snapshot)..ranges[1].end.to_point(text_snapshot);
assert_eq!(r0.start.row, 1, "first hunk should start at row 1");
assert_eq!(r0.end.row, 1, "first hunk should end at row 1");
assert_eq!(r1.start.row, 5, "second hunk should start at row 5");
assert_eq!(r1.end.row, 5, "second hunk should end at row 5");
});
}
#[gpui::test]
fn test_compute_modified_ranges_unchanged_buffer(cx: &mut gpui::TestAppContext) {
let buffer_text = "line0\nline1\nline2\n";
let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
let diff_snapshot = buffer.update(cx, |buffer, cx| {
let diff = cx.new(|cx| {
buffer_diff::BufferDiff::new_with_base_text(
buffer_text,
&buffer.text_snapshot(),
cx,
)
});
diff.read(cx).snapshot(cx)
});
let ranges = buffer.update(cx, |buffer, _cx| {
compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
});
assert_eq!(
ranges,
Vec::new(),
"buffer that matches its diff base should produce no modified ranges"
);
}
#[gpui::test]
fn test_compute_modified_ranges_deletion_only(cx: &mut gpui::TestAppContext) {
let base_text = "line0\nline1\nline2\n";
// Buffer has line1 deleted (pure deletion).
let buffer_text = "line0\nline2\n";
let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
let diff_snapshot = buffer.update(cx, |buffer, cx| {
let diff = cx.new(|cx| {
buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx)
});
diff.read(cx).snapshot(cx)
});
// Verify the diff has a deletion hunk.
let hunk_count = buffer.update(cx, |buffer, _cx| {
let text_snapshot: &text::BufferSnapshot = buffer;
diff_snapshot.hunks(text_snapshot).count()
});
assert!(hunk_count > 0, "diff should have hunks");
let ranges = buffer.update(cx, |buffer, _cx| {
compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
});
assert_eq!(
ranges,
Vec::new(),
"deletion-only hunks should be skipped, leaving no ranges"
);
}
#[gpui::test]
fn test_compute_modified_ranges_adjacent_hunks(cx: &mut gpui::TestAppContext) {
let base_text = "line0\nline1\nline2\nline3\nline4\n";
// Modify lines 2 and 3 which are adjacent; they should merge into one range.
let buffer_text = "line0\nline1\nMOD2\nMOD3\nline4\n";
let buffer = cx.new(|cx| language::Buffer::local(buffer_text, cx));
let diff_snapshot = buffer.update(cx, |buffer, cx| {
let diff = cx.new(|cx| {
buffer_diff::BufferDiff::new_with_base_text(base_text, &buffer.text_snapshot(), cx)
});
diff.read(cx).snapshot(cx)
});
let ranges = buffer.update(cx, |buffer, _cx| {
compute_modified_ranges(&buffer.snapshot(), &diff_snapshot)
});
assert_eq!(
ranges.len(),
1,
"adjacent hunks (rows 2 and 3) should be merged into one range"
);
buffer.update(cx, |buffer, _cx| {
let text_snapshot: &text::BufferSnapshot = buffer;
let r = ranges[0].start.to_point(text_snapshot)..ranges[0].end.to_point(text_snapshot);
assert_eq!(r.start.row, 2, "merged range should start at row 2");
assert_eq!(r.end.row, 3, "merged range should end at row 3");
});
}
}

View file

@ -582,6 +582,99 @@ pub fn end_of_paragraph(
map.max_point()
}
/// Returns whether `row` is part of a comment paragraph: a line whose first
/// non-whitespace character lies within a comment scope and which contains at
/// least one alphanumeric character.
///
/// This intentionally excludes:
/// - blank lines and code lines,
/// - end-of-line comments preceded by code (the first non-whitespace character
/// is then code, not a comment),
/// - "blank"/divider comment lines such as a bare `//` or `// -----` (no
/// alphanumeric content), which act as paragraph separators.
fn is_comment_paragraph_line(snapshot: &MultiBufferSnapshot, row: u32) -> bool {
let buffer_row = MultiBufferRow(row);
if snapshot.is_line_blank(buffer_row) {
return false;
}
let indent_len = snapshot.indent_size_for_line(buffer_row).len;
let indent_end = Point::new(row, indent_len);
let in_comment = snapshot.language_scope_at(indent_end).is_some_and(|scope| {
matches!(
scope.override_name(),
Some("comment") | Some("comment.inclusive")
)
});
if !in_comment {
return false;
}
let line_end = Point::new(row, snapshot.line_len(buffer_row));
snapshot
.text_for_range(indent_end..line_end)
.flat_map(|chunk| chunk.chars())
.any(|c| c.is_alphanumeric())
}
/// Returns the position of the first non-whitespace character of the next or
/// previous comment paragraph, relative to `from`.
///
/// A comment paragraph is a run of consecutive comment lines (see
/// [`is_comment_paragraph_line`]); paragraphs are separated by blank lines, code
/// lines, and blank/divider comment lines. If no such paragraph exists in the
/// requested direction, `from` is returned unchanged.
///
/// Both directions always move to a *different* paragraph than the one the
/// caret is in: when the caret is inside a comment paragraph, the entire
/// current paragraph is skipped, so `Prev` lands on the previous paragraph's
/// start rather than the current paragraph's own start.
pub fn comment_paragraph(
map: &DisplaySnapshot,
from: DisplayPoint,
direction: Direction,
) -> DisplayPoint {
let snapshot = map.buffer_snapshot();
let from_point = from.to_point(map);
let max_row = snapshot.max_row().0;
let is_paragraph_start = |row: u32| {
is_comment_paragraph_line(snapshot, row)
&& (row == 0 || !is_comment_paragraph_line(snapshot, row - 1))
};
let paragraph_start_point =
|row: u32| Point::new(row, snapshot.indent_size_for_line(MultiBufferRow(row)).len);
let target = match direction {
Direction::Next => (from_point.row..=max_row).find_map(|row| {
let point = paragraph_start_point(row);
(point > from_point && is_paragraph_start(row)).then_some(point)
}),
Direction::Prev => {
// If the caret is within a comment paragraph, skip over the whole
// current paragraph so we land on the *previous* paragraph rather
// than stopping at the current paragraph's own start.
let mut boundary_row = from_point.row;
if is_comment_paragraph_line(snapshot, boundary_row) {
while boundary_row > 0 && is_comment_paragraph_line(snapshot, boundary_row - 1) {
boundary_row -= 1;
}
(0..boundary_row)
.rev()
.find_map(|row| is_paragraph_start(row).then(|| paragraph_start_point(row)))
} else {
(0..=from_point.row).rev().find_map(|row| {
let point = paragraph_start_point(row);
(point < from_point && is_paragraph_start(row)).then_some(point)
})
}
}
};
match target {
Some(point) => map.clip_point(point.to_display_point(map), Bias::Right),
None => from,
}
}
pub fn start_of_excerpt(
map: &DisplaySnapshot,
display_point: DisplayPoint,

View file

@ -608,6 +608,68 @@ impl Editor {
})
}
pub fn move_to_next_comment_paragraph(
&mut self,
_: &MoveToNextCommentParagraph,
window: &mut Window,
cx: &mut Context<Self>,
) {
if matches!(self.mode, EditorMode::SingleLine) {
cx.propagate();
return;
}
// Keep the destination paragraph near the top of the viewport so the
// whole paragraph below the caret stays visible after a jump.
self.change_selections(
SelectionEffects::scroll(Autoscroll::top_relative(5.0)),
window,
cx,
|s| {
s.move_with(&mut |map, selection| {
selection.collapse_to(
movement::comment_paragraph(
map,
selection.head(),
workspace::searchable::Direction::Next,
),
SelectionGoal::None,
)
});
},
)
}
pub fn move_to_previous_comment_paragraph(
&mut self,
_: &MoveToPreviousCommentParagraph,
window: &mut Window,
cx: &mut Context<Self>,
) {
if matches!(self.mode, EditorMode::SingleLine) {
cx.propagate();
return;
}
// Keep the destination paragraph near the top of the viewport so the
// whole paragraph below the caret stays visible after a jump.
self.change_selections(
SelectionEffects::scroll(Autoscroll::top_relative(5.0)),
window,
cx,
|s| {
s.move_with(&mut |map, selection| {
selection.collapse_to(
movement::comment_paragraph(
map,
selection.head(),
workspace::searchable::Direction::Prev,
),
SelectionGoal::None,
)
});
},
)
}
pub fn select_to_start_of_paragraph(
&mut self,
_: &SelectToStartOfParagraph,

View file

@ -537,6 +537,7 @@ impl Editor {
let quick_launch = match e {
ClickEvent::Keyboard(_) => true,
ClickEvent::Mouse(e) => e.down.button == MouseButton::Left,
ClickEvent::Touch(_) => true,
};
window.focus(&editor.focus_handle(cx), cx);

View file

@ -5,7 +5,7 @@ pub(crate) mod scroll_amount;
use crate::editor_settings::ScrollBeyondLastLine;
use crate::{
Anchor, DisplayPoint, DisplayRow, Editor, EditorEvent, EditorMode, EditorSettings,
MultiBufferSnapshot, RowExt, SizingBehavior, ToPoint,
MultiBufferSnapshot, RowExt, SelectionEffects, SizingBehavior, ToPoint,
display_map::{DisplaySnapshot, ToDisplayPoint},
hover_popover::hide_hover,
persistence::EditorDb,
@ -945,6 +945,63 @@ impl Editor {
self.set_scroll_position(new_position, window, cx);
}
pub fn scroll_screen_with_cursor_margin(
&mut self,
amount: &ScrollAmount,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.scroll_screen(amount, window, cx);
let Some(visible_line_count) = self.visible_line_count() else {
return;
};
let display_snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx));
let top = self
.scroll_manager
.scroll_top_display_point(&display_snapshot, cx);
let vertical_scroll_margin =
(self.vertical_scroll_margin() as u32).min(visible_line_count as u32 / 2);
let max_point = display_snapshot.max_point();
let min_row = if top.row().0 == 0 {
DisplayRow(0)
} else {
DisplayRow(top.row().0 + vertical_scroll_margin)
};
let max_row = if top.row().0 + visible_line_count as u32 >= max_point.row().0 {
max_point.row()
} else {
DisplayRow(
(top.row().0 + visible_line_count as u32)
.saturating_sub(1 + vertical_scroll_margin),
)
};
self.change_selections(
SelectionEffects::no_scroll().nav_history(false),
window,
cx,
|s| {
s.move_with(&mut |map, selection| {
let head = selection.head();
let new_row = if head.row() < min_row {
min_row
} else if head.row() > max_row {
max_row
} else {
head.row()
};
if new_row != head.row() {
let new_head =
map.clip_point(DisplayPoint::new(new_row, head.column()), Bias::Left);
selection.collapse_to(new_head, selection.goal);
}
})
},
);
}
/// Returns an ordering. The newest selection is:
/// Ordering::Equal => on screen
/// Ordering::Less => above or to the left of the screen

View file

@ -6,6 +6,8 @@ use std::{collections::HashMap, num::NonZeroU32};
pub struct LanguageSettings {
/// How many columns a tab should occupy.
pub tab_size: NonZeroU32,
/// Whether to indent with hard tabs (true) or spaces (false).
pub hard_tabs: bool,
/// The preferred line length (column at which to wrap).
pub preferred_line_length: u32,
}

View file

@ -968,6 +968,7 @@ impl ExtensionImports for WasmState {
);
Ok(serde_json::to_string(&settings::LanguageSettings {
tab_size: settings.tab_size,
hard_tabs: settings.hard_tabs,
preferred_line_length: settings.preferred_line_length,
})?)
}

View file

@ -118,14 +118,3 @@ impl FeatureFlag for AutoWatchFeatureFlag {
type Value = PresenceFlag;
}
register_feature_flag!(AutoWatchFeatureFlag);
/// Wraps agent-run terminal commands in an OS-level sandbox where supported
/// (currently macOS Seatbelt only). When off, terminal commands run with the
/// agent's full ambient permissions, as they always have.
pub struct SandboxingFeatureFlag;
impl FeatureFlag for SandboxingFeatureFlag {
const NAME: &'static str = "sandboxing";
type Value = PresenceFlag;
}
register_feature_flag!(SandboxingFeatureFlag);

View file

@ -262,7 +262,18 @@ impl GitRepository for FakeGitRepository {
fn show(&self, commit: String) -> BoxFuture<'_, Result<CommitDetails>> {
self.with_state_async(false, move |state| {
let sha = state.refs.get(&commit).cloned().unwrap_or(commit);
let sha = match state.refs.get(&commit) {
Some(sha) => sha.clone(),
// Real git fails to show an unresolvable revision (e.g. HEAD on an
// unborn branch), so only fall back to treating the input as a sha.
None => {
anyhow::ensure!(
commit.parse::<Oid>().is_ok(),
"unable to resolve revision: {commit}"
);
commit
}
};
Ok(CommitDetails {
sha: sha.into(),
message: "initial commit".into(),

View file

@ -295,6 +295,7 @@ pub struct Metadata {
pub len: u64,
pub is_fifo: bool,
pub is_executable: bool,
pub is_writable: bool,
}
/// Filesystem modification time. The purpose of this newtype is to discourage use of operations
@ -1025,6 +1026,7 @@ impl Fs for RealFs {
is_dir: metadata.file_type().is_dir(),
is_fifo,
is_executable,
is_writable: !metadata.permissions().readonly(),
}))
}
@ -3085,6 +3087,7 @@ impl Fs for FakeFs {
is_symlink,
is_fifo: false,
is_executable: false,
is_writable: true,
},
FakeFsEntry::Dir {
inode, mtime, len, ..
@ -3096,6 +3099,7 @@ impl Fs for FakeFs {
is_symlink,
is_fifo: false,
is_executable: false,
is_writable: true,
},
FakeFsEntry::Symlink { .. } => unreachable!(),
}))

View file

@ -56,13 +56,22 @@ impl FsWatcher {
log::trace!("path to watch is already watched: {path:?}");
return Ok(());
}
if let Some(registration) = register_existing_path(
path,
match register_existing_path(
path.clone(),
case_insensitive,
self.tx.clone(),
self.pending_path_events.clone(),
)? {
self.registrations.lock().insert(key, registration);
Some(registration) => {
self.registrations.lock().insert(key, registration);
}
None => {
// Registration was skipped (e.g. the native watch-limit cooldown
// is active). Retry in the background rather than silently leaving
// the path unwatched forever.
log::warn!("watch registration for {path:?} was skipped; retrying in background");
self.add_pending_path(path);
}
}
Ok(())
}

View file

@ -21,6 +21,8 @@ pub const GITIGNORE: &str = ".gitignore";
pub const FSMONITOR_DAEMON: &str = "fsmonitor--daemon";
pub const LFS_DIR: &str = "lfs";
pub const OBJECTS_DIR: &str = "objects";
pub const REFS_DIR: &str = "refs";
pub const REFTABLE_DIR: &str = "reftable";
pub const HOOKS_DIR: &str = "hooks";
pub const LOGS_DIR: &str = "logs";
pub const LOGS_REF_STASH: &str = "logs/refs/stash";

View file

@ -18,6 +18,7 @@ use smallvec::SmallVec;
use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use text::LineEnding;
use std::borrow::Cow;
use std::collections::HashSet;
use std::ffi::{OsStr, OsString};
use std::sync::atomic::AtomicBool;
@ -757,20 +758,22 @@ pub enum LogSource {
}
impl LogSource {
fn get_args(&self) -> Result<Vec<&str>> {
fn get_args(&self) -> Vec<Cow<'_, str>> {
match self {
LogSource::All => Ok(vec![
"--ignore-missing", // needed in case of unborn HEAD
"--branches",
"--remotes",
"--tags",
"HEAD",
]),
LogSource::Branch(branch) => Ok(vec![branch.as_str()]),
LogSource::Sha(oid) => Ok(vec![
str::from_utf8(oid.as_bytes()).context("Failed to build str from sha")?,
]),
LogSource::Path(path) => Ok(vec!["--follow", "--", path.as_unix_str()]),
LogSource::All => vec![
Cow::Borrowed("--ignore-missing"), // needed in case of unborn HEAD
Cow::Borrowed("--branches"),
Cow::Borrowed("--remotes"),
Cow::Borrowed("--tags"),
Cow::Borrowed("HEAD"),
],
LogSource::Branch(branch) => vec![Cow::Borrowed(branch.as_str())],
LogSource::Sha(oid) => vec![Cow::Owned(oid.to_string())],
LogSource::Path(path) => vec![
Cow::Borrowed("--follow"),
Cow::Borrowed("--"),
Cow::Borrowed(path.as_unix_str()),
],
}
}
}
@ -3111,8 +3114,9 @@ impl GitRepository for RealGitRepository {
let git = self.git_binary();
async move {
let log_source_args = log_source.get_args();
let mut git_log_command = vec!["log", GRAPH_COMMIT_FORMAT, log_order.as_arg()];
git_log_command.extend(log_source.get_args()?);
git_log_command.extend(log_source_args.iter().map(|arg| arg.as_ref()));
let mut command = git.build_command(&git_log_command);
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
@ -3182,6 +3186,7 @@ impl GitRepository for RealGitRepository {
let git = self.git_binary();
async move {
let log_source_args = log_source.get_args();
let mut args = vec!["log", SEARCH_COMMIT_FORMAT];
let hash_query = commit_hash_search_query(search_args.query.as_str())
.map(|query| query.to_ascii_lowercase());
@ -3197,7 +3202,7 @@ impl GitRepository for RealGitRepository {
args.push(search_args.query.as_str());
}
args.extend(log_source.get_args()?);
args.extend(log_source_args.iter().map(|arg| arg.as_ref()));
let mut command = git.build_command(&args);
command.stdout(Stdio::piped());
command.stderr(Stdio::null());
@ -3942,7 +3947,7 @@ mod tests {
#[allow(clippy::disallowed_methods)]
#[track_caller]
fn git_command<I, S>(working_directory: &Path, arguments: I)
fn git_command_output<I, S>(working_directory: &Path, arguments: I) -> String
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
@ -3963,6 +3968,19 @@ mod tests {
"git command failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout)
.expect("git command output was not valid UTF-8")
.trim()
.to_string()
}
#[track_caller]
fn git_command<I, S>(working_directory: &Path, arguments: I)
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
git_command_output(working_directory, arguments);
}
fn git_init_repo(path: &Path) {
@ -4481,6 +4499,42 @@ mod tests {
);
}
#[gpui::test]
async fn test_initial_graph_data_accepts_sha_log_source(cx: &mut TestAppContext) {
disable_git_global_config();
cx.executor().allow_parking();
let repo_dir = tempfile::tempdir().unwrap();
git_init_repo(repo_dir.path());
fs::write(repo_dir.path().join("file"), "initial").unwrap();
git_command(repo_dir.path(), ["add", "file"]);
git_command(repo_dir.path(), ["commit", "-m", "Initial commit"]);
let commit_sha: Oid = git_command_output(repo_dir.path(), ["rev-parse", "HEAD"])
.parse()
.unwrap();
let repo = RealGitRepository::new(
&repo_dir.path().join(".git"),
None,
Some("git".into()),
cx.executor(),
)
.unwrap();
let (request_tx, request_rx) = async_channel::unbounded();
repo.initial_graph_data(LogSource::Sha(commit_sha), LogOrder::DateOrder, request_tx)
.await
.unwrap();
let graph_data = request_rx.recv().await.unwrap();
assert_eq!(graph_data.len(), 1);
assert_eq!(graph_data[0].sha, commit_sha);
}
#[gpui::test]
async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) {
cx.executor().allow_parking();

View file

@ -365,6 +365,28 @@ impl DiffMultibuffer {
}
}
pub(crate) fn restore_selected_hunks(
&mut self,
move_to_next: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let editor = self.editor.read(cx).rhs_editor().clone();
let ranges = self.hunk_action_ranges(cx);
editor.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let hunks: Vec<_> = editor.diff_hunks_in_ranges(&ranges, &snapshot).collect();
if !hunks.is_empty() {
editor.apply_restore(hunks, window, cx);
}
});
if move_to_next {
editor
.focus_handle(cx)
.dispatch_action(&GoToHunk, window, cx);
}
}
fn handle_editor_event(
&mut self,
editor: &Entity<SplittableEditor>,

View file

@ -1210,32 +1210,32 @@ pub fn open_or_reuse_graph(
graph.repo_id == repo_id && graph.log_source == log_source
});
if let Some(existing) = existing {
if let Some(sha) = sha {
existing.update(cx, |graph, cx| {
let git_graph = if let Some(existing) = existing {
workspace.activate_item(&existing, true, true, window, cx);
existing
} else {
let workspace_handle = workspace.weak_handle();
let git_graph = cx.new(|cx| {
GitGraph::new(
repo_id,
git_store,
workspace_handle,
Some(log_source),
window,
cx,
)
});
workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx);
git_graph
};
if let Some(sha) = sha {
cx.defer(move |cx| {
git_graph.update(cx, |graph, cx| {
graph.select_commit_by_sha(sha.as_str(), cx);
});
}
workspace.activate_item(&existing, true, true, window, cx);
return;
});
}
let workspace_handle = workspace.weak_handle();
let git_graph = cx.new(|cx| {
let mut graph = GitGraph::new(
repo_id,
git_store,
workspace_handle,
Some(log_source),
window,
cx,
);
if let Some(sha) = sha {
graph.select_commit_by_sha(sha.as_str(), cx);
}
graph
});
workspace.add_item_to_active_pane(Box::new(git_graph), None, true, window, cx);
}
fn lane_center_x(bounds: Bounds<Pixels>, lane: f32) -> Pixels {
@ -6911,6 +6911,106 @@ mod tests {
);
}
#[gpui::test]
async fn test_open_at_commit_reuses_loaded_graph(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
Path::new("/project"),
json!({ ".git": {}, "file.txt": "content" }),
)
.await;
let first_sha = Oid::from_bytes(&[1; 20]).expect("valid commit SHA");
let second_sha = Oid::from_bytes(&[2; 20]).expect("valid commit SHA");
fs.set_graph_commits(
Path::new("/project/.git"),
vec![
Arc::new(InitialGraphCommitData {
sha: second_sha,
parents: smallvec![first_sha],
ref_names: vec!["HEAD -> main".into()],
}),
Arc::new(InitialGraphCommitData {
sha: first_sha,
parents: smallvec![],
ref_names: Vec::new(),
}),
],
);
fs.set_commit_data(
Path::new("/project/.git"),
[first_sha, second_sha].map(|sha| {
(
CommitData {
sha,
parents: smallvec![],
author_name: "Author".into(),
author_email: "author@example.com".into(),
commit_timestamp: 1_700_000_000,
subject: "Commit subject".into(),
message: "Commit message".into(),
},
false,
)
}),
);
let project = Project::test(fs, [Path::new("/project")], cx).await;
cx.run_until_parked();
let repository = project.read_with(cx, |project, cx| {
project
.active_repository(cx)
.expect("should have a repository")
});
let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
workspace::MultiWorkspace::test_new(project.clone(), window, cx)
});
let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().clone());
let git_graph = cx.new_window_entity(|window, cx| {
GitGraph::new(
repository.read(cx).id,
project.read(cx).git_store().clone(),
workspace.downgrade(),
None,
window,
cx,
)
});
workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx);
});
cx.run_until_parked();
git_graph.update(cx, |graph, cx| {
graph.select_commit_by_sha(first_sha, cx);
});
cx.run_until_parked();
git_graph.update(cx, |graph, cx| {
graph.select_commit_by_sha(second_sha, cx);
});
cx.run_until_parked();
workspace.update_in(cx, |workspace, window, cx| {
open_or_reuse_graph(
workspace,
repository.read(cx).id,
project.read(cx).git_store().clone(),
LogSource::All,
Some(first_sha.to_string()),
window,
cx,
);
});
cx.run_until_parked();
git_graph.read_with(&*cx, |graph, _| {
assert_eq!(graph.selected_entry_idx, Some(1));
});
}
#[gpui::test]
async fn test_git_graph_navigation(cx: &mut TestAppContext) {
init_test(cx);

File diff suppressed because it is too large Load diff

View file

@ -198,13 +198,21 @@ impl StagedDiff {
if let Some(entry) = entry {
staged_diff.update(cx, |staged_diff, cx| {
staged_diff
.diff
.update(cx, |diff, cx| diff.move_to_entry(entry, window, cx));
staged_diff.move_to_entry(entry, window, cx);
});
}
}
pub(crate) fn move_to_entry(
&mut self,
entry: GitStatusEntry,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.diff
.update(cx, |diff, cx| diff.move_to_entry(entry, window, cx));
}
pub(crate) fn new(
project: Entity<Project>,
workspace: Entity<Workspace>,

View file

@ -81,12 +81,38 @@ impl DiffHunkDelegate for UnstagedDiffDelegate {
}
}
fn restore(
&self,
hunks: Vec<ResolvedDiffHunks>,
editor: &mut Editor,
window: &mut Window,
cx: &mut Context<Editor>,
) {
if hunks.is_empty() || editor.read_only(cx) {
return;
}
editor.transact(window, cx, |editor, window, cx| {
editor.restore_diff_hunks(hunks, cx);
let selections = editor
.selections
.all::<editor::MultiBufferOffset>(&editor.display_snapshot(cx));
editor.change_selections(
editor::SelectionEffects::no_scroll(),
window,
cx,
|selections_state| {
selections_state.select(selections);
},
);
});
}
fn render_hunk_controls(
&self,
row: u32,
status: &DiffHunkStatus,
hunk_range: Range<editor::Anchor>,
_is_created_file: bool,
is_created_file: bool,
line_height: Pixels,
editor: &Entity<Editor>,
_window: &mut Window,
@ -98,6 +124,7 @@ impl DiffHunkDelegate for UnstagedDiffDelegate {
{
return gpui::Empty.into_any_element();
}
let hunk_range_for_restore = hunk_range.clone();
let hunk_range = hunk_range.start..hunk_range.start;
h_flex()
.h(line_height)
@ -130,6 +157,29 @@ impl DiffHunkDelegate for UnstagedDiffDelegate {
}
}),
)
.child(
Button::new(("restore", row as u64), "Restore")
.tooltip(Tooltip::text("Restore Hunk"))
.on_click({
let editor = editor.clone();
let hunk_range = hunk_range_for_restore;
move |_event, window, cx| {
editor.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let hunks: Vec<_> = editor
.diff_hunks_in_ranges(
std::slice::from_ref(&hunk_range),
&snapshot,
)
.collect();
if !hunks.is_empty() {
editor.apply_restore(hunks, window, cx);
}
});
}
})
.disabled(is_created_file),
)
.into_any_element()
}
@ -202,13 +252,21 @@ impl UnstagedDiff {
if let Some(entry) = entry {
unstaged_diff.update(cx, |unstaged_diff, cx| {
unstaged_diff
.diff
.update(cx, |diff, cx| diff.move_to_entry(entry, window, cx));
unstaged_diff.move_to_entry(entry, window, cx);
});
}
}
pub(crate) fn move_to_entry(
&mut self,
entry: GitStatusEntry,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.diff
.update(cx, |diff, cx| diff.move_to_entry(entry, window, cx));
}
pub(crate) fn new(
project: Entity<Project>,
workspace: Entity<Workspace>,
@ -270,6 +328,9 @@ impl UnstagedDiff {
.diff_hunks_in_ranges(&ranges, &snapshot)
.next()
.is_some();
let restore = editor
.diff_hunks_in_ranges(&ranges, &snapshot)
.any(|h| !h.is_created_file());
let mut stage_all = false;
self.workspace
.read_with(cx, |workspace, cx| {
@ -278,9 +339,12 @@ impl UnstagedDiff {
}
})
.ok();
let restore_all = snapshot.diff_hunks().any(|h| !h.is_created_file());
ButtonStates {
stage,
restore,
restore_all,
prev_next,
selection,
stage_all,
@ -297,10 +361,23 @@ impl UnstagedDiff {
diff.stage_or_unstage_selected_hunks(true, move_to_next, window, cx)
});
}
fn restore_selected_unstaged_hunks(
&mut self,
move_to_next: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.diff.update(cx, |diff, cx| {
diff.restore_selected_hunks(move_to_next, window, cx)
});
}
}
struct ButtonStates {
stage: bool,
restore: bool,
restore_all: bool,
prev_next: bool,
selection: bool,
stage_all: bool,
@ -579,6 +656,20 @@ impl UnstagedDiffToolbar {
});
}
fn restore_selected_unstaged_hunks(
&mut self,
move_to_next: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(unstaged_diff) = self.unstaged_diff(cx) else {
return;
};
unstaged_diff.update(cx, |unstaged_diff, cx| {
unstaged_diff.restore_selected_unstaged_hunks(move_to_next, window, cx);
});
}
fn stage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.workspace
.update(cx, |workspace, cx| {
@ -591,6 +682,24 @@ impl UnstagedDiffToolbar {
})
.ok();
}
fn restore_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(unstaged_diff) = self.unstaged_diff(cx) else {
return;
};
let diff = unstaged_diff.read(cx).diff.read(cx);
let editor = diff.editor().read(cx).rhs_editor().clone();
let snapshot = diff.multibuffer().read(cx).snapshot(cx);
let hunks: Vec<_> = snapshot
.diff_hunks()
.filter(|h| !h.is_created_file())
.collect();
if !hunks.is_empty() {
editor.update(cx, |editor, cx| {
editor.apply_restore(hunks, window, cx);
});
}
}
}
impl EventEmitter<ToolbarItemEvent> for UnstagedDiffToolbar {}
@ -704,7 +813,15 @@ impl Render for UnstagedDiffToolbar {
this.stage_selected_unstaged_hunks(true, window, cx)
})),
)
}),
})
.child(
Button::new("restore", "Restore")
.disabled(!button_states.restore)
.tooltip(Tooltip::text("Restore Selected Hunks"))
.on_click(cx.listener(|this, _, window, cx| {
this.restore_selected_unstaged_hunks(false, window, cx)
})),
),
)
.child(Divider::vertical())
.child(
@ -718,5 +835,13 @@ impl Render for UnstagedDiffToolbar {
))
.on_click(cx.listener(|this, _, window, cx| this.stage_all(window, cx))),
)
.child(Divider::vertical())
.child(
Button::new("restore-all", "Restore All")
.width(rems_from_px(80.))
.disabled(!button_states.restore_all)
.tooltip(Tooltip::text("Restore All Changes"))
.on_click(cx.listener(|this, _, window, cx| this.restore_all(window, cx))),
)
}
}

View file

@ -2,8 +2,9 @@ use anyhow::Result;
use futures::{Stream, StreamExt};
use language_model_core::{
LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRequest,
LanguageModelToolChoice, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role,
StopReason, TokenUsage,
LanguageModelRequestToolInput, LanguageModelToolChoice, LanguageModelToolUse,
LanguageModelToolUseId, LanguageModelToolUseInput, MessageContent, Role, StopReason,
TokenUsage,
};
use std::pin::Pin;
use std::sync::Arc;
@ -20,20 +21,18 @@ pub fn into_google(
mut request: LanguageModelRequest,
model_id: String,
mode: GoogleModelMode,
) -> crate::GenerateContentRequest {
fn map_content(content: Vec<MessageContent>) -> Vec<Part> {
content
.into_iter()
.flat_map(|content| match content {
) -> Result<crate::GenerateContentRequest> {
fn map_content(content: Vec<MessageContent>) -> Result<Vec<Part>> {
let mut mapped_parts = Vec::new();
for content in content {
match content {
MessageContent::Text(text) => {
if !text.is_empty() {
vec![Part::TextPart(TextPart {
mapped_parts.push(Part::TextPart(TextPart {
text,
thought: false,
thought_signature: None,
})]
} else {
vec![]
}));
}
}
MessageContent::Thinking {
@ -41,39 +40,37 @@ pub fn into_google(
signature: Some(signature),
} => {
if !signature.is_empty() {
vec![Part::TextPart(TextPart {
mapped_parts.push(Part::TextPart(TextPart {
text,
thought: true,
thought_signature: Some(signature),
})]
} else {
vec![]
}));
}
}
MessageContent::Thinking { .. } => {
vec![]
}
MessageContent::RedactedThinking(_) | MessageContent::Compaction(_) => vec![],
MessageContent::Thinking { .. } => {}
MessageContent::RedactedThinking(_) | MessageContent::Compaction(_) => {}
MessageContent::Image(image) => {
vec![Part::InlineDataPart(InlineDataPart {
mapped_parts.push(Part::InlineDataPart(InlineDataPart {
inline_data: GenerativeContentBlob {
mime_type: "image/png".to_string(),
data: image.source.to_string(),
},
})]
}));
}
MessageContent::ToolUse(tool_use) => {
// Normalize empty string signatures to None
let thought_signature = tool_use.thought_signature.filter(|s| !s.is_empty());
let LanguageModelToolUseInput::Json(input) = tool_use.input else {
anyhow::bail!("Google AI does not support custom tool calls");
};
vec![Part::FunctionCallPart(crate::FunctionCallPart {
mapped_parts.push(Part::FunctionCallPart(crate::FunctionCallPart {
function_call: crate::FunctionCall {
name: tool_use.name.to_string(),
args: tool_use.input,
args: input,
id: Some(tool_use.id.to_string()),
},
thought_signature,
})]
}));
}
MessageContent::ToolResult(tool_result) => {
let mut text_output = String::new();
@ -98,7 +95,7 @@ pub fn into_google(
} else {
text_output
};
let mut parts = vec![Part::FunctionResponsePart(crate::FunctionResponsePart {
mapped_parts.push(Part::FunctionResponsePart(crate::FunctionResponsePart {
function_response: crate::FunctionResponse {
name: tool_result.tool_name.to_string(),
// The API expects a valid JSON object
@ -107,12 +104,12 @@ pub fn into_google(
}),
id: Some(tool_result.tool_use_id.to_string()),
},
})];
parts.extend(images.into_iter().map(Part::InlineDataPart));
parts
}));
mapped_parts.extend(images.into_iter().map(Part::InlineDataPart));
}
})
.collect()
}
}
Ok(mapped_parts)
}
let thinking_config = thinking_config_for_request(&request, &model_id, mode);
@ -124,33 +121,59 @@ pub fn into_google(
{
let message = request.messages.remove(0);
Some(SystemInstruction {
parts: map_content(message.content),
parts: map_content(message.content)?,
})
} else {
None
};
crate::GenerateContentRequest {
let tools = if request.tools.is_empty() {
None
} else {
Some(vec![crate::Tool {
function_declarations: request
.tools
.into_iter()
.map(|tool| match tool.input {
LanguageModelRequestToolInput::Function { input_schema, .. } => {
Ok(FunctionDeclaration {
name: tool.name,
description: tool.description,
parameters: input_schema,
})
}
LanguageModelRequestToolInput::Custom { .. } => {
Err(anyhow::anyhow!("Google AI does not support custom tools"))
}
})
.collect::<Result<_>>()?,
}])
};
Ok(crate::GenerateContentRequest {
model: ModelName { model_id },
system_instruction: system_instructions,
contents: request
.messages
.into_iter()
.filter_map(|message| {
let parts = map_content(message.content);
.map(|message| {
let parts = map_content(message.content)?;
if parts.is_empty() {
None
Ok(None)
} else {
Some(Content {
Ok(Some(Content {
parts,
role: match message.role {
Role::User => crate::Role::User,
Role::Assistant => crate::Role::Model,
Role::System => crate::Role::User, // Google AI doesn't have a system role
},
})
}))
}
})
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect(),
generation_config: Some(GenerationConfig {
candidate_count: Some(1),
@ -162,19 +185,7 @@ pub fn into_google(
top_k: None,
}),
safety_settings: None,
tools: (!request.tools.is_empty()).then(|| {
vec![crate::Tool {
function_declarations: request
.tools
.into_iter()
.map(|tool| FunctionDeclaration {
name: tool.name,
description: tool.description,
parameters: tool.input_schema,
})
.collect(),
}]
}),
tools,
tool_config: request.tool_choice.map(|choice| ToolConfig {
function_calling_config: FunctionCallingConfig {
mode: match choice {
@ -185,7 +196,7 @@ pub fn into_google(
allowed_function_names: None,
},
}),
}
})
}
fn thinking_config_for_request(
@ -404,7 +415,9 @@ impl GoogleEventMapper {
name,
is_input_complete: true,
raw_input: function_call_part.function_call.args.to_string(),
input: function_call_part.function_call.args,
input: LanguageModelToolUseInput::Json(
function_call_part.function_call.args,
),
thought_signature,
},
)));
@ -493,7 +506,8 @@ mod tests {
GoogleModelMode::Thinking {
budget_tokens: None,
},
);
)
.unwrap();
let thinking_config = request.generation_config.unwrap().thinking_config.unwrap();
assert_eq!(thinking_config.include_thoughts, Some(true));
@ -515,7 +529,8 @@ mod tests {
GoogleModelMode::Thinking {
budget_tokens: None,
},
);
)
.unwrap();
let thinking_config = request.generation_config.unwrap().thinking_config.unwrap();
assert_eq!(thinking_config.thinking_budget, Some(0));
@ -533,7 +548,8 @@ mod tests {
GoogleModelMode::Thinking {
budget_tokens: None,
},
);
)
.unwrap();
let thinking_config = request.generation_config.unwrap().thinking_config.unwrap();
assert_eq!(thinking_config.thinking_level, Some(ThinkingLevel::Minimal));
@ -561,7 +577,8 @@ mod tests {
GoogleModelMode::Thinking {
budget_tokens: None,
},
);
)
.unwrap();
let Part::TextPart(text_part) = &request.contents[0].parts[0] else {
panic!("expected text part");

View file

@ -29,9 +29,7 @@ test-support = [
bench = ["test-support", "dep:criterion", "dep:hdrhistogram"]
inspector = ["gpui_macros/inspector"]
leak-detection = ["backtrace"]
wayland = [
"bitflags",
]
wayland = []
x11 = [
"scap?/x11",
]
@ -51,7 +49,7 @@ accesskit.workspace = true
anyhow.workspace = true
async-task = "4.7"
backtrace = { workspace = true, optional = true }
bitflags = { workspace = true, optional = true }
bitflags.workspace = true
collections.workspace = true
criterion = { workspace = true, optional = true }
@ -256,3 +254,7 @@ path = "examples/mouse_pressure.rs"
[[example]]
name = "a11y"
path = "examples/a11y.rs"
[[example]]
name = "view_example"
path = "examples/view_example/view_example_main.rs"

View file

@ -1,68 +1,63 @@
#![cfg_attr(target_family = "wasm", no_main)]
use gpui::{
App, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size,
App, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, container_query, div,
prelude::*, px, rgb, size,
};
use gpui_platform::application;
// https://en.wikipedia.org/wiki/Holy_grail_(web_design)
//
// Resize the window: the layout is chosen by `container_query` based on the
// measured size of the container, collapsing to a single stacked column when
// it becomes too narrow for the three-column grid.
struct HolyGrailExample {}
impl Render for HolyGrailExample {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let block = |color: Hsla| {
div()
.size_full()
.bg(color)
.border_1()
.border_dashed()
.rounded_md()
.border_color(gpui::white())
.items_center()
};
container_query(|container_size, _window, _cx| {
let block = |color: Hsla| {
div()
.size_full()
.bg(color)
.border_1()
.border_dashed()
.rounded_md()
.border_color(gpui::white())
.items_center()
};
div()
.gap_1()
.grid()
.bg(rgb(0x505050))
.size(px(500.0))
.shadow_lg()
.border_1()
.size_full()
.grid_cols(5)
.grid_rows(5)
.child(
block(gpui::white())
.row_span(1)
.col_span_full()
.child("Header"),
)
.child(
block(gpui::red())
.col_span(1)
.h_56()
.child("Table of contents"),
)
.child(
block(gpui::green())
.col_span(3)
.row_span(3)
.child("Content"),
)
.child(
block(gpui::blue())
.col_span(1)
.row_span(3)
.child("AD :(")
.text_color(gpui::white()),
)
.child(
block(gpui::black())
.row_span(1)
.col_span_full()
.text_color(gpui::white())
.child("Footer"),
)
let header = block(gpui::white()).child(format!("Header — {}", container_size.width));
let table_of_contents = block(gpui::red()).child("Table of contents");
let content = block(gpui::green()).child("Content");
let ad = block(gpui::blue()).child("AD :(").text_color(gpui::white());
let footer = block(gpui::black())
.text_color(gpui::white())
.child("Footer");
let container = div().gap_1().bg(rgb(0x505050)).shadow_lg().size_full();
if container_size.width < px(400.) {
container
.flex()
.flex_col()
.child(header.h_12().flex_none())
.child(table_of_contents.h_20().flex_none())
.child(content.flex_1())
.child(ad.h_20().flex_none())
.child(footer.h_12().flex_none())
} else {
container
.grid()
.grid_cols(5)
.grid_rows(5)
.child(header.row_span(1).col_span_full())
.child(table_of_contents.col_span(1).h_56())
.child(content.col_span(3).row_span(3))
.child(ad.col_span(1).row_span(3))
.child(footer.row_span(1).col_span_full())
}
})
}
}

View file

@ -0,0 +1,549 @@
//! `Editor` — the workhorse entity. It owns the cursor, blink, focus, keyboard
//! handling, and the specialized text-shaping renderer. The *text itself* lives
//! in a shared `Entity<String>` it's handed at construction, so the value is
//! readable/writable from outside while the editing machinery stays in here.
//!
//! This is the piece that proves the point: a text input is genuinely
//! complicated, and `View` lets all of that complexity live in one entity that
//! anything can embed.
use std::ops::Range;
use std::time::Duration;
use gpui::{
App, Bounds, Context, ElementInputHandler, Entity, EntityInputHandler, FocusHandle, Focusable,
InteractiveElement, LayoutId, PaintQuad, Pixels, ShapedLine, SharedString, Subscription, Task,
TextRun, UTF16Selection, Window, fill, hsla, point, prelude::*, px, relative, size,
};
use unicode_segmentation::*;
use crate::{Backspace, Delete, End, Home, Left, Right};
pub struct Editor {
pub value: Entity<String>,
pub focus_handle: FocusHandle,
pub cursor: usize,
pub cursor_visible: bool,
_blink_task: Task<()>,
_subscriptions: Vec<Subscription>,
}
impl Editor {
/// An editor that owns its own string internally, seeded with `text`.
/// Nothing to allocate or wire up at the call site.
pub fn new(text: impl Into<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let value = cx.new(|_| text.into());
Self::over(value, window, cx)
}
/// An editor over a string *you* own, so the value is shared in and out.
pub fn over(value: Entity<String>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
let focus_sub = cx.on_focus(&focus_handle, window, |this, _window, cx| {
this.start_blink(cx);
});
let blur_sub = cx.on_blur(&focus_handle, window, |this, _window, cx| {
this.stop_blink(cx);
});
// The value is shared: anything can write it while we hold a cursor into
// it. Observe it so external writes (a) clamp the cursor back onto a char
// boundary before the next IME round-trip can slice out of bounds, and
// (b) notify us, so an `editor.cached(..)` subtree re-renders — the cache
// is keyed on *our* notify, not the value's.
let value_sub = cx.observe(&value, |this, value, cx| {
let content = value.read(cx);
let mut cursor = this.cursor.min(content.len());
while cursor > 0 && !content.is_char_boundary(cursor) {
cursor -= 1;
}
this.cursor = cursor;
cx.notify();
});
Self {
value,
focus_handle,
cursor: 0,
cursor_visible: false,
_blink_task: Task::ready(()),
_subscriptions: vec![focus_sub, blur_sub, value_sub],
}
}
/// The current text. Read this from anywhere to get the value out.
pub fn text(&self, cx: &App) -> String {
self.value.read(cx).clone()
}
fn start_blink(&mut self, cx: &mut Context<Self>) {
self.cursor_visible = true;
self._blink_task = Self::spawn_blink_task(cx);
}
fn stop_blink(&mut self, cx: &mut Context<Self>) {
self.cursor_visible = false;
self._blink_task = Task::ready(());
cx.notify();
}
fn spawn_blink_task(cx: &mut Context<Self>) -> Task<()> {
cx.spawn(async move |this, cx| {
loop {
cx.background_executor()
.timer(Duration::from_millis(500))
.await;
let result = this.update(cx, |editor, cx| {
editor.cursor_visible = !editor.cursor_visible;
cx.notify();
});
if result.is_err() {
break;
}
}
})
}
fn reset_blink(&mut self, cx: &mut Context<Self>) {
self.cursor_visible = true;
self._blink_task = Self::spawn_blink_task(cx);
}
pub fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
let content = self.text(cx);
if self.cursor > 0 {
self.cursor = previous_boundary(&content, self.cursor);
}
self.reset_blink(cx);
cx.notify();
}
pub fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
let content = self.text(cx);
if self.cursor < content.len() {
self.cursor = next_boundary(&content, self.cursor);
}
self.reset_blink(cx);
cx.notify();
}
pub fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
self.cursor = 0;
self.reset_blink(cx);
cx.notify();
}
pub fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
self.cursor = self.text(cx).len();
self.reset_blink(cx);
cx.notify();
}
pub fn backspace(&mut self, _: &Backspace, _: &mut Window, cx: &mut Context<Self>) {
let content = self.text(cx);
if self.cursor > 0 {
let prev = previous_boundary(&content, self.cursor);
let cursor = self.cursor;
self.value.update(cx, |s, cx| {
s.drain(prev..cursor);
cx.notify();
});
self.cursor = prev;
}
self.reset_blink(cx);
cx.notify();
}
pub fn delete(&mut self, _: &Delete, _: &mut Window, cx: &mut Context<Self>) {
let content = self.text(cx);
if self.cursor < content.len() {
let next = next_boundary(&content, self.cursor);
let cursor = self.cursor;
self.value.update(cx, |s, cx| {
s.drain(cursor..next);
cx.notify();
});
}
self.reset_blink(cx);
cx.notify();
}
pub fn insert_newline(&mut self, cx: &mut Context<Self>) {
let cursor = self.cursor;
self.value.update(cx, |s, cx| {
s.insert(cursor, '\n');
cx.notify();
});
self.cursor += 1;
self.reset_blink(cx);
cx.notify();
}
}
fn previous_boundary(content: &str, offset: usize) -> usize {
content
.grapheme_indices(true)
.rev()
.find_map(|(idx, _)| (idx < offset).then_some(idx))
.unwrap_or(0)
}
fn next_boundary(content: &str, offset: usize) -> usize {
content
.grapheme_indices(true)
.find_map(|(idx, _)| (idx > offset).then_some(idx))
.unwrap_or(content.len())
}
fn offset_from_utf16(content: &str, offset: usize) -> usize {
let mut utf8_offset = 0;
let mut utf16_count = 0;
for ch in content.chars() {
if utf16_count >= offset {
break;
}
utf16_count += ch.len_utf16();
utf8_offset += ch.len_utf8();
}
utf8_offset
}
fn offset_to_utf16(content: &str, offset: usize) -> usize {
let mut utf16_offset = 0;
let mut utf8_count = 0;
for ch in content.chars() {
if utf8_count >= offset {
break;
}
utf8_count += ch.len_utf8();
utf16_offset += ch.len_utf16();
}
utf16_offset
}
fn range_to_utf16(content: &str, range: &Range<usize>) -> Range<usize> {
offset_to_utf16(content, range.start)..offset_to_utf16(content, range.end)
}
fn range_from_utf16(content: &str, range_utf16: &Range<usize>) -> Range<usize> {
offset_from_utf16(content, range_utf16.start)..offset_from_utf16(content, range_utf16.end)
}
impl Focusable for Editor {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EntityInputHandler for Editor {
fn text_for_range(
&mut self,
range_utf16: Range<usize>,
actual_range: &mut Option<Range<usize>>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<String> {
let content = self.text(cx);
let range = range_from_utf16(&content, &range_utf16);
actual_range.replace(range_to_utf16(&content, &range));
Some(content[range].to_string())
}
fn selected_text_range(
&mut self,
_ignore_disabled_input: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<UTF16Selection> {
let content = self.text(cx);
let utf16_cursor = offset_to_utf16(&content, self.cursor);
Some(UTF16Selection {
range: utf16_cursor..utf16_cursor,
reversed: false,
})
}
fn marked_text_range(
&self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Range<usize>> {
None
}
fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
fn replace_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let content = self.text(cx);
let range = range_utf16
.as_ref()
.map(|r| range_from_utf16(&content, r))
.unwrap_or(self.cursor..self.cursor);
let new_content = content[..range.start].to_owned() + new_text + &content[range.end..];
self.cursor = range.start + new_text.len();
self.value.update(cx, |s, cx| {
*s = new_content;
cx.notify();
});
self.reset_blink(cx);
cx.notify();
}
fn replace_and_mark_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
_new_selected_range_utf16: Option<Range<usize>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.replace_text_in_range(range_utf16, new_text, window, cx);
}
fn bounds_for_range(
&mut self,
_range_utf16: Range<usize>,
_bounds: Bounds<Pixels>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
None
}
fn character_index_for_point(
&mut self,
_point: gpui::Point<Pixels>,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<usize> {
None
}
}
impl gpui::Render for Editor {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Editor>) -> impl IntoElement {
EditorText {
editor: cx.entity(),
}
}
}
// ---------------------------------------------------------------------------
// EditorText — the specialized renderer: shapes the text and paints the cursor.
// ---------------------------------------------------------------------------
struct EditorText {
editor: Entity<Editor>,
}
struct EditorTextPrepaint {
lines: Vec<ShapedLine>,
cursor: Option<PaintQuad>,
}
impl IntoElement for EditorText {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for EditorText {
type RequestLayoutState = ();
type PrepaintState = EditorTextPrepaint;
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&gpui::GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let editor = self.editor.read(cx);
let content = editor.value.read(cx);
let line_count = content.split('\n').count().max(1);
let line_height = window.line_height();
let mut style = gpui::Style::default();
style.size.width = relative(1.).into();
style.size.height = (line_height * line_count as f32).into();
(window.request_layout(style, [], cx), ())
}
fn prepaint(
&mut self,
_id: Option<&gpui::GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let editor = self.editor.read(cx);
let content = editor.value.read(cx).clone();
let cursor_offset = editor.cursor;
let cursor_visible = editor.cursor_visible;
let is_focused = editor.focus_handle.is_focused(window);
let style = window.text_style();
let text_color = style.color;
let font_size = style.font_size.to_pixels(window.rem_size());
let line_height = window.line_height();
let is_placeholder = content.is_empty();
let lines: Vec<ShapedLine> = if is_placeholder {
let placeholder: SharedString = "Type here...".into();
let run = TextRun {
len: placeholder.len(),
font: style.font(),
color: hsla(0., 0., 0.5, 0.5),
background_color: None,
underline: None,
strikethrough: None,
};
vec![
window
.text_system()
.shape_line(placeholder, font_size, &[run], None),
]
} else {
content
.split('\n')
.map(|line_str| {
let text: SharedString = SharedString::from(line_str.to_string());
let run = TextRun {
len: text.len(),
font: style.font(),
color: text_color,
background_color: None,
underline: None,
strikethrough: None,
};
window
.text_system()
.shape_line(text, font_size, &[run], None)
})
.collect()
};
let cursor = if is_focused && cursor_visible && !is_placeholder {
let (cursor_line, offset_in_line) = cursor_line_and_offset(&content, cursor_offset);
let cursor_line = cursor_line.min(lines.len().saturating_sub(1));
let cursor_x = lines[cursor_line].x_for_index(offset_in_line);
Some(fill(
Bounds::new(
point(
bounds.left() + cursor_x,
bounds.top() + line_height * cursor_line as f32,
),
size(px(1.5), line_height),
),
text_color,
))
} else if is_focused && cursor_visible && is_placeholder {
Some(fill(
Bounds::new(
point(bounds.left(), bounds.top()),
size(px(1.5), line_height),
),
text_color,
))
} else {
None
};
EditorTextPrepaint { lines, cursor }
}
fn paint(
&mut self,
_id: Option<&gpui::GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
let focus_handle = self.editor.read(cx).focus_handle.clone();
window.handle_input(
&focus_handle,
ElementInputHandler::new(bounds, self.editor.clone()),
cx,
);
let line_height = window.line_height();
for (i, line) in prepaint.lines.iter().enumerate() {
let origin = point(bounds.left(), bounds.top() + line_height * i as f32);
line.paint(origin, line_height, gpui::TextAlign::Left, None, window, cx)
.unwrap();
}
if let Some(cursor) = prepaint.cursor.take() {
window.paint_quad(cursor);
}
}
}
fn cursor_line_and_offset(content: &str, cursor: usize) -> (usize, usize) {
let mut line_index = 0;
let mut line_start = 0;
for (i, ch) in content.char_indices() {
if i >= cursor {
break;
}
if ch == '\n' {
line_index += 1;
line_start = i + 1;
}
}
(line_index, cursor - line_start)
}
pub fn standard_actions<E: InteractiveElement>(editor: Entity<Editor>) -> impl FnOnce(E) -> E {
move |element| {
element
.on_action({
let editor = editor.clone();
move |a: &Left, window, cx| editor.update(cx, |e, cx| e.left(a, window, cx))
})
.on_action({
let editor = editor.clone();
move |a: &Right, window, cx| editor.update(cx, |e, cx| e.right(a, window, cx))
})
.on_action({
let editor = editor.clone();
move |a: &Home, window, cx| editor.update(cx, |e, cx| e.home(a, window, cx))
})
.on_action({
let editor = editor.clone();
move |a: &End, window, cx| editor.update(cx, |e, cx| e.end(a, window, cx))
})
.on_action({
let editor = editor.clone();
move |a: &Backspace, window, cx| {
editor.update(cx, |e, cx| e.backspace(a, window, cx))
}
})
.on_action(move |a: &Delete, window, cx| {
editor.update(cx, |e, cx| e.delete(a, window, cx))
})
}
}

View file

@ -0,0 +1,121 @@
//! `Input` — a single-line text input. The shaping layer over `Editor`.
//!
//! Construct it two ways, depending on how much state you want to own:
//! * `Input::new(value: Entity<String>)` — you hold just the string; the input
//! allocates the `Editor` internally via `use_state`. Value readable, cursor hidden.
//! * `Input::editor(editor: Entity<Editor>)` — you hold the editor; cursor/selection
//! are now yours to read and drive too.
//!
//! Either way the chrome is identical. Because the string (or editor) is the
//! input's *identity*, the internal `use_state(Editor)` is collision-safe across
//! any number of inputs.
use gpui::{
App, BoxShadow, CursorStyle, Entity, EntityId, Hsla, IntoElement, Pixels, StyleRefinement,
Window, div, hsla, point, prelude::*, px, white,
};
use crate::example_editor::{Editor, standard_actions};
enum Source {
Value(Entity<String>),
Editor(Entity<Editor>),
}
#[derive(IntoElement)]
pub struct Input {
source: Source,
width: Option<Pixels>,
color: Option<Hsla>,
}
impl Input {
/// Backed by a bare string; the editor is allocated internally.
pub fn new(value: Entity<String>) -> Self {
Self {
source: Source::Value(value),
width: None,
color: None,
}
}
/// Backed by an editor you own (so you can read/drive its cursor).
pub fn editor(editor: Entity<Editor>) -> Self {
Self {
source: Source::Editor(editor),
width: None,
color: None,
}
}
pub fn width(mut self, width: Pixels) -> Self {
self.width = Some(width);
self
}
pub fn color(mut self, color: Hsla) -> Self {
self.color = Some(color);
self
}
}
impl gpui::View for Input {
fn entity_id(&self) -> Option<EntityId> {
Some(match &self.source {
Source::Value(value) => value.entity_id(),
Source::Editor(editor) => editor.entity_id(),
})
}
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
// Get the editor: use the one we were handed, or allocate it under our
// own (string-derived) identity so it persists and never collides.
let editor = match self.source {
Source::Value(value) => {
window.use_state(cx, move |window, cx| Editor::over(value, window, cx))
}
Source::Editor(editor) => editor,
};
let focus_handle = editor.read(cx).focus_handle.clone();
let is_focused = focus_handle.is_focused(window);
let text_color = self.color.unwrap_or(hsla(0., 0., 0.1, 1.));
let box_width = self.width.unwrap_or(px(300.));
let border = if is_focused {
hsla(220. / 360., 0.8, 0.5, 1.)
} else {
hsla(0., 0., 0.75, 1.)
};
div()
.id("input")
.key_context("TextInput")
.track_focus(&focus_handle)
.cursor(CursorStyle::IBeam)
.map(standard_actions(editor.clone()))
.w(box_width)
.h(px(36.))
.px(px(8.))
.bg(white())
.border_1()
.border_color(border)
.when(is_focused, |this| {
this.shadow(vec![BoxShadow {
color: hsla(220. / 360., 0.8, 0.5, 0.3),
offset: point(px(0.), px(0.)),
blur_radius: px(4.),
spread_radius: px(1.),
inset: false,
}])
})
.rounded(px(4.))
.overflow_hidden()
.flex()
.items_center()
.line_height(px(20.))
.text_size(px(14.))
.text_color(text_color)
.child(editor.cached(StyleRefinement::default().size_full()))
}
}

View file

@ -0,0 +1,131 @@
//! Tests for the input composition. Require the `test-support` feature:
//!
//! ```sh
//! cargo test -p gpui --example view_example --features test-support
//! ```
#[cfg(test)]
mod tests {
use gpui::{Context, Entity, KeyBinding, TestAppContext, Window, prelude::*};
use crate::example_editor::Editor;
use crate::example_input::Input;
use crate::{Backspace, Delete, End, Home, Left, Right};
/// Two inputs, each backed by an editor we own (so the test can focus and
/// read them). Proves data flows through the shared `String` and that
/// sibling inputs stay isolated.
struct Harness {
a: Entity<Editor>,
b: Entity<Editor>,
}
impl Render for Harness {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
gpui::div()
.child(Input::editor(self.a.clone()))
.child(Input::editor(self.b.clone()))
}
}
fn bind_keys(cx: &mut TestAppContext) {
cx.update(|cx| {
cx.bind_keys([
KeyBinding::new("backspace", Backspace, None),
KeyBinding::new("delete", Delete, None),
KeyBinding::new("left", Left, None),
KeyBinding::new("right", Right, None),
KeyBinding::new("home", Home, None),
KeyBinding::new("end", End, None),
]);
});
}
fn setup(
cx: &mut TestAppContext,
) -> (
Entity<Editor>,
Entity<String>,
Entity<String>,
&mut gpui::VisualTestContext,
) {
bind_keys(cx);
let (harness, cx) = cx.add_window_view(|window, cx| {
let a_value = cx.new(|_| String::new());
let b_value = cx.new(|_| String::new());
let a = cx.new(|cx| Editor::over(a_value, window, cx));
let b = cx.new(|cx| Editor::over(b_value, window, cx));
Harness { a, b }
});
let a = cx.read_entity(&harness, |h, _| h.a.clone());
let b = cx.read_entity(&harness, |h, _| h.b.clone());
let a_value = cx.read_entity(&a, |e, _| e.value.clone());
let b_value = cx.read_entity(&b, |e, _| e.value.clone());
// Focus the first input's editor.
cx.update(|window, cx| {
let focus_handle = a.read(cx).focus_handle.clone();
window.focus(&focus_handle, cx);
});
(a, a_value, b_value, cx)
}
#[gpui::test]
fn typing_updates_the_shared_string(cx: &mut TestAppContext) {
let (editor, a_value, _b_value, cx) = setup(cx);
cx.simulate_input("hello");
cx.read_entity(&a_value, |value, _| assert_eq!(value, "hello"));
cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 5));
}
#[gpui::test]
fn sibling_inputs_are_isolated(cx: &mut TestAppContext) {
let (_editor, a_value, b_value, cx) = setup(cx);
cx.simulate_input("x");
cx.read_entity(&a_value, |value, _| assert_eq!(value, "x"));
cx.read_entity(&b_value, |value, _| {
assert_eq!(value, "", "typing in input A must not touch input B")
});
}
#[gpui::test]
fn external_writes_clamp_the_cursor(cx: &mut TestAppContext) {
let (editor, a_value, _b_value, cx) = setup(cx);
cx.simulate_input("hello");
cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 5));
// Write the shared value from outside the editor. The old cursor (5)
// now points into the middle of a multi-byte character; the editor's
// observation must clamp it back onto a boundary.
cx.update(|_, cx| {
a_value.update(cx, |value, cx| {
*value = "日本".to_string();
cx.notify();
})
});
cx.read_entity(&a_value, |value, _| assert_eq!(value, "日本"));
cx.read_entity(&editor, |editor, _| {
assert_eq!(editor.cursor, 3, "cursor must clamp to a char boundary");
});
}
#[gpui::test]
fn arrows_move_the_cursor(cx: &mut TestAppContext) {
let (editor, _a_value, _b_value, cx) = setup(cx);
cx.simulate_input("abc");
cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 3));
cx.simulate_keystrokes("left left");
cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 1));
}
}

View file

@ -0,0 +1,118 @@
//! `TextArea` — a multi-line text box. Same `Editor` workhorse, taller chrome,
//! and `Enter` inserts a newline instead of being ignored. Constructible from a
//! string or an editor, exactly like [`Input`](crate::example_input::Input).
use gpui::{
App, BoxShadow, CursorStyle, Entity, EntityId, Hsla, IntoElement, StyleRefinement, Window, div,
hsla, point, prelude::*, px, white,
};
use crate::Enter;
use crate::example_editor::{Editor, standard_actions};
enum Source {
Value(Entity<String>),
Editor(Entity<Editor>),
}
#[derive(IntoElement)]
pub struct TextArea {
source: Source,
rows: usize,
color: Option<Hsla>,
}
impl TextArea {
pub fn new(value: Entity<String>, rows: usize) -> Self {
Self {
source: Source::Value(value),
rows,
color: None,
}
}
pub fn editor(editor: Entity<Editor>, rows: usize) -> Self {
Self {
source: Source::Editor(editor),
rows,
color: None,
}
}
pub fn color(mut self, color: Hsla) -> Self {
self.color = Some(color);
self
}
}
impl gpui::View for TextArea {
fn entity_id(&self) -> Option<EntityId> {
Some(match &self.source {
Source::Value(value) => value.entity_id(),
Source::Editor(editor) => editor.entity_id(),
})
}
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let editor = match self.source {
Source::Value(value) => {
window.use_state(cx, move |window, cx| Editor::over(value, window, cx))
}
Source::Editor(editor) => editor,
};
let focus_handle = editor.read(cx).focus_handle.clone();
let is_focused = focus_handle.is_focused(window);
let text_color = self.color.unwrap_or(hsla(0., 0., 0.1, 1.));
let row_height = px(20.);
let box_height = row_height * self.rows as f32 + px(16.);
let border = if is_focused {
hsla(220. / 360., 0.8, 0.5, 1.)
} else {
hsla(0., 0., 0.75, 1.)
};
div()
.id("text-area")
.key_context("TextInput")
.track_focus(&focus_handle)
.cursor(CursorStyle::IBeam)
.map(standard_actions(editor.clone()))
// Enter is the one binding that differs from a single-line input.
.on_action({
let editor = editor.clone();
move |_: &Enter, _window, cx| editor.update(cx, |e, cx| e.insert_newline(cx))
})
.w(px(400.))
.h(box_height)
.p(px(8.))
.bg(white())
.border_1()
.border_color(border)
.when(is_focused, |this| {
this.shadow(vec![BoxShadow {
color: hsla(220. / 360., 0.8, 0.5, 0.3),
offset: point(px(0.), px(0.)),
blur_radius: px(4.),
spread_radius: px(1.),
inset: false,
}])
})
.rounded(px(4.))
.overflow_hidden()
.line_height(row_height)
.text_size(px(14.))
.text_color(text_color)
// The cache style is computed from the `rows` prop: change `rows` and
// the editor's cached bounds change, busting its cache and re-laying
// out the text. (`Input` just uses `size_full()` — nothing to vary.)
.child(
editor.cached(
StyleRefinement::default()
.w_full()
.h(row_height * self.rows as f32),
),
)
}
}

View file

@ -0,0 +1,173 @@
#![cfg_attr(target_family = "wasm", no_main)]
//! View example — composing a text input from the `View` primitives.
//!
//! The whole point: a text input is deceptively complicated, and `View` makes it
//! easy to compose one. Three pieces, each shown in its own section:
//!
//! * `Editor` — the workhorse entity: cursor, blink, focus, keyboard, and a
//! specialized text renderer. All the hard parts live here.
//! * `String` — the data plane. `editor.text(cx)` / `value.read(cx)` get it out.
//! * `Input` / `TextArea` — the shaping layer. Each takes a `String` (and grows
//! the editor internally) OR an `Editor` (so you can read the cursor).
//!
//! Run: `cargo run -p gpui --example view_example`
mod example_editor;
mod example_input;
mod example_text_area;
#[cfg(test)]
mod example_tests;
use example_editor::Editor;
use example_input::Input;
use example_text_area::TextArea;
use gpui::{
App, Bounds, Context, Div, Entity, IntoElement, KeyBinding, Render, SharedString, Window,
WindowBounds, WindowOptions, actions, div, hsla, prelude::*, px, rgb, size,
};
use gpui_platform::application;
actions!(
view_example,
[Backspace, Delete, Left, Right, Home, End, Enter, Quit]
);
/// A tiny stateless view that reads an editor's cursor and is composed *beside*
/// the thing editing it — two views over one entity, zero wiring.
#[derive(IntoElement)]
struct CursorReadout {
editor: Entity<Editor>,
}
impl CursorReadout {
fn new(editor: Entity<Editor>) -> Self {
Self { editor }
}
}
impl gpui::RenderOnce for CursorReadout {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let cursor = self.editor.read(cx).cursor;
div()
.text_sm()
.text_color(hsla(0., 0., 0.45, 1.))
.child(SharedString::from(format!("cursor @ {cursor}")))
}
}
struct ViewExample;
impl ViewExample {
fn new() -> Self {
Self
}
}
impl Render for ViewExample {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// The data plane: plain strings, allocated at the top by the hook.
let name = window.use_state(cx, |_, _| String::new());
let email = window.use_state(cx, |_, _| String::from("me@example.com"));
let bio = window.use_state(cx, |_, _| String::new());
// Editors that own their own string internally — no extra wiring up top.
let notes = window.use_state(cx, |window, cx| Editor::new("multi\nline", window, cx));
let owned = window.use_state(cx, |window, cx| Editor::new("editable", window, cx));
div()
.flex()
.flex_col()
.size_full()
.bg(rgb(0xf0f0f0))
.p(px(24.))
.gap(px(24.))
.child(
section("Inputs — from a String (cursor stays internal)")
.child(Input::new(name).width(px(320.)))
.child(
Input::new(email)
.width(px(320.))
.color(hsla(0., 0., 0.3, 1.)),
),
)
.child(
section("Input — from an Editor (read its cursor beside it)").child(
div()
.flex()
.items_center()
.gap(px(12.))
.child(Input::editor(owned.clone()).width(px(320.)))
.child(CursorReadout::new(owned)),
),
)
.child(
section("Text areas — from a String, or from an Editor")
.child(TextArea::new(bio, 3))
.child(
div()
.flex()
.items_start()
.gap(px(12.))
.child(TextArea::editor(notes.clone(), 3).color(hsla(
250. / 360.,
0.7,
0.4,
1.,
)))
.child(CursorReadout::new(notes)),
),
)
}
}
/// A labeled vertical section.
fn section(title: &str) -> Div {
div().flex().flex_col().gap(px(8.)).child(
div()
.text_sm()
.text_color(hsla(0., 0., 0.3, 1.))
.child(SharedString::from(title.to_string())),
)
}
fn run_example() {
application().run(|cx: &mut App| {
let bounds = Bounds::centered(None, size(px(560.0), px(480.0)), cx);
cx.bind_keys([
KeyBinding::new("backspace", Backspace, None),
KeyBinding::new("delete", Delete, None),
KeyBinding::new("left", Left, None),
KeyBinding::new("right", Right, None),
KeyBinding::new("home", Home, None),
KeyBinding::new("end", End, None),
KeyBinding::new("enter", Enter, None),
KeyBinding::new("cmd-q", Quit, None),
]);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|_, cx| cx.new(|_| ViewExample::new()),
)
.unwrap();
cx.on_action(|_: &Quit, cx| cx.quit());
cx.activate(true);
});
}
#[cfg(not(target_family = "wasm"))]
fn main() {
run_example();
}
#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
gpui_platform::web_init();
run_example();
}

View file

@ -143,6 +143,31 @@ impl Drop for AppRefMut<'_> {
/// You won't interact with this type much outside of initial configuration and startup.
pub struct Application(Rc<AppCell>);
/// A strong handle to an [`Application`] started with [`Application::run_embedded`].
///
/// Dropping this handle releases the app, so an embedder must hold it for as long as the
/// app should run. While held, it is the embedder's entry point back into GPUI each time
/// the external run loop gives it control.
pub struct ApplicationHandle {
app: Rc<AppCell>,
}
impl ApplicationHandle {
/// Invoke `f` with the app context. Must not be called re-entrantly from code that
/// is already inside an update; the app state is a `RefCell` and will panic on a
/// double borrow.
pub fn update<R>(&self, f: impl FnOnce(&mut App) -> R) -> R {
let cx = &mut *self.app.borrow_mut();
f(cx)
}
/// An [`AsyncApp`] for use across await points. It holds the app weakly; keeping the
/// app alive remains this handle's job.
pub fn to_async(&self) -> AsyncApp {
self.update(|cx| cx.to_async())
}
}
/// Represents an application before it is fully launched. Once your app is
/// configured, you'll start the app with `App::run`.
impl Application {
@ -209,6 +234,28 @@ impl Application {
}));
}
/// Start the application for an embedder that drives the run loop itself.
///
/// On ordinary platforms `Platform::run` blocks for the lifetime of the app, and the
/// app state is kept alive by [`Application::run`]'s stack frame. Embedded platforms —
/// where the run loop belongs to someone else, e.g. GPUI compiled into a Wasm guest,
/// or a GPUI view hosted inside a foreign native application — implement
/// `Platform::run` to invoke the launch callback and return immediately. This method
/// supports that shape: it returns an [`ApplicationHandle`] that keeps the app alive
/// and lets the embedder re-enter it whenever the external run loop yields control.
pub fn run_embedded<F>(self, on_finish_launching: F) -> ApplicationHandle
where
F: 'static + FnOnce(&mut App),
{
let this = self.0.clone();
let platform = self.0.borrow().platform.clone();
platform.run(Box::new(move || {
let cx = &mut *this.borrow_mut();
on_finish_launching(cx);
}));
ApplicationHandle { app: self.0 }
}
/// Register a handler to be invoked when the platform instructs the application
/// to open one or more URLs.
pub fn on_open_urls<F>(&self, mut callback: F) -> &Self

View file

@ -33,12 +33,12 @@
use crate::{
A11ySubtreeBuilder, App, ArenaBox, AvailableSpace, Bounds, Context, DispatchNodeId, ElementId,
FocusHandle, InspectorElementId, LayoutId, Pixels, Point, SharedString, Size, Style, Window,
FocusHandle, InspectorElementId, LayoutId, Pixels, Point, Size, Style, Window,
util::FluentBuilder, window::with_element_arena,
};
use derive_more::{Deref, DerefMut};
use std::{
any::{Any, type_name},
any::Any,
fmt::{self, Debug, Display},
mem, panic,
sync::Arc,
@ -208,116 +208,6 @@ pub trait ParentElement {
}
}
/// An element for rendering components. An implementation detail of the [`IntoElement`] derive macro
/// for [`RenderOnce`]
#[doc(hidden)]
pub struct Component<C: RenderOnce> {
component: Option<C>,
#[cfg(debug_assertions)]
source: &'static core::panic::Location<'static>,
}
impl<C: RenderOnce> Component<C> {
/// Create a new component from the given RenderOnce type.
#[track_caller]
pub fn new(component: C) -> Self {
Component {
component: Some(component),
#[cfg(debug_assertions)]
source: core::panic::Location::caller(),
}
}
}
fn prepaint_component(
(element, name): &mut (AnyElement, &'static str),
window: &mut Window,
cx: &mut App,
) {
window.with_id(ElementId::Name(SharedString::new_static(name)), |window| {
element.prepaint(window, cx);
})
}
fn paint_component(
(element, name): &mut (AnyElement, &'static str),
window: &mut Window,
cx: &mut App,
) {
window.with_id(ElementId::Name(SharedString::new_static(name)), |window| {
element.paint(window, cx);
})
}
impl<C: RenderOnce> Element for Component<C> {
type RequestLayoutState = (AnyElement, &'static str);
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
#[cfg(debug_assertions)]
return Some(self.source);
#[cfg(not(debug_assertions))]
return None;
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
window.with_id(ElementId::Name(type_name::<C>().into()), |window| {
let mut element = self
.component
.take()
.unwrap()
.render(window, cx)
.into_any_element();
let layout_id = element.request_layout(window, cx);
(layout_id, (element, type_name::<C>()))
})
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
_: Bounds<Pixels>,
state: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) {
prepaint_component(state, window, cx);
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
_: Bounds<Pixels>,
state: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
paint_component(state, window, cx);
}
}
impl<C: RenderOnce> IntoElement for Component<C> {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
/// A globally unique identifier for an element, used to track state across frames.
#[derive(Deref, DerefMut, Clone, Default, Debug, Eq, PartialEq, Hash)]
pub struct GlobalElementId(pub(crate) Arc<[ElementId]>);

View file

@ -0,0 +1,126 @@
//! A container query element, in the spirit of CSS container queries.
//! The element's own size is determined solely by its style and the space
//! offered by its parent.
use refineable::Refineable as _;
use crate::{
AnyElement, App, AvailableSpace, Bounds, Element, ElementId, GlobalElementId,
InspectorElementId, IntoElement, LayoutId, Pixels, Size, Style, StyleRefinement, Styled,
Window, relative,
};
/// Construct a container query element with the given render callback.
/// The callback receives the size the element was assigned during layout and
/// returns the contents to display within it.
///
/// By default the element fills its parent (equivalent to `.size_full()`);
/// use the [`Styled`] methods to size it differently. Because the contents
/// don't exist until after layout, they cannot influence the element's size.
///
/// # Example
///
/// ```
/// # use gpui::{container_query, div, px, IntoElement, ParentElement};
/// container_query(|size, _window, _cx| {
/// if size.width < px(240.) {
/// div().child("Narrow layout")
/// } else {
/// div().child("Wide layout")
/// }
/// });
/// ```
pub fn container_query<E>(
render: impl 'static + FnOnce(Size<Pixels>, &mut Window, &mut App) -> E,
) -> ContainerQuery
where
E: IntoElement,
{
let mut base_style = StyleRefinement::default();
base_style.size.width = Some(relative(1.).into());
base_style.size.height = Some(relative(1.).into());
ContainerQuery {
render: Some(Box::new(|size, window, cx| {
render(size, window, cx).into_any_element()
})),
style: base_style,
}
}
/// A container query element, created with [`container_query`].
pub struct ContainerQuery {
render: Option<Box<dyn FnOnce(Size<Pixels>, &mut Window, &mut App) -> AnyElement>>,
style: StyleRefinement,
}
impl Element for ContainerQuery {
type RequestLayoutState = ();
type PrepaintState = Option<AnyElement>;
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
style.refine(&self.style);
let layout_id = window.request_layout(style, [], cx);
(layout_id, ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
let render = self.render.take()?;
let mut child = render(bounds.size, window, cx);
child.layout_as_root(bounds.size.map(AvailableSpace::Definite), window, cx);
child.prepaint_at(bounds.origin, window, cx);
Some(child)
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
if let Some(child) = prepaint {
child.paint(window, cx);
}
}
}
impl IntoElement for ContainerQuery {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Styled for ContainerQuery {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}

View file

@ -2860,7 +2860,6 @@ impl Interactivity {
}
if let Some(hover_listener) = self.hover_listener.take() {
let hitbox = hitbox.clone();
let was_hovered = element_state
.hover_listener_state
.get_or_insert_with(Default::default)
@ -2869,22 +2868,35 @@ impl Interactivity {
.pending_mouse_down
.get_or_insert_with(Default::default)
.clone();
window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble {
return;
}
let is_hovered = has_mouse_down.borrow().is_none()
&& !cx.has_active_drag()
&& hitbox.is_hovered(window);
let hover_listener = Rc::new(hover_listener);
let update_hover = move |is_hovered: bool, window: &mut Window, cx: &mut App| {
let mut was_hovered = was_hovered.borrow_mut();
if is_hovered != *was_hovered {
*was_hovered = is_hovered;
drop(was_hovered);
hover_listener(&is_hovered, window, cx);
}
};
window.on_mouse_event({
let update_hover = update_hover.clone();
let hitbox = hitbox.clone();
move |_: &MouseMoveEvent, phase, window, cx| {
if phase == DispatchPhase::Bubble {
let is_hovered = has_mouse_down.borrow().is_none()
&& !cx.has_active_drag()
&& hitbox.is_hovered(window);
update_hover(is_hovered, window, cx);
}
}
});
// The pointer can leave the window without a final MouseMove, so also
// clear hover on MouseExited.
window.on_mouse_event(move |_: &MouseExitEvent, phase, window, cx| {
if phase == DispatchPhase::Bubble {
update_hover(false, window, cx);
}
});
}

View file

@ -338,6 +338,17 @@ impl ListState {
self
}
/// Pre-populate every unmeasured item with a uniform height hint so the scrollbar thumb
/// is correctly sized from the first frame, without measuring all items up front.
///
/// As items are actually rendered their real heights replace the hint, so the scrollbar
/// converges to the exact size over time. This is a cheaper alternative to [`Self::measure_all`]
/// for lists where items have roughly uniform heights (e.g. table rows).
pub fn with_uniform_item_height(self, height: Pixels) -> Self {
self.apply_uniform_item_height(height);
self
}
/// Reset this instantiation of the list state.
///
/// Note that this will cause scroll events to be dropped until the next paint.
@ -355,6 +366,33 @@ impl ListState {
self.splice(0..old_count, element_count);
}
/// Reset the list to `element_count` items, pre-populating every item with a
/// uniform height hint so the scrollbar thumb is correctly sized from the first
/// frame even for off-screen items.
pub fn reset_with_uniform_height(&self, element_count: usize, height: Pixels) {
self.reset(element_count);
self.apply_uniform_item_height(height);
}
fn apply_uniform_item_height(&self, height: Pixels) {
let size_hint = Size {
width: px(0.),
height,
};
let mut state = self.0.borrow_mut();
let new_items = state
.items
.iter()
.map(|item| ListItem::Unmeasured {
size_hint: Some(item.size_hint().unwrap_or(size_hint)),
focus_handle: item.focus_handle(),
})
.collect::<Vec<_>>();
let mut tree = SumTree::default();
tree.extend(new_items, ());
state.items = tree;
}
/// Remeasure all items while preserving proportional scroll position.
///
/// Use this when item heights may have changed (e.g., font size changes)

View file

@ -1,6 +1,7 @@
mod anchored;
mod animation;
mod canvas;
mod container_query;
mod deferred;
mod div;
mod image_cache;
@ -14,6 +15,7 @@ mod uniform_list;
pub use anchored::*;
pub use animation::*;
pub use canvas::*;
pub use container_query::*;
pub use deferred::*;
pub use div::*;
pub use image_cache::*;

Some files were not shown because too many files have changed in this diff Show more