Compare commits

..

32 Commits

Author SHA1 Message Date
Agus Zubiaga
5cc3b3a04f wait for workspace window to show 2025-12-17 23:00:30 -03:00
Agus Zubiaga
422dc4f307 Reuse cloning code from git_panel 2025-12-17 21:50:05 -03:00
Agus Zubiaga
b1aa0e2efd Use git/commit style format 2025-12-17 21:10:05 -03:00
Agus Zubiaga
3dbfee1c47 Merge branch 'main' into git-clone 2025-12-17 20:53:10 -03:00
Kingsword
0c9992c5e9 terminal: Forward Ctrl+V when clipboard contains images (#42258)
When running Codex CLI, Claude Code, or other TUI agents in Zed’s
terminal, pasting images wasn’t supported — Zed
treated all clipboard content as plain text and simply pushed it into
the PTY, so the agent never saw the image data.
This change makes terminal pastes behave like they do in a native
terminal: if the clipboard contains an image, Zed now emits a raw Ctrl+V
to the PTY so the agent can read the system clipboard itself.

Release Notes:

- Fixed terminal-launched Codex/Claude sessions by forwarding Ctrl+V for
clipboard images so agents can attach them
2025-12-17 20:42:47 -03:00
Mayank Verma
cec46079fe git_ui: Preserve newlines in commit messages (#45167)
Closes #44982

Release Notes:

- Fixed Git panel to preserve newlines in commit messages
2025-12-17 22:52:10 +00:00
Ben Kunkle
f9b69aeff0 Fix Wayland platform resize resulting in non-interactive window (#45153)
Closes  #40361

Release Notes:

- Linux(Wayland): Fixed an issue where the settings window would not
respond to user interaction until resized
2025-12-17 17:44:25 -05:00
Nathan Sobo
f00cb371f4 macOS: Bundle placeholder Document.icns so Finder can display Zed file icons (#44833)
Generated by AI.

`DocumentTypes.plist` declares `CFBundleTypeIconFile` as `Document` for
Zed’s document types, but the macOS bundle did not include
`Contents/Resources/Document.icns`, causing Finder to fall back to
generic icons.

This PR:
- Adds `crates/zed/resources/Document.icns` as a placeholder document
icon (currently derived from the app icon).
- Updates `script/bundle-mac` to copy it into the `.app` at
`Contents/Resources/Document.icns` during bundling.
- Adds `script/verify-macos-document-icon` for one-command validation.

## How to test (CLI)
1. Build a debug bundle:
   - `./script/bundle-mac -d aarch64-apple-darwin`
2. Verify the bundle contains the referenced icon:
- `./script/verify-macos-document-icon
"target/aarch64-apple-darwin/debug/bundle/osx/Zed Dev.app"`

## Optional visual validation in Finder
- Pick a file (e.g. `.rs`), Get Info → Open with: Zed Dev → Change
All...
- Restart Finder: `killall Finder` (or log out/in)

@JosephTLyons — would you mind running the steps above and confirming
Finder shows Zed’s icon for source files after "Change All" + Finder
restart?

@danilo-leal — this PR ships a placeholder `Document.icns`. When the
real document icon is ready, replace
`crates/zed/resources/Document.icns` and the bundling script will
include it automatically.


Closes #44403.

Release Notes:

- TODO

---------

Co-authored-by: Matt Miller <mattrx@gmail.com>
2025-12-17 16:42:31 -06:00
Ben Kunkle
25e1e2ecdd Don't trigger autosave on focus change in modals (#45166)
Closes #28732

Release Notes:

- Opening the command palette or other modals no longer triggers
auto-save with the `{ "autosave": "on_focus_change" }` setting. This
reduces the chance of unwanted format changes when executing actions,
and fixes a race condition with `:w` in Vim mode
2025-12-17 17:42:18 -05:00
Conrad Irwin
f2d29f4790 Auto-release preview as Zippy (#45163)
I think we're not triggering the after-release workflow because of
github's loop detection when you use the default GITHUB_TOKEN

Closes #ISSUE

Release Notes:

- N/A
2025-12-17 15:32:28 -07:00
LoricAndre
623e13761b git: Unify commit popups (#38749)
Closes #26424
Supersedes #35328

Originally, `git::blame` uses its own `ParsedCommitMessage` as the
source for the commit information, including the PR section. This
changes unifies this with `git::repository` and `git_ui::git_panel` by
moving this and some other commit-related structs to `git::commit`
instead, and making both `git_ui::blame_ui` and `git_ui::git_panel` pull
their information from these structs.

Release notes :

- (Let's Git Together) Fixed the commit tooltip in the git panel not
showing information like avatars.

---------

Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
2025-12-17 17:31:12 -05:00
Danilo Leal
302a4bbdd0 git panel: Fix file path truncation and add some UI code clean up (#45161)
This PR ensures truncation works for the file paths, which should set up
the stage for when the new GPUI `truncation_start` method lands
(https://github.com/zed-industries/zed/pull/45122) so that we can use
for them. In the process of doing so and figuring it out why it wasn't
working as well before, I noticed some opportunities to clean up some UI
code: removing unnecessary styles, making the file easier to navigate
given all of the different UI conditions, etc.

Note: You might notice a subtle label flashing that comes with the label
truncation and that's a standalone GPUI bug that's also visible in other
surface areas of the app. I don't think it should block these changes
here as it's something we should fix on its own...

Release Notes:

- N/A
2025-12-17 19:28:27 -03:00
Kirill Bulatov
c4f8f2fbf4 Use less generic globs for JSONC to avoid overmatching (#45162)
Otherwise, all *.json files under `zed` directory will be matched as
JSONC, e.g `zed/crates/vim/test_data/test_a.json` which is not right.
On top, `globset` considers that `zed/crates/vim/test_data/test_a.json`
matches `**/zed/*.json` glob (!).

Release Notes:

- N/A
2025-12-17 22:22:37 +00:00
Cameron Mcloughlin
52c7447106 gpui: Add Vietnamese chars to LineWrapper::is_word_char (#45160) 2025-12-17 21:53:12 +00:00
Michael Benfield
65f7412a02 A couple new inline assistant tests (#45049)
Also adjust the code for streaming tool use to always use a
rewrite_section; remove insert_here entirely.

Release Notes:

- N/A

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
2025-12-17 13:02:03 -08:00
Dave Waggoner
8aab646aec terminal: Improve regex hyperlink performance for long lines (#44721)
Related to
- #44407

This PR further improves performance for regex hyperlink finding by
eliminating unnecessary regex matching. Currently, we repeatedly search
for matches from the start of the line until the match contains the
hovered point. This is only required to support custom regexes which
match strings containing spaces, with multiple matches on a single line.
This isn't actually a useful scenario, and is no longer supported. This
PR changes to only search twice, the first match starting from the start
of the line, and the hovered word (space-delimited). The most dramatic
improvement is for long lines with many words.

In addition to the above changes, this PR:
- Adds test for the scenarios from #44407 and #44510 
- Simplifies the logic added in #44407

Performance measurements

For the scenario from #44407, this improves the perf test's iteration
time from 1.22ms to 0.47ms.

main:

| Branch | Command | Iter/sec | Mean [ms] | SD [ms] | Iterations |
Importance (weight) |
|:---|:---|---:|---:|---:|---:|---:|
| main |
terminal_hyperlinks::tests::path::perf::pr_44407_hyperlink_benchmark |
819.64 | 937.60 | 2.20 | 768 | average (50) |
| this PR |
terminal_hyperlinks::tests::path::perf::pr_44407_hyperlink_benchmark |
2099.79 | 1463.20 | 7.20 | 3072 | average (50) |

Release Notes:

- terminal: Improve path hyperlink performance for long lines
2025-12-17 15:53:22 -05:00
Piotr Osiewicz
9ad059d3be copilot: Add support for Next Edit Suggestion (#44486)
This PR introduces support for Next Edit Suggestions while doing away
with calling legacy endpoints. In the process we've also removed support
for cycling completions, as NES will give us a single prediction, for
the most part.

Closes #30124

Release Notes:

- Zed now supports Copilot's [Next Edit
Suggestions](https://code.visualstudio.com/blogs/2025/02/12/next-edit-suggestions).
2025-12-17 21:43:42 +01:00
localcc
0d0a08203f Fix windows path canonicalization (#45145)
Closes #44962 

Release Notes:

- N/A
2025-12-17 19:55:36 +00:00
Ichimura Tomoo
81463223d5 Support opening and saving files with legacy encodings (#44819)
## Summary

Addresses #16965

This PR adds support for **opening and saving** files with legacy
encodings (non-UTF-8).
Previously, Zed failed to open files encoded in Shift-JIS, EUC-JP, Big5,
etc., displaying a "Could not open file" error screen. This PR
implements automatic encoding detection upon opening and ensures the
original encoding is preserved when saving.

## Implementation Details

1.  **Worktree (Loading)**:
* Updated `load_file` to use `chardetng` for automatic encoding
detection.
* Files are decoded to UTF-8 internal strings for editing, while
preserving the detected `Encoding` metadata.
2.  **Language / Buffer**:
* Added an `encoding` field to the `Buffer` struct to store the detected
encoding.
3.  **Worktree (Saving)**:
    * Updated `write_file` to accept the stored encoding.
    * **Performance Optimization**:
* **UTF-8 Path**: Uses the existing optimized `fs.save` (streaming
chunks directly from Rope), ensuring no performance regression for the
vast majority of files.
* **Legacy Encoding Path**: Implemented a fallback that converts the
Rope to a contiguous `String/Bytes` in memory, re-encodes it to the
target format (e.g., Shift-JIS), and writes it to disk.
* *Note*: This fallback involves memory allocation, but it is necessary
to support legacy encodings without refactoring the `fs` crate's
streaming interfaces.

## Changes

- `crates/worktree`:
    - Add dependencies: `encoding_rs`, `chardetng`.
    - Update `load_file` to detect encoding and decode content.
    - Update `write_file` to handle re-encoding on save.
- `crates/language`: Add `encoding` field and accessors to `Buffer`.
- `crates/project`: Pass encoding information between Worktree and
Buffer.
- `crates/vim`: Update `:w` command to use the new `write_file`
signature.

## Verification

I validated this manually using a Rust script to generate test files
with various encodings.

**Results:**

*  **Success (Opened & Saved correctly):**
    * **Japanese:** `Shift-JIS` (CP932), `EUC-JP`, `ISO-2022-JP`
    * **Chinese:** `Big5` (Traditional), `GBK/GB2312` (Simplified)
* **Western/Unicode:** `Windows-1252` (CP1252), `UTF-16LE`, `UTF-16BE`
* ⚠️ **limitations (Detection accuracy):**
* Some specific encodings like `KOI8-R` or generic `Latin1` (ISO-8859-1)
may partially display replacement characters (`?`) depending on the file
content length. This is a known limitation of the heuristic detection
library (`chardetng`) rather than the saving logic.


Release Notes:

- Added support for opening and saving files with legacy encodings
(Shift-JIS, Big5, etc.)

---------

Co-authored-by: CrazyboyQCD <53971641+CrazyboyQCD@users.noreply.github.com>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-12-17 19:46:17 +00:00
Xipeng Jin
e8807e5764 git: Fix tree view folders not opening when file inside is selected (#45137)
Closes #44715

Release Notes:

- Fixed git tree view folders don't open when file inside is selected
2025-12-17 19:43:53 +00:00
Luis Cossío
73f129a685 git: New actions for git panel navigation (#43701)
I could not find any related issue, but at least I want to use the git
panel like this :)

Being used to `lazygit`, this PR makes navigation of the git panel more
similar to the CLI tool.

Instead of selecting -> enter'ing for skimming each file, I just want to
move between the files in the git panel and have the diff multibuffer
advance to the appropriate file. This also adheres to the behavior of
the outline panel, which I like better.

If the multibuffer is not active, it behaves same as before (just
selecting the file in the panel, nothing else).

I did not modify existing `menu::Select*` actions in case anybody still
prefers previous behavior.




https://github.com/user-attachments/assets/2d1303d4-50c8-4500-ab3b-302eb7d4afda



Release Notes:

- Improved navigation of the git panel, by advancing the "Uncommitted
Changes" multibuffer to the current selected file. To restore the old
behavior, you can bind `up` and `down` to `menu::SelectPrevious` and
`menu::SelectNext` under the `GitPanel` context in your keymap.

Co-authored-by: Cole Miller <cole@zed.dev>
2025-12-17 19:40:15 +00:00
Oleksii (Alexey) Orlenko
fa529b2ad2 agent_ui_v2: Fix broken LICENSE-GPL symlink pointing to itself (#45136)
Fix broken LICENSE-GPL symlink that was pointing to itself instead of
the LICENSE-GPL file in the root of the repo.

It caused jujutsu to freak out and made it impossible to work with the
repo using it without switching to raw git:

```
Internal error: Failed to check out commit 22d04a82b119882e7aed88fb422430367c4df5f9
Caused by:
1: Failed to validate path /Users/aqrln/git/zed/crates/agent_ui_v2/LICENSE-GPL
2: Too many levels of symbolic links (os error 62)
```

Release Notes:

- N/A
2025-12-17 19:00:37 +00:00
Richard Feldman
27c5d39d28 Add Gemini 3 Flash (#45139)
Add support for the new Gemini 3 Flash model

Release Notes:

- Added support for Gemini 3 Flash model
2025-12-17 18:56:15 +00:00
Xipeng Jin
83ca2f9e88 Add Vim-like Which-key Popup menu (#43618)
Closes #10910

Follow up work continuing from the last PR
https://github.com/zed-industries/zed/pull/42659. Add the UI element for
displaying vim like which-key menu.




https://github.com/user-attachments/assets/3dc5f0c9-5a2f-459e-a3db-859169aeba26


Release Notes:

- Added a which-key like modal with a compact, single-column panel
anchored to the bottom-right. You can enable with `{"which_key":
{"enabled": true}}` in your settings.

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
2025-12-17 11:53:48 -07:00
Mikayla Maki
847457df1b Fix a bug where switching the disable AI flag would cause a panic (#45050)
Also quiet some noisy logs

Release Notes:

- N/A
2025-12-17 18:49:39 +00:00
Conrad Irwin
8c7a04c6bf Autotrust new git worktrees (#45138)
Follow-up of https://github.com/zed-industries/zed/pull/44887

- Inherit git worktree trust
- Tidy up the security modal


Release Notes:

- N/A

---------

Co-authored-by: Kirill Bulatov <mail4score@gmail.com>
2025-12-17 20:41:46 +02:00
Anthony Eid
b22ccfaff5 gpui: Fix macOS memory leaks (#45051)
The below memory leaks were caused by failing to release reference
counted resources. I confirmed using instruments that my changes stopped
the leaks from occurring.

- System prompts 
- Screen capturing 
- loading font families

There were also two memory leaks I found from some of our dependencies
that I made PRs to fix
- https://github.com/RustAudio/coreaudio-rs/pull/147
- https://github.com/servo/core-foundation-rs/pull/746

Release Notes:

- N/A
2025-12-17 13:31:21 -05:00
Alvaro Parker
4930d3aa80 Improve code 2025-11-11 08:52:04 -03:00
Alvaro Parker
5d633a3968 Remove modal indicator 2025-11-11 08:52:04 -03:00
Alvaro Parker
6967ea41e5 Open project panel on success 2025-11-11 08:52:04 -03:00
Alvaro Parker
5068581b39 Add loading modal 2025-11-11 08:52:04 -03:00
Alvaro Parker
f3bd6b88db WIP Git clone 2025-11-11 08:52:04 -03:00
88 changed files with 2747 additions and 1198 deletions

View File

@@ -90,7 +90,7 @@ jobs:
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- id: get-app-token
name: autofix_pr::commit_changes::authenticate_as_zippy
name: steps::authenticate_as_zippy
uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
with:
app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}

View File

@@ -30,7 +30,7 @@ jobs:
with:
clean: false
- id: get-app-token
name: cherry_pick::run_cherry_pick::authenticate_as_zippy
name: steps::authenticate_as_zippy
uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
with:
app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}

View File

@@ -478,11 +478,17 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- id: get-app-token
name: steps::authenticate_as_zippy
uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1
with:
app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
- name: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false
run: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false
shell: bash -euxo pipefail {0}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }}
notify_on_failure:
needs:
- upload_release_assets

54
Cargo.lock generated
View File

@@ -2667,9 +2667,9 @@ dependencies = [
[[package]]
name = "cap-fs-ext"
version = "3.4.5"
version = "3.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654"
checksum = "e41cc18551193fe8fa6f15c1e3c799bc5ec9e2cfbfaa8ed46f37013e3e6c173c"
dependencies = [
"cap-primitives",
"cap-std",
@@ -2679,9 +2679,9 @@ dependencies = [
[[package]]
name = "cap-net-ext"
version = "3.4.5"
version = "3.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7"
checksum = "9f83833816c66c986e913b22ac887cec216ea09301802054316fc5301809702c"
dependencies = [
"cap-primitives",
"cap-std",
@@ -2691,9 +2691,9 @@ dependencies = [
[[package]]
name = "cap-primitives"
version = "3.4.5"
version = "3.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a"
checksum = "0a1e394ed14f39f8bc26f59d4c0c010dbe7f0a1b9bafff451b1f98b67c8af62a"
dependencies = [
"ambient-authority",
"fs-set-times",
@@ -2709,9 +2709,9 @@ dependencies = [
[[package]]
name = "cap-rand"
version = "3.4.5"
version = "3.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d"
checksum = "0acb89ccf798a28683f00089d0630dfaceec087234eae0d308c05ddeaa941b40"
dependencies = [
"ambient-authority",
"rand 0.8.5",
@@ -2719,9 +2719,9 @@ dependencies = [
[[package]]
name = "cap-std"
version = "3.4.5"
version = "3.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a"
checksum = "07c0355ca583dd58f176c3c12489d684163861ede3c9efa6fd8bba314c984189"
dependencies = [
"cap-primitives",
"io-extras",
@@ -2731,9 +2731,9 @@ dependencies = [
[[package]]
name = "cap-time-ext"
version = "3.4.5"
version = "3.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80"
checksum = "491af520b8770085daa0466978c75db90368c71896523f2464214e38359b1a5b"
dependencies = [
"ambient-authority",
"cap-primitives",
@@ -2896,6 +2896,17 @@ dependencies = [
"util",
]
[[package]]
name = "chardetng"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea"
dependencies = [
"cfg-if",
"encoding_rs",
"memchr",
]
[[package]]
name = "chrono"
version = "0.4.42"
@@ -8797,6 +8808,7 @@ dependencies = [
"ctor",
"diffy",
"ec4rs",
"encoding_rs",
"fs",
"futures 0.3.31",
"fuzzy",
@@ -12465,6 +12477,7 @@ dependencies = [
"dap",
"dap_adapters",
"db",
"encoding_rs",
"extension",
"fancy-regex",
"fs",
@@ -19120,6 +19133,20 @@ dependencies = [
"winsafe",
]
[[package]]
name = "which_key"
version = "0.1.0"
dependencies = [
"command_palette",
"gpui",
"serde",
"settings",
"theme",
"ui",
"util",
"workspace",
]
[[package]]
name = "whoami"
version = "1.6.1"
@@ -20217,8 +20244,10 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-lock 2.8.0",
"chardetng",
"clock",
"collections",
"encoding_rs",
"fs",
"futures 0.3.31",
"fuzzy",
@@ -20730,6 +20759,7 @@ dependencies = [
"watch",
"web_search",
"web_search_providers",
"which_key",
"windows 0.61.3",
"winresource",
"workspace",

View File

@@ -192,6 +192,7 @@ members = [
"crates/vercel",
"crates/vim",
"crates/vim_mode_setting",
"crates/which_key",
"crates/watch",
"crates/web_search",
"crates/web_search_providers",
@@ -415,6 +416,7 @@ util_macros = { path = "crates/util_macros" }
vercel = { path = "crates/vercel" }
vim = { path = "crates/vim" }
vim_mode_setting = { path = "crates/vim_mode_setting" }
which_key = { path = "crates/which_key" }
watch = { path = "crates/watch" }
web_search = { path = "crates/web_search" }
@@ -476,6 +478,7 @@ bytes = "1.0"
cargo_metadata = "0.19"
cargo_toml = "0.21"
cfg-if = "1.0.3"
chardetng = "0.1"
chrono = { version = "0.4", features = ["serde"] }
ciborium = "0.2"
circular-buffer = "1.0"
@@ -499,6 +502,7 @@ dotenvy = "0.15.0"
ec4rs = "1.1"
emojis = "0.6.1"
env_logger = "0.11"
encoding_rs = "0.8"
exec = "0.3.1"
fancy-regex = "0.16.0"
fork = "0.4.0"

View File

@@ -905,8 +905,8 @@
"bindings": {
"left": "git_panel::CollapseSelectedEntry",
"right": "git_panel::ExpandSelectedEntry",
"up": "menu::SelectPrevious",
"down": "menu::SelectNext",
"up": "git_panel::PreviousEntry",
"down": "git_panel::NextEntry",
"enter": "menu::Confirm",
"alt-y": "git::StageFile",
"alt-shift-y": "git::UnstageFile",

View File

@@ -981,12 +981,12 @@
"context": "GitPanel && ChangesList",
"use_key_equivalents": true,
"bindings": {
"up": "git_panel::PreviousEntry",
"down": "git_panel::NextEntry",
"cmd-up": "git_panel::FirstEntry",
"cmd-down": "git_panel::LastEntry",
"left": "git_panel::CollapseSelectedEntry",
"right": "git_panel::ExpandSelectedEntry",
"up": "menu::SelectPrevious",
"down": "menu::SelectNext",
"cmd-up": "menu::SelectFirst",
"cmd-down": "menu::SelectLast",
"enter": "menu::Confirm",
"cmd-alt-y": "git::ToggleStaged",
"space": "git::ToggleStaged",

View File

@@ -908,10 +908,10 @@
"context": "GitPanel && ChangesList",
"use_key_equivalents": true,
"bindings": {
"up": "git_panel::PreviousEntry",
"down": "git_panel::NextEntry",
"left": "git_panel::CollapseSelectedEntry",
"right": "git_panel::ExpandSelectedEntry",
"up": "menu::SelectPrevious",
"down": "menu::SelectNext",
"enter": "menu::Confirm",
"alt-y": "git::StageFile",
"shift-alt-y": "git::UnstageFile",

View File

@@ -14,7 +14,6 @@ The section you'll need to rewrite is marked with <rewrite_this></rewrite_this>
The context around the relevant section has been truncated (possibly in the middle of a line) for brevity.
{{/if}}
{{#if rewrite_section}}
And here's the section to rewrite based on that prompt again for reference:
<rewrite_this>
@@ -33,8 +32,6 @@ Below are the diagnostic errors visible to the user. If the user requests probl
{{/each}}
{{/if}}
{{/if}}
Only make changes that are necessary to fulfill the prompt, leave everything else as-is. All surrounding {{content_type}} will be preserved.
Start at the indentation level in the original file in the rewritten {{content_type}}.

View File

@@ -1705,7 +1705,12 @@
// }
//
"file_types": {
"JSONC": ["**/.zed/**/*.json", "**/zed/**/*.json", "**/Zed/**/*.json", "**/.vscode/**/*.json", "tsconfig*.json"],
"JSONC": [
"**/.zed/*.json",
"**/.vscode/**/*.json",
"**/{zed,Zed}/{settings,keymap,tasks,debug}.json",
"tsconfig*.json",
],
"Markdown": [".rules", ".cursorrules", ".windsurfrules", ".clinerules"],
"Shell Script": [".env.*"],
},
@@ -2152,6 +2157,13 @@
// The shape can be one of the following: "block", "bar", "underline", "hollow".
"cursor_shape": {},
},
// Which-key popup settings
"which_key": {
// Whether to show the which-key popup when holding down key combinations.
"enabled": false,
// Delay in milliseconds before showing the which-key popup.
"delay_ms": 1000,
},
// The server to connect to. If the environment variable
// ZED_SERVER_URL is set, it will override this setting.
"server_url": "https://zed.dev",

View File

@@ -216,14 +216,10 @@ impl HistoryStore {
}
pub fn reload(&self, cx: &mut Context<Self>) {
let database_future = ThreadsDatabase::connect(cx);
let database_connection = ThreadsDatabase::connect(cx);
cx.spawn(async move |this, cx| {
let threads = database_future
.await
.map_err(|err| anyhow!(err))?
.list_threads()
.await?;
let database = database_connection.await;
let threads = database.map_err(|err| anyhow!(err))?.list_threads().await?;
this.update(cx, |this, cx| {
if this.recently_opened_entries.len() < MAX_RECENTLY_OPENED_ENTRIES {
for thread in threads
@@ -344,7 +340,8 @@ impl HistoryStore {
fn load_recently_opened_entries(cx: &AsyncApp) -> Task<Result<VecDeque<HistoryEntryId>>> {
cx.background_spawn(async move {
if cfg!(any(feature = "test-support", test)) {
anyhow::bail!("history store does not persist in tests");
log::warn!("history store does not persist in tests");
return Ok(VecDeque::new());
}
let json = KEY_VALUE_STORE
.read_kvp(RECENTLY_OPENED_THREADS_KEY)?

View File

@@ -13,7 +13,7 @@ path = "src/agent_ui.rs"
doctest = false
[features]
test-support = ["assistant_text_thread/test-support", "eval_utils", "gpui/test-support", "language/test-support", "reqwest_client", "workspace/test-support"]
test-support = ["assistant_text_thread/test-support", "eval_utils", "gpui/test-support", "language/test-support", "reqwest_client", "workspace/test-support", "agent/test-support"]
unit-eval = []
[dependencies]

View File

@@ -75,6 +75,9 @@ pub struct BufferCodegen {
session_id: Uuid,
}
pub const REWRITE_SECTION_TOOL_NAME: &str = "rewrite_section";
pub const FAILURE_MESSAGE_TOOL_NAME: &str = "failure_message";
impl BufferCodegen {
pub fn new(
buffer: Entity<MultiBuffer>,
@@ -522,12 +525,12 @@ impl CodegenAlternative {
let tools = vec![
LanguageModelRequestTool {
name: "rewrite_section".to_string(),
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(),
},
LanguageModelRequestTool {
name: "failure_message".to_string(),
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(),
},
@@ -1167,7 +1170,7 @@ impl CodegenAlternative {
let process_tool_use = move |tool_use: LanguageModelToolUse| -> Option<ToolUseOutput> {
let mut chars_read_so_far = chars_read_so_far.lock();
match tool_use.name.as_ref() {
"rewrite_section" => {
REWRITE_SECTION_TOOL_NAME => {
let Ok(input) =
serde_json::from_value::<RewriteSectionInput>(tool_use.input)
else {
@@ -1180,7 +1183,7 @@ impl CodegenAlternative {
description: None,
})
}
"failure_message" => {
FAILURE_MESSAGE_TOOL_NAME => {
let Ok(mut input) =
serde_json::from_value::<FailureMessageInput>(tool_use.input)
else {
@@ -1493,7 +1496,10 @@ mod tests {
use indoc::indoc;
use language::{Buffer, Point};
use language_model::fake_provider::FakeLanguageModel;
use language_model::{LanguageModelRegistry, TokenUsage};
use language_model::{
LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRegistry,
LanguageModelToolUse, StopReason, TokenUsage,
};
use languages::rust_lang;
use rand::prelude::*;
use settings::SettingsStore;
@@ -1805,6 +1811,51 @@ mod tests {
);
}
// When not streaming tool calls, we strip backticks as part of parsing the model's
// plain text response. This is a regression test for a bug where we stripped
// backticks incorrectly.
#[gpui::test]
async fn test_allows_model_to_output_backticks(cx: &mut TestAppContext) {
init_test(cx);
let text = "- Improved; `cmd+click` behavior. Now requires `cmd` to be pressed before the click starts or it doesn't run. ([#44579](https://github.com/zed-industries/zed/pull/44579); thanks [Zachiah](https://github.com/Zachiah))";
let buffer = cx.new(|cx| Buffer::local("", cx));
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let range = buffer.read_with(cx, |buffer, cx| {
let snapshot = buffer.snapshot(cx);
snapshot.anchor_before(Point::new(0, 0))..snapshot.anchor_after(Point::new(0, 0))
});
let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap());
let codegen = cx.new(|cx| {
CodegenAlternative::new(
buffer.clone(),
range.clone(),
true,
prompt_builder,
Uuid::new_v4(),
cx,
)
});
let events_tx = simulate_tool_based_completion(&codegen, cx);
let chunk_len = text.find('`').unwrap();
events_tx
.unbounded_send(rewrite_tool_use("tool_1", &text[..chunk_len], false))
.unwrap();
events_tx
.unbounded_send(rewrite_tool_use("tool_2", &text, true))
.unwrap();
events_tx
.unbounded_send(LanguageModelCompletionEvent::Stop(StopReason::EndTurn))
.unwrap();
drop(events_tx);
cx.run_until_parked();
assert_eq!(
buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()),
text
);
}
#[gpui::test]
async fn test_strip_invalid_spans_from_codeblock() {
assert_chunks("Lorem ipsum dolor", "Lorem ipsum dolor").await;
@@ -1870,4 +1921,39 @@ mod tests {
});
chunks_tx
}
fn simulate_tool_based_completion(
codegen: &Entity<CodegenAlternative>,
cx: &mut TestAppContext,
) -> mpsc::UnboundedSender<LanguageModelCompletionEvent> {
let (events_tx, events_rx) = mpsc::unbounded();
let model = Arc::new(FakeLanguageModel::default());
codegen.update(cx, |codegen, cx| {
let completion_stream = Task::ready(Ok(events_rx.map(Ok).boxed()
as BoxStream<
'static,
Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
>));
codegen.generation = codegen.handle_completion(model, completion_stream, cx);
});
events_tx
}
fn rewrite_tool_use(
id: &str,
replacement_text: &str,
is_complete: bool,
) -> LanguageModelCompletionEvent {
let input = RewriteSectionInput {
replacement_text: replacement_text.into(),
};
LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
id: id.into(),
name: REWRITE_SECTION_TOOL_NAME.into(),
raw_input: serde_json::to_string(&input).unwrap(),
input: serde_json::to_value(&input).unwrap(),
is_input_complete: is_complete,
thought_signature: None,
})
}
}

View File

@@ -2271,6 +2271,36 @@ pub mod evals {
);
}
#[test]
#[cfg_attr(not(feature = "unit-eval"), ignore)]
fn eval_empty_buffer() {
run_eval(
20,
1.0,
"Write a Python hello, world program".to_string(),
"ˇ".to_string(),
|output| match output {
InlineAssistantOutput::Success {
full_buffer_text, ..
} => {
if full_buffer_text.is_empty() {
EvalOutput::failed("expected some output".to_string())
} else {
EvalOutput::passed(format!("Produced {full_buffer_text}"))
}
}
o @ InlineAssistantOutput::Failure { .. } => EvalOutput::failed(format!(
"Assistant output does not match expected output: {:?}",
o
)),
o @ InlineAssistantOutput::Malformed { .. } => EvalOutput::failed(format!(
"Assistant output does not match expected output: {:?}",
o
)),
},
);
}
fn run_eval(
iterations: usize,
expected_pass_ratio: f32,

View File

@@ -12,6 +12,10 @@ workspace = true
path = "src/agent_ui_v2.rs"
doctest = false
[features]
test-support = ["agent/test-support"]
[dependencies]
agent.workspace = true
agent_servers.workspace = true
@@ -38,3 +42,6 @@ time_format.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
[dev-dependencies]
agent = { workspace = true, features = ["test-support"] }

View File

@@ -1 +1 @@
LICENSE-GPL
../../LICENSE-GPL

View File

@@ -1,6 +1,6 @@
use anyhow::{Context as _, Result};
use edit_prediction_context::{EditPredictionExcerpt, EditPredictionExcerptOptions};
use edit_prediction_types::{Direction, EditPrediction, EditPredictionDelegate};
use edit_prediction_types::{EditPrediction, EditPredictionDelegate};
use futures::AsyncReadExt;
use gpui::{App, Context, Entity, Task};
use http_client::HttpClient;
@@ -300,16 +300,6 @@ impl EditPredictionDelegate for CodestralEditPredictionDelegate {
}));
}
fn cycle(
&mut self,
_buffer: Entity<Buffer>,
_cursor_position: Anchor,
_direction: Direction,
_cx: &mut Context<Self>,
) {
// Codestral doesn't support multiple completions, so cycling does nothing
}
fn accept(&mut self, _cx: &mut Context<Self>) {
log::debug!("Codestral: Completion accepted");
self.pending_request = None;

View File

@@ -4,6 +4,7 @@ pub mod copilot_responses;
pub mod request;
mod sign_in;
use crate::request::NextEditSuggestions;
use crate::sign_in::initiate_sign_out;
use ::fs::Fs;
use anyhow::{Context as _, Result, anyhow};
@@ -18,7 +19,7 @@ use http_client::HttpClient;
use language::language_settings::CopilotSettings;
use language::{
Anchor, Bias, Buffer, BufferSnapshot, Language, PointUtf16, ToPointUtf16,
language_settings::{EditPredictionProvider, all_language_settings, language_settings},
language_settings::{EditPredictionProvider, all_language_settings},
point_from_lsp, point_to_lsp,
};
use lsp::{LanguageServer, LanguageServerBinary, LanguageServerId, LanguageServerName};
@@ -40,7 +41,7 @@ use std::{
sync::Arc,
};
use sum_tree::Dimensions;
use util::{ResultExt, fs::remove_matching, rel_path::RelPath};
use util::{ResultExt, fs::remove_matching};
use workspace::Workspace;
pub use crate::copilot_edit_prediction_delegate::CopilotEditPredictionDelegate;
@@ -315,6 +316,15 @@ struct GlobalCopilot(Entity<Copilot>);
impl Global for GlobalCopilot {}
/// Copilot's NextEditSuggestion response, with coordinates converted to Anchors.
struct CopilotEditPrediction {
buffer: Entity<Buffer>,
range: Range<Anchor>,
text: String,
command: Option<lsp::Command>,
snapshot: BufferSnapshot,
}
impl Copilot {
pub fn global(cx: &App) -> Option<Entity<Self>> {
cx.try_global::<GlobalCopilot>()
@@ -873,101 +883,19 @@ impl Copilot {
}
}
pub fn completions<T>(
pub(crate) fn completions(
&mut self,
buffer: &Entity<Buffer>,
position: T,
position: Anchor,
cx: &mut Context<Self>,
) -> Task<Result<Vec<Completion>>>
where
T: ToPointUtf16,
{
self.request_completions::<request::GetCompletions, _>(buffer, position, cx)
}
pub fn completions_cycling<T>(
&mut self,
buffer: &Entity<Buffer>,
position: T,
cx: &mut Context<Self>,
) -> Task<Result<Vec<Completion>>>
where
T: ToPointUtf16,
{
self.request_completions::<request::GetCompletionsCycling, _>(buffer, position, cx)
}
pub fn accept_completion(
&mut self,
completion: &Completion,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let server = match self.server.as_authenticated() {
Ok(server) => server,
Err(error) => return Task::ready(Err(error)),
};
let request =
server
.lsp
.request::<request::NotifyAccepted>(request::NotifyAcceptedParams {
uuid: completion.uuid.clone(),
});
cx.background_spawn(async move {
request
.await
.into_response()
.context("copilot: notify accepted")?;
Ok(())
})
}
pub fn discard_completions(
&mut self,
completions: &[Completion],
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let server = match self.server.as_authenticated() {
Ok(server) => server,
Err(_) => return Task::ready(Ok(())),
};
let request =
server
.lsp
.request::<request::NotifyRejected>(request::NotifyRejectedParams {
uuids: completions
.iter()
.map(|completion| completion.uuid.clone())
.collect(),
});
cx.background_spawn(async move {
request
.await
.into_response()
.context("copilot: notify rejected")?;
Ok(())
})
}
fn request_completions<R, T>(
&mut self,
buffer: &Entity<Buffer>,
position: T,
cx: &mut Context<Self>,
) -> Task<Result<Vec<Completion>>>
where
R: 'static
+ lsp::request::Request<
Params = request::GetCompletionsParams,
Result = request::GetCompletionsResult,
>,
T: ToPointUtf16,
{
) -> Task<Result<Vec<CopilotEditPrediction>>> {
self.register_buffer(buffer, cx);
let server = match self.server.as_authenticated() {
Ok(server) => server,
Err(error) => return Task::ready(Err(error)),
};
let buffer_entity = buffer.clone();
let lsp = server.lsp.clone();
let registered_buffer = server
.registered_buffers
@@ -977,46 +905,31 @@ impl Copilot {
let buffer = buffer.read(cx);
let uri = registered_buffer.uri.clone();
let position = position.to_point_utf16(buffer);
let settings = language_settings(
buffer.language_at(position).map(|l| l.name()),
buffer.file(),
cx,
);
let tab_size = settings.tab_size;
let hard_tabs = settings.hard_tabs;
let relative_path = buffer
.file()
.map_or(RelPath::empty().into(), |file| file.path().clone());
cx.background_spawn(async move {
let (version, snapshot) = snapshot.await?;
let result = lsp
.request::<R>(request::GetCompletionsParams {
doc: request::GetCompletionsDocument {
uri,
tab_size: tab_size.into(),
indent_size: 1,
insert_spaces: !hard_tabs,
relative_path: relative_path.to_proto(),
position: point_to_lsp(position),
version: version.try_into().unwrap(),
},
.request::<NextEditSuggestions>(request::NextEditSuggestionsParams {
text_document: lsp::VersionedTextDocumentIdentifier { uri, version },
position: point_to_lsp(position),
})
.await
.into_response()
.context("copilot: get completions")?;
let completions = result
.completions
.edits
.into_iter()
.map(|completion| {
let start = snapshot
.clip_point_utf16(point_from_lsp(completion.range.start), Bias::Left);
let end =
snapshot.clip_point_utf16(point_from_lsp(completion.range.end), Bias::Left);
Completion {
uuid: completion.uuid,
CopilotEditPrediction {
buffer: buffer_entity.clone(),
range: snapshot.anchor_before(start)..snapshot.anchor_after(end),
text: completion.text,
command: completion.command,
snapshot: snapshot.clone(),
}
})
.collect();
@@ -1024,6 +937,35 @@ impl Copilot {
})
}
pub(crate) fn accept_completion(
&mut self,
completion: &CopilotEditPrediction,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let server = match self.server.as_authenticated() {
Ok(server) => server,
Err(error) => return Task::ready(Err(error)),
};
if let Some(command) = &completion.command {
let request = server
.lsp
.request::<lsp::ExecuteCommand>(lsp::ExecuteCommandParams {
command: command.command.clone(),
arguments: command.arguments.clone().unwrap_or_default(),
..Default::default()
});
cx.background_spawn(async move {
request
.await
.into_response()
.context("copilot: notify accepted")?;
Ok(())
})
} else {
Task::ready(Ok(()))
}
}
pub fn status(&self) -> Status {
match &self.server {
CopilotServer::Starting { task } => Status::Starting { task: task.clone() },
@@ -1260,7 +1202,11 @@ async fn get_copilot_lsp(fs: Arc<dyn Fs>, node_runtime: NodeRuntime) -> anyhow::
mod tests {
use super::*;
use gpui::TestAppContext;
use util::{path, paths::PathStyle, rel_path::rel_path};
use util::{
path,
paths::PathStyle,
rel_path::{RelPath, rel_path},
};
#[gpui::test(iterations = 10)]
async fn test_buffer_management(cx: &mut TestAppContext) {

View File

@@ -1,49 +1,29 @@
use crate::{Completion, Copilot};
use crate::{Copilot, CopilotEditPrediction};
use anyhow::Result;
use edit_prediction_types::{Direction, EditPrediction, EditPredictionDelegate};
use gpui::{App, Context, Entity, EntityId, Task};
use language::{Buffer, OffsetRangeExt, ToOffset, language_settings::AllLanguageSettings};
use settings::Settings;
use std::{path::Path, time::Duration};
use edit_prediction_types::{EditPrediction, EditPredictionDelegate, interpolate_edits};
use gpui::{App, Context, Entity, Task};
use language::{Anchor, Buffer, EditPreview, OffsetRangeExt};
use std::{ops::Range, sync::Arc, time::Duration};
pub const COPILOT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(75);
pub struct CopilotEditPredictionDelegate {
cycled: bool,
buffer_id: Option<EntityId>,
completions: Vec<Completion>,
active_completion_index: usize,
file_extension: Option<String>,
completion: Option<(CopilotEditPrediction, EditPreview)>,
pending_refresh: Option<Task<Result<()>>>,
pending_cycling_refresh: Option<Task<Result<()>>>,
copilot: Entity<Copilot>,
}
impl CopilotEditPredictionDelegate {
pub fn new(copilot: Entity<Copilot>) -> Self {
Self {
cycled: false,
buffer_id: None,
completions: Vec::new(),
active_completion_index: 0,
file_extension: None,
completion: None,
pending_refresh: None,
pending_cycling_refresh: None,
copilot,
}
}
fn active_completion(&self) -> Option<&Completion> {
self.completions.get(self.active_completion_index)
}
fn push_completion(&mut self, new_completion: Completion) {
for completion in &self.completions {
if completion.text == new_completion.text && completion.range == new_completion.range {
return;
}
}
self.completions.push(new_completion);
fn active_completion(&self) -> Option<&(CopilotEditPrediction, EditPreview)> {
self.completion.as_ref()
}
}
@@ -64,12 +44,8 @@ impl EditPredictionDelegate for CopilotEditPredictionDelegate {
true
}
fn supports_jump_to_edit() -> bool {
false
}
fn is_refreshing(&self, _cx: &App) -> bool {
self.pending_refresh.is_some() && self.completions.is_empty()
self.pending_refresh.is_some() && self.completion.is_none()
}
fn is_enabled(
@@ -102,160 +78,96 @@ impl EditPredictionDelegate for CopilotEditPredictionDelegate {
})?
.await?;
this.update(cx, |this, cx| {
if !completions.is_empty() {
this.cycled = false;
if let Some(mut completion) = completions.into_iter().next()
&& let Some(trimmed_completion) = cx
.update(|cx| trim_completion(&completion, cx))
.ok()
.flatten()
{
let preview = buffer
.update(cx, |this, cx| {
this.preview_edits(Arc::from(std::slice::from_ref(&trimmed_completion)), cx)
})?
.await;
this.update(cx, |this, cx| {
this.pending_refresh = None;
this.pending_cycling_refresh = None;
this.completions.clear();
this.active_completion_index = 0;
this.buffer_id = Some(buffer.entity_id());
this.file_extension = buffer.read(cx).file().and_then(|file| {
Some(
Path::new(file.file_name(cx))
.extension()?
.to_str()?
.to_string(),
)
});
completion.range = trimmed_completion.0;
completion.text = trimmed_completion.1.to_string();
this.completion = Some((completion, preview));
for completion in completions {
this.push_completion(completion);
}
cx.notify();
}
})?;
})?;
}
Ok(())
}));
}
fn cycle(
&mut self,
buffer: Entity<Buffer>,
cursor_position: language::Anchor,
direction: Direction,
cx: &mut Context<Self>,
) {
if self.cycled {
match direction {
Direction::Prev => {
self.active_completion_index = if self.active_completion_index == 0 {
self.completions.len().saturating_sub(1)
} else {
self.active_completion_index - 1
};
}
Direction::Next => {
if self.completions.is_empty() {
self.active_completion_index = 0
} else {
self.active_completion_index =
(self.active_completion_index + 1) % self.completions.len();
}
}
}
cx.notify();
} else {
let copilot = self.copilot.clone();
self.pending_cycling_refresh = Some(cx.spawn(async move |this, cx| {
let completions = copilot
.update(cx, |copilot, cx| {
copilot.completions_cycling(&buffer, cursor_position, cx)
})?
.await?;
this.update(cx, |this, cx| {
this.cycled = true;
this.file_extension = buffer.read(cx).file().and_then(|file| {
Some(
Path::new(file.file_name(cx))
.extension()?
.to_str()?
.to_string(),
)
});
for completion in completions {
this.push_completion(completion);
}
this.cycle(buffer, cursor_position, direction, cx);
})?;
Ok(())
}));
}
}
fn accept(&mut self, cx: &mut Context<Self>) {
if let Some(completion) = self.active_completion() {
if let Some((completion, _)) = self.active_completion() {
self.copilot
.update(cx, |copilot, cx| copilot.accept_completion(completion, cx))
.detach_and_log_err(cx);
}
}
fn discard(&mut self, cx: &mut Context<Self>) {
let settings = AllLanguageSettings::get_global(cx);
let copilot_enabled = settings.show_edit_predictions(None, cx);
if !copilot_enabled {
return;
}
self.copilot
.update(cx, |copilot, cx| {
copilot.discard_completions(&self.completions, cx)
})
.detach_and_log_err(cx);
}
fn discard(&mut self, _: &mut Context<Self>) {}
fn suggest(
&mut self,
buffer: &Entity<Buffer>,
cursor_position: language::Anchor,
_: language::Anchor,
cx: &mut Context<Self>,
) -> Option<EditPrediction> {
let buffer_id = buffer.entity_id();
let buffer = buffer.read(cx);
let completion = self.active_completion()?;
if Some(buffer_id) != self.buffer_id
let (completion, edit_preview) = self.active_completion()?;
if Some(buffer_id) != Some(completion.buffer.entity_id())
|| !completion.range.start.is_valid(buffer)
|| !completion.range.end.is_valid(buffer)
{
return None;
}
let edits = vec![(
completion.range.clone(),
Arc::from(completion.text.as_ref()),
)];
let edits = interpolate_edits(&completion.snapshot, &buffer.snapshot(), &edits)
.filter(|edits| !edits.is_empty())?;
let mut completion_range = completion.range.to_offset(buffer);
let prefix_len = common_prefix(
buffer.chars_for_range(completion_range.clone()),
completion.text.chars(),
);
completion_range.start += prefix_len;
let suffix_len = common_prefix(
buffer.reversed_chars_for_range(completion_range.clone()),
completion.text[prefix_len..].chars().rev(),
);
completion_range.end = completion_range.end.saturating_sub(suffix_len);
Some(EditPrediction::Local {
id: None,
edits,
edit_preview: Some(edit_preview.clone()),
})
}
}
if completion_range.is_empty()
&& completion_range.start == cursor_position.to_offset(buffer)
{
let completion_text = &completion.text[prefix_len..completion.text.len() - suffix_len];
if completion_text.trim().is_empty() {
None
} else {
let position = cursor_position.bias_right(buffer);
Some(EditPrediction::Local {
id: None,
edits: vec![(position..position, completion_text.into())],
edit_preview: None,
})
}
} else {
None
}
fn trim_completion(
completion: &CopilotEditPrediction,
cx: &mut App,
) -> Option<(Range<Anchor>, Arc<str>)> {
let buffer = completion.buffer.read(cx);
let mut completion_range = completion.range.to_offset(buffer);
let prefix_len = common_prefix(
buffer.chars_for_range(completion_range.clone()),
completion.text.chars(),
);
completion_range.start += prefix_len;
let suffix_len = common_prefix(
buffer.reversed_chars_for_range(completion_range.clone()),
completion.text[prefix_len..].chars().rev(),
);
completion_range.end = completion_range.end.saturating_sub(suffix_len);
let completion_text = &completion.text[prefix_len..completion.text.len() - suffix_len];
if completion_text.trim().is_empty() {
None
} else {
let completion_range =
buffer.anchor_after(completion_range.start)..buffer.anchor_after(completion_range.end);
Some((completion_range, Arc::from(completion_text)))
}
}
@@ -282,6 +194,7 @@ mod tests {
Point,
language_settings::{CompletionSettingsContent, LspInsertMode, WordsCompletionMode},
};
use lsp::Uri;
use project::Project;
use serde_json::json;
use settings::{AllLanguageSettingsContent, SettingsStore};
@@ -337,12 +250,15 @@ mod tests {
));
handle_copilot_completion_request(
&copilot_lsp,
vec![crate::request::Completion {
vec![crate::request::NextEditSuggestion {
text: "one.copilot1".into(),
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)),
..Default::default()
command: None,
text_document: lsp::VersionedTextDocumentIdentifier {
uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
version: 0,
},
}],
vec![],
);
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
@@ -383,12 +299,15 @@ mod tests {
));
handle_copilot_completion_request(
&copilot_lsp,
vec![crate::request::Completion {
vec![crate::request::NextEditSuggestion {
text: "one.copilot1".into(),
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)),
..Default::default()
command: None,
text_document: lsp::VersionedTextDocumentIdentifier {
uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
version: 0,
},
}],
vec![],
);
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, _, cx| {
@@ -412,12 +331,15 @@ mod tests {
// After debouncing, new Copilot completions should be requested.
handle_copilot_completion_request(
&copilot_lsp,
vec![crate::request::Completion {
vec![crate::request::NextEditSuggestion {
text: "one.copilot2".into(),
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 5)),
..Default::default()
command: None,
text_document: lsp::VersionedTextDocumentIdentifier {
uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
version: 0,
},
}],
vec![],
);
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
@@ -479,45 +401,6 @@ mod tests {
assert_eq!(editor.display_text(cx), "one.cop\ntwo\nthree\n");
assert_eq!(editor.text(cx), "one.cop\ntwo\nthree\n");
});
// Reset the editor to verify how suggestions behave when tabbing on leading indentation.
cx.update_editor(|editor, window, cx| {
editor.set_text("fn foo() {\n \n}", window, cx);
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([Point::new(1, 2)..Point::new(1, 2)])
});
});
handle_copilot_completion_request(
&copilot_lsp,
vec![crate::request::Completion {
text: " let x = 4;".into(),
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)),
..Default::default()
}],
vec![],
);
cx.update_editor(|editor, window, cx| {
editor.next_edit_prediction(&Default::default(), window, cx)
});
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}");
assert_eq!(editor.text(cx), "fn foo() {\n \n}");
// Tabbing inside of leading whitespace inserts indentation without accepting the suggestion.
editor.tab(&Default::default(), window, cx);
assert!(editor.has_active_edit_prediction());
assert_eq!(editor.text(cx), "fn foo() {\n \n}");
assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}");
// Using AcceptEditPrediction again accepts the suggestion.
editor.accept_edit_prediction(&Default::default(), window, cx);
assert!(!editor.has_active_edit_prediction());
assert_eq!(editor.text(cx), "fn foo() {\n let x = 4;\n}");
assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}");
});
}
#[gpui::test(iterations = 10)]
@@ -570,12 +453,15 @@ mod tests {
));
handle_copilot_completion_request(
&copilot_lsp,
vec![crate::request::Completion {
vec![crate::request::NextEditSuggestion {
text: "one.copilot1".into(),
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)),
..Default::default()
command: None,
text_document: lsp::VersionedTextDocumentIdentifier {
uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
version: 0,
},
}],
vec![],
);
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
@@ -614,12 +500,15 @@ mod tests {
));
handle_copilot_completion_request(
&copilot_lsp,
vec![crate::request::Completion {
vec![crate::request::NextEditSuggestion {
text: "one.123. copilot\n 456".into(),
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)),
..Default::default()
command: None,
text_document: lsp::VersionedTextDocumentIdentifier {
uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
version: 0,
},
}],
vec![],
);
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
@@ -686,15 +575,18 @@ mod tests {
handle_copilot_completion_request(
&copilot_lsp,
vec![crate::request::Completion {
vec![crate::request::NextEditSuggestion {
text: "two.foo()".into(),
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)),
..Default::default()
command: None,
text_document: lsp::VersionedTextDocumentIdentifier {
uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
version: 0,
},
}],
vec![],
);
cx.update_editor(|editor, window, cx| {
editor.next_edit_prediction(&Default::default(), window, cx)
editor.show_edit_prediction(&Default::default(), window, cx)
});
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, window, cx| {
@@ -703,15 +595,22 @@ mod tests {
assert_eq!(editor.text(cx), "one\ntw\nthree\n");
editor.backspace(&Default::default(), window, cx);
});
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.run_until_parked();
cx.update_editor(|editor, window, cx| {
assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n");
assert_eq!(editor.text(cx), "one\nt\nthree\n");
editor.backspace(&Default::default(), window, cx);
});
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.run_until_parked();
cx.update_editor(|editor, window, cx| {
assert!(editor.has_active_edit_prediction());
assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n");
assert_eq!(editor.text(cx), "one\n\nthree\n");
// Deleting across the original suggestion range invalidates it.
editor.backspace(&Default::default(), window, cx);
assert!(!editor.has_active_edit_prediction());
@@ -765,19 +664,22 @@ mod tests {
handle_copilot_completion_request(
&copilot_lsp,
vec![crate::request::Completion {
vec![crate::request::NextEditSuggestion {
text: "b = 2 + a".into(),
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 5)),
..Default::default()
command: None,
text_document: lsp::VersionedTextDocumentIdentifier {
uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
version: 0,
},
}],
vec![],
);
_ = editor.update(cx, |editor, window, cx| {
// Ensure copilot suggestions are shown for the first excerpt.
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([Point::new(1, 5)..Point::new(1, 5)])
});
editor.next_edit_prediction(&Default::default(), window, cx);
editor.show_edit_prediction(&Default::default(), window, cx);
});
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
_ = editor.update(cx, |editor, _, cx| {
@@ -791,12 +693,15 @@ mod tests {
handle_copilot_completion_request(
&copilot_lsp,
vec![crate::request::Completion {
vec![crate::request::NextEditSuggestion {
text: "d = 4 + c".into(),
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 6)),
..Default::default()
command: None,
text_document: lsp::VersionedTextDocumentIdentifier {
uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
version: 0,
},
}],
vec![],
);
_ = editor.update(cx, |editor, window, cx| {
// Move to another excerpt, ensuring the suggestion gets cleared.
@@ -873,15 +778,18 @@ mod tests {
));
handle_copilot_completion_request(
&copilot_lsp,
vec![crate::request::Completion {
vec![crate::request::NextEditSuggestion {
text: "two.foo()".into(),
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)),
..Default::default()
command: None,
text_document: lsp::VersionedTextDocumentIdentifier {
uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
version: 0,
},
}],
vec![],
);
cx.update_editor(|editor, window, cx| {
editor.next_edit_prediction(&Default::default(), window, cx)
editor.show_edit_prediction(&Default::default(), window, cx)
});
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, _, cx| {
@@ -903,12 +811,15 @@ mod tests {
));
handle_copilot_completion_request(
&copilot_lsp,
vec![crate::request::Completion {
vec![crate::request::NextEditSuggestion {
text: "two.foo()".into(),
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 3)),
..Default::default()
command: None,
text_document: lsp::VersionedTextDocumentIdentifier {
uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
version: 0,
},
}],
vec![],
);
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, _, cx| {
@@ -930,12 +841,15 @@ mod tests {
));
handle_copilot_completion_request(
&copilot_lsp,
vec![crate::request::Completion {
vec![crate::request::NextEditSuggestion {
text: "two.foo()".into(),
range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 4)),
..Default::default()
command: None,
text_document: lsp::VersionedTextDocumentIdentifier {
uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
version: 0,
},
}],
vec![],
);
executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
cx.update_editor(|editor, _, cx| {
@@ -1011,16 +925,20 @@ mod tests {
.unwrap();
let mut copilot_requests = copilot_lsp
.set_request_handler::<crate::request::GetCompletions, _, _>(
.set_request_handler::<crate::request::NextEditSuggestions, _, _>(
move |_params, _cx| async move {
Ok(crate::request::GetCompletionsResult {
completions: vec![crate::request::Completion {
Ok(crate::request::NextEditSuggestionsResult {
edits: vec![crate::request::NextEditSuggestion {
text: "next line".into(),
range: lsp::Range::new(
lsp::Position::new(1, 0),
lsp::Position::new(1, 0),
),
..Default::default()
command: None,
text_document: lsp::VersionedTextDocumentIdentifier {
uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(),
version: 0,
},
}],
})
},
@@ -1049,23 +967,14 @@ mod tests {
fn handle_copilot_completion_request(
lsp: &lsp::FakeLanguageServer,
completions: Vec<crate::request::Completion>,
completions_cycling: Vec<crate::request::Completion>,
completions: Vec<crate::request::NextEditSuggestion>,
) {
lsp.set_request_handler::<crate::request::GetCompletions, _, _>(move |_params, _cx| {
let completions = completions.clone();
async move {
Ok(crate::request::GetCompletionsResult {
completions: completions.clone(),
})
}
});
lsp.set_request_handler::<crate::request::GetCompletionsCycling, _, _>(
lsp.set_request_handler::<crate::request::NextEditSuggestions, _, _>(
move |_params, _cx| {
let completions_cycling = completions_cycling.clone();
let completions = completions.clone();
async move {
Ok(crate::request::GetCompletionsResult {
completions: completions_cycling.clone(),
Ok(crate::request::NextEditSuggestionsResult {
edits: completions.clone(),
})
}
},

View File

@@ -1,3 +1,4 @@
use lsp::VersionedTextDocumentIdentifier;
use serde::{Deserialize, Serialize};
pub enum CheckStatus {}
@@ -88,72 +89,6 @@ impl lsp::request::Request for SignOut {
const METHOD: &'static str = "signOut";
}
pub enum GetCompletions {}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetCompletionsParams {
pub doc: GetCompletionsDocument,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetCompletionsDocument {
pub tab_size: u32,
pub indent_size: u32,
pub insert_spaces: bool,
pub uri: lsp::Uri,
pub relative_path: String,
pub position: lsp::Position,
pub version: usize,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetCompletionsResult {
pub completions: Vec<Completion>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Completion {
pub text: String,
pub position: lsp::Position,
pub uuid: String,
pub range: lsp::Range,
pub display_text: String,
}
impl lsp::request::Request for GetCompletions {
type Params = GetCompletionsParams;
type Result = GetCompletionsResult;
const METHOD: &'static str = "getCompletions";
}
pub enum GetCompletionsCycling {}
impl lsp::request::Request for GetCompletionsCycling {
type Params = GetCompletionsParams;
type Result = GetCompletionsResult;
const METHOD: &'static str = "getCompletionsCycling";
}
pub enum LogMessage {}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LogMessageParams {
pub level: u8,
pub message: String,
pub metadata_str: String,
pub extra: Vec<String>,
}
impl lsp::notification::Notification for LogMessage {
type Params = LogMessageParams;
const METHOD: &'static str = "LogMessage";
}
pub enum StatusNotification {}
#[derive(Debug, Serialize, Deserialize)]
@@ -223,3 +158,36 @@ impl lsp::request::Request for NotifyRejected {
type Result = String;
const METHOD: &'static str = "notifyRejected";
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NextEditSuggestions;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NextEditSuggestionsParams {
pub(crate) text_document: VersionedTextDocumentIdentifier,
pub(crate) position: lsp::Position,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NextEditSuggestion {
pub text: String,
pub text_document: VersionedTextDocumentIdentifier,
pub range: lsp::Range,
pub command: Option<lsp::Command>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NextEditSuggestionsResult {
pub edits: Vec<NextEditSuggestion>,
}
impl lsp::request::Request for NextEditSuggestions {
type Params = NextEditSuggestionsParams;
type Result = NextEditSuggestionsResult;
const METHOD: &'static str = "textDocument/copilotInlineEdit";
}

View File

@@ -2,7 +2,7 @@ use std::{cmp, sync::Arc};
use client::{Client, UserStore};
use cloud_llm_client::EditPredictionRejectReason;
use edit_prediction_types::{DataCollectionState, Direction, EditPredictionDelegate};
use edit_prediction_types::{DataCollectionState, EditPredictionDelegate};
use gpui::{App, Entity, prelude::*};
use language::{Buffer, ToPoint as _};
use project::Project;
@@ -139,15 +139,6 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate {
});
}
fn cycle(
&mut self,
_buffer: Entity<language::Buffer>,
_cursor_position: language::Anchor,
_direction: Direction,
_cx: &mut Context<Self>,
) {
}
fn accept(&mut self, cx: &mut Context<Self>) {
self.store.update(cx, |store, cx| {
store.accept_current_prediction(&self.project, cx);

View File

@@ -95,13 +95,6 @@ pub trait EditPredictionDelegate: 'static + Sized {
debounce: bool,
cx: &mut Context<Self>,
);
fn cycle(
&mut self,
buffer: Entity<Buffer>,
cursor_position: language::Anchor,
direction: Direction,
cx: &mut Context<Self>,
);
fn accept(&mut self, cx: &mut Context<Self>);
fn discard(&mut self, cx: &mut Context<Self>);
fn did_show(&mut self, _cx: &mut Context<Self>) {}
@@ -136,13 +129,6 @@ pub trait EditPredictionDelegateHandle {
debounce: bool,
cx: &mut App,
);
fn cycle(
&self,
buffer: Entity<Buffer>,
cursor_position: language::Anchor,
direction: Direction,
cx: &mut App,
);
fn did_show(&self, cx: &mut App);
fn accept(&self, cx: &mut App);
fn discard(&self, cx: &mut App);
@@ -215,18 +201,6 @@ where
})
}
fn cycle(
&self,
buffer: Entity<Buffer>,
cursor_position: language::Anchor,
direction: Direction,
cx: &mut App,
) {
self.update(cx, |this, cx| {
this.cycle(buffer, cursor_position, direction, cx)
})
}
fn accept(&self, cx: &mut App) {
self.update(cx, |this, cx| this.accept(cx))
}

View File

@@ -485,15 +485,6 @@ impl EditPredictionDelegate for FakeEditPredictionDelegate {
) {
}
fn cycle(
&mut self,
_buffer: gpui::Entity<language::Buffer>,
_cursor_position: language::Anchor,
_direction: edit_prediction_types::Direction,
_cx: &mut gpui::Context<Self>,
) {
}
fn accept(&mut self, _cx: &mut gpui::Context<Self>) {}
fn discard(&mut self, _cx: &mut gpui::Context<Self>) {}
@@ -561,15 +552,6 @@ impl EditPredictionDelegate for FakeNonZedEditPredictionDelegate {
) {
}
fn cycle(
&mut self,
_buffer: gpui::Entity<language::Buffer>,
_cursor_position: language::Anchor,
_direction: edit_prediction_types::Direction,
_cx: &mut gpui::Context<Self>,
) {
}
fn accept(&mut self, _cx: &mut gpui::Context<Self>) {}
fn discard(&mut self, _cx: &mut gpui::Context<Self>) {}

View File

@@ -73,11 +73,7 @@ pub use multi_buffer::{
pub use split::SplittableEditor;
pub use text::Bias;
use ::git::{
Restore,
blame::{BlameEntry, ParsedCommitMessage},
status::FileStatus,
};
use ::git::{Restore, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus};
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, BuildError};
use anyhow::{Context as _, Result, anyhow, bail};
use blink_manager::BlinkManager;
@@ -7468,26 +7464,6 @@ impl Editor {
.unwrap_or(false)
}
fn cycle_edit_prediction(
&mut self,
direction: Direction,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<()> {
let provider = self.edit_prediction_provider()?;
let cursor = self.selections.newest_anchor().head();
let (buffer, cursor_buffer_position) =
self.buffer.read(cx).text_anchor_for_position(cursor, cx)?;
if self.edit_predictions_hidden_for_vim_mode || !self.should_show_edit_predictions() {
return None;
}
provider.cycle(buffer, cursor_buffer_position, direction, cx);
self.update_visible_edit_prediction(window, cx);
Some(())
}
pub fn show_edit_prediction(
&mut self,
_: &ShowEditPrediction,
@@ -7525,42 +7501,6 @@ impl Editor {
.detach();
}
pub fn next_edit_prediction(
&mut self,
_: &NextEditPrediction,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.has_active_edit_prediction() {
self.cycle_edit_prediction(Direction::Next, window, cx);
} else {
let is_copilot_disabled = self
.refresh_edit_prediction(false, true, window, cx)
.is_none();
if is_copilot_disabled {
cx.propagate();
}
}
}
pub fn previous_edit_prediction(
&mut self,
_: &PreviousEditPrediction,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.has_active_edit_prediction() {
self.cycle_edit_prediction(Direction::Prev, window, cx);
} else {
let is_copilot_disabled = self
.refresh_edit_prediction(false, true, window, cx)
.is_none();
if is_copilot_disabled {
cx.propagate();
}
}
}
pub fn accept_partial_edit_prediction(
&mut self,
granularity: EditPredictionGranularity,

View File

@@ -69,7 +69,6 @@ use util::{
use workspace::{
CloseActiveItem, CloseAllItems, CloseOtherItems, MoveItemToPaneInDirection, NavigationEntry,
OpenOptions, ViewId,
invalid_item_view::InvalidItemView,
item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions},
register_project_item,
};
@@ -27667,11 +27666,10 @@ async fn test_non_utf_8_opens(cx: &mut TestAppContext) {
})
.await
.unwrap();
assert_eq!(
handle.to_any_view().entity_type(),
TypeId::of::<InvalidItemView>()
);
// The test file content `vec![0xff, 0xfe, ...]` starts with a UTF-16 LE BOM.
// Previously, this fell back to `InvalidItemView` because it wasn't valid UTF-8.
// With auto-detection enabled, this is now recognized as UTF-16 and opens in the Editor.
assert_eq!(handle.to_any_view().entity_type(), TypeId::of::<Editor>());
}
#[gpui::test]

View File

@@ -37,11 +37,7 @@ use crate::{
use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
use collections::{BTreeMap, HashMap};
use file_icons::FileIcons;
use git::{
Oid,
blame::{BlameEntry, ParsedCommitMessage},
status::FileStatus,
};
use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus};
use gpui::{
Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle,
Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle,
@@ -594,8 +590,6 @@ impl EditorElement {
register_action(editor, window, Editor::show_signature_help);
register_action(editor, window, Editor::signature_help_prev);
register_action(editor, window, Editor::signature_help_next);
register_action(editor, window, Editor::next_edit_prediction);
register_action(editor, window, Editor::previous_edit_prediction);
register_action(editor, window, Editor::show_edit_prediction);
register_action(editor, window, Editor::context_menu_first);
register_action(editor, window, Editor::context_menu_prev);

View File

@@ -3,9 +3,9 @@ use anyhow::{Context as _, Result};
use collections::HashMap;
use git::{
GitHostingProviderRegistry, GitRemote, Oid,
blame::{Blame, BlameEntry, ParsedCommitMessage},
parse_git_remote_url,
GitHostingProviderRegistry, Oid,
blame::{Blame, BlameEntry},
commit::ParsedCommitMessage,
};
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task,
@@ -525,12 +525,7 @@ impl GitBlame {
.git_store()
.read(cx)
.repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx)
.and_then(|(repo, _)| {
repo.read(cx)
.remote_upstream_url
.clone()
.or(repo.read(cx).remote_origin_url.clone())
});
.and_then(|(repo, _)| repo.read(cx).default_remote_url());
let blame_buffer = project
.update(cx, |project, cx| project.blame_buffer(&buffer, None, cx));
Ok(async move {
@@ -554,13 +549,19 @@ impl GitBlame {
entries,
snapshot.max_point().row,
);
let commit_details = parse_commit_messages(
messages,
remote_url,
provider_registry.clone(),
)
.await;
let commit_details = messages
.into_iter()
.map(|(oid, message)| {
let parsed_commit_message =
ParsedCommitMessage::parse(
oid.to_string(),
message,
remote_url.as_deref(),
Some(provider_registry.clone()),
);
(oid, parsed_commit_message)
})
.collect();
res.push((
id,
snapshot,
@@ -680,55 +681,6 @@ fn build_blame_entry_sum_tree(entries: Vec<BlameEntry>, max_row: u32) -> SumTree
entries
}
async fn parse_commit_messages(
messages: impl IntoIterator<Item = (Oid, String)>,
remote_url: Option<String>,
provider_registry: Arc<GitHostingProviderRegistry>,
) -> HashMap<Oid, ParsedCommitMessage> {
let mut commit_details = HashMap::default();
let parsed_remote_url = remote_url
.as_deref()
.and_then(|remote_url| parse_git_remote_url(provider_registry, remote_url));
for (oid, message) in messages {
let permalink = if let Some((provider, git_remote)) = parsed_remote_url.as_ref() {
Some(provider.build_commit_permalink(
git_remote,
git::BuildCommitPermalinkParams {
sha: oid.to_string().as_str(),
},
))
} else {
None
};
let remote = parsed_remote_url
.as_ref()
.map(|(provider, remote)| GitRemote {
host: provider.clone(),
owner: remote.owner.clone().into(),
repo: remote.repo.clone().into(),
});
let pull_request = parsed_remote_url
.as_ref()
.and_then(|(provider, remote)| provider.extract_pull_request(remote, &message));
commit_details.insert(
oid,
ParsedCommitMessage {
message: message.into(),
permalink,
remote,
pull_request,
},
);
}
commit_details
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -434,7 +434,18 @@ impl RealFs {
for component in path.components() {
match component {
std::path::Component::Prefix(_) => {
let canonicalized = std::fs::canonicalize(component)?;
let component = component.as_os_str();
let canonicalized = if component
.to_str()
.map(|e| e.ends_with("\\"))
.unwrap_or(false)
{
std::fs::canonicalize(component)
} else {
let mut component = component.to_os_string();
component.push("\\");
std::fs::canonicalize(component)
}?;
let mut strip = PathBuf::new();
for component in canonicalized.components() {
@@ -3394,6 +3405,26 @@ mod tests {
assert_eq!(content, "Hello");
}
#[gpui::test]
#[cfg(target_os = "windows")]
async fn test_realfs_canonicalize(executor: BackgroundExecutor) {
use util::paths::SanitizedPath;
let fs = RealFs {
bundled_git_binary_path: None,
executor,
next_job_id: Arc::new(AtomicUsize::new(0)),
job_event_subscribers: Arc::new(Mutex::new(Vec::new())),
};
let temp_dir = TempDir::new().unwrap();
let file = temp_dir.path().join("test (1).txt");
let file = SanitizedPath::new(&file);
std::fs::write(&file, "test").unwrap();
let canonicalized = fs.canonicalize(file.as_path()).await;
assert!(canonicalized.is_ok());
}
#[gpui::test]
async fn test_rename(executor: BackgroundExecutor) {
let fs = FakeFs::new(executor.clone());

View File

@@ -1,10 +1,9 @@
use crate::Oid;
use crate::commit::get_messages;
use crate::repository::RepoPath;
use crate::{GitRemote, Oid};
use anyhow::{Context as _, Result};
use collections::{HashMap, HashSet};
use futures::AsyncWriteExt;
use gpui::SharedString;
use serde::{Deserialize, Serialize};
use std::process::Stdio;
use std::{ops::Range, path::Path};
@@ -21,14 +20,6 @@ pub struct Blame {
pub messages: HashMap<Oid, String>,
}
#[derive(Clone, Debug, Default)]
pub struct ParsedCommitMessage {
pub message: SharedString,
pub permalink: Option<url::Url>,
pub pull_request: Option<crate::hosting_provider::PullRequest>,
pub remote: Option<GitRemote>,
}
impl Blame {
pub async fn for_path(
git_binary: &Path,

View File

@@ -1,7 +1,52 @@
use crate::{Oid, status::StatusCode};
use crate::{
BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, parse_git_remote_url,
status::StatusCode,
};
use anyhow::{Context as _, Result};
use collections::HashMap;
use std::path::Path;
use gpui::SharedString;
use std::{path::Path, sync::Arc};
#[derive(Clone, Debug, Default)]
pub struct ParsedCommitMessage {
pub message: SharedString,
pub permalink: Option<url::Url>,
pub pull_request: Option<crate::hosting_provider::PullRequest>,
pub remote: Option<GitRemote>,
}
impl ParsedCommitMessage {
pub fn parse(
sha: String,
message: String,
remote_url: Option<&str>,
provider_registry: Option<Arc<GitHostingProviderRegistry>>,
) -> Self {
if let Some((hosting_provider, remote)) = provider_registry
.and_then(|reg| remote_url.and_then(|url| parse_git_remote_url(reg, url)))
{
let pull_request = hosting_provider.extract_pull_request(&remote, &message);
Self {
message: message.into(),
permalink: Some(
hosting_provider
.build_commit_permalink(&remote, BuildCommitPermalinkParams { sha: &sha }),
),
pull_request,
remote: Some(GitRemote {
host: hosting_provider,
owner: remote.owner.into(),
repo: remote.repo.into(),
}),
}
} else {
Self {
message: message.into(),
..Default::default()
}
}
}
}
pub async fn get_messages(working_directory: &Path, shas: &[Oid]) -> Result<HashMap<Oid, String>> {
if shas.is_empty() {

View File

@@ -3,10 +3,7 @@ use crate::{
commit_view::CommitView,
};
use editor::{BlameRenderer, Editor, hover_markdown_style};
use git::{
blame::{BlameEntry, ParsedCommitMessage},
repository::CommitSummary,
};
use git::{blame::BlameEntry, commit::ParsedCommitMessage, repository::CommitSummary};
use gpui::{
ClipboardItem, Entity, Hsla, MouseButton, ScrollHandle, Subscription, TextStyle,
TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*,

155
crates/git_ui/src/clone.rs Normal file
View File

@@ -0,0 +1,155 @@
use gpui::{App, Context, WeakEntity, Window};
use notifications::status_toast::{StatusToast, ToastIcon};
use std::sync::Arc;
use ui::{Color, IconName, SharedString};
use util::ResultExt;
use workspace::{self, Workspace};
pub fn clone_and_open(
repo_url: SharedString,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
on_success: Arc<
dyn Fn(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send + Sync + 'static,
>,
) {
let destination_prompt = cx.prompt_for_paths(gpui::PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select as Repository Destination".into()),
});
window
.spawn(cx, async move |cx| {
let mut paths = destination_prompt.await.ok()?.ok()??;
let mut destination_dir = paths.pop()?;
let repo_name = repo_url
.split('/')
.next_back()
.map(|name| name.strip_suffix(".git").unwrap_or(name))
.unwrap_or("repository")
.to_owned();
let clone_task = workspace
.update(cx, |workspace, cx| {
let fs = workspace.app_state().fs.clone();
let destination_dir = destination_dir.clone();
let repo_url = repo_url.clone();
cx.spawn(async move |_workspace, _cx| {
fs.git_clone(&repo_url, destination_dir.as_path()).await
})
})
.ok()?;
if let Err(error) = clone_task.await {
workspace
.update(cx, |workspace, cx| {
let toast = StatusToast::new(error.to_string(), cx, |this, _| {
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
.dismiss_button(true)
});
workspace.toggle_status_toast(toast, cx);
})
.log_err();
return None;
}
let has_worktrees = workspace
.read_with(cx, |workspace, cx| {
workspace.project().read(cx).worktrees(cx).next().is_some()
})
.ok()?;
let prompt_answer = if has_worktrees {
cx.update(|window, cx| {
window.prompt(
gpui::PromptLevel::Info,
&format!("Git Clone: {}", repo_name),
None,
&["Add repo to project", "Open repo in new project"],
cx,
)
})
.ok()?
.await
.ok()?
} else {
// Don't ask if project is empty
0
};
destination_dir.push(&repo_name);
match prompt_answer {
0 => {
workspace
.update_in(cx, |workspace, window, cx| {
let create_task = workspace.project().update(cx, |project, cx| {
project.create_worktree(destination_dir.as_path(), true, cx)
});
let workspace_weak = cx.weak_entity();
let on_success = on_success.clone();
cx.spawn_in(window, async move |_window, cx| {
if create_task.await.log_err().is_some() {
workspace_weak
.update_in(cx, |workspace, window, cx| {
(on_success)(workspace, window, cx);
})
.ok();
}
})
.detach();
})
.ok()?;
}
1 => {
workspace
.update(cx, move |workspace, cx| {
let app_state = workspace.app_state().clone();
let destination_path = destination_dir.clone();
let on_success = on_success.clone();
workspace::open_new(
Default::default(),
app_state,
cx,
move |workspace, window, cx| {
cx.activate(true);
let create_task =
workspace.project().update(cx, |project, cx| {
project.create_worktree(
destination_path.as_path(),
true,
cx,
)
});
let workspace_weak = cx.weak_entity();
cx.spawn_in(window, async move |_window, cx| {
if create_task.await.log_err().is_some() {
workspace_weak
.update_in(cx, |workspace, window, cx| {
(on_success)(workspace, window, cx);
})
.ok();
}
})
.detach();
},
)
.detach();
})
.ok();
}
_ => {}
}
Some(())
})
.detach();
}

View File

@@ -3,7 +3,7 @@ use editor::hover_markdown_style;
use futures::Future;
use git::blame::BlameEntry;
use git::repository::CommitSummary;
use git::{GitRemote, blame::ParsedCommitMessage};
use git::{GitRemote, commit::ParsedCommitMessage};
use gpui::{
App, Asset, ClipboardItem, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle,
StatefulInteractiveElement, WeakEntity, prelude::*,

View File

@@ -15,12 +15,13 @@ use askpass::AskPassDelegate;
use cloud_llm_client::CompletionIntent;
use collections::{BTreeMap, HashMap, HashSet};
use db::kvp::KEY_VALUE_STORE;
use editor::RewrapOptions;
use editor::{
Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset,
actions::ExpandAllDiffHunks,
};
use futures::StreamExt as _;
use git::blame::ParsedCommitMessage;
use git::commit::ParsedCommitMessage;
use git::repository::{
Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitter,
PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
@@ -30,15 +31,14 @@ use git::stash::GitStash;
use git::status::StageStatus;
use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
use git::{
ExpandCommitEditor, RestoreTrackedFiles, StageAll, StashAll, StashApply, StashPop,
TrashUntrackedFiles, UnstageAll,
ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll,
StashApply, StashPop, TrashUntrackedFiles, UnstageAll,
};
use gpui::{
Action, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, Corner, DismissEvent, Entity,
EventEmitter, FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior,
ListSizingBehavior, MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy,
Subscription, Task, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point,
size, uniform_list,
EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, Point,
PromptLevel, ScrollStrategy, Subscription, Task, UniformListScrollHandle, WeakEntity, actions,
anchored, deferred, point, size, uniform_list,
};
use itertools::Itertools;
use language::{Buffer, File};
@@ -46,7 +46,7 @@ use language_model::{
ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
Role, ZED_CLOUD_PROVIDER_ID,
};
use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
use menu;
use multi_buffer::ExcerptInfo;
use notifications::status_toast::{StatusToast, ToastIcon};
use panel::{
@@ -93,6 +93,14 @@ actions!(
FocusEditor,
/// Focuses on the changes list.
FocusChanges,
/// Select next git panel menu item, and show it in the diff view
NextEntry,
/// Select previous git panel menu item, and show it in the diff view
PreviousEntry,
/// Select first git panel menu item, and show it in the diff view
FirstEntry,
/// Select last git panel menu item, and show it in the diff view
LastEntry,
/// Toggles automatic co-author suggestions.
ToggleFillCoAuthors,
/// Toggles sorting entries by path vs status.
@@ -204,8 +212,7 @@ const GIT_PANEL_KEY: &str = "GitPanel";
const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
// TODO: We should revise this part. It seems the indentation width is not aligned with the one in project panel
const TREE_INDENT: f32 = 12.0;
const TREE_INDENT_GUIDE_OFFSET: f32 = 16.0;
const TREE_INDENT: f32 = 16.0;
pub fn register(workspace: &mut Workspace) {
workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
@@ -793,20 +800,63 @@ impl GitPanel {
pub fn select_entry_by_path(
&mut self,
path: ProjectPath,
_: &mut Window,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(git_repo) = self.active_repository.as_ref() else {
return;
};
let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path, cx) else {
return;
let (repo_path, section) = {
let repo = git_repo.read(cx);
let Some(repo_path) = repo.project_path_to_repo_path(&path, cx) else {
return;
};
let section = repo
.status_for_path(&repo_path)
.map(|status| status.status)
.map(|status| {
if repo.had_conflict_on_last_merge_head_change(&repo_path) {
Section::Conflict
} else if status.is_created() {
Section::New
} else {
Section::Tracked
}
});
(repo_path, section)
};
let mut needs_rebuild = false;
if let (Some(section), Some(tree_state)) = (section, self.view_mode.tree_state_mut()) {
let mut current_dir = repo_path.parent();
while let Some(dir) = current_dir {
let key = TreeKey {
section,
path: RepoPath::from_rel_path(dir),
};
if tree_state.expanded_dirs.get(&key) == Some(&false) {
tree_state.expanded_dirs.insert(key, true);
needs_rebuild = true;
}
current_dir = dir.parent();
}
}
if needs_rebuild {
self.update_visible_entries(window, cx);
}
let Some(ix) = self.entry_by_path(&repo_path) else {
return;
};
self.selected_entry = Some(ix);
cx.notify();
self.scroll_to_selected_entry(cx);
}
fn serialization_key(workspace: &Workspace) -> Option<String> {
@@ -894,9 +944,22 @@ impl GitPanel {
}
fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
if let Some(selected_entry) = self.selected_entry {
let Some(selected_entry) = self.selected_entry else {
cx.notify();
return;
};
let visible_index = match &self.view_mode {
GitPanelViewMode::Flat => Some(selected_entry),
GitPanelViewMode::Tree(state) => state
.logical_indices
.iter()
.position(|&ix| ix == selected_entry),
};
if let Some(visible_index) = visible_index {
self.scroll_handle
.scroll_to_item(selected_entry, ScrollStrategy::Center);
.scroll_to_item(visible_index, ScrollStrategy::Center);
}
cx.notify();
@@ -914,12 +977,12 @@ impl GitPanel {
if let GitListEntry::Directory(dir_entry) = entry {
if dir_entry.expanded {
self.select_next(&SelectNext, window, cx);
self.select_next(&menu::SelectNext, window, cx);
} else {
self.toggle_directory(&dir_entry.key, window, cx);
}
} else {
self.select_next(&SelectNext, window, cx);
self.select_next(&menu::SelectNext, window, cx);
}
}
@@ -937,14 +1000,19 @@ impl GitPanel {
if dir_entry.expanded {
self.toggle_directory(&dir_entry.key, window, cx);
} else {
self.select_previous(&SelectPrevious, window, cx);
self.select_previous(&menu::SelectPrevious, window, cx);
}
} else {
self.select_previous(&SelectPrevious, window, cx);
self.select_previous(&menu::SelectPrevious, window, cx);
}
}
fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
fn select_first(
&mut self,
_: &menu::SelectFirst,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let first_entry = match &self.view_mode {
GitPanelViewMode::Flat => self
.entries
@@ -967,7 +1035,7 @@ impl GitPanel {
fn select_previous(
&mut self,
_: &SelectPrevious,
_: &menu::SelectPrevious,
_window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -1016,7 +1084,7 @@ impl GitPanel {
self.scroll_to_selected_entry(cx);
}
fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
let item_count = self.entries.len();
if item_count == 0 {
return;
@@ -1054,13 +1122,50 @@ impl GitPanel {
self.scroll_to_selected_entry(cx);
}
fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
if self.entries.last().is_some() {
self.selected_entry = Some(self.entries.len() - 1);
self.scroll_to_selected_entry(cx);
}
}
/// Show diff view at selected entry, only if the diff view is open
fn move_diff_to_entry(&mut self, window: &mut Window, cx: &mut Context<Self>) {
maybe!({
let workspace = self.workspace.upgrade()?;
if let Some(project_diff) = workspace.read(cx).item_of_type::<ProjectDiff>(cx) {
let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
project_diff.update(cx, |project_diff, cx| {
project_diff.move_to_entry(entry.clone(), window, cx);
});
}
Some(())
});
}
fn first_entry(&mut self, _: &FirstEntry, window: &mut Window, cx: &mut Context<Self>) {
self.select_first(&menu::SelectFirst, window, cx);
self.move_diff_to_entry(window, cx);
}
fn last_entry(&mut self, _: &LastEntry, window: &mut Window, cx: &mut Context<Self>) {
self.select_last(&menu::SelectLast, window, cx);
self.move_diff_to_entry(window, cx);
}
fn next_entry(&mut self, _: &NextEntry, window: &mut Window, cx: &mut Context<Self>) {
self.select_next(&menu::SelectNext, window, cx);
self.move_diff_to_entry(window, cx);
}
fn previous_entry(&mut self, _: &PreviousEntry, window: &mut Window, cx: &mut Context<Self>) {
self.select_previous(&menu::SelectPrevious, window, cx);
self.move_diff_to_entry(window, cx);
}
fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
self.commit_editor.update(cx, |editor, cx| {
window.focus(&editor.focus_handle(cx), cx);
@@ -1074,7 +1179,7 @@ impl GitPanel {
.as_ref()
.is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0);
if have_entries && self.selected_entry.is_none() {
self.select_first(&SelectFirst, window, cx);
self.select_first(&menu::SelectFirst, window, cx);
}
}
@@ -2076,7 +2181,13 @@ impl GitPanel {
let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx));
let wrapped_message = editor.update(cx, |editor, cx| {
editor.select_all(&Default::default(), window, cx);
editor.rewrap(&Default::default(), window, cx);
editor.rewrap_impl(
RewrapOptions {
override_language_settings: false,
preserve_existing_whitespace: true,
},
cx,
);
editor.text(cx)
});
if wrapped_message.trim().is_empty() {
@@ -2737,93 +2848,15 @@ impl GitPanel {
}
pub(crate) fn git_clone(&mut self, repo: String, window: &mut Window, cx: &mut Context<Self>) {
let path = cx.prompt_for_paths(gpui::PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select as Repository Destination".into()),
});
let workspace = self.workspace.clone();
cx.spawn_in(window, async move |this, cx| {
let mut paths = path.await.ok()?.ok()??;
let mut path = paths.pop()?;
let repo_name = repo.split("/").last()?.strip_suffix(".git")?.to_owned();
let fs = this.read_with(cx, |this, _| this.fs.clone()).ok()?;
let prompt_answer = match fs.git_clone(&repo, path.as_path()).await {
Ok(_) => cx.update(|window, cx| {
window.prompt(
PromptLevel::Info,
&format!("Git Clone: {}", repo_name),
None,
&["Add repo to project", "Open repo in new project"],
cx,
)
}),
Err(e) => {
this.update(cx, |this: &mut GitPanel, cx| {
let toast = StatusToast::new(e.to_string(), cx, |this, _| {
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
.dismiss_button(true)
});
this.workspace
.update(cx, |workspace, cx| {
workspace.toggle_status_toast(toast, cx);
})
.ok();
})
.ok()?;
return None;
}
}
.ok()?;
path.push(repo_name);
match prompt_answer.await.ok()? {
0 => {
workspace
.update(cx, |workspace, cx| {
workspace
.project()
.update(cx, |project, cx| {
project.create_worktree(path.as_path(), true, cx)
})
.detach();
})
.ok();
}
1 => {
workspace
.update(cx, move |workspace, cx| {
workspace::open_new(
Default::default(),
workspace.app_state().clone(),
cx,
move |workspace, _, cx| {
cx.activate(true);
workspace
.project()
.update(cx, |project, cx| {
project.create_worktree(&path, true, cx)
})
.detach();
},
)
.detach();
})
.ok();
}
_ => {}
}
Some(())
})
.detach();
crate::clone::clone_and_open(
repo.into(),
workspace,
window,
cx,
Arc::new(|_workspace: &mut workspace::Workspace, _window, _cx| {}),
);
}
pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -4591,7 +4624,10 @@ impl GitPanel {
},
)
.with_render_fn(cx.entity(), |_, params, _, _| {
let left_offset = px(TREE_INDENT_GUIDE_OFFSET);
// Magic number to align the tree item is 3 here
// because we're using 12px as the left-side padding
// and 3 makes the alignment work with the bounding box of the icon
let left_offset = px(TREE_INDENT + 3_f32);
let indent_size = params.indent_size;
let item_height = params.item_height;
@@ -4619,10 +4655,6 @@ impl GitPanel {
})
.size_full()
.flex_grow()
.with_sizing_behavior(ListSizingBehavior::Auto)
.with_horizontal_sizing_behavior(
ListHorizontalSizingBehavior::Unconstrained,
)
.with_width_from_item(self.max_width_item_index)
.track_scroll(&self.scroll_handle),
)
@@ -4646,7 +4678,7 @@ impl GitPanel {
}
fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
Label::new(label.into()).color(color).single_line()
Label::new(label.into()).color(color)
}
fn list_item_height(&self) -> Rems {
@@ -4668,8 +4700,8 @@ impl GitPanel {
.h(self.list_item_height())
.w_full()
.items_end()
.px(rems(0.75)) // ~12px
.pb(rems(0.3125)) // ~ 5px
.px_3()
.pb_1()
.child(
Label::new(header.title())
.color(Color::Muted)
@@ -4726,8 +4758,8 @@ impl GitPanel {
git::AddToGitignore.boxed_clone(),
)
.separator()
.action("Open Diff", Confirm.boxed_clone())
.action("Open File", SecondaryConfirm.boxed_clone())
.action("Open Diff", menu::Confirm.boxed_clone())
.action("Open File", menu::SecondaryConfirm.boxed_clone())
.separator()
.action_disabled_when(is_created, "View File History", Box::new(git::FileHistory))
});
@@ -4857,113 +4889,68 @@ impl GitPanel {
let marked_bg_alpha = 0.12;
let state_opacity_step = 0.04;
let info_color = cx.theme().status().info;
let base_bg = match (selected, marked) {
(true, true) => cx
.theme()
.status()
.info
.alpha(selected_bg_alpha + marked_bg_alpha),
(true, false) => cx.theme().status().info.alpha(selected_bg_alpha),
(false, true) => cx.theme().status().info.alpha(marked_bg_alpha),
(true, true) => info_color.alpha(selected_bg_alpha + marked_bg_alpha),
(true, false) => info_color.alpha(selected_bg_alpha),
(false, true) => info_color.alpha(marked_bg_alpha),
_ => cx.theme().colors().ghost_element_background,
};
let hover_bg = if selected {
cx.theme()
.status()
.info
.alpha(selected_bg_alpha + state_opacity_step)
} else {
cx.theme().colors().ghost_element_hover
};
let active_bg = if selected {
cx.theme()
.status()
.info
.alpha(selected_bg_alpha + state_opacity_step * 2.0)
} else {
cx.theme().colors().ghost_element_active
};
let mut name_row = h_flex()
.items_center()
.gap_1()
.flex_1()
.pl(if tree_view {
px(depth as f32 * TREE_INDENT)
} else {
px(0.)
})
.child(git_status_icon(status));
name_row = if tree_view {
name_row.child(
self.entry_label(display_name, label_color)
.when(status.is_deleted(), Label::strikethrough)
.truncate(),
let (hover_bg, active_bg) = if selected {
(
info_color.alpha(selected_bg_alpha + state_opacity_step),
info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0),
)
} else {
name_row.child(h_flex().items_center().flex_1().map(|this| {
self.path_formatted(
this,
entry.parent_dir(path_style),
path_color,
display_name,
label_color,
path_style,
git_path_style,
status.is_deleted(),
)
}))
(
cx.theme().colors().ghost_element_hover,
cx.theme().colors().ghost_element_active,
)
};
let name_row = h_flex()
.min_w_0()
.flex_1()
.gap_1()
.child(git_status_icon(status))
.map(|this| {
if tree_view {
this.pl(px(depth as f32 * TREE_INDENT)).child(
self.entry_label(display_name, label_color)
.when(status.is_deleted(), Label::strikethrough)
.truncate(),
)
} else {
this.child(self.path_formatted(
entry.parent_dir(path_style),
path_color,
display_name,
label_color,
path_style,
git_path_style,
status.is_deleted(),
))
}
});
h_flex()
.id(id)
.h(self.list_item_height())
.w_full()
.pl_3()
.pr_1()
.gap_1p5()
.border_1()
.border_r_2()
.when(selected && self.focus_handle.is_focused(window), |el| {
el.border_color(cx.theme().colors().panel_focused_border)
})
.px(rems(0.75)) // ~12px
.overflow_hidden()
.flex_none()
.gap_1p5()
.bg(base_bg)
.hover(|this| this.bg(hover_bg))
.active(|this| this.bg(active_bg))
.on_click({
cx.listener(move |this, event: &ClickEvent, window, cx| {
this.selected_entry = Some(ix);
cx.notify();
if event.modifiers().secondary() {
this.open_file(&Default::default(), window, cx)
} else {
this.open_diff(&Default::default(), window, cx);
this.focus_handle.focus(window, cx);
}
})
})
.on_mouse_down(
MouseButton::Right,
move |event: &MouseDownEvent, window, cx| {
// why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
if event.button != MouseButton::Right {
return;
}
let Some(this) = handle.upgrade() else {
return;
};
this.update(cx, |this, cx| {
this.deploy_entry_context_menu(event.position, ix, window, cx);
});
cx.stop_propagation();
},
)
.child(name_row.overflow_x_hidden())
.hover(|s| s.bg(hover_bg))
.active(|s| s.bg(active_bg))
.child(name_row)
.child(
div()
.id(checkbox_wrapper_id)
@@ -5013,6 +5000,35 @@ impl GitPanel {
}),
),
)
.on_click({
cx.listener(move |this, event: &ClickEvent, window, cx| {
this.selected_entry = Some(ix);
cx.notify();
if event.modifiers().secondary() {
this.open_file(&Default::default(), window, cx)
} else {
this.open_diff(&Default::default(), window, cx);
this.focus_handle.focus(window, cx);
}
})
})
.on_mouse_down(
MouseButton::Right,
move |event: &MouseDownEvent, window, cx| {
// why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
if event.button != MouseButton::Right {
return;
}
let Some(this) = handle.upgrade() else {
return;
};
this.update(cx, |this, cx| {
this.deploy_entry_context_menu(event.position, ix, window, cx);
});
cx.stop_propagation();
},
)
.into_any_element()
}
@@ -5037,29 +5053,23 @@ impl GitPanel {
let selected_bg_alpha = 0.08;
let state_opacity_step = 0.04;
let base_bg = if selected {
cx.theme().status().info.alpha(selected_bg_alpha)
let info_color = cx.theme().status().info;
let colors = cx.theme().colors();
let (base_bg, hover_bg, active_bg) = if selected {
(
info_color.alpha(selected_bg_alpha),
info_color.alpha(selected_bg_alpha + state_opacity_step),
info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0),
)
} else {
cx.theme().colors().ghost_element_background
(
colors.ghost_element_background,
colors.ghost_element_hover,
colors.ghost_element_active,
)
};
let hover_bg = if selected {
cx.theme()
.status()
.info
.alpha(selected_bg_alpha + state_opacity_step)
} else {
cx.theme().colors().ghost_element_hover
};
let active_bg = if selected {
cx.theme()
.status()
.info
.alpha(selected_bg_alpha + state_opacity_step * 2.0)
} else {
cx.theme().colors().ghost_element_active
};
let folder_icon = if entry.expanded {
IconName::FolderOpen
} else {
@@ -5082,9 +5092,8 @@ impl GitPanel {
};
let name_row = h_flex()
.items_center()
.min_w_0()
.gap_1()
.flex_1()
.pl(px(entry.depth as f32 * TREE_INDENT))
.child(
Icon::new(folder_icon)
@@ -5096,28 +5105,21 @@ impl GitPanel {
h_flex()
.id(id)
.h(self.list_item_height())
.min_w_0()
.w_full()
.items_center()
.pl_3()
.pr_1()
.gap_1p5()
.justify_between()
.border_1()
.border_r_2()
.when(selected && self.focus_handle.is_focused(window), |el| {
el.border_color(cx.theme().colors().panel_focused_border)
})
.px(rems(0.75))
.overflow_hidden()
.flex_none()
.gap_1p5()
.bg(base_bg)
.hover(|this| this.bg(hover_bg))
.active(|this| this.bg(active_bg))
.on_click({
let key = entry.key.clone();
cx.listener(move |this, _event: &ClickEvent, window, cx| {
this.selected_entry = Some(ix);
this.toggle_directory(&key, window, cx);
})
})
.child(name_row.overflow_x_hidden())
.hover(|s| s.bg(hover_bg))
.active(|s| s.bg(active_bg))
.child(name_row)
.child(
div()
.id(checkbox_wrapper_id)
@@ -5156,12 +5158,18 @@ impl GitPanel {
}),
),
)
.on_click({
let key = entry.key.clone();
cx.listener(move |this, _event: &ClickEvent, window, cx| {
this.selected_entry = Some(ix);
this.toggle_directory(&key, window, cx);
})
})
.into_any_element()
}
fn path_formatted(
&self,
parent: Div,
directory: Option<String>,
path_color: Color,
file_name: String,
@@ -5170,42 +5178,32 @@ impl GitPanel {
git_path_style: GitPathStyle,
strikethrough: bool,
) -> Div {
parent
.when(git_path_style == GitPathStyle::FileNameFirst, |this| {
this.child(
self.entry_label(
match directory.as_ref().is_none_or(|d| d.is_empty()) {
true => file_name.clone(),
false => format!("{file_name} "),
},
label_color,
)
.when(strikethrough, Label::strikethrough),
)
})
.when_some(directory, |this, dir| {
match (
!dir.is_empty(),
git_path_style == GitPathStyle::FileNameFirst,
) {
(true, true) => this.child(
self.entry_label(dir, path_color)
.when(strikethrough, Label::strikethrough),
),
(true, false) => this.child(
self.entry_label(
format!("{dir}{}", path_style.primary_separator()),
path_color,
)
.when(strikethrough, Label::strikethrough),
),
_ => this,
}
})
.when(git_path_style == GitPathStyle::FilePathFirst, |this| {
this.child(
let file_name_first = git_path_style == GitPathStyle::FileNameFirst;
let file_path_first = git_path_style == GitPathStyle::FilePathFirst;
let file_name = format!("{} ", file_name);
h_flex()
.min_w_0()
.overflow_hidden()
.when(file_path_first, |this| this.flex_row_reverse())
.child(
div().flex_none().child(
self.entry_label(file_name, label_color)
.when(strikethrough, Label::strikethrough),
),
)
.when_some(directory, |this, dir| {
let path_name = if file_name_first {
dir
} else {
format!("{dir}{}", path_style.primary_separator())
};
this.child(
self.entry_label(path_name, path_color)
.truncate()
.when(strikethrough, Label::strikethrough),
)
})
}
@@ -5390,6 +5388,10 @@ impl Render for GitPanel {
.on_action(cx.listener(Self::select_next))
.on_action(cx.listener(Self::select_previous))
.on_action(cx.listener(Self::select_last))
.on_action(cx.listener(Self::first_entry))
.on_action(cx.listener(Self::next_entry))
.on_action(cx.listener(Self::previous_entry))
.on_action(cx.listener(Self::last_entry))
.on_action(cx.listener(Self::close_panel))
.on_action(cx.listener(Self::open_diff))
.on_action(cx.listener(Self::open_file))
@@ -5540,6 +5542,7 @@ impl GitPanelMessageTooltip {
window: &mut Window,
cx: &mut App,
) -> Entity<Self> {
let remote_url = repository.read(cx).default_remote_url();
cx.new(|cx| {
cx.spawn_in(window, async move |this, cx| {
let (details, workspace) = git_panel.update(cx, |git_panel, cx| {
@@ -5549,16 +5552,21 @@ impl GitPanelMessageTooltip {
)
})?;
let details = details.await?;
let provider_registry = cx
.update(|_, app| GitHostingProviderRegistry::default_global(app))
.ok();
let commit_details = crate::commit_tooltip::CommitDetails {
sha: details.sha.clone(),
author_name: details.author_name.clone(),
author_email: details.author_email.clone(),
commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
message: Some(ParsedCommitMessage {
message: details.message,
..Default::default()
}),
message: Some(ParsedCommitMessage::parse(
details.sha.to_string(),
details.message.to_string(),
remote_url.as_deref(),
provider_registry,
)),
};
this.update(cx, |this: &mut GitPanelMessageTooltip, cx| {
@@ -6855,7 +6863,7 @@ mod tests {
// the Project Diff's active path.
panel.update_in(cx, |panel, window, cx| {
panel.selected_entry = Some(1);
panel.open_diff(&Confirm, window, cx);
panel.open_diff(&menu::Confirm, window, cx);
});
cx.run_until_parked();
@@ -6871,6 +6879,128 @@ mod tests {
});
}
#[gpui::test]
async fn test_tree_view_reveals_collapsed_parent_on_select_entry_by_path(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"src": {
"a": {
"foo.rs": "fn foo() {}",
},
"b": {
"bar.rs": "fn bar() {}",
},
},
}),
)
.await;
fs.set_status_for_repo(
path!("/project/.git").as_ref(),
&[
("src/a/foo.rs", StatusCode::Modified.worktree()),
("src/b/bar.rs", StatusCode::Modified.worktree()),
],
);
let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
let workspace =
cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
cx.read(|cx| {
project
.read(cx)
.worktrees(cx)
.next()
.unwrap()
.read(cx)
.as_local()
.unwrap()
.scan_complete()
})
.await;
cx.executor().run_until_parked();
cx.update(|_window, cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.git_panel.get_or_insert_default().tree_view = Some(true);
})
});
});
let panel = workspace.update(cx, GitPanel::new).unwrap();
let handle = cx.update_window_entity(&panel, |panel, _, _| {
std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
});
cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
handle.await;
let src_key = panel.read_with(cx, |panel, _| {
panel
.entries
.iter()
.find_map(|entry| match entry {
GitListEntry::Directory(dir) if dir.key.path == repo_path("src") => {
Some(dir.key.clone())
}
_ => None,
})
.expect("src directory should exist in tree view")
});
panel.update_in(cx, |panel, window, cx| {
panel.toggle_directory(&src_key, window, cx);
});
panel.read_with(cx, |panel, _| {
let state = panel
.view_mode
.tree_state()
.expect("tree view state should exist");
assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(false));
});
let worktree_id =
cx.read(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
let project_path = ProjectPath {
worktree_id,
path: RelPath::unix("src/a/foo.rs").unwrap().into_arc(),
};
panel.update_in(cx, |panel, window, cx| {
panel.select_entry_by_path(project_path, window, cx);
});
panel.read_with(cx, |panel, _| {
let state = panel
.view_mode
.tree_state()
.expect("tree view state should exist");
assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(true));
let selected_ix = panel.selected_entry.expect("selection should be set");
assert!(state.logical_indices.contains(&selected_ix));
let selected_entry = panel
.entries
.get(selected_ix)
.and_then(|entry| entry.status_entry())
.expect("selected entry should be a status entry");
assert_eq!(selected_entry.repo_path, repo_path("src/a/foo.rs"));
});
}
fn assert_entry_paths(entries: &[GitListEntry], expected_paths: &[Option<&str>]) {
assert_eq!(entries.len(), expected_paths.len());
for (entry, expected_path) in entries.iter().zip(expected_paths) {

View File

@@ -10,6 +10,7 @@ use ui::{
};
mod blame_ui;
pub mod clone;
use git::{
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},

View File

@@ -1,4 +1,5 @@
use anyhow::Context as _;
use collections::HashSet;
use fuzzy::StringMatchCandidate;
use git::repository::Worktree as GitWorktree;
@@ -9,7 +10,11 @@ use gpui::{
actions, rems,
};
use picker::{Picker, PickerDelegate, PickerEditorPosition};
use project::{DirectoryLister, git_store::Repository};
use project::{
DirectoryLister,
git_store::Repository,
trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
};
use recent_projects::{RemoteConnectionModal, connect};
use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier};
use std::{path::PathBuf, sync::Arc};
@@ -219,7 +224,6 @@ impl WorktreeListDelegate {
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) {
let workspace = self.workspace.clone();
let Some(repo) = self.repo.clone() else {
return;
};
@@ -247,6 +251,7 @@ impl WorktreeListDelegate {
let branch = worktree_branch.to_string();
let window_handle = window.window_handle();
let workspace = self.workspace.clone();
cx.spawn_in(window, async move |_, cx| {
let Some(paths) = worktree_path.await? else {
return anyhow::Ok(());
@@ -257,8 +262,32 @@ impl WorktreeListDelegate {
repo.create_worktree(branch.clone(), path.clone(), commit)
})?
.await??;
let new_worktree_path = path.join(branch);
let final_path = path.join(branch);
workspace.update(cx, |workspace, cx| {
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
let repo_path = &repo.read(cx).snapshot().work_directory_abs_path;
let project = workspace.project();
if let Some((parent_worktree, _)) =
project.read(cx).find_worktree(repo_path, cx)
{
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
if trusted_worktrees.can_trust(parent_worktree.read(cx).id(), cx) {
trusted_worktrees.trust(
HashSet::from_iter([PathTrust::AbsPath(
new_worktree_path.clone(),
)]),
project
.read(cx)
.remote_connection_options(cx)
.map(RemoteHostLocation::from),
cx,
);
}
});
}
}
})?;
let (connection_options, app_state, is_local) =
workspace.update(cx, |workspace, cx| {
@@ -274,7 +303,7 @@ impl WorktreeListDelegate {
.update_in(cx, |workspace, window, cx| {
workspace.open_workspace_for_paths(
replace_current_window,
vec![final_path],
vec![new_worktree_path],
window,
cx,
)
@@ -283,7 +312,7 @@ impl WorktreeListDelegate {
} else if let Some(connection_options) = connection_options {
open_remote_worktree(
connection_options,
vec![final_path],
vec![new_worktree_path],
app_state,
window_handle,
replace_current_window,

View File

@@ -512,6 +512,8 @@ pub enum Model {
Gemini25Pro,
#[serde(rename = "gemini-3-pro-preview")]
Gemini3Pro,
#[serde(rename = "gemini-3-flash-preview")]
Gemini3Flash,
#[serde(rename = "custom")]
Custom {
name: String,
@@ -534,6 +536,7 @@ impl Model {
Self::Gemini25Flash => "gemini-2.5-flash",
Self::Gemini25Pro => "gemini-2.5-pro",
Self::Gemini3Pro => "gemini-3-pro-preview",
Self::Gemini3Flash => "gemini-3-flash-preview",
Self::Custom { name, .. } => name,
}
}
@@ -543,6 +546,7 @@ impl Model {
Self::Gemini25Flash => "gemini-2.5-flash",
Self::Gemini25Pro => "gemini-2.5-pro",
Self::Gemini3Pro => "gemini-3-pro-preview",
Self::Gemini3Flash => "gemini-3-flash-preview",
Self::Custom { name, .. } => name,
}
}
@@ -553,6 +557,7 @@ impl Model {
Self::Gemini25Flash => "Gemini 2.5 Flash",
Self::Gemini25Pro => "Gemini 2.5 Pro",
Self::Gemini3Pro => "Gemini 3 Pro",
Self::Gemini3Flash => "Gemini 3 Flash",
Self::Custom {
name, display_name, ..
} => display_name.as_ref().unwrap_or(name),
@@ -565,6 +570,7 @@ impl Model {
Self::Gemini25Flash => 1_048_576,
Self::Gemini25Pro => 1_048_576,
Self::Gemini3Pro => 1_048_576,
Self::Gemini3Flash => 1_048_576,
Self::Custom { max_tokens, .. } => *max_tokens,
}
}
@@ -575,6 +581,7 @@ impl Model {
Model::Gemini25Flash => Some(65_536),
Model::Gemini25Pro => Some(65_536),
Model::Gemini3Pro => Some(65_536),
Model::Gemini3Flash => Some(65_536),
Model::Custom { .. } => None,
}
}
@@ -599,6 +606,7 @@ impl Model {
budget_tokens: None,
}
}
Self::Gemini3Flash => GoogleModelMode::Default,
Self::Custom { mode, .. } => *mode,
}
}

View File

@@ -462,6 +462,17 @@ impl DispatchTree {
(bindings, partial, context_stack)
}
/// Find the bindings that can follow the current input sequence.
pub fn possible_next_bindings_for_input(
&self,
input: &[Keystroke],
context_stack: &[KeyContext],
) -> Vec<KeyBinding> {
self.keymap
.borrow()
.possible_next_bindings_for_input(input, context_stack)
}
/// dispatch_key processes the keystroke
/// input should be set to the value of `pending` from the previous call to dispatch_key.
/// This returns three instructions to the input handler:

View File

@@ -215,6 +215,41 @@ impl Keymap {
Some(contexts.len())
}
}
/// Find the bindings that can follow the current input sequence.
pub fn possible_next_bindings_for_input(
&self,
input: &[Keystroke],
context_stack: &[KeyContext],
) -> Vec<KeyBinding> {
let mut bindings = self
.bindings()
.enumerate()
.rev()
.filter_map(|(ix, binding)| {
let depth = self.binding_enabled(binding, context_stack)?;
let pending = binding.match_keystrokes(input);
match pending {
None => None,
Some(is_pending) => {
if !is_pending || is_no_action(&*binding.action) {
return None;
}
Some((depth, BindingIndex(ix), binding))
}
}
})
.collect::<Vec<_>>();
bindings.sort_by(|(depth_a, ix_a, _), (depth_b, ix_b, _)| {
depth_b.cmp(depth_a).then(ix_b.cmp(ix_a))
});
bindings
.into_iter()
.map(|(_, _, binding)| binding.clone())
.collect::<Vec<_>>()
}
}
#[cfg(test)]

View File

@@ -1025,13 +1025,26 @@ impl PlatformWindow for WaylandWindow {
fn resize(&mut self, size: Size<Pixels>) {
let state = self.borrow();
let state_ptr = self.0.clone();
let dp_size = size.to_device_pixels(self.scale_factor());
// Keep window geometry consistent with configure handling. On Wayland, window geometry is
// surface-local: resizing should not attempt to translate the window; the compositor
// controls placement. We also account for client-side decoration insets and tiling.
let window_geometry = inset_by_tiling(
Bounds {
origin: Point::default(),
size,
},
state.inset(),
state.tiling,
)
.map(|v| v.0 as i32)
.map_size(|v| if v <= 0 { 1 } else { v });
state.surface_state.set_geometry(
state.bounds.origin.x.0 as i32,
state.bounds.origin.y.0 as i32,
dp_size.width.0,
dp_size.height.0,
window_geometry.origin.x,
window_geometry.origin.y,
window_geometry.size.width,
window_geometry.size.height,
);
state

View File

@@ -52,6 +52,11 @@ pub fn apply_features_and_fallbacks(
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks,
);
for value in &values {
CFRelease(*value as _);
}
let new_descriptor = CTFontDescriptorCreateWithAttributes(attrs);
CFRelease(attrs as _);
let new_descriptor = CTFontDescriptor::wrap_under_create_rule(new_descriptor);

View File

@@ -1398,7 +1398,6 @@ extern "C" fn will_finish_launching(_this: &mut Object, _: Sel, _: id) {
}
extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) {
log::info!("did_finish_launching: entered");
unsafe {
let app: id = msg_send![APP_CLASS, sharedApplication];
app.setActivationPolicy_(NSApplicationActivationPolicyRegular);
@@ -1413,13 +1412,10 @@ extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) {
];
let platform = get_mac_platform(this);
log::info!("did_finish_launching: about to call finish_launching callback");
let callback = platform.0.lock().finish_launching.take();
if let Some(callback) = callback {
callback();
}
log::info!("did_finish_launching: finish_launching callback completed");
}
}
@@ -1462,7 +1458,6 @@ extern "C" fn on_keyboard_layout_change(this: &mut Object, _: Sel, _: id) {
}
extern "C" fn open_urls(this: &mut Object, _: Sel, _: id, urls: id) {
log::info!("open_urls: entered");
let urls = unsafe {
(0..urls.count())
.filter_map(|i| {
@@ -1477,7 +1472,6 @@ extern "C" fn open_urls(this: &mut Object, _: Sel, _: id, urls: id) {
})
.collect::<Vec<_>>()
};
log::info!("open_urls: received {} URLs: {:?}", urls.len(), urls);
let platform = unsafe { get_mac_platform(this) };
let mut lock = platform.0.lock();
if let Some(mut callback) = lock.open_urls.take() {

View File

@@ -110,13 +110,21 @@ impl ScreenCaptureSource for MacScreenCaptureSource {
let _: id = msg_send![configuration, setHeight: meta.resolution.height.0 as i64];
let stream: id = msg_send![stream, initWithFilter:filter configuration:configuration delegate:delegate];
// Stream contains filter, configuration, and delegate internally so we release them here
// to prevent a memory leak when steam is dropped
let _: () = msg_send![filter, release];
let _: () = msg_send![configuration, release];
let _: () = msg_send![delegate, release];
let (mut tx, rx) = oneshot::channel();
let mut error: id = nil;
let _: () = msg_send![stream, addStreamOutput:output type:SCStreamOutputTypeScreen sampleHandlerQueue:0 error:&mut error as *mut id];
if error != nil {
let message: id = msg_send![error, localizedDescription];
tx.send(Err(anyhow!("failed to add stream output {message:?}")))
let _: () = msg_send![stream, release];
let _: () = msg_send![output, release];
tx.send(Err(anyhow!("failed to add stream output {message:?}")))
.ok();
return rx;
}
@@ -132,8 +140,10 @@ impl ScreenCaptureSource for MacScreenCaptureSource {
};
Ok(Box::new(stream) as Box<dyn ScreenCaptureStream>)
} else {
let _: () = msg_send![stream, release];
let _: () = msg_send![output, release];
let message: id = msg_send![error, localizedDescription];
Err(anyhow!("failed to stop screen capture stream {message:?}"))
Err(anyhow!("failed to start screen capture stream {message:?}"))
};
if let Some(tx) = tx.borrow_mut().take() {
tx.send(result).ok();

View File

@@ -8,6 +8,7 @@ use anyhow::anyhow;
use cocoa::appkit::CGFloat;
use collections::HashMap;
use core_foundation::{
array::{CFArray, CFArrayRef},
attributed_string::CFMutableAttributedString,
base::{CFRange, TCFType},
number::CFNumber,
@@ -21,8 +22,10 @@ use core_graphics::{
};
use core_text::{
font::CTFont,
font_collection::CTFontCollectionRef,
font_descriptor::{
kCTFontSlantTrait, kCTFontSymbolicTrait, kCTFontWeightTrait, kCTFontWidthTrait,
CTFontDescriptor, kCTFontSlantTrait, kCTFontSymbolicTrait, kCTFontWeightTrait,
kCTFontWidthTrait,
},
line::CTLine,
string_attributes::kCTFontAttributeName,
@@ -97,7 +100,26 @@ impl PlatformTextSystem for MacTextSystem {
fn all_font_names(&self) -> Vec<String> {
let mut names = Vec::new();
let collection = core_text::font_collection::create_for_all_families();
let Some(descriptors) = collection.get_descriptors() else {
// NOTE: We intentionally avoid using `collection.get_descriptors()` here because
// it has a memory leak bug in core-text v21.0.0. The upstream code uses
// `wrap_under_get_rule` but `CTFontCollectionCreateMatchingFontDescriptors`
// follows the Create Rule (caller owns the result), so it should use
// `wrap_under_create_rule`. We call the function directly with correct memory management.
unsafe extern "C" {
fn CTFontCollectionCreateMatchingFontDescriptors(
collection: CTFontCollectionRef,
) -> CFArrayRef;
}
let descriptors: Option<CFArray<CTFontDescriptor>> = unsafe {
let array_ref =
CTFontCollectionCreateMatchingFontDescriptors(collection.as_concrete_TypeRef());
if array_ref.is_null() {
None
} else {
Some(CFArray::wrap_under_create_rule(array_ref))
}
};
let Some(descriptors) = descriptors else {
return names;
};
for descriptor in descriptors.into_iter() {

View File

@@ -1190,6 +1190,7 @@ impl PlatformWindow for MacWindow {
let (done_tx, done_rx) = oneshot::channel();
let done_tx = Cell::new(Some(done_tx));
let block = ConcreteBlock::new(move |answer: NSInteger| {
let _: () = msg_send![alert, release];
if let Some(done_tx) = done_tx.take() {
let _ = done_tx.send(answer.try_into().unwrap());
}

View File

@@ -182,6 +182,11 @@ impl LineWrapper {
// Cyrillic for Russian, Ukrainian, etc.
// https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode
matches!(c, '\u{0400}'..='\u{04FF}') ||
// Vietnamese (https://vietunicode.sourceforge.net/charset/)
matches!(c, '\u{1E00}'..='\u{1EFF}') || // Latin Extended Additional
matches!(c, '\u{0300}'..='\u{036F}') || // Combining Diacritical Marks
// Some other known special characters that should be treated as word characters,
// e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`,
// `2^3`, `a~b`, `a=1`, `Self::new`, etc.
@@ -618,7 +623,12 @@ mod tests {
#[track_caller]
fn assert_word(word: &str) {
for c in word.chars() {
assert!(LineWrapper::is_word_char(c), "assertion failed for '{}'", c);
assert!(
LineWrapper::is_word_char(c),
"assertion failed for '{}' (unicode 0x{:x})",
c,
c as u32
);
}
}
@@ -661,6 +671,8 @@ mod tests {
assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ");
// Cyrillic
assert_word("АБВГДЕЖЗИЙКЛМНОП");
// Vietnamese (https://github.com/zed-industries/zed/issues/23245)
assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng");
// non-word characters
assert_not_word("你好");

View File

@@ -4450,6 +4450,13 @@ impl Window {
dispatch_tree.highest_precedence_binding_for_action(action, &context_stack)
}
/// Find the bindings that can follow the current input sequence for the current context stack.
pub fn possible_bindings_for_input(&self, input: &[Keystroke]) -> Vec<KeyBinding> {
self.rendered_frame
.dispatch_tree
.possible_next_bindings_for_input(input, &self.context_stack())
}
fn context_stack_for_focus_handle(
&self,
focus_handle: &FocusHandle,

View File

@@ -32,6 +32,7 @@ async-trait.workspace = true
clock.workspace = true
collections.workspace = true
ec4rs.workspace = true
encoding_rs.workspace = true
fs.workspace = true
futures.workspace = true
fuzzy.workspace = true

View File

@@ -25,6 +25,7 @@ use anyhow::{Context as _, Result};
use clock::Lamport;
pub use clock::ReplicaId;
use collections::{HashMap, HashSet};
use encoding_rs::Encoding;
use fs::MTime;
use futures::channel::oneshot;
use gpui::{
@@ -131,6 +132,8 @@ pub struct Buffer {
change_bits: Vec<rc::Weak<Cell<bool>>>,
_subscriptions: Vec<gpui::Subscription>,
tree_sitter_data: Arc<TreeSitterData>,
encoding: &'static Encoding,
has_bom: bool,
}
#[derive(Debug)]
@@ -1100,6 +1103,8 @@ impl Buffer {
has_conflict: false,
change_bits: Default::default(),
_subscriptions: Vec::new(),
encoding: encoding_rs::UTF_8,
has_bom: false,
}
}
@@ -1383,6 +1388,26 @@ impl Buffer {
self.saved_mtime
}
/// Returns the character encoding of the buffer's file.
pub fn encoding(&self) -> &'static Encoding {
self.encoding
}
/// Sets the character encoding of the buffer.
pub fn set_encoding(&mut self, encoding: &'static Encoding) {
self.encoding = encoding;
}
/// Returns whether the buffer has a Byte Order Mark.
pub fn has_bom(&self) -> bool {
self.has_bom
}
/// Sets whether the buffer has a Byte Order Mark.
pub fn set_has_bom(&mut self, has_bom: bool) {
self.has_bom = has_bom;
}
/// Assign a language to the buffer.
pub fn set_language_async(&mut self, language: Option<Arc<Language>>, cx: &mut Context<Self>) {
self.set_language_(language, cfg!(any(test, feature = "test-support")), cx);

View File

@@ -125,7 +125,7 @@ pub fn init(on_headless_host: bool, cx: &mut App) {
let server_id = server.server_id();
let weak_lsp_store = cx.weak_entity();
log_store.copilot_log_subscription =
Some(server.on_notification::<copilot::request::LogMessage, _>(
Some(server.on_notification::<lsp::notification::LogMessage, _>(
move |params, cx| {
weak_lsp_store
.update(cx, |lsp_store, cx| {

View File

@@ -40,6 +40,7 @@ clock.workspace = true
collections.workspace = true
context_server.workspace = true
dap.workspace = true
encoding_rs.workspace = true
extension.workspace = true
fancy-regex.workspace = true
fs.workspace = true

View File

@@ -376,6 +376,8 @@ impl LocalBufferStore {
let text = buffer.as_rope().clone();
let line_ending = buffer.line_ending();
let encoding = buffer.encoding();
let has_bom = buffer.has_bom();
let version = buffer.version();
let buffer_id = buffer.remote_id();
let file = buffer.file().cloned();
@@ -387,7 +389,7 @@ impl LocalBufferStore {
}
let save = worktree.update(cx, |worktree, cx| {
worktree.write_file(path, text, line_ending, cx)
worktree.write_file(path, text, line_ending, encoding, has_bom, cx)
});
cx.spawn(async move |this, cx| {
@@ -630,7 +632,11 @@ impl LocalBufferStore {
})
.await;
cx.insert_entity(reservation, |_| {
Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite)
let mut buffer =
Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite);
buffer.set_encoding(loaded.encoding);
buffer.set_has_bom(loaded.has_bom);
buffer
})?
}
Err(error) if is_not_found_error(&error) => cx.new(|cx| {

View File

@@ -5948,6 +5948,11 @@ impl Repository {
self.pending_ops.edit(edits, ());
ids
}
pub fn default_remote_url(&self) -> Option<String> {
self.remote_upstream_url
.clone()
.or(self.remote_origin_url.clone())
}
}
fn get_permalink_in_rust_registry_src(

View File

@@ -65,6 +65,7 @@ use debugger::{
dap_store::{DapStore, DapStoreEvent},
session::Session,
};
use encoding_rs;
pub use environment::ProjectEnvironment;
#[cfg(test)]
use futures::future::join_all;
@@ -5461,13 +5462,22 @@ impl Project {
.await
.context("Failed to load settings file")?;
let has_bom = file.has_bom;
let new_text = cx.read_global::<SettingsStore, _>(|store, cx| {
store.new_text_for_update(file.text, move |settings| update(settings, cx))
})?;
worktree
.update(cx, |worktree, cx| {
let line_ending = text::LineEnding::detect(&new_text);
worktree.write_file(rel_path.clone(), new_text.into(), line_ending, cx)
worktree.write_file(
rel_path.clone(),
new_text.into(),
line_ending,
encoding_rs::UTF_8,
has_bom,
cx,
)
})?
.await
.context("Failed to write settings file")?;

View File

@@ -112,7 +112,7 @@ pub struct ContentPromptContextV2 {
pub language_name: Option<String>,
pub is_truncated: bool,
pub document_content: String,
pub rewrite_section: Option<String>,
pub rewrite_section: String,
pub diagnostic_errors: Vec<ContentPromptDiagnosticContext>,
}
@@ -310,7 +310,6 @@ impl PromptBuilder {
};
const MAX_CTX: usize = 50000;
let is_insert = range.is_empty();
let mut is_truncated = false;
let before_range = 0..range.start;
@@ -335,28 +334,19 @@ impl PromptBuilder {
for chunk in buffer.text_for_range(truncated_before) {
document_content.push_str(chunk);
}
if is_insert {
document_content.push_str("<insert_here></insert_here>");
} else {
document_content.push_str("<rewrite_this>\n");
for chunk in buffer.text_for_range(range.clone()) {
document_content.push_str(chunk);
}
document_content.push_str("\n</rewrite_this>");
document_content.push_str("<rewrite_this>\n");
for chunk in buffer.text_for_range(range.clone()) {
document_content.push_str(chunk);
}
document_content.push_str("\n</rewrite_this>");
for chunk in buffer.text_for_range(truncated_after) {
document_content.push_str(chunk);
}
let rewrite_section = if !is_insert {
let mut section = String::new();
for chunk in buffer.text_for_range(range.clone()) {
section.push_str(chunk);
}
Some(section)
} else {
None
};
let rewrite_section: String = buffer.text_for_range(range.clone()).collect();
let diagnostics = buffer.diagnostics_in_range::<_, Point>(range, false);
let diagnostic_errors: Vec<ContentPromptDiagnosticContext> = diagnostics
.map(|entry| {

View File

@@ -7,7 +7,6 @@ use crate::{
search_bar::{ActionButtonState, input_base_styles, render_action_button, render_text_input},
};
use any_vec::AnyVec;
use anyhow::Context as _;
use collections::HashMap;
use editor::{
DisplayPoint, Editor, EditorSettings, MultiBufferOffset,
@@ -634,15 +633,19 @@ impl BufferSearchBar {
.read(cx)
.as_singleton()
.expect("query editor should be backed by a singleton buffer");
query_buffer
.read(cx)
.set_language_registry(languages.clone());
cx.spawn(async move |buffer_search_bar, cx| {
use anyhow::Context as _;
let regex_language = languages
.language_for_name("regex")
.await
.context("loading regex language")?;
buffer_search_bar
.update(cx, |buffer_search_bar, cx| {
buffer_search_bar.regex_language = Some(regex_language);

View File

@@ -158,6 +158,9 @@ pub struct SettingsContent {
/// Default: false
pub disable_ai: Option<SaturatingBool>,
/// Settings for the which-key popup.
pub which_key: Option<WhichKeySettingsContent>,
/// Settings related to Vim mode in Zed.
pub vim: Option<VimSettingsContent>,
}
@@ -976,6 +979,19 @@ pub struct ReplSettingsContent {
pub max_columns: Option<usize>,
}
/// Settings for configuring the which-key popup behaviour.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct WhichKeySettingsContent {
/// Whether to show the which-key popup when holding down key combinations
///
/// Default: false
pub enabled: Option<bool>,
/// Delay in milliseconds before showing the which-key popup.
///
/// Default: 700
pub delay_ms: Option<u64>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
/// An ExtendingVec in the settings can only accumulate new values.
///

View File

@@ -215,6 +215,7 @@ impl VsCodeSettings {
vim: None,
vim_mode: None,
workspace: self.workspace_settings_content(),
which_key: None,
}
}

View File

@@ -1233,6 +1233,49 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
}
}).collect(),
}),
SettingsPageItem::SectionHeader("Which-key Menu"),
SettingsPageItem::SettingItem(SettingItem {
title: "Show Which-key Menu",
description: "Display the which-key menu with matching bindings while a multi-stroke binding is pending.",
field: Box::new(SettingField {
json_path: Some("which_key.enabled"),
pick: |settings_content| {
settings_content
.which_key
.as_ref()
.and_then(|settings| settings.enabled.as_ref())
},
write: |settings_content, value| {
settings_content
.which_key
.get_or_insert_default()
.enabled = value;
},
}),
metadata: None,
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Menu Delay",
description: "Delay in milliseconds before the which-key menu appears.",
field: Box::new(SettingField {
json_path: Some("which_key.delay_ms"),
pick: |settings_content| {
settings_content
.which_key
.as_ref()
.and_then(|settings| settings.delay_ms.as_ref())
},
write: |settings_content, value| {
settings_content
.which_key
.get_or_insert_default()
.delay_ms = value;
},
}),
metadata: None,
files: USER,
}),
SettingsPageItem::SectionHeader("Multibuffer"),
SettingsPageItem::SettingItem(SettingItem {
title: "Double Click In Multibuffer",

View File

@@ -1,6 +1,6 @@
use crate::{Supermaven, SupermavenCompletionStateId};
use anyhow::Result;
use edit_prediction_types::{Direction, EditPrediction, EditPredictionDelegate};
use edit_prediction_types::{EditPrediction, EditPredictionDelegate};
use futures::StreamExt as _;
use gpui::{App, Context, Entity, EntityId, Task};
use language::{Anchor, Buffer, BufferSnapshot};
@@ -189,15 +189,6 @@ impl EditPredictionDelegate for SupermavenEditPredictionDelegate {
}));
}
fn cycle(
&mut self,
_buffer: Entity<Buffer>,
_cursor_position: Anchor,
_direction: Direction,
_cx: &mut Context<Self>,
) {
}
fn accept(&mut self, _cx: &mut Context<Self>) {
reset_completion_cache(self, _cx);
}

View File

@@ -11,6 +11,7 @@ use alacritty_terminal::{
use log::{info, warn};
use regex::Regex;
use std::{
iter::{once, once_with},
ops::{Index, Range},
time::{Duration, Instant},
};
@@ -232,14 +233,17 @@ fn path_match<T>(
(line_end.line.0 - line_start.line.0 + 1) as usize * term.grid().columns(),
);
let first_cell = &term.grid()[line_start];
let mut prev_len = 0;
line.push(first_cell.c);
let mut start_offset = 0;
let mut prev_char_is_space = first_cell.c == ' ';
let mut hovered_point_byte_offset = None;
let mut hovered_word_start_offset = None;
let mut hovered_word_end_offset = None;
if !first_cell.flags.intersects(WIDE_CHAR_SPACERS) {
start_offset += first_cell.c.len_utf8();
if line_start == hovered {
hovered_point_byte_offset = Some(0);
if line_start == hovered {
hovered_point_byte_offset = Some(0);
if first_cell.c != ' ' {
hovered_word_start_offset = Some(0);
}
}
@@ -247,27 +251,44 @@ fn path_match<T>(
if cell.point > line_end {
break;
}
let is_spacer = cell.flags.intersects(WIDE_CHAR_SPACERS);
if cell.point == hovered {
debug_assert!(hovered_point_byte_offset.is_none());
if start_offset > 0 && cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
// If we hovered on a trailing spacer, back up to the end of the previous char's bytes.
start_offset -= 1;
if !cell.flags.intersects(WIDE_CHAR_SPACERS) {
prev_len = line.len();
match cell.c {
' ' | '\t' => {
if hovered_point_byte_offset.is_some() && !prev_char_is_space {
if hovered_word_end_offset.is_none() {
hovered_word_end_offset = Some(line.len());
}
}
line.push(' ');
prev_char_is_space = true;
}
c @ _ => {
if hovered_point_byte_offset.is_none() && prev_char_is_space {
hovered_word_start_offset = Some(line.len());
}
line.push(c);
prev_char_is_space = false;
}
}
hovered_point_byte_offset = Some(start_offset);
} else if cell.point < hovered && !is_spacer {
start_offset += cell.c.len_utf8();
}
if !is_spacer {
line.push(match cell.c {
'\t' => ' ',
c @ _ => c,
});
if cell.point == hovered {
debug_assert!(hovered_point_byte_offset.is_none());
hovered_point_byte_offset = Some(prev_len);
}
}
let line = line.trim_ascii_end();
let hovered_point_byte_offset = hovered_point_byte_offset?;
let hovered_word_range = {
let word_start_offset = hovered_word_start_offset.unwrap_or(0);
(word_start_offset != 0)
.then_some(word_start_offset..hovered_word_end_offset.unwrap_or(line.len()))
};
if line.len() <= hovered_point_byte_offset {
return None;
}
let found_from_range = |path_range: Range<usize>,
link_range: Range<usize>,
position: Option<(u32, Option<u32>)>| {
@@ -313,10 +334,27 @@ fn path_match<T>(
for regex in path_hyperlink_regexes {
let mut path_found = false;
for captures in regex.captures_iter(&line) {
for (line_start_offset, captures) in once(
regex
.captures_iter(&line)
.next()
.map(|captures| (0, captures)),
)
.chain(once_with(|| {
if let Some(hovered_word_range) = &hovered_word_range {
regex
.captures_iter(&line[hovered_word_range.clone()])
.next()
.map(|captures| (hovered_word_range.start, captures))
} else {
None
}
}))
.flatten()
{
path_found = true;
let match_range = captures.get(0).unwrap().range();
let (path_range, line_column) = if let Some(path) = captures.name("path") {
let (mut path_range, line_column) = if let Some(path) = captures.name("path") {
let parse = |name: &str| {
captures
.name(name)
@@ -330,10 +368,15 @@ fn path_match<T>(
} else {
(match_range.clone(), None)
};
let link_range = captures
let mut link_range = captures
.name("link")
.map_or_else(|| match_range.clone(), |link| link.range());
path_range.start += line_start_offset;
path_range.end += line_start_offset;
link_range.start += line_start_offset;
link_range.end += line_start_offset;
if !link_range.contains(&hovered_point_byte_offset) {
// No match, just skip.
continue;
@@ -638,9 +681,6 @@ mod tests {
test_path!(
"‹«🦀 multiple_👉same_line 🦀» 🚣«4» 🏛«2»: 🦀 multiple_same_line 🦀 🚣4 🏛2:"
);
test_path!(
"🦀 multiple_same_line 🦀 🚣4 🏛2 ‹«🦀 multiple_👉same_line 🦀» 🚣«4» 🏛«2»:"
);
// ls output (tab separated)
test_path!(
@@ -977,7 +1017,7 @@ mod tests {
use crate::TerminalSettings;
use alacritty_terminal::{
event::VoidListener,
grid::Dimensions,
grid::Scroll,
index::{Column, Point as AlacPoint},
term::test::mock_term,
term::{Term, search::Match},
@@ -986,14 +1026,20 @@ mod tests {
use std::{cell::RefCell, rc::Rc};
use util_macros::perf;
fn build_test_term(line: &str) -> (Term<VoidListener>, AlacPoint) {
let content = line.repeat(500);
let term = mock_term(&content);
let point = AlacPoint::new(
term.grid().bottommost_line() - 1,
Column(term.grid().last_column().0 / 2),
);
fn build_test_term(
line: &str,
repeat: usize,
hover_offset_column: usize,
) -> (Term<VoidListener>, AlacPoint) {
let content = line.repeat(repeat);
let mut term = mock_term(&content);
term.resize(TermSize {
columns: 1024,
screen_lines: 10,
});
term.scroll_display(Scroll::Top);
let point =
AlacPoint::new(Line(term.topmost_line().0 + 3), Column(hover_offset_column));
(term, point)
}
@@ -1002,11 +1048,14 @@ mod tests {
const LINE: &str = " Compiling terminal v0.1.0 (/Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal)\r\n";
thread_local! {
static TEST_TERM_AND_POINT: (Term<VoidListener>, AlacPoint) =
build_test_term(LINE);
build_test_term(LINE, 500, 50);
}
TEST_TERM_AND_POINT.with(|(term, point)| {
assert!(
find_from_grid_point_bench(term, *point).is_some(),
assert_eq!(
find_from_grid_point_bench(term, *point)
.map(|(path, ..)| path)
.unwrap_or_default(),
"/Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal",
"Hyperlink should have been found"
);
});
@@ -1017,11 +1066,14 @@ mod tests {
const LINE: &str = " --> /Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal/terminal.rs:1000:42\r\n";
thread_local! {
static TEST_TERM_AND_POINT: (Term<VoidListener>, AlacPoint) =
build_test_term(LINE);
build_test_term(LINE, 500, 50);
}
TEST_TERM_AND_POINT.with(|(term, point)| {
assert!(
find_from_grid_point_bench(term, *point).is_some(),
assert_eq!(
find_from_grid_point_bench(term, *point)
.map(|(path, ..)| path)
.unwrap_or_default(),
"/Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal/terminal.rs:1000:42",
"Hyperlink should have been found"
);
});
@@ -1032,11 +1084,111 @@ mod tests {
const LINE: &str = "Cargo.toml experiments notebooks rust-toolchain.toml tooling\r\n";
thread_local! {
static TEST_TERM_AND_POINT: (Term<VoidListener>, AlacPoint) =
build_test_term(LINE);
build_test_term(LINE, 500, 60);
}
TEST_TERM_AND_POINT.with(|(term, point)| {
assert!(
find_from_grid_point_bench(term, *point).is_some(),
assert_eq!(
find_from_grid_point_bench(term, *point)
.map(|(path, ..)| path)
.unwrap_or_default(),
"rust-toolchain.toml",
"Hyperlink should have been found"
);
});
}
#[perf]
// https://github.com/zed-industries/zed/pull/44407
pub fn pr_44407_hyperlink_benchmark() {
const LINE: &str = "-748, 706, 163, 222, -980, 949, 381, -568, 199, 501, 760, -821, 90, -451, 183, 867, -351, -810, -762, -109, 423, 84, 14, -77, -820, -345, 74, -791, 930, -618, -900, 862, -959, 289, -19, 471, -757, 793, 155, -554, 249, 830, 402, 732, -731, -866, -720, -703, -257, -439, 731, 872, -489, 676, -167, 613, -698, 415, -80, -453, -896, 333, -511, 621, -450, 624, -309, -575, 177, 141, 891, -104, -97, -367, -599, -675, 607, -225, -760, 552, -465, 804, 55, 282, 104, -929, -252,\
-311, 900, 550, 599, -80, 774, 553, 837, -395, 541, 953, 154, -396, -596, -111, -802, -221, -337, -633, -73, -527, -82, -658, -264, 222, 375, 434, 204, -756, -703, 303, 239, -257, -365, -351, 904, 364, -743, -484, 655, -542, 446, 888, 632, -167, -260, 716, 150, 806, 723, 513, -118, -323, -683, 983, -564, 358, -16, -287, 277, -607, 87, 365, -1, 164, 401, 257, 369, -893, 145, -969, 375, -53, 541, -408, -865, 753, 258, 337, -886, 593, -378, -528, 191, 204, 566, -61, -621, 769, 524, -628, 6,\
249, 896, -785, -776, 321, -681, 604, -740, 886, 426, -480, -983, 23, -247, 125, -666, 913, 842, -460, -797, -483, -58, -565, -587, -206, 197, 715, 764, -97, 457, -149, -226, 261, 194, -390, 431, 180, -778, 829, -657, -668, 397, 859, 152, -178, 677, -18, 687, -247, 96, 466, -572, 478, 622, -143, -25, -471, 265, 335, 957, 152, -951, -647, 670, 57, 152, -115, 206, 87, 629, -798, -125, -725, -31, 844, 398, -876, 44, 963, -211, 518, -8, -103, -999, 948, 823, 149, -803, 769, -236, -683, 527,\
-108, -36, 18, -437, 687, -305, -526, 972, -965, 276, 420, -259, -379, -142, -747, 600, -578, 197, 673, 890, 324, -931, 755, -765, -422, 785, -369, -110, -505, 532, -208, -438, 713, 110, 853, 996, -360, 823, 289, -699, 629, -661, 560, -329, -323, 439, 571, -537, 644, -84, 25, -536, -161, 112, 169, -922, -537, -734, -423, 37, 451, -149, 408, 18, -672, 206, -784, 444, 593, -241, 502, -259, -798, -352, -658, 712, -675, -734, 627, -620, 64, -554, 999, -537, -160, -641, 464, 894, 29, 322, 566,\
-510, -749, 982, 204, 967, -261, -986, -136, 251, -598, 995, -831, 891, 22, 761, -783, -415, 125, 470, -919, -97, -668, 85, 205, -175, -550, 502, 652, -468, 798, 775, -216, 89, -433, -24, -621, 877, -126, 951, 809, 782, 156, -618, -841, -463, 19, -723, -904, 550, 263, 991, -758, -114, 446, -731, -623, -634, 462, 48, 851, 333, -846, 480, 892, -966, -910, -436, 317, -711, -341, -294, 124, 238, -214, -281, 467, -950, -342, 913, -90, -388, -573, 740, -883, -451, 493, -500, 863, 930, 127, 530,\
-810, 540, 541, -664, -951, -227, -420, -476, -581, -534, 549, 253, 984, -985, -84, -521, 538, 484, -440, 371, 784, -306, -850, 530, -133, 251, -799, 446, -170, -243, -674, 769, 646, 778, -680, -714, -442, 804, 901, -774, 69, 307, -293, 755, 443, 224, -918, -771, 723, 40, 132, 568, -847, -47, 844, 69, 986, -293, -459, 313, 155, 331, 69, 280, -637, 569, 104, -119, -988, 252, 857, -590, 810, -891, 484, 566, -934, -587, -290, 566, 587, 489, 870, 280, 454, -252, 613, -701, -278, 195, -198,\
683, 533, -372, 707, -152, 371, 866, 609, -5, -372, -30, -694, 552, 192, 452, -663, 350, -985, 10, 884, 813, -592, -331, -470, 711, -941, 928, 379, -339, 220, 999, 376, 507, 179, 916, 84, 104, 392, 192, 299, -860, 218, -698, -919, -452, 37, 850, 5, -874, 287, 123, -746, -575, 776, -909, 118, 903, -275, 450, -996, -591, -920, -850, 453, -896, 73, 83, -535, -20, 287, -765, 442, 808, 45, 445, 202, 917, -208, 783, 790, -534, 373, -129, 556, -757, -69, 459, -163, -59, 265, -563, -889, 635,\
-583, -261, -790, 799, 826, 953, 85, 619, 334, 842, 672, -869, -4, -833, 315, 942, -524, 579, 926, 628, -404, 128, -629, 161, 568, -117, -526, 223, -876, 906, 176, -549, -317, 381, 375, -801, -416, 647, 335, 253, -386, -375, -254, 635, 352, 317, 398, -422, 111, 201, 220, 554, -972, 853, 378, 956, 942, -857, -289, -333, -180, 488, -814, -42, -595, 721, 39, 644, 721, -242, -44, 643, -457, -419, 560, -863, 974, 458, 222, -882, 526, -243, -318, -343, -707, -401, 117, 677, -489, 546, -903,\
-960, -881, -684, 125, -928, -995, -692, -773, 647, -718, -862, -814, 671, 664, -130, -856, -674, 653, 711, 194, -685, -160, 138, -27, -128, -671, -242, 526, 494, -674, 424, -921, -778, 313, -237, 332, 913, 252, 808, -936, 289, 755, 52, -139, 57, -19, -827, -775, -561, -14, 107, -84, 622, -303, -747, 258, -942, 290, 211, -919, -207, 797, 95, 794, -830, -181, -788, 757, 75, -946, -949, -988, 152, 340, 732, 886, -891, -642, -666, 321, -910, 841, 632, 298, 55, -349, 498, 287, -711, 97, 305,\
-974, -987, 790, -64, 605, -583, -821, 345, 887, -861, 548, 894, 288, 452, 556, -448, 813, 420, 545, 967, 127, -947, 19, -314, -607, -513, -851, 254, -290, -938, -783, -93, 474, 368, -485, -935, -539, 81, 404, -283, 779, 345, -164, 53, 563, -771, 911, -323, 522, -998, 315, 415, 460, 58, -541, -878, -152, -886, 201, -446, -810, 549, -142, -575, -632, 521, 549, 209, -681, 998, 798, -611, -919, -708, -4, 677, -172, 588, 750, -435, 508, 609, 498, -535, -691, -738, 85, 615, 705, 169, 425,\
-669, -491, -783, 73, -847, 228, -981, -812, -229, 950, -904, 175, -438, 632, -556, 910, 173, 576, -751, -53, -169, 635, 607, -944, -13, -84, 105, -644, 984, 935, 259, -445, 620, -405, 832, 167, 114, 209, -181, -944, -496, 693, -473, 137, 38, -873, -334, -353, -57, 397, 944, 698, 811, -401, 712, -667, 905, 276, -653, 368, -543, -349, 414, 287, 894, 935, 461, 55, 741, -623, -660, -773, 617, 834, 278, -121, 52, 495, -855, -440, -210, -99, 279, -661, 540, 934, 540, 784, 895, 268, -503, 513,\
-484, -352, 528, 341, -451, 885, -71, 799, -195, -885, -585, -233, 92, 453, 994, 464, 694, 190, -561, -116, 675, -775, -236, 556, -110, -465, 77, -781, 507, -960, -410, 229, -632, 717, 597, 429, 358, -430, -692, -825, 576, 571, 758, -891, 528, -267, 190, -869, 132, -811, 796, 750, -596, -681, 870, 360, 969, 860, -412, -567, 694, -86, -498, 38, -178, -583, -778, 412, 842, -586, 722, -192, 350, 363, 81, -677, -163, 564, 543, 671, 110, 314, 739, -552, -224, -644, 922, 685, 134, 613, 793,\
-363, -244, -284, -257, -561, 418, 988, 333, 110, -966, 790, 927, 536, -620, -309, -358, 895, -867, -796, -357, 308, -740, 287, -732, -363, -969, 658, 711, 511, 256, 590, -574, 815, -845, -84, 546, -581, -71, -334, -890, 652, -959, 320, -236, 445, -851, 825, -756, -4, 877, 308, 573, -117, 293, 686, -483, 391, 342, -550, -982, 713, 886, 552, 474, -673, 283, -591, -383, 988, 435, -131, 708, -326, -884, 87, 680, -818, -408, -486, 813, -307, -799, 23, -497, 802, -146, -100, 541, 7, -493, 577,\
50, -270, 672, 834, 111, -788, 247, 337, 628, -33, -964, -519, 683, 54, -703, 633, -127, -448, 759, -975, 696, 2, -870, -760, 67, 696, 306, 750, 615, 155, -933, -568, 399, 795, 164, -460, 205, 439, -526, -691, 35, -136, -481, -63, 73, -598, 748, 133, 874, -29, 4, -73, 472, 389, 962, 231, -328, 240, 149, 959, 46, -207, 72, -514, -608, 0, -14, 32, 374, -478, -806, 919, -729, -286, 652, 109, 509, -879, -979, -865, 584, -92, -346, -992, 781, 401, 575, 993, -746, -33, 684, -683, 750, -105,\
-425, -508, -627, 27, 770, -45, 338, 921, -139, -392, -933, 634, 563, 224, -780, 921, 991, 737, 22, 64, 414, -249, -687, 869, 50, 759, -97, 515, 20, -775, -332, 957, 138, -542, -835, 591, -819, 363, -715, -146, -950, -641, -35, -435, -407, -548, -984, 383, -216, -559, 853, 4, -410, -319, -831, -459, -628, -819, -324, 755, 696, -192, 238, -234, -724, -445, 915, 302, -708, 484, 224, -641, 25, -771, 528, -106, -744, -588, 913, -554, -515, -239, -843, -812, -171, 721, 543, -269, 440, 151,\
996, -723, -557, -522, -280, -514, -593, 208, 715, 404, 353, 270, -483, -785, 318, -313, 798, 638, 764, 748, -929, -827, -318, -56, 389, -546, -958, -398, 463, -700, 461, 311, -787, -488, 877, 456, 166, 535, -995, -189, -715, 244, 40, 484, 212, -329, -351, 638, -69, -446, -292, 801, -822, 490, -486, -185, 790, 370, -340, 401, -656, 584, 561, -749, 269, -19, -294, -111, 975, 874, -73, 851, 231, -331, -684, 460, 765, -654, -76, 10, 733, 520, 521, 416, -958, -202, -186, -167, 175, 343, -50,\
673, -763, -854, -977, -17, -853, -122, -25, 180, 149, 268, 874, -816, -745, 747, -303, -959, 390, 509, 18, -66, 275, -277, 9, 837, -124, 989, -542, -649, -845, 894, 926, 997, -847, -809, -579, -96, -372, 766, 238, -251, 503, 559, 276, -281, -102, -735, 815, 109, 175, -10, 128, 543, -558, -707, 949, 996, -422, -506, 252, 702, -930, 552, -961, 584, -79, -177, 341, -275, 503, -21, 677, -545, 8, -956, -795, -870, -254, 170, -502, -880, 106, 174, 459, 603, -600, -963, 164, -136, -641, -309,\
-380, -707, -727, -10, 727, 952, 997, -731, -133, 269, 287, 855, 716, -650, 479, 299, -839, -308, -782, 769, 545, 663, -536, -115, 904, -986, -258, -562, 582, 664, 408, -525, -889, 471, -370, -534, -220, 310, 766, 931, -193, -897, -192, -74, -365, -256, -359, -328, 658, -691, -431, 406, 699, 425, 713, -584, -45, -588, 289, 658, -290, -880, -987, -444, 371, 904, -155, 81, -278, -708, -189, -78, 655, 342, -998, -647, -734, -218, 726, 619, 663, 744, 518, 60, -409, 561, -727, -961, -306,\
-147, -550, 240, -218, -393, 267, 724, 791, -548, 480, 180, -631, 825, -170, 107, 227, -691, 905, -909, 359, 227, 287, 909, 632, -89, -522, 80, -429, 37, 561, -732, -474, 565, -798, -460, 188, 507, -511, -654, 212, -314, -376, -997, -114, -708, 512, -848, 781, 126, -956, -298, 354, -400, -121, 510, 445, 926, 27, -708, 676, 248, 834, 542, 236, -105, -153, 102, 128, 96, -348, -626, 598, 8, 978, -589, -461, -38, 381, -232, -817, 467, 356, -151, -460, 429, -408, 425, 618, -611, -247, 819,\
963, -160, 1000, 141, -647, -875, 108, 790, -127, 463, -37, -195, -542, 12, 845, -384, 770, -129, 315, 826, -942, 430, 146, -170, -583, -903, -489, 497, -559, -401, -29, -129, -411, 166, 942, -646, -862, -404, 785, 777, -111, -481, -738, 490, 741, -398, 846, -178, -509, -661, 748, 297, -658, -567, 531, 427, -201, -41, -808, -668, 782, -860, -324, 249, 835, -234, 116, 542, -201, 328, 675, 480, -906, 188, 445, 63, -525, 811, 277, 133, 779, -680, 950, -477, -306, -64, 552, -890, -956, 169,\
442, 44, -169, -243, -242, 423, -884, -757, -403, 739, -350, 383, 429, 153, -702, -725, 51, 310, 857, -56, 538, 46, -311, 132, -620, -297, -124, 534, 884, -629, -117, 506, -837, -100, -27, -381, -735, 262, 843, 703, 260, -457, 834, 469, 9, 950, 59, 127, -820, 518, 64, -783, 659, -608, -676, 802, 30, 589, 246, -369, 361, 347, 534, -376, 68, 941, 709, 264, 384, 481, 628, 199, -568, -342, -337, 853, -804, -858, -169, -270, 641, -344, 112, 530, -773, -349, -135, -367, -350, -756, -911, 180,\
-660, 116, -478, -265, -581, 510, 520, -986, 935, 219, 522, 744, 47, -145, 917, 638, 301, 296, 858, -721, 511, -816, 328, 473, 441, 697, -260, -673, -379, 893, 458, 154, 86, 905, 590, 231, -717, -179, 79, 272, -439, -192, 178, -200, 51, 717, -256, -358, -626, -518, -314, -825, -325, 588, 675, -892, -798, 448, -518, 603, -23, 668, -655, 845, -314, 783, -347, -496, 921, 893, -163, -748, -906, 11, -143, -64, 300, 336, 882, 646, 533, 676, -98, -148, -607, -952, -481, -959, -874, 764, 537,\
736, -347, 646, -843, 966, -916, -718, -391, -648, 740, 755, 919, -608, 388, -655, 68, 201, 675, -855, 7, -503, 881, 760, 669, 831, 721, -564, -445, 217, 331, 970, 521, 486, -254, 25, -259, 336, -831, 252, -995, 908, -412, -240, 123, -478, 366, 264, -504, -843, 632, -288, 896, 301, 423, 185, 318, 380, 457, -450, -162, -313, 673, -963, 570, 433, -548, 107, -39, -142, -98, -884, -3, 599, -486, -926, 923, -82, 686, 290, 99, -382, -789, 16, 495, 570, 284, 474, -504, -201, -178, -1, 592, 52,\
827, -540, -151, -991, 130, 353, -420, -467, -661, 417, -690, 942, 936, 814, -566, -251, -298, 341, -139, 786, 129, 525, -861, 680, 955, -245, -50, 331, 412, -38, -66, 611, -558, 392, -629, -471, -68, -535, 744, 495, 87, 558, 695, 260, -308, 215, -464, 239, -50, 193, -540, 184, -8, -194, 148, 898, -557, -21, 884, 644, -785, -689, -281, -737, 267, 50, 206, 292, 265, 380, -511, 310, 53, 375, -497, -40, 312, -606, -395, 142, 422, 662, -584, 72, 144, 40, -679, -593, 581, 689, -829, 442, 822,\
977, -832, -134, -248, -207, 248, 29, 259, 189, 592, -834, -866, 102, 0, 340, 25, -354, -239, 420, -730, -992, -925, -314, 420, 914, 607, -296, -415, -30, 813, 866, 153, -90, 150, -81, 636, -392, -222, -835, 482, -631, -962, -413, -727, 280, 686, -382, 157, -404, -511, -432, 455, 58, 108, -408, 290, -829, -252, 113, 550, -935, 925, 422, 38, 789, 361, 487, -460, -769, -963, -285, 206, -799, -488, -233, 416, 143, -456, 753, 520, 599, 621, -168, 178, -841, 51, 952, 374, 166, -300, -576, 844,\
-656, 90, 780, 371, 730, -896, -895, -386, -662, 467, -61, 130, -362, -675, -113, 135, -761, -55, 408, 822, 675, -347, 725, 114, 952, -510, -972, 390, -413, -277, -52, 315, -80, 401, -712, 147, -202, 84, 214, -178, 970, -571, -210, 525, -887, -863, 504, 192, 837, -594, 203, -876, -209, 305, -826, 377, 103, -928, -803, -956, 949, -868, -547, 824, -994, 516, 93, -524, -866, -890, -988, -501, 15, -6, 413, -825, 304, -818, -223, 525, 176, 610, 828, 391, 940, 540, -831, 650, 438, 589, 941, 57,\
523, 126, 221, 860, -282, -262, -226, 764, 743, -640, 390, 384, -434, 608, -983, 566, -446, 618, 456, -176, -278, 215, 871, -180, 444, -931, -200, -781, 404, 881, 780, -782, 517, -739, -548, -811, 201, -95, -249, -228, 491, -299, 700, 964, -550, 108, 334, -653, 245, -293, -552, 350, -685, -415, -818, 216, -194, -255, 295, 249, 408, 351, 287, 379, 682, 231, -693, 902, -902, 574, 937, -708, -402, -460, 827, -268, 791, 343, -780, -150, -738, 920, -430, -88, -361, -588, -727, -47, -297, 662,\
-840, -637, -635, 916, -857, 938, 132, -553, 391, -522, 640, 626, 690, 833, 867, -555, 577, 226, 686, -44, 0, -965, 651, -1, 909, 595, -646, 740, -821, -648, -962, 927, -193, 159, 490, 594, -189, 707, -884, 759, -278, -160, -566, -340, 19, 862, -440, 445, -598, 341, 664, -311, 309, -159, 19, -672, 705, -646, 976, 247, 686, -830, -27, -667, 81, 399, -423, -567, 945, 38, 51, 740, 621, 204, -199, -908, -593, 424, 250, -561, 695, 9, 520, 878, 120, -109, 42, -375, -635, -711, -687, 383, -278,\
36, 970, 925, 864, 836, 309, 117, 89, 654, -387, 346, -53, 617, -164, -624, 184, -45, 852, 498, -513, 794, -682, -576, 13, -147, 285, -776, -886, -96, 483, 994, -188, 346, -629, -848, 738, 51, 128, -898, -753, -906, 270, -203, -577, 48, -243, -210, 666, 353, 636, -954, 862, 560, -944, -877, -137, 440, -945, -316, 274, -211, -435, 615, -635, -468, 744, 948, -589, 525, 757, -191, -431, 42, 451, -160, -827, -991, 324, 697, 342, -610, 894, -787, -384, 872, 734, 878, 70, -260, 57, 397, -518,\
629, -510, -94, 207, 214, -625, 106, -882, -575, 908, -650, 723, -154, 45, 108, -69, -565, 927, -68, -351, 707, -282, 429, -889, -596, 848, 578, -492, 41, -822, -992, 168, -286, -780, 970, 597, -293, -12, 367, 708, -415, 194, -86, -390, 224, 69, -368, -674, 1000, -672, 356, -202, -169, 826, 476, -285, 29, -448, 545, 186, 319, 67, 705, 412, 225, -212, -351, -391, -783, -9, 875, -59, -159, -123, -151, -296, 871, -638, 359, 909, -945, 345, -16, -562, -363, -183, -625, -115, -571, -329, 514,\
99, 263, 463, -39, 597, -652, -349, 246, 77, -127, -563, -879, -30, 756, 777, -865, 675, -813, -501, 871, -406, -627, 834, -609, -205, -812, 643, -204, 291, -251, -184, -584, -541, 410, -573, -600, 908, -871, -687, 296, -713, -139, -778, -790, 347, -52, -400, 407, -653, 670, 39, -856, 904, 433, 392, 590, -271, -144, -863, 443, 353, 468, -544, 486, -930, 458, -596, -890, 163, 822, 768, 980, -783, -792, 126, 386, 367, -264, 603, -61, 728, 160, -4, -837, 832, 591, 436, 518, 796, -622, -867,\
-669, -947, 253, 100, -792, 841, 413, 833, -249, -550, 282, -825, 936, -348, 898, -451, -283, 818, -237, 630, 216, -499, -637, -511, 767, -396, 221, 958, -586, -920, 401, -313, -580, -145, -270, 118, 497, 426, -975, 480, -445, -150, -721, -929, 439, -893, 902, 960, -525, -793, 924, 563, 683, -727, -86, 309, 432, -762, -345, 371, -617, 149, -215, -228, 505, 593, -20, -292, 704, -999, 149, -104, 819, -414, -443, 517, -599, -5, 145, -24, -993, -283, 904, 174, -112, -276, -860, 44, -257,\
-931, -821, -667, 540, 421, 485, 531, 407, 833, 431, -415, 878, 503, -901, 639, -608, 896, 860, 927, 424, 113, -808, -323, 729, 382, -922, 548, -791, -379, 207, 203, 559, 537, 137, 999, -913, -240, 942, 249, 616, 775, -4, 915, 855, -987, -234, -384, 948, -310, -542, 125, -289, -599, 967, -492, -349, -552, 562, -926, 632, -164, 217, -165, -496, 847, 684, -884, 457, -748, -745, -38, 93, 961, 934, 588, 366, -130, 851, -803, -811, -211, 428, 183, -469, 888, 596, -475, -899, -681, 508, 184,\
921, 863, -610, -416, -119, -966, -686, 210, 733, 715, -889, -925, -434, -566, -455, 596, -514, 983, 755, -194, -802, -313, 91, -541, 808, -834, 243, -377, 256, 966, -402, -773, -308, -605, 266, 866, 118, -425, -531, 498, 666, 813, -267, 830, 69, -869, -496, 735, 28, 488, -645, -493, -689, 170, -940, 532, 844, -658, -617, 408, -200, 764, -665, 568, 342, 621, 908, 471, 280, 859, 709, 898, 81, -547, 406, 514, -595, 43, -824, -696, -746, -429, -59, -263, -813, 233, 279, -125, 687, -418,\
-530, 409, 614, 803, -407, 78, -676, -39, -887, -141, -292, 270, -343, 400, 907, 588, 668, 899, 973, 103, -101, -11, 397, -16, 165, 705, -410, -585, 316, 391, -346, -336, 957, -118, -538, -441, -845, 121, 591, -359, -188, -362, -208, 27, -925, -157, -495, -177, -580, 9, 531, -752, 94, 107, 820, 769, -500, 852, 617, 145, 355, 34, -463, -265, -709, -111, -855, -405, 560, 470, 3, -177, -164, -249, 450, 662, 841, -689, -509, 987, -33, 769, 234, -2, 203, 780, 744, -895, 497, -432, -406, -264,\
-71, 124, 778, -897, 495, 127, -76, 52, -768, 205, 464, -992, 801, -83, -806, 545, -316, 146, 772, 786, 289, -936, 145, -30, -722, -455, 270, 444, 427, -482, 383, -861, 36, 630, -404, 83, 864, 743, -351, -846, 315, -837, 357, -195, 450, -715, 227, -942, 740, -519, 476, 716, 713, 169, 492, -112, -49, -931, 866, 95, -725, 198, -50, -17, -660, 356, -142, -781, 53, 431, 720, 143, -416, 446, -497, 490, -96, 157, 239, 487, -337, -224, -445, 813, 92, -22, 603, 424, 952, -632, -367, 898, -927,\
884, -277, -187, -777, 537, -575, -313, 347, -33, 800, 672, -919, -541, 5, -270, -94, -265, -793, -183, -761, -516, -608, -218, 57, -889, -912, 508, 93, -90, 34, 530, 201, 999, -37, -186, -62, -980, 239, 902, 983, -287, -634, 524, -772, 470, -961, 32, 162, 315, -411, 400, -235, -283, -787, -703, 869, 792, 543, -274, 239, 733, -439, 306, 349, 579, -200, -201, -824, 384, -246, 133, -508, 770, -102, 957, -825, 740, 748, -376, 183, -426, 46, 668, -886, -43, -174, 672, -419, 390, 927, 1000,\
318, 886, 47, 908, -540, -825, -5, 314, -999, 354, -603, 966, -633, -689, 985, 534, -290, 167, -652, -797, -612, -79, 488, 622, -464, -950, 595, 897, 704, -238, -395, 125, 831, -180, 226, -379, 310, 564, 56, -978, 895, -61, 686, -251, 434, -417, 161, -512, 752, 528, -589, -425, 66, -925, -157, 1000, 96, 256, -239, -784, -882, -464, -909, 663, -177, -678, -441, 669, -564, -201, -121, -743, 187, -107, -768, -682, 355, 161, 411, 984, -954, 166, -842, -755, 267, -709, 372, -699, -272, -850,\
403, -839, 949, 622, -62, 51, 917, 70, 528, -558, -632, 832, 276, 61, -445, -195, 960, 846, -474, 764, 879, -411, 948, -62, -592, -123, -96, -551, -555, -724, 849, 250, -808, -732, 797, -839, -554, 306, -919, 888, 484, -728, 152, -122, -287, 16, -345, -396, -268, -963, -500, 433, 343, 418, -480, 828, 594, 821, -9, 933, -230, 707, -847, -610, -748, -234, 688, 935, 713, 865, -743, 293, -143, -20, 928, -906, -762, 528, 722, 412, -70, 622, -245, 539, -686, 730, -866, -705, 28, -916, -623,\
-768, -614, -915, -123, -183, 680, -223, 515, -37, -235, -5, 260, 347, -239, -322, -861, -848, -936, 945, 721, -580, -639, 780, -153, -26, 685, 177, 587, 307, -915, 435, 658, 539, -229, -719, -171, -858, 162, 734, -539, -437, 246, 639, 765, -477, -342, -209, -284, -779, -414, -452, 914, 338, -83, 759, 567, 266, -485, 14, 225, 347, -432, -242, 997, -365, -764, 119, -641, -416, -388, -436, -388, -54, -649, -571, -920, -477, 714, -363, 836, 369, 702, 869, 503, -287, -679, 46, -666, -202,\
-602, 71, -259, 967, 601, -571, -830, -993, -271, 281, -494, 482, -180, 572, 587, -651, -566, -448, -228, 511, -924, 832, -52, -712, 402, -644, -533, -865, 269, 965, 56, 675, 179, -338, -272, 614, 602, -283, 303, -70, 909, -942, 117, 839, 468, 813, -765, 884, -697, -813, 352, 374, -705, -295, 633, 211, -754, 597, -941, -142, -393, -469, -653, 688, 996, 911, 214, 431, 453, -141, 874, -81, -258, -735, -3, -110, -338, -929, -182, -306, -104, -840, -588, -759, -157, -801, 848, -698, 627, 914,\
-33, -353, 425, 150, -798, 553, 934, -778, -196, -132, 808, 745, -894, 144, 213, 662, 273, -79, 454, -60, -467, 48, -15, -807, 69, -930, 749, 559, -867, -103, 258, -677, 750, -303, 846, -227, -936, 744, -770, 770, -434, 594, -477, 589, -612, 535, 357, -623, 683, 369, 905, 980, -410, -663, 762, -888, -563, -845, 843, 353, -491, 996, -255, -336, -132, 695, -823, 289, -143, 365, 916, 877, 245, -530, -848, -804, -118, -108, 847, 620, -355, 499, 881, 92, -640, 542, 38, 626, -260, -34, -378,\
598, 890, 305, -118, 711, -385, 600, -570, 27, -129, -893, 354, 459, 374, 816, 470, 356, 661, 877, 735, -286, -780, 620, 943, -169, -888, 978, 441, -667, -399, 662, 249, 137, 598, -863, -453, 722, -815, -251, -995, -294, -707, 901, 763, 977, 137, 431, -994, 905, 593, 694, 444, -626, -816, 252, 282, 616, 841, 360, -932, 817, -908, 50, 394, -120, -786, -338, 499, -982, -95, -454, 838, -312, 320, -127, -653, 53, 16, 988, -968, -151, -369, -836, 293, -271, 483, 18, 724, -204, -965, 245, 310,\
987, 552, -835, -912, -861, 254, 560, 124, 145, 798, 178, 476, 138, -311, 151, -907, -886, -592, 728, -43, -489, 873, -422, -439, -489, 375, -703, -459, 338, 418, -25, 332, -454, 730, -604, -800, 37, -172, -197, -568, -563, -332, 228, -182, 994, -123, 444, -567, 98, 78, 0, -504, -150, 88, -936, 199, -651, -776, 192, 46, 526, -727, -991, 534, -659, -738, 256, -894, 965, -76, 816, 435, -418, 800, 838, 67, -733, 570, 112, -514, -416\r\
";
thread_local! {
static TEST_TERM_AND_POINT: (Term<VoidListener>, AlacPoint) =
build_test_term(&LINE, 5, 50);
}
TEST_TERM_AND_POINT.with(|(term, point)| {
assert_eq!(
find_from_grid_point_bench(term, *point)
.map(|(path, ..)| path)
.unwrap_or_default(),
"392",
"Hyperlink should have been found"
);
});
}
#[perf]
// https://github.com/zed-industries/zed/issues/44510
pub fn issue_44510_hyperlink_benchmark() {
const LINE: &str = "..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\
...............................................E.\r\
";
thread_local! {
static TEST_TERM_AND_POINT: (Term<VoidListener>, AlacPoint) =
build_test_term(&LINE, 5, 50);
}
TEST_TERM_AND_POINT.with(|(term, point)| {
assert_eq!(
find_from_grid_point_bench(term, *point)
.map(|(path, ..)| path)
.unwrap_or_default(),
LINE.trim_end_matches(['.', '\r', '\n']),
"Hyperlink should have been found"
);
});

View File

@@ -8,8 +8,8 @@ mod terminal_slash_command;
use assistant_slash_command::SlashCommandRegistry;
use editor::{EditorSettings, actions::SelectAll, blink_manager::BlinkManager};
use gpui::{
Action, AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render,
Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, FocusHandle,
Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render,
ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred, div,
};
use persistence::TERMINAL_DB;
@@ -687,10 +687,30 @@ impl TerminalView {
///Attempt to paste the clipboard into the terminal
fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
if let Some(clipboard_string) = cx.read_from_clipboard().and_then(|item| item.text()) {
self.terminal
.update(cx, |terminal, _cx| terminal.paste(&clipboard_string));
let Some(clipboard) = cx.read_from_clipboard() else {
return;
};
if clipboard.entries().iter().any(|entry| match entry {
ClipboardEntry::Image(image) => !image.bytes.is_empty(),
_ => false,
}) {
self.forward_ctrl_v(cx);
return;
}
if let Some(text) = clipboard.text() {
self.terminal
.update(cx, |terminal, _cx| terminal.paste(&text));
}
}
/// Emits a raw Ctrl+V so TUI agents can read the OS clipboard directly
/// and attach images using their native workflows.
fn forward_ctrl_v(&self, cx: &mut Context<Self>) {
self.terminal.update(cx, |term, _| {
term.input(vec![0x16]);
});
}
fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context<Self>) {

View File

@@ -330,10 +330,12 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
let Some(range) = range.buffer_range(vim, editor, window, cx).ok() else {
return;
};
let Some((line_ending, text, whole_buffer)) = editor.buffer().update(cx, |multi, cx| {
let Some((line_ending, encoding, has_bom, text, whole_buffer)) = editor.buffer().update(cx, |multi, cx| {
Some(multi.as_singleton()?.update(cx, |buffer, _| {
(
buffer.line_ending(),
buffer.encoding(),
buffer.has_bom(),
buffer.as_rope().slice_rows(range.start.0..range.end.0 + 1),
range.start.0 == 0 && range.end.0 + 1 >= buffer.row_count(),
)
@@ -429,7 +431,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
return;
};
worktree
.write_file(path.into_arc(), text.clone(), line_ending, cx)
.write_file(path.into_arc(), text.clone(), line_ending, encoding, has_bom, cx)
.detach_and_prompt_err("Failed to write lines", window, cx, |_, _, _| None);
});
})

View File

@@ -0,0 +1,23 @@
[package]
name = "which_key"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/which_key.rs"
doctest = false
[dependencies]
command_palette.workspace = true
gpui.workspace = true
serde.workspace = true
settings.workspace = true
theme.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true

View File

@@ -0,0 +1 @@
../../LICENSE-GPL

View File

@@ -0,0 +1,98 @@
//! Which-key support for Zed.
mod which_key_modal;
mod which_key_settings;
use gpui::{App, Keystroke};
use settings::Settings;
use std::{sync::LazyLock, time::Duration};
use util::ResultExt;
use which_key_modal::WhichKeyModal;
use which_key_settings::WhichKeySettings;
use workspace::Workspace;
pub fn init(cx: &mut App) {
WhichKeySettings::register(cx);
cx.observe_new(|_: &mut Workspace, window, cx| {
let Some(window) = window else {
return;
};
let mut timer = None;
cx.observe_pending_input(window, move |workspace, window, cx| {
if window.pending_input_keystrokes().is_none() {
if let Some(modal) = workspace.active_modal::<WhichKeyModal>(cx) {
modal.update(cx, |modal, cx| modal.dismiss(cx));
};
timer.take();
return;
}
let which_key_settings = WhichKeySettings::get_global(cx);
if !which_key_settings.enabled {
return;
}
let delay_ms = which_key_settings.delay_ms;
timer.replace(cx.spawn_in(window, async move |workspace_handle, cx| {
cx.background_executor()
.timer(Duration::from_millis(delay_ms))
.await;
workspace_handle
.update_in(cx, |workspace, window, cx| {
if workspace.active_modal::<WhichKeyModal>(cx).is_some() {
return;
};
workspace.toggle_modal(window, cx, |window, cx| {
WhichKeyModal::new(workspace_handle.clone(), window, cx)
});
})
.log_err();
}));
})
.detach();
})
.detach();
}
// Hard-coded list of keystrokes to filter out from which-key display
pub static FILTERED_KEYSTROKES: LazyLock<Vec<Vec<Keystroke>>> = LazyLock::new(|| {
[
// Modifiers on normal vim commands
"g h",
"g j",
"g k",
"g l",
"g $",
"g ^",
// Duplicate keys with "ctrl" held, e.g. "ctrl-w ctrl-a" is duplicate of "ctrl-w a"
"ctrl-w ctrl-a",
"ctrl-w ctrl-c",
"ctrl-w ctrl-h",
"ctrl-w ctrl-j",
"ctrl-w ctrl-k",
"ctrl-w ctrl-l",
"ctrl-w ctrl-n",
"ctrl-w ctrl-o",
"ctrl-w ctrl-p",
"ctrl-w ctrl-q",
"ctrl-w ctrl-s",
"ctrl-w ctrl-v",
"ctrl-w ctrl-w",
"ctrl-w ctrl-]",
"ctrl-w ctrl-shift-w",
"ctrl-w ctrl-g t",
"ctrl-w ctrl-g shift-t",
]
.iter()
.filter_map(|s| {
let keystrokes: Result<Vec<_>, _> = s
.split(' ')
.map(|keystroke_str| Keystroke::parse(keystroke_str))
.collect();
keystrokes.ok()
})
.collect()
});

View File

@@ -0,0 +1,308 @@
//! Modal implementation for the which-key display.
use gpui::prelude::FluentBuilder;
use gpui::{
App, Context, DismissEvent, EventEmitter, FocusHandle, Focusable, FontWeight, Keystroke,
ScrollHandle, Subscription, WeakEntity, Window,
};
use settings::Settings;
use std::collections::HashMap;
use theme::ThemeSettings;
use ui::{
Divider, DividerColor, DynamicSpacing, LabelSize, WithScrollbar, prelude::*,
text_for_keystrokes,
};
use workspace::{ModalView, Workspace};
use crate::FILTERED_KEYSTROKES;
pub struct WhichKeyModal {
_workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
scroll_handle: ScrollHandle,
bindings: Vec<(SharedString, SharedString)>,
pending_keys: SharedString,
_pending_input_subscription: Subscription,
_focus_out_subscription: Subscription,
}
impl WhichKeyModal {
pub fn new(
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
// Keep focus where it currently is
let focus_handle = window.focused(cx).unwrap_or(cx.focus_handle());
let handle = cx.weak_entity();
let mut this = Self {
_workspace: workspace,
focus_handle: focus_handle.clone(),
scroll_handle: ScrollHandle::new(),
bindings: Vec::new(),
pending_keys: SharedString::new_static(""),
_pending_input_subscription: cx.observe_pending_input(
window,
|this: &mut Self, window, cx| {
this.update_pending_keys(window, cx);
},
),
_focus_out_subscription: window.on_focus_out(&focus_handle, cx, move |_, _, cx| {
handle.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
}),
};
this.update_pending_keys(window, cx);
this
}
pub fn dismiss(&self, cx: &mut Context<Self>) {
cx.emit(DismissEvent)
}
fn update_pending_keys(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(pending_keys) = window.pending_input_keystrokes() else {
cx.emit(DismissEvent);
return;
};
let bindings = window.possible_bindings_for_input(pending_keys);
let mut binding_data = bindings
.iter()
.map(|binding| {
// Map to keystrokes
(
binding
.keystrokes()
.iter()
.map(|k| k.inner().to_owned())
.collect::<Vec<_>>(),
binding.action(),
)
})
.filter(|(keystrokes, _action)| {
// Check if this binding matches any filtered keystroke pattern
!FILTERED_KEYSTROKES.iter().any(|filtered| {
keystrokes.len() >= filtered.len()
&& keystrokes[..filtered.len()] == filtered[..]
})
})
.map(|(keystrokes, action)| {
// Map to remaining keystrokes and action name
let remaining_keystrokes = keystrokes[pending_keys.len()..].to_vec();
let action_name: SharedString =
command_palette::humanize_action_name(action.name()).into();
(remaining_keystrokes, action_name)
})
.collect();
binding_data = group_bindings(binding_data);
// Sort bindings from shortest to longest, with groups last
// Using stable sort to preserve relative order of equal elements
binding_data.sort_by(|(keystrokes_a, action_a), (keystrokes_b, action_b)| {
// Groups (actions starting with "+") should go last
let is_group_a = action_a.starts_with('+');
let is_group_b = action_b.starts_with('+');
// First, separate groups from non-groups
let group_cmp = is_group_a.cmp(&is_group_b);
if group_cmp != std::cmp::Ordering::Equal {
return group_cmp;
}
// Then sort by keystroke count
let keystroke_cmp = keystrokes_a.len().cmp(&keystrokes_b.len());
if keystroke_cmp != std::cmp::Ordering::Equal {
return keystroke_cmp;
}
// Finally sort by text length, then lexicographically for full stability
let text_a = text_for_keystrokes(keystrokes_a, cx);
let text_b = text_for_keystrokes(keystrokes_b, cx);
let text_len_cmp = text_a.len().cmp(&text_b.len());
if text_len_cmp != std::cmp::Ordering::Equal {
return text_len_cmp;
}
text_a.cmp(&text_b)
});
binding_data.dedup();
self.pending_keys = text_for_keystrokes(&pending_keys, cx).into();
self.bindings = binding_data
.into_iter()
.map(|(keystrokes, action)| (text_for_keystrokes(&keystrokes, cx).into(), action))
.collect();
}
}
impl Render for WhichKeyModal {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let has_rows = !self.bindings.is_empty();
let viewport_size = window.viewport_size();
let max_panel_width = px((f32::from(viewport_size.width) * 0.5).min(480.0));
let max_content_height = px(f32::from(viewport_size.height) * 0.4);
// Push above status bar when visible
let status_height = self
._workspace
.upgrade()
.and_then(|workspace| {
workspace.read_with(cx, |workspace, cx| {
if workspace.status_bar_visible(cx) {
Some(
DynamicSpacing::Base04.px(cx) * 2.0
+ ThemeSettings::get_global(cx).ui_font_size(cx),
)
} else {
None
}
})
})
.unwrap_or(px(0.));
let margin_bottom = px(16.);
let bottom_offset = margin_bottom + status_height;
// Title section
let title_section = {
let mut column = v_flex().gap(px(0.)).child(
div()
.child(
Label::new(self.pending_keys.clone())
.size(LabelSize::Default)
.weight(FontWeight::MEDIUM)
.color(Color::Accent),
)
.mb(px(2.)),
);
if has_rows {
column = column.child(
div()
.child(Divider::horizontal().color(DividerColor::BorderFaded))
.mb(px(2.)),
);
}
column
};
let content = h_flex()
.items_start()
.id("which-key-content")
.gap(px(8.))
.overflow_y_scroll()
.track_scroll(&self.scroll_handle)
.h_full()
.max_h(max_content_height)
.child(
// Keystrokes column
v_flex()
.gap(px(4.))
.flex_shrink_0()
.children(self.bindings.iter().map(|(keystrokes, _)| {
div()
.child(
Label::new(keystrokes.clone())
.size(LabelSize::Default)
.color(Color::Accent),
)
.text_align(gpui::TextAlign::Right)
})),
)
.child(
// Actions column
v_flex()
.gap(px(4.))
.flex_1()
.min_w_0()
.children(self.bindings.iter().map(|(_, action_name)| {
let is_group = action_name.starts_with('+');
let label_color = if is_group {
Color::Success
} else {
Color::Default
};
div().child(
Label::new(action_name.clone())
.size(LabelSize::Default)
.color(label_color)
.single_line()
.truncate(),
)
})),
);
div()
.id("which-key-buffer-panel-scroll")
.occlude()
.absolute()
.bottom(bottom_offset)
.right(px(16.))
.min_w(px(220.))
.max_w(max_panel_width)
.elevation_3(cx)
.px(px(12.))
.child(v_flex().child(title_section).when(has_rows, |el| {
el.child(
div()
.max_h(max_content_height)
.child(content)
.vertical_scrollbar_for(&self.scroll_handle, window, cx),
)
}))
}
}
impl EventEmitter<DismissEvent> for WhichKeyModal {}
impl Focusable for WhichKeyModal {
fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl ModalView for WhichKeyModal {
fn render_bare(&self) -> bool {
true
}
}
fn group_bindings(
binding_data: Vec<(Vec<Keystroke>, SharedString)>,
) -> Vec<(Vec<Keystroke>, SharedString)> {
let mut groups: HashMap<Option<Keystroke>, Vec<(Vec<Keystroke>, SharedString)>> =
HashMap::new();
// Group bindings by their first keystroke
for (remaining_keystrokes, action_name) in binding_data {
let first_key = remaining_keystrokes.first().cloned();
groups
.entry(first_key)
.or_default()
.push((remaining_keystrokes, action_name));
}
let mut result = Vec::new();
for (first_key, mut group_bindings) in groups {
// Remove duplicates within each group
group_bindings.dedup_by_key(|(keystrokes, _)| keystrokes.clone());
if let Some(first_key) = first_key
&& group_bindings.len() > 1
{
// This is a group - create a single entry with just the first keystroke
let first_keystroke = vec![first_key];
let count = group_bindings.len();
result.push((first_keystroke, format!("+{} keybinds", count).into()));
} else {
// Not a group or empty keystrokes - add all bindings as-is
result.append(&mut group_bindings);
}
}
result
}

View File

@@ -0,0 +1,18 @@
use settings::{RegisterSetting, Settings, SettingsContent, WhichKeySettingsContent};
#[derive(Debug, Clone, Copy, RegisterSetting)]
pub struct WhichKeySettings {
pub enabled: bool,
pub delay_ms: u64,
}
impl Settings for WhichKeySettings {
fn from_settings(content: &SettingsContent) -> Self {
let which_key: &WhichKeySettingsContent = content.which_key.as_ref().unwrap();
Self {
enabled: which_key.enabled.unwrap(),
delay_ms: which_key.delay_ms.unwrap(),
}
}
}

View File

@@ -1,5 +1,4 @@
use crate::persistence::model::DockData;
use crate::utility_pane::utility_slot_for_dock_position;
use crate::{DraggedDock, Event, ModalLayer, Pane};
use crate::{Workspace, status_bar::StatusItemView};
use anyhow::Context as _;
@@ -705,7 +704,7 @@ impl Dock {
panel: &Entity<T>,
window: &mut Window,
cx: &mut Context<Self>,
) {
) -> bool {
if let Some(panel_ix) = self
.panel_entries
.iter()
@@ -724,15 +723,12 @@ impl Dock {
}
}
let slot = utility_slot_for_dock_position(self.position);
if let Some(workspace) = self.workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
workspace.clear_utility_pane_if_provider(slot, Entity::entity_id(panel), cx);
});
}
self.panel_entries.remove(panel_ix);
cx.notify();
true
} else {
false
}
}

View File

@@ -886,8 +886,12 @@ impl<T: Item> ItemHandle for Entity<T> {
// Only trigger autosave if focus has truly left the item.
// If focus is still within the item's hierarchy (e.g., moved to a context menu),
// don't trigger autosave to avoid unwanted formatting and cursor jumps.
// Also skip autosave if focus moved to a modal (e.g., command palette),
// since the user is still interacting with the workspace.
let focus_handle = item.item_focus_handle(cx);
if !focus_handle.contains_focused(window, cx) {
if !focus_handle.contains_focused(window, cx)
&& !workspace.has_active_modal(window, cx)
{
Pane::autosave_item(&item, workspace.project.clone(), window, cx)
.detach_and_log_err(cx);
}

View File

@@ -22,12 +22,17 @@ pub trait ModalView: ManagedView {
fn fade_out_background(&self) -> bool {
false
}
fn render_bare(&self) -> bool {
false
}
}
trait ModalViewHandle {
fn on_before_dismiss(&mut self, window: &mut Window, cx: &mut App) -> DismissDecision;
fn view(&self) -> AnyView;
fn fade_out_background(&self, cx: &mut App) -> bool;
fn render_bare(&self, cx: &mut App) -> bool;
}
impl<V: ModalView> ModalViewHandle for Entity<V> {
@@ -42,6 +47,10 @@ impl<V: ModalView> ModalViewHandle for Entity<V> {
fn fade_out_background(&self, cx: &mut App) -> bool {
self.read(cx).fade_out_background()
}
fn render_bare(&self, cx: &mut App) -> bool {
self.read(cx).render_bare()
}
}
pub struct ActiveModal {
@@ -167,9 +176,13 @@ impl ModalLayer {
impl Render for ModalLayer {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(active_modal) = &self.active_modal else {
return div();
return div().into_any_element();
};
if active_modal.modal.render_bare(cx) {
return active_modal.modal.view().into_any_element();
}
div()
.absolute()
.size_full()
@@ -195,5 +208,6 @@ impl Render for ModalLayer {
}),
),
)
.into_any_element()
}
}

View File

@@ -102,46 +102,31 @@ impl Render for SecurityModal {
.child(Icon::new(IconName::Warning).color(Color::Warning))
.child(Label::new(header_label)),
)
.children(self.restricted_paths.values().map(|restricted_path| {
.children(self.restricted_paths.values().filter_map(|restricted_path| {
let abs_path = if restricted_path.is_file {
restricted_path.abs_path.parent()
} else {
Some(restricted_path.abs_path.as_ref())
};
let label = match abs_path {
Some(abs_path) => match &restricted_path.host {
Some(remote_host) => match &remote_host.user_name {
Some(user_name) => format!(
"{} ({}@{})",
self.shorten_path(abs_path).display(),
user_name,
remote_host.host_identifier
),
None => format!(
"{} ({})",
self.shorten_path(abs_path).display(),
remote_host.host_identifier
),
},
None => self.shorten_path(abs_path).display().to_string(),
},
None => match &restricted_path.host {
Some(remote_host) => match &remote_host.user_name {
Some(user_name) => format!(
"Workspace trust ({}@{})",
user_name, remote_host.host_identifier
),
None => {
format!("Workspace trust ({})", remote_host.host_identifier)
}
},
None => "Workspace trust".to_string(),
}?;
let label = match &restricted_path.host {
Some(remote_host) => match &remote_host.user_name {
Some(user_name) => format!(
"{} ({}@{})",
self.shorten_path(abs_path).display(),
user_name,
remote_host.host_identifier
),
None => format!(
"{} ({})",
self.shorten_path(abs_path).display(),
remote_host.host_identifier
),
},
None => self.shorten_path(abs_path).display().to_string(),
};
h_flex()
Some(h_flex()
.pl(IconSize::default().rems() + rems(0.5))
.child(Label::new(label).color(Color::Muted))
.child(Label::new(label).color(Color::Muted)))
})),
)
.child(

View File

@@ -135,7 +135,9 @@ pub use workspace_settings::{
use zed_actions::{Spawn, feedback::FileBugReport};
use crate::{
item::ItemBufferKind, notifications::NotificationId, utility_pane::UTILITY_PANE_MIN_WIDTH,
item::ItemBufferKind,
notifications::NotificationId,
utility_pane::{UTILITY_PANE_MIN_WIDTH, utility_slot_for_dock_position},
};
use crate::{
persistence::{
@@ -986,6 +988,7 @@ impl AppState {
#[cfg(any(test, feature = "test-support"))]
pub fn test(cx: &mut App) -> Arc<Self> {
use fs::Fs;
use node_runtime::NodeRuntime;
use session::Session;
use settings::SettingsStore;
@@ -996,6 +999,7 @@ impl AppState {
}
let fs = fs::FakeFs::new(cx.background_executor().clone());
<dyn Fs>::set_global(fs.clone(), cx);
let languages = Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
let clock = Arc::new(clock::FakeSystemClock::new());
let http_client = http_client::FakeHttpClient::with_404_response();
@@ -1890,10 +1894,18 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) {
let mut found_in_dock = None;
for dock in [&self.left_dock, &self.bottom_dock, &self.right_dock] {
dock.update(cx, |dock, cx| {
dock.remove_panel(panel, window, cx);
})
let found = dock.update(cx, |dock, cx| dock.remove_panel(panel, window, cx));
if found {
found_in_dock = Some(dock.clone());
}
}
if let Some(found_in_dock) = found_in_dock {
let position = found_in_dock.read(cx).position();
let slot = utility_slot_for_dock_position(position);
self.clear_utility_pane_if_provider(slot, Entity::entity_id(panel), cx);
}
}

View File

@@ -25,8 +25,10 @@ test-support = [
[dependencies]
anyhow.workspace = true
async-lock.workspace = true
chardetng.workspace = true
clock.workspace = true
collections.workspace = true
encoding_rs.workspace = true
fs.workspace = true
futures.workspace = true
fuzzy.workspace = true

View File

@@ -5,8 +5,10 @@ mod worktree_tests;
use ::ignore::gitignore::{Gitignore, GitignoreBuilder};
use anyhow::{Context as _, Result, anyhow};
use chardetng::EncodingDetector;
use clock::ReplicaId;
use collections::{HashMap, HashSet, VecDeque};
use encoding_rs::Encoding;
use fs::{Fs, MTime, PathEvent, RemoveOptions, Watcher, copy_recursive, read_dir_items};
use futures::{
FutureExt as _, Stream, StreamExt,
@@ -105,6 +107,8 @@ pub enum CreatedEntry {
pub struct LoadedFile {
pub file: Arc<File>,
pub text: String,
pub encoding: &'static Encoding,
pub has_bom: bool,
}
pub struct LoadedBinaryFile {
@@ -741,10 +745,14 @@ impl Worktree {
path: Arc<RelPath>,
text: Rope,
line_ending: LineEnding,
encoding: &'static Encoding,
has_bom: bool,
cx: &Context<Worktree>,
) -> Task<Result<Arc<File>>> {
match self {
Worktree::Local(this) => this.write_file(path, text, line_ending, cx),
Worktree::Local(this) => {
this.write_file(path, text, line_ending, encoding, has_bom, cx)
}
Worktree::Remote(_) => {
Task::ready(Err(anyhow!("remote worktree can't yet write files")))
}
@@ -1351,7 +1359,9 @@ impl LocalWorktree {
anyhow::bail!("File is too large to load");
}
}
let text = fs.load(&abs_path).await?;
let content = fs.load_bytes(&abs_path).await?;
let (text, encoding, has_bom) = decode_byte(content);
let worktree = this.upgrade().context("worktree was dropped")?;
let file = match entry.await? {
@@ -1379,7 +1389,12 @@ impl LocalWorktree {
}
};
Ok(LoadedFile { file, text })
Ok(LoadedFile {
file,
text,
encoding,
has_bom,
})
})
}
@@ -1462,6 +1477,8 @@ impl LocalWorktree {
path: Arc<RelPath>,
text: Rope,
line_ending: LineEnding,
encoding: &'static Encoding,
has_bom: bool,
cx: &Context<Worktree>,
) -> Task<Result<Arc<File>>> {
let fs = self.fs.clone();
@@ -1471,7 +1488,49 @@ impl LocalWorktree {
let write = cx.background_spawn({
let fs = fs.clone();
let abs_path = abs_path.clone();
async move { fs.save(&abs_path, &text, line_ending).await }
async move {
let bom_bytes = if has_bom {
if encoding == encoding_rs::UTF_16LE {
vec![0xFF, 0xFE]
} else if encoding == encoding_rs::UTF_16BE {
vec![0xFE, 0xFF]
} else if encoding == encoding_rs::UTF_8 {
vec![0xEF, 0xBB, 0xBF]
} else {
vec![]
}
} else {
vec![]
};
// For UTF-8, use the optimized `fs.save` which writes Rope chunks directly to disk
// without allocating a contiguous string.
if encoding == encoding_rs::UTF_8 && !has_bom {
return fs.save(&abs_path, &text, line_ending).await;
}
// For legacy encodings (e.g. Shift-JIS), we fall back to converting the entire Rope
// to a String/Bytes in memory before writing.
//
// Note: This is inefficient for very large files compared to the streaming approach above,
// but supporting streaming writes for arbitrary encodings would require a significant
// refactor of the `fs` crate to expose a Writer interface.
let text_string = text.to_string();
let normalized_text = match line_ending {
LineEnding::Unix => text_string,
LineEnding::Windows => text_string.replace('\n', "\r\n"),
};
let (cow, _, _) = encoding.encode(&normalized_text);
let bytes = if !bom_bytes.is_empty() {
let mut bytes = bom_bytes;
bytes.extend_from_slice(&cow);
bytes.into()
} else {
cow
};
fs.write(&abs_path, &bytes).await
}
});
cx.spawn(async move |this, cx| {
@@ -5782,3 +5841,40 @@ impl fs::Watcher for NullWatcher {
Ok(())
}
}
fn decode_byte(bytes: Vec<u8>) -> (String, &'static Encoding, bool) {
// check BOM
if let Some((encoding, _bom_len)) = Encoding::for_bom(&bytes) {
let (cow, _) = encoding.decode_with_bom_removal(&bytes);
return (cow.into_owned(), encoding, true);
}
fn detect_encoding(bytes: Vec<u8>) -> (String, &'static Encoding) {
let mut detector = EncodingDetector::new();
detector.feed(&bytes, true);
let encoding = detector.guess(None, true); // Use None for TLD hint to ensure neutral detection logic.
let (cow, _, _) = encoding.decode(&bytes);
(cow.into_owned(), encoding)
}
match String::from_utf8(bytes) {
Ok(text) => {
// ISO-2022-JP (and other ISO-2022 variants) consists entirely of 7-bit ASCII bytes,
// so it is valid UTF-8. However, it contains escape sequences starting with '\x1b'.
// If we find an escape character, we double-check the encoding to prevent
// displaying raw escape sequences instead of the correct characters.
if text.contains('\x1b') {
let (s, enc) = detect_encoding(text.into_bytes());
(s, enc, false)
} else {
(text, encoding_rs::UTF_8, false)
}
}
Err(e) => {
let (s, enc) = detect_encoding(e.into_bytes());
(s, enc, false)
}
}
}

View File

@@ -1,5 +1,6 @@
use crate::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandle};
use anyhow::Result;
use anyhow::{Context as _, Result};
use encoding_rs;
use fs::{FakeFs, Fs, RealFs, RemoveOptions};
use git::{DOT_GIT, GITIGNORE, REPO_EXCLUDE};
use gpui::{AppContext as _, BackgroundExecutor, BorrowAppContext, Context, Task, TestAppContext};
@@ -19,6 +20,7 @@ use std::{
};
use util::{
ResultExt, path,
paths::PathStyle,
rel_path::{RelPath, rel_path},
test::TempTree,
};
@@ -723,6 +725,8 @@ async fn test_write_file(cx: &mut TestAppContext) {
rel_path("tracked-dir/file.txt").into(),
"hello".into(),
Default::default(),
encoding_rs::UTF_8,
false,
cx,
)
})
@@ -734,6 +738,8 @@ async fn test_write_file(cx: &mut TestAppContext) {
rel_path("ignored-dir/file.txt").into(),
"world".into(),
Default::default(),
encoding_rs::UTF_8,
false,
cx,
)
})
@@ -2035,8 +2041,14 @@ fn randomly_mutate_worktree(
})
} else {
log::info!("overwriting file {:?} ({})", &entry.path, entry.id.0);
let task =
worktree.write_file(entry.path.clone(), "".into(), Default::default(), cx);
let task = worktree.write_file(
entry.path.clone(),
"".into(),
Default::default(),
encoding_rs::UTF_8,
false,
cx,
);
cx.background_spawn(async move {
task.await?;
Ok(())
@@ -2552,3 +2564,176 @@ fn init_test(cx: &mut gpui::TestAppContext) {
cx.set_global(settings_store);
});
}
#[gpui::test]
async fn test_load_file_encoding(cx: &mut TestAppContext) {
init_test(cx);
let test_cases: Vec<(&str, &[u8], &str)> = vec![
("utf8.txt", "こんにちは".as_bytes(), "こんにちは"), // "こんにちは" is Japanese "Hello"
(
"sjis.txt",
&[0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd],
"こんにちは",
),
(
"eucjp.txt",
&[0xa4, 0xb3, 0xa4, 0xf3, 0xa4, 0xcb, 0xa4, 0xc1, 0xa4, 0xcf],
"こんにちは",
),
(
"iso2022jp.txt",
&[
0x1b, 0x24, 0x42, 0x24, 0x33, 0x24, 0x73, 0x24, 0x4b, 0x24, 0x41, 0x24, 0x4f, 0x1b,
0x28, 0x42,
],
"こんにちは",
),
// Western Europe (Windows-1252)
// "Café" -> 0xE9 is 'é' in Windows-1252 (it is typically 0xC3 0xA9 in UTF-8)
("win1252.txt", &[0x43, 0x61, 0x66, 0xe9], "Café"),
// Chinese Simplified (GBK)
// Note: We use a slightly longer string here because short byte sequences can be ambiguous
// in multi-byte encodings. Providing more context helps the heuristic detector guess correctly.
// Text: "今天天气不错" (Today's weather is not bad / nice)
// Bytes:
// 今: BD F1
// 天: CC EC
// 天: CC EC
// 气: C6 F8
// 不: B2 BB
// 错: B4 ED
(
"gbk.txt",
&[
0xbd, 0xf1, 0xcc, 0xec, 0xcc, 0xec, 0xc6, 0xf8, 0xb2, 0xbb, 0xb4, 0xed,
],
"今天天气不错",
),
(
"utf16le_bom.txt",
&[
0xFF, 0xFE, // BOM
0x53, 0x30, // こ
0x93, 0x30, // ん
0x6B, 0x30, // に
0x61, 0x30, // ち
0x6F, 0x30, // は
],
"こんにちは",
),
(
"utf8_bom.txt",
&[
0xEF, 0xBB, 0xBF, // UTF-8 BOM
0xE3, 0x81, 0x93, // こ
0xE3, 0x82, 0x93, // ん
0xE3, 0x81, 0xAB, // に
0xE3, 0x81, 0xA1, // ち
0xE3, 0x81, 0xAF, // は
],
"こんにちは",
),
];
let root_path = if cfg!(windows) {
Path::new("C:\\root")
} else {
Path::new("/root")
};
let fs = FakeFs::new(cx.background_executor.clone());
let mut files_json = serde_json::Map::new();
for (name, _, _) in &test_cases {
files_json.insert(name.to_string(), serde_json::Value::String("".to_string()));
}
for (name, bytes, _) in &test_cases {
let path = root_path.join(name);
fs.write(&path, bytes).await.unwrap();
}
let tree = Worktree::local(
root_path,
true,
fs,
Default::default(),
true,
&mut cx.to_async(),
)
.await
.unwrap();
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
.await;
for (name, _, expected) in test_cases {
let loaded = tree
.update(cx, |tree, cx| tree.load_file(rel_path(name), cx))
.await
.with_context(|| format!("Failed to load {}", name))
.unwrap();
assert_eq!(
loaded.text, expected,
"Encoding mismatch for file: {}",
name
);
}
}
#[gpui::test]
async fn test_write_file_encoding(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let root_path = if cfg!(windows) {
Path::new("C:\\root")
} else {
Path::new("/root")
};
fs.create_dir(root_path).await.unwrap();
let file_path = root_path.join("test.txt");
fs.insert_file(&file_path, "initial".into()).await;
let worktree = Worktree::local(
root_path,
true,
fs.clone(),
Default::default(),
true,
&mut cx.to_async(),
)
.await
.unwrap();
let path: Arc<Path> = Path::new("test.txt").into();
let rel_path = RelPath::new(&path, PathStyle::local()).unwrap().into_arc();
let text = text::Rope::from("こんにちは");
let task = worktree.update(cx, |wt, cx| {
wt.write_file(
rel_path,
text,
text::LineEnding::Unix,
encoding_rs::SHIFT_JIS,
false,
cx,
)
});
task.await.unwrap();
let bytes = fs.load_bytes(&file_path).await.unwrap();
let expected_bytes = vec![
0x82, 0xb1, // こ
0x82, 0xf1, // ん
0x82, 0xc9, // に
0x82, 0xbf, // ち
0x82, 0xcd, // は
];
assert_eq!(bytes, expected_bytes, "Should be saved as Shift-JIS");
}

View File

@@ -163,6 +163,7 @@ vim_mode_setting.workspace = true
watch.workspace = true
web_search.workspace = true
web_search_providers.workspace = true
which_key.workspace = true
workspace.workspace = true
zed_actions.workspace = true
zed_env_vars.workspace = true
@@ -195,6 +196,10 @@ terminal_view = { workspace = true, features = ["test-support"] }
tree-sitter-md.workspace = true
tree-sitter-rust.workspace = true
workspace = { workspace = true, features = ["test-support"] }
agent_ui = { workspace = true, features = ["test-support"] }
agent_ui_v2 = { workspace = true, features = ["test-support"] }
search = { workspace = true, features = ["test-support"] }
[package.metadata.bundle-dev]
icon = ["resources/app-icon-dev@2x.png", "resources/app-icon-dev.png"]

Binary file not shown.

View File

@@ -15,11 +15,13 @@ use extension::ExtensionHostProxy;
use fs::{Fs, RealFs};
use futures::{StreamExt, channel::oneshot, future};
use git::GitHostingProviderRegistry;
use git_ui::clone::clone_and_open;
use gpui::{App, AppContext, Application, AsyncApp, Focusable as _, QuitMode, UpdateGlobal as _};
use gpui_tokio::Tokio;
use language::LanguageRegistry;
use onboarding::{FIRST_OPEN, show_onboarding_view};
use project_panel::ProjectPanel;
use prompt_store::PromptBuilder;
use remote::RemoteConnectionOptions;
use reqwest_client::ReqwestClient;
@@ -33,10 +35,12 @@ use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
use session::{AppSession, Session};
use settings::{BaseKeymap, Settings, SettingsStore, watch_config_file};
use std::{
cell::RefCell,
env,
io::{self, IsTerminal},
path::{Path, PathBuf},
process,
rc::Rc,
sync::{Arc, OnceLock},
time::Instant,
};
@@ -656,6 +660,7 @@ pub fn main() {
inspector_ui::init(app_state.clone(), cx);
json_schema_store::init(cx);
miniprofiler_ui::init(*STARTUP_TIME.get().unwrap(), cx);
which_key::init(cx);
cx.observe_global::<SettingsStore>({
let http = app_state.client.http_client();
@@ -742,18 +747,16 @@ pub fn main() {
})
}
let request = open_rx.try_next().ok().flatten();
log::info!(
"finish_launching: try_next() returned {:?}",
request.as_ref().map(|r| &r.urls)
);
match request.and_then(|request| OpenRequest::parse(request, cx).log_err()) {
match open_rx
.try_next()
.ok()
.flatten()
.and_then(|request| OpenRequest::parse(request, cx).log_err())
{
Some(request) => {
log::info!("finish_launching: got request, calling handle_open_request");
handle_open_request(request, app_state.clone(), cx);
}
None => {
log::info!("finish_launching: no request, spawning restore_or_create_workspace");
cx.spawn({
let app_state = app_state.clone();
async move |cx| {
@@ -894,6 +897,41 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
})
.detach_and_log_err(cx);
}
OpenRequestKind::GitClone { repo_url } => {
workspace::with_active_or_new_workspace(cx, |_workspace, window, cx| {
if window.is_window_active() {
clone_and_open(
repo_url,
cx.weak_entity(),
window,
cx,
Arc::new(|workspace: &mut workspace::Workspace, window, cx| {
workspace.focus_panel::<ProjectPanel>(window, cx);
}),
);
return;
}
let subscription = Rc::new(RefCell::new(None));
subscription.replace(Some(cx.observe_in(&cx.entity(), window, {
let subscription = subscription.clone();
let repo_url = repo_url.clone();
move |_, workspace_entity, window, cx| {
if window.is_window_active() && subscription.take().is_some() {
clone_and_open(
repo_url.clone(),
workspace_entity.downgrade(),
window,
cx,
Arc::new(|workspace: &mut workspace::Workspace, window, cx| {
workspace.focus_panel::<ProjectPanel>(window, cx);
}),
);
}
}
})));
});
}
OpenRequestKind::GitCommit { sha } => {
cx.spawn(async move |cx| {
let paths_with_position =

View File

@@ -707,7 +707,6 @@ fn setup_or_teardown_ai_panel<P: Panel>(
.disable_ai
|| cfg!(test);
let existing_panel = workspace.panel::<P>(cx);
match (disable_ai, existing_panel) {
(false, None) => cx.spawn_in(window, async move |workspace, cx| {
let panel = load_panel(workspace.clone(), cx.clone()).await?;
@@ -2327,7 +2326,7 @@ mod tests {
use project::{Project, ProjectPath};
use semver::Version;
use serde_json::json;
use settings::{SettingsStore, watch_config_file};
use settings::{SaturatingBool, SettingsStore, watch_config_file};
use std::{
path::{Path, PathBuf},
time::Duration,
@@ -5171,6 +5170,28 @@ mod tests {
);
}
#[gpui::test]
async fn test_disable_ai_crash(cx: &mut gpui::TestAppContext) {
let app_state = init_test(cx);
cx.update(init);
let project = Project::test(app_state.fs.clone(), [], cx).await;
let _window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
cx.run_until_parked();
cx.update(|cx| {
SettingsStore::update_global(cx, |settings_store, cx| {
settings_store.update_user_settings(cx, |settings| {
settings.disable_ai = Some(SaturatingBool(true));
});
});
});
cx.run_until_parked();
// If this panics, the test has failed
}
#[gpui::test]
async fn test_prefer_focused_window(cx: &mut gpui::TestAppContext) {
let app_state = init_test(cx);

View File

@@ -145,23 +145,6 @@ fn register_backward_compatible_actions(editor: &mut Editor, cx: &mut Context<Ed
},
))
.detach();
editor
.register_action(cx.listener(
|editor, _: &copilot::NextSuggestion, window: &mut Window, cx: &mut Context<Editor>| {
editor.next_edit_prediction(&Default::default(), window, cx);
},
))
.detach();
editor
.register_action(cx.listener(
|editor,
_: &copilot::PreviousSuggestion,
window: &mut Window,
cx: &mut Context<Editor>| {
editor.previous_edit_prediction(&Default::default(), window, cx);
},
))
.detach();
}
fn assign_edit_prediction_provider(

View File

@@ -25,6 +25,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use ui::SharedString;
use util::ResultExt;
use util::paths::PathWithPosition;
use workspace::PathList;
@@ -58,6 +59,9 @@ pub enum OpenRequestKind {
/// `None` opens settings without navigating to a specific path.
setting_path: Option<String>,
},
GitClone {
repo_url: SharedString,
},
GitCommit {
sha: String,
},
@@ -113,6 +117,8 @@ impl OpenRequest {
this.kind = Some(OpenRequestKind::Setting {
setting_path: Some(setting_path.to_string()),
});
} else if let Some(clone_path) = url.strip_prefix("zed://git/clone") {
this.parse_git_clone_url(clone_path)?
} else if let Some(commit_path) = url.strip_prefix("zed://git/commit/") {
this.parse_git_commit_url(commit_path)?
} else if url.starts_with("ssh://") {
@@ -143,6 +149,26 @@ impl OpenRequest {
}
}
fn parse_git_clone_url(&mut self, clone_path: &str) -> Result<()> {
// Format: /?repo=<url> or ?repo=<url>
let clone_path = clone_path.strip_prefix('/').unwrap_or(clone_path);
let query = clone_path
.strip_prefix('?')
.context("invalid git clone url: missing query string")?;
let repo_url = url::form_urlencoded::parse(query.as_bytes())
.find_map(|(key, value)| (key == "repo").then_some(value))
.filter(|s| !s.is_empty())
.context("invalid git clone url: missing repo query parameter")?
.to_string()
.into();
self.kind = Some(OpenRequestKind::GitClone { repo_url });
Ok(())
}
fn parse_git_commit_url(&mut self, commit_path: &str) -> Result<()> {
// Format: <sha>?repo=<path>
let (sha, query) = commit_path
@@ -1087,4 +1113,80 @@ mod tests {
assert!(!errored_reuse);
}
#[gpui::test]
fn test_parse_git_clone_url(cx: &mut TestAppContext) {
let _app_state = init_test(cx);
let request = cx.update(|cx| {
OpenRequest::parse(
RawOpenRequest {
urls: vec![
"zed://git/clone/?repo=https://github.com/zed-industries/zed.git".into(),
],
..Default::default()
},
cx,
)
.unwrap()
});
match request.kind {
Some(OpenRequestKind::GitClone { repo_url }) => {
assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
}
_ => panic!("Expected GitClone kind"),
}
}
#[gpui::test]
fn test_parse_git_clone_url_without_slash(cx: &mut TestAppContext) {
let _app_state = init_test(cx);
let request = cx.update(|cx| {
OpenRequest::parse(
RawOpenRequest {
urls: vec![
"zed://git/clone?repo=https://github.com/zed-industries/zed.git".into(),
],
..Default::default()
},
cx,
)
.unwrap()
});
match request.kind {
Some(OpenRequestKind::GitClone { repo_url }) => {
assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
}
_ => panic!("Expected GitClone kind"),
}
}
#[gpui::test]
fn test_parse_git_clone_url_with_encoding(cx: &mut TestAppContext) {
let _app_state = init_test(cx);
let request = cx.update(|cx| {
OpenRequest::parse(
RawOpenRequest {
urls: vec![
"zed://git/clone/?repo=https%3A%2F%2Fgithub.com%2Fzed-industries%2Fzed.git"
.into(),
],
..Default::default()
},
cx,
)
.unwrap()
});
match request.kind {
Some(OpenRequestKind::GitClone { repo_url }) => {
assert_eq!(repo_url, "https://github.com/zed-industries/zed.git");
}
_ => panic!("Expected GitClone kind"),
}
}
}

View File

@@ -106,6 +106,17 @@ mv Cargo.toml.backup Cargo.toml
popd
echo "Bundled ${app_path}"
# DocumentTypes.plist references CFBundleTypeIconFile "Document", so the bundle must contain Document.icns.
# We use the app icon as a placeholder document icon for now.
document_icon_source="crates/zed/resources/Document.icns"
document_icon_target="${app_path}/Contents/Resources/Document.icns"
if [[ -f "${document_icon_source}" ]]; then
mkdir -p "$(dirname "${document_icon_target}")"
cp "${document_icon_source}" "${document_icon_target}"
else
echo "cargo::warning=Missing ${document_icon_source}; macOS document icons may not appear in Finder."
fi
if [[ -n "${MACOS_CERTIFICATE:-}" && -n "${MACOS_CERTIFICATE_PASSWORD:-}" && -n "${APPLE_NOTARIZATION_KEY:-}" && -n "${APPLE_NOTARIZATION_KEY_ID:-}" && -n "${APPLE_NOTARIZATION_ISSUER_ID:-}" ]]; then
can_code_sign=true

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
script/verify-macos-document-icon /path/to/Zed.app
Verifies that the given macOS app bundle's Info.plist references a document icon
named "Document" and that the corresponding icon file exists in the bundle.
Specifically checks:
- CFBundleDocumentTypes[*].CFBundleTypeIconFile includes "Document"
- Contents/Resources/Document.icns exists
Exit codes:
0 - success
1 - verification failed
2 - invalid usage / missing prerequisites
USAGE
}
fail() {
echo "error: $*" >&2
exit 1
}
if [[ $# -ne 1 ]]; then
usage >&2
exit 2
fi
app_path="$1"
if [[ ! -d "${app_path}" ]]; then
fail "app bundle not found: ${app_path}"
fi
info_plist="${app_path}/Contents/Info.plist"
if [[ ! -f "${info_plist}" ]]; then
fail "missing Info.plist: ${info_plist}"
fi
if ! command -v plutil >/dev/null 2>&1; then
fail "plutil not found (required on macOS to read Info.plist)"
fi
# Convert to JSON for robust parsing. plutil outputs JSON to stdout in this mode.
info_json="$(plutil -convert json -o - "${info_plist}")"
# Check that CFBundleDocumentTypes exists and that at least one entry references "Document".
# We use Python for JSON parsing; macOS ships with Python 3 on many setups, but not all.
# If python3 isn't available, fall back to a simpler grep-based check.
has_document_icon_ref="false"
if command -v python3 >/dev/null 2>&1; then
has_document_icon_ref="$(python3 -c "import json,sys; d=json.load(sys.stdin); types=d.get('CFBundleDocumentTypes', []); vals=[t.get('CFBundleTypeIconFile') for t in types if isinstance(t, dict)]; print('true' if 'Document' in vals else 'false')" <<<"${info_json}")"
else
# This is a best-effort fallback. It may produce false negatives if the JSON formatting differs.
if echo "${info_json}" | grep -q '"CFBundleTypeIconFile"[[:space:]]*:[[:space:]]*"Document"'; then
has_document_icon_ref="true"
fi
fi
if [[ "${has_document_icon_ref}" != "true" ]]; then
echo "Verification failed for: ${app_path}" >&2
echo "Expected Info.plist to reference CFBundleTypeIconFile \"Document\" in CFBundleDocumentTypes." >&2
echo "Tip: This bundle may be missing DocumentTypes.plist extensions or may have different icon naming." >&2
exit 1
fi
document_icon_path="${app_path}/Contents/Resources/Document.icns"
if [[ ! -f "${document_icon_path}" ]]; then
echo "Verification failed for: ${app_path}" >&2
echo "Expected document icon to exist: ${document_icon_path}" >&2
echo "Tip: The bundle script should copy crates/zed/resources/Document.icns into Contents/Resources/Document.icns." >&2
exit 1
fi
echo "OK: ${app_path}"
echo " - Info.plist references CFBundleTypeIconFile \"Document\""
echo " - Found ${document_icon_path}"

View File

@@ -109,19 +109,6 @@ fn run_autofix(pr_number: &WorkflowInput, run_clippy: &WorkflowInput) -> NamedJo
}
fn commit_changes(pr_number: &WorkflowInput, autofix_job: &NamedJob) -> NamedJob {
fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
let step = named::uses(
"actions",
"create-github-app-token",
"bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1",
)
.add_with(("app-id", vars::ZED_ZIPPY_APP_ID))
.add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
.id("get-app-token");
let output = StepOutput::new(&step, "token");
(step, output)
}
fn checkout_pr(pr_number: &WorkflowInput, token: &StepOutput) -> Step<Run> {
named::bash(&format!("gh pr checkout {pr_number}")).add_env(("GITHUB_TOKEN", token))
}
@@ -148,7 +135,7 @@ fn commit_changes(pr_number: &WorkflowInput, autofix_job: &NamedJob) -> NamedJob
.add_env(("GITHUB_TOKEN", token))
}
let (authenticate, token) = authenticate_as_zippy();
let (authenticate, token) = steps::authenticate_as_zippy();
named::job(
Job::default()

View File

@@ -3,7 +3,7 @@ use gh_workflow::*;
use crate::tasks::workflows::{
runners,
steps::{self, NamedJob, named},
vars::{self, StepOutput, WorkflowInput},
vars::{StepOutput, WorkflowInput},
};
pub fn cherry_pick() -> Workflow {
@@ -29,19 +29,6 @@ fn run_cherry_pick(
commit: &WorkflowInput,
channel: &WorkflowInput,
) -> NamedJob {
fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
let step = named::uses(
"actions",
"create-github-app-token",
"bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1",
) // v2
.add_with(("app-id", vars::ZED_ZIPPY_APP_ID))
.add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
.id("get-app-token");
let output = StepOutput::new(&step, "token");
(step, output)
}
fn cherry_pick(
branch: &WorkflowInput,
commit: &WorkflowInput,
@@ -54,7 +41,7 @@ fn run_cherry_pick(
.add_env(("GITHUB_TOKEN", token))
}
let (authenticate, token) = authenticate_as_zippy();
let (authenticate, token) = steps::authenticate_as_zippy();
named::job(
Job::default()

View File

@@ -97,17 +97,20 @@ pub(crate) fn create_sentry_release() -> Step<Use> {
}
fn auto_release_preview(deps: &[&NamedJob; 1]) -> NamedJob {
let (authenticate, token) = steps::authenticate_as_zippy();
named::job(
dependant_job(deps)
.runs_on(runners::LINUX_SMALL)
.cond(Expression::new(indoc::indoc!(
r#"startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')"#
)))
.add_step(authenticate)
.add_step(
steps::script(
r#"gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false"#,
)
.add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)),
.add_env(("GITHUB_TOKEN", &token)),
)
)
}

View File

@@ -354,3 +354,16 @@ pub fn trigger_autofix(run_clippy: bool) -> Step<Run> {
))
.add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN))
}
pub fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
let step = named::uses(
"actions",
"create-github-app-token",
"bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1",
)
.add_with(("app-id", vars::ZED_ZIPPY_APP_ID))
.add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
.id("get-app-token");
let output = StepOutput::new(&step, "token");
(step, output)
}