Compare commits

..

56 Commits

Author SHA1 Message Date
Richard Feldman
0e1fe66a19 Show Lua script with syntax highlighting in assistant2 2025-03-10 11:26:12 -04:00
Marshall Bowers
ed52e759d7 docs: Fix language links (#26368)
This PR fixes some language links in the docs.

The Shell Script page wasn't being linked from `SUMMARY.md`, so no page
was being generated.

There were also some differences in the language lists in the sidebar
and on the top-level languages page.

Release Notes:

- N/A
2025-03-10 15:22:51 +00:00
Richard Feldman
6da099a9d7 Unsandbox Lua scripts (#26365)
Per a conversation with @nathansobo, have the Lua scripts run
unsandboxed for now (while this feature is behind the staff feature
flag).

Release Notes:

- N/A
2025-03-10 11:04:37 -04:00
Smit Barmase
5f159bc95e go_to_line: Fix goto line + mouse click jumps to previous scroll position (#26362)
Closes #20658

Now, when the "Go to Line" palette is open:  
- Clicking on the editor will dismiss the palette without changing the
scroll position. (PR change)
- Pressing Enter will jump to the line number entered in the palette.
(Unchanged)
- Pressing Escape will jump back to the previous cursor location.
(Unchanged)

Release Notes:

- Fixed an issue where clicking the editor with the mouse while the "Go
to Line" palette is open would cause it to jump to the previous scroll
position.
2025-03-10 20:33:07 +05:30
Marshall Bowers
a4462577bf Sort Cargo.tomls (#26367)
This PR sorts some `Cargo.toml`s that had become unsorted.

Release Notes:

- N/A
2025-03-10 14:48:21 +00:00
João Marcos
c147b58558 Remove redundant checks in do_stage_or_unstage_and_next (#26364)
Release Notes:

- N/A
2025-03-10 14:23:17 +00:00
Devzeth
84fe1bfe9b Recognize ixx as part of the cpp suffix (#26333)
Adds "ixx" as path suffix to be recognized for c++. 

> ixx documentation
https://learn.microsoft.com/en-us/cpp/cpp/modules-cpp?view=msvc-

I've also added it to the icon file. 

Release Notes:

- N/A
2025-03-10 09:10:29 -05:00
Danilo Leal
657d7a911d Add logo for wgsl (WebGPU Shading Language) (#26360)
Was dabbling on the shaders these past few days and felt like we could
have the WGSL logo. This is based on the logo found on the GPU Web
repository: https://github.com/gpuweb/gpuweb/tree/main/logo

Release Notes:

- N/A
2025-03-10 09:44:19 -03:00
Danilo Leal
ee05cc3ad9 Add a line numbers toggle to the editor controls menu (#26318)
Closes https://github.com/zed-industries/zed/issues/26305

<img
src="https://github.com/user-attachments/assets/795029ad-128a-471f-9adf-c0ef26319bbf"
width="400px" />

Release Notes:

- N/A
2025-03-10 09:28:46 -03:00
Smit Barmase
5ed144f9d2 macOS: Add support for external file managers to open directory in Zed (#26357)
Closes #25421

This PR adds support for external file managers to show Zed as an option
in the "Open With" context menu for directories on macOS.

<img width="350" alt="image"
src="https://github.com/user-attachments/assets/c52acd48-73c4-47be-8683-6950e0371b73"
/>


Release Notes:

- Added support for opening folders in Zed from third-party macOS file
managers like Path Finder and Super Charge through their "Open With"
menu.
2025-03-10 15:21:39 +05:30
Julia Ryan
2a862b3c54 nix: Disable checks and remove crane workaround (#26356)
The checkPhase was failing for me in darwin so I turned it off. I think
eventually we'll want to use a separate derivation for tests (which
crane has a helper for).

Crane also solved our issue with spaces in paths so I bumped the flake
to pick up that fix and removed our workaround: ipetkov/crane#808.

Release Notes:

- N/A
2025-03-10 02:00:57 -07:00
Julia Ryan
4a7c84f490 Fix nix build (#26270)
This PR includes lots of small fixes to get our `build.nix` and
`shell.nix` back to a working state.

I've tested this by running `cargo run` (inside the devshell) and `nix
run` on x86 nixos and arm64 darwin machines. I'd appreciate it if others
could test building inside the devshell to double-check that it's not
just working because I happen to have some system-level packages
installed, as well as seeing if it works on other platforms (non-nixos
linux, arm linux, x86 darwin).

I couldn't get the full test suite (`cargo nextest run --workspace`)
passing in the devshell on darwin, but they _are_ all passing on nixos.
nixpkgs [disables some of our
tests](92d11f06d5/pkgs/by-name/ze/zed-editor/package.nix (L226-L234))
that apparently fail or are flakey on hydra, but they don't know why.
I'm going to punt on debugging those for now, especially given that they
seem to be working for me. I'm also unsure of whether we actually want
the nix checkPhase to run the full test suite (it's currently not
passing `--workspace`) given that we have separate CI that should
enforce that those pass on all PRs.

Here's an overview of the changes made:
- Fix our `generate-licenses` script
- Relaxes the `cargo-about` version requirement slightly so it doesn't
try to install an older binary when the nixpkgs one is newer than our
requirement
- Add a workaround for [this cargo-about
issue](https://github.com/zed-industries/zed/issues/19971) obviating the
need for the patching done in the nixpkgs package
- Set the new `--frozen` flag to avoid network access/mutating the
lockfile
- Use dynamic webrtc lib from nixpkgs, and fixes up the build script in
webrtc-sys that hardcodes it to be statically linked.
- Use `inputsFrom` in `shell.nix` and avoid duplicating everything from
`build.nix`
- Add a temporary workaround for an [upstream crane
bug](https://github.com/ipetkov/crane/issues/808).
- Fix shebangs in our `script` dir to not hard-code `/bin/bash`

There are still a bunch of issues that aren't resolved here, I'll make a
tracking issue for those and try to land this first just to get back to
an unbroken state. Eventually among other things I'd like to use a
`libgit2` from `staticPkgs` and musl cross compilation to build the
remote server under nix, and then add that as a separate flake output
and include it in the shell's `inputsFrom` list.

Thanks @niklaskorz, @GaetanLepage, @bbigras and all the other nixpkgs
maintainers that have kept the `zed-editor` package working and up to
date! I seriously considered just making our flake `overrideAttrs` the
package in nixpkgs given how well maintained it is.

Thanks @WeetHet for your volunteer maintinance of this flake. I
referenced #24953 while working on these fixes, and I'd love to
collaborate on adding some of those pieces like treefmt and a github
action. If you're interested I'd really appreciate some help debugging
why crane's `buildDepsOnly` isn't working for us. I'm assuming it'd make
our `nix build` times go way down from the improved dep caching if we
could get it working.

Thanks @rrbutani for all the help on this PR 💙.

Release Notes:

- N/A

---------

Co-authored-by: Rahul Butani <rrbutani@users.noreply.github.com>
Co-authored-by: Rahul Butani <rr.butani@gmail.com>
2025-03-10 01:06:11 -07:00
Mikayla Maki
230e2e4107 Restore git panel header (#26354)
Let's play around with it. This should not be added to tomorrow's
preview.

Release Notes:

- Git Beta: Added a panel header with an open diff and stage/unstage all
buttons.
2025-03-10 07:08:10 +00:00
Richard Hao
d732b8ba0f git: Disable commit message generation when commit not possible (#26329)
## Issue:

- `Generate Commit Message` will generate a random message if there are
no changes.
<img width="614" alt="image"
src="https://github.com/user-attachments/assets/c16cadac-01af-47c0-a2db-a5bbf62f84bb"
/>


## After Fixed:

- `Generate Commit Message` will be disabled if commit is not possible
<img width="610" alt="image"
src="https://github.com/user-attachments/assets/5ea9ca70-6fa3-4144-ab4e-be7a986d5496"
/>


## Release Notes:

- Fixed: Disable commit message generation when commit is not possible
2025-03-09 23:45:25 -07:00
Michael Sloan
7c3eecc9c7 Add support for querying file outline in assistant script (#26351)
Release Notes:

- N/A
2025-03-10 05:26:17 +00:00
Max Brunsfeld
fff37ab823 Follow-up fixes for recent multi buffer optimizations (#26345)
I realized that the optimization broke multi buffer syncing after buffer
reparses.

Release Notes:

- N/A
2025-03-09 22:15:38 -07:00
Kirill Bulatov
8a7a78fafb Avoid modifying the LSP message before resolving it (#26347)
Closes https://github.com/zed-industries/zed/issues/21277

To the left is current Zed, right is the improved version.
3rd message, from Zed, to resolve the item, does not have `textEdit` on
the right side, and has one on the left.
Seems to not influence the end result though, but at least Zed behaves
more appropriate now.

<img width="1727" alt="image"
src="https://github.com/user-attachments/assets/ca1236fd-9ce2-41ba-88fe-1f3178cdcbde"
/>


Instead of modifying the original LSP completion item, store completion
list defaults and apply them when the item is requested (except `data`
defaults, needed for resolve).

Now, the only place that can modify the completion items is this method,
and Python impl seems to be the one doing it:


ca9c3af56f/crates/languages/src/python.rs (L182-L204)

Seems ok to leave untouched for now.

Release Notes:

- Fixed LSP completion items modified before resolve request
2025-03-10 00:12:53 +02:00
Ben Kunkle
6de3ac3e17 Revert "Highlight super and this as keywords in JS/TS/TSX" (#26342)
Reverts zed-industries/zed#25135

This approach was not the best as explained in the response to the
original PR. Likely, the better approach is to create a newer specific
scope for these kinds of variables under the `@variable` prefix so that
themes can control these pseudo-keywords specifically
2025-03-09 16:11:37 +00:00
Smit Barmase
5aae3bdc69 copilot: Fix missing sign-out button when Zed is the edit prediction provider (#26340)
Closes #25884

Added a sign-out button for Copilot in Assistant settings, allowing
sign-out even when copilot is disabled.

<img width="500" alt="image"
src="https://github.com/user-attachments/assets/43fc97ad-f73c-49e1-a7b6-a3910434d661"
/>



Release Notes:

- Added a sign-out button for Copilot in Assistant settings.
2025-03-09 21:39:14 +05:30
Agus Zubiaga
e298301b40 assistant: Make scripting a first-class concept instead of a tool (#26338)
This PR makes refactors the scripting functionality to be a first-class
concept of the assistant instead of a generic tool, which will allow us
to build a more customized experience.

- The tool prompt has been slightly tweaked and is now included as a
system message in all conversations. I'm getting decent results, but now
that it isn't in the tools framework, it will probably require more
refining.

- The model will now include an `<eval ...>` tag at the end of the
message with the script. We parse this tag incrementally as it streams
in so that we can indicate that we are generating a script before we see
the closing `</eval>` tag. Later, this will help us interpret the script
as it arrives also.

- Threads now hold a `ScriptSession` entity which manages the state of
all scripts (from parsing to exited) in a centralized way, and will
later collect all script operations so they can be displayed in the UI.

- `script_tool` has been renamed to `assistant_scripting` 

- Script source now opens in a regular read-only buffer  

Note: We still need to handle persistence properly

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>
2025-03-09 09:01:49 +00:00
brian tan
ed6bf7f161 diagnostics: Fix losing focus when activating from diagnostics view (#25517)
Closes #25509

Changes:
- If active item is already diagnostics, don't try to focus it again.

Instead of not focusing, should it just not activate instead? Something
like:

            if !workspace
                .active_item(cx)
                .map(|item| item.item_id() == existing.item_id())
                .unwrap_or(false)
            {
workspace.activate_item(&existing, true, true, window, cx);
            }


Release Notes:

- N/A
2025-03-08 22:17:20 +00:00
Smit Barmase
f14d6670ba copilot: Fix onboarding into Copilot requires Zed restart (#26330)
Closes #25594

This PR fixes an issue where signing into Copilot required restarting
Zed.

Copilot depends on an OAuth token that comes from either `hosts.json` or
`apps.json`. Initially, both files don't exist. If neither file is
found, we fallback to watching `hosts.json` for updates. However, if the
auth process creates `apps.json`, we won't receive updates from it,
causing the UI to remain outdated.

This PR fixes that by watching the parent `github-copilot` directory
instead, which will always contain one of those files along with an
additional version file.

I have tested this on macOS and Linux Wayland.

Release Notes:

- Fixed an issue where signing into Copilot required restarting Zed.
2025-03-09 03:19:09 +05:30
Joseph T. Lyons
22d9b5d8ca Update key binding documentation (#26321)
Release Notes:

- N/A
2025-03-08 01:03:52 -05:00
Danilo Leal
6ed6e8bc26 git: Refine diff hunk controls visuals (#26317)
You may need to zoom in hard to see this 😅 but the main addition of this
PR is just ensuring there's also horizontal border instead of just on
the bottom. Also added some box-shadow here to make it pop out of the
diff a bit more.

| Before | After |
|--------|--------|
| ![CleanShot 2025-03-08 at 12  37
40@2x](https://github.com/user-attachments/assets/98e0329e-646f-4455-89fa-3f6ec1211361)
| ![CleanShot 2025-03-08 at 12  36
40@2x](https://github.com/user-attachments/assets/7f667e65-5b72-4156-b0ec-2b162eb76f2f)
|

Release Notes:

- N/A
2025-03-08 01:00:47 -03:00
Max Brunsfeld
4846e6fb3a Fix performance bottlenecks when multi buffers have huge numbers of buffers (#26308)
This is motivated by trying to make the Project Diff view usable with
huge Git change sets.

Release Notes:

- Improved performance of rendering multibuffers with very large numbers
of buffers
2025-03-08 02:15:15 +00:00
Mikayla Maki
cb543f9546 Git UI papercuts (#26316)
Release Notes:

- Git Beta: added `git:Add` as an alias for the existing `git::Diff`
- Git Beta: Fixed a bug where the 'generate commit message' keybinding
wasn't working.
- Git Beta: Made the empty project diff state a little more helpful with
a button to push, and a button to close the item.
2025-03-08 01:49:06 +00:00
Michael Sloan
450d727a04 Fixes to excerpt movement actions and bindings + add multibuffer and singleton_buffer key contexts (#26264)
Closes #26002 

Release Notes:

- Added `multibuffer` key context.
- `cmd-down` and `cmd-shift-down` on Mac now moves to the end of the
last line of a singleton buffer instead of the beginning. In
multibuffers, these now move to the start of the next excerpt.
- Fixed `vim::PreviousSectionEnd` (bound to `[ ]`) to move to the
beginning of the line, matching the behavior of `vim::NextSectionEnd`.
- Added `editor::MoveToStartOfNextExcerpt` and
`editor::MoveToEndOfPreviousExcerpt`.
2025-03-08 00:58:47 +00:00
Mikayla Maki
60b3eb3f76 Add git branch switching aliases (#26315)
This gives us _very_ rudimentary support for `git switch` and `git
checkout` now, by making them aliases for our existing `git::branch`
call.

Release Notes:

- Git Beta: Added `git::Switch` and `git::CheckoutBranch` as aliases for
the existing `git::Branch`
2025-03-08 00:02:57 +00:00
Marshall Bowers
bbe7c9a738 assistant2: Factor out Thread::all_tools_finished method (#26314)
This PR factors out a new `Thread::all_tools_finished` method to
encapsulate some of the boilerplate in the `ThreadEvent::ToolFinished`
event handler.

This should make this event handler easier to replicate for the eval
use-case.

Release Notes:

- N/A
2025-03-07 23:35:32 +00:00
Mikayla Maki
f6345a6995 Improve when the commit suggestions would show (#26313)
Release Notes:

- Git Beta: Fixed a few bugs where the suggested commit text wouldn't
show in certain cases, or would update slowly.
2025-03-07 23:33:48 +00:00
Marshall Bowers
e70d0edfac assistant_tool: Pass an Entity<Project> to Tool::run (#26312)
This PR updates the `Tool::run` method to take an `Entity<Project>`
instead of a `WeakEntity<Project>`.

Release Notes:

- N/A
2025-03-07 23:30:56 +00:00
Marshall Bowers
921c24e274 assistant2: Add helper methods to Thread for dealing with tool use (#26310)
This PR adds two new helper methods to the `Thread` for dealing with
tool use:

- `use_pending_tools` - This uses all of the tools that are pending
- The reason we aren't calling this directly in `stream_completion` is
that we still might need to have a way for users to confirm that they
want tools to be run, which would need to happen at the UI layer in the
`ActiveThread`.
- `send_tool_results_to_model` - This encapsulates inserting a new user
message that contains the tool results and sending them up to the model.

Release Notes:

- N/A
2025-03-07 23:16:45 +00:00
Marshall Bowers
18f3f8097f assistant_tool: Decouple Tool from Workspace (#26309)
This PR decouples the `Tool` trait from the `Workspace` (and from the
UI, in general).

`Tool::run` now takes a `WeakEntity<Project>` instead of a
`WeakEntity<Workspace>` and a `Window`.

Release Notes:

- N/A
2025-03-07 22:41:56 +00:00
Marshall Bowers
4f6682c7fe haskell: Extract to zed-extensions/haskell repository (#26306)
This PR extracts the Haskell extension to the
[zed-extensions/haskell](https://github.com/zed-extensions/haskell)
repository.

Release Notes:

- N/A
2025-03-07 22:07:04 +00:00
Kiran_Peraka
f57dece2d5 git: Fix errors not showing in the toast notification (#26303)
Release Notes:

- Resolved an issue where error messages from Git were not being
displayed in toast notifications.
<img width="1702" alt="Screenshot 2025-03-08 at 1 11 30 AM"
src="https://github.com/user-attachments/assets/a46517db-4e64-4c5e-a64e-96e820ca9aec"
/>
2025-03-07 20:57:53 +00:00
Kirill Bulatov
103ad635d9 Refactor Completions to allow non-LSP ones better (#26300)
A preparation for https://github.com/zed-industries/zed/issues/4957 that
pushes all LSP-related data out from the basic completion item, so that
it's possible to create completion items without any trace of LSP
clearly.

Release Notes:

- N/A
2025-03-07 20:19:28 +00:00
Mikayla Maki
ec5e7a2653 Change the default staging and unstaging state display (#26299)
This adds a setting for the "border" hunk display mode, as discussed,
and makes it the default.

Here's how it looks in light mode:

<img width="1512" alt="Screenshot 2025-03-07 at 11 39 25 AM"
src="https://github.com/user-attachments/assets/a934faa3-ec69-47e1-ad46-535e48b98e9f"
/>

And dark mode: 

<img width="1511" alt="Screenshot 2025-03-07 at 11 39 56 AM"
src="https://github.com/user-attachments/assets/43c9afd1-22bb-4bd8-96ce-82702a6cbc80"
/>


Release Notes:

- Git Beta: Adjusted the default hunk styling for staged and unstaged
changes

Co-authored-by: Conrad <conrad@zed.dev>
Co-authored-by: Nate <nate@zed.dev>
2025-03-07 19:56:24 +00:00
Marshall Bowers
05d3ee8555 extension: Require that grammar names are written in snake_case (#26295)
This PR updates the `ExtensionBuilder` to require that grammar names are
written in snake_case.

The grammar names are used to construct identifiers, so we need them to
be valid C identifiers.

Release Notes:

- N/A
2025-03-07 19:02:35 +00:00
Nate Butler
1b34437839 component_preview: Add component pages (#26284)
This PR adds pages to component preview when clicking on a given
component in the sidebar.

This will let us create richer previews & better docs for using
components in the future.

Release Notes:

- N/A
2025-03-07 18:56:17 +00:00
Danilo Leal
3ff2c8fc38 Add file icon for Luau (#26293)
Closes https://github.com/zed-industries/zed/issues/14948

Release Notes:

- N/A
2025-03-07 15:27:00 -03:00
Cole Miller
b0b0b00fae worktree: Add some info-level logging about added and removed repository entries (#26291)
Trying to track down a user's reported issue with parent repositories
not getting picked up.

Release Notes:

- N/A
2025-03-07 18:02:05 +00:00
Conrad Irwin
80fb88520f Remove worktree and project notifies (#26244)
This reduces the number of multibuffer syncs from 100,000 to 20,000.
Before this change each editor individually observed the project, so
literally any project change was amplified by the number of editors you
had open.

Now editors listen to their buffers instead of the project, and other
users of `cx.observe` on the project have been updated to use specific
events to reduce churn.

Follow up to #26237


Release Notes:

- Improved performance of Zed in large repos with lots of file system
events.

---------

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
2025-03-07 10:51:46 -07:00
Conrad Irwin
aef84d453a Update Linux Graphics troubleshooting (#26263)
Try to re-order the tips for clarity, and make it clear that /etc/prime
could be wrong either way around.

Also remove section on FIPS now we nolonger bundle openssl

Updates #15629

Release Notes:

- N/A
2025-03-07 09:57:11 -07:00
João Marcos
e06d010aab Test folded buffers navigation (#26286)
#25944 but now with Vim mode off.

Release Notes:

- N/A
2025-03-07 16:19:12 +00:00
Marshall Bowers
14148f53d4 scripting_tool: Move description into a separate file (#26283)
This PR moves the `scripting_tool` description into a separate file so
it's a bit easier to work with.

Release Notes:

- N/A
2025-03-07 15:52:38 +00:00
Antonio Scandurra
efde5aa2bb Extract a Session struct to hold state about a given thread's scripting session (#26282)
We're still recreating a session for every tool call, but the idea is to
have a long-lived `Session` per assistant thread.

Release Notes:

- N/A

---------

Co-authored-by: Agus Zubiaga <hi@aguz.me>
2025-03-07 15:44:36 +00:00
Guilherme Gonçalves
fcc5e27455 Fix hotkey for toggle filters in project search (#25917)
Closes #24741 

Adjusted the shortcut key handling to properly toggle filters in the project search feature.

Release Notes:

- linux: Fixed `ctrl-alt-f` not correctly toggling search filters in project search.

---------

Co-authored-by: Peter Tripp <peter@zed.dev>
2025-03-07 15:27:10 +00:00
Marshall Bowers
ed417da536 git_ui: Try to prompt the model out of including the diff output (#26281)
This PR updates the prompt for generating commit messages to tell the
model not to include the raw diff output in the message.

Release Notes:

- N/A
2025-03-07 15:05:35 +00:00
Piotr Osiewicz
d1c67897c5 chore: Do not bust Rust build cache when opening projects with dev build (#26278)
## Problem
Running `cargo run .` twice in Zed repository required a rebuild two
times in a row. The second rebuild was triggered around libz-sys, which
in practice caused a rebuild of the ~entire project.

Some concrete examples:
```
cargo test -p project # Requires a rebuild (warranted)
cargo run .
cargo test -p project # Requires a rebuild (unwarranted)
```
or
```
cargo run . # Requires a rebuild (warranted)
cargo run . # Requires a rebuild (unwarranted)
```

## What's going on
Zed build script on MacOS sets MACOSX_DEPLOYMENT_TARGET to 10.15. This
is fine. However, **cargo propagates all environment variables to child
processes during `cargo run`**. This then affects Rust Analyzer spawned
by dev Zed - it clobbers build cache of whatever package it touches,
because it's behavior is not same between running it with `cargo run`
(where MACOS_DEPLOYMENT_TARGET gets propagated to child Zed) and running
it directly via `target/debug/zed` or whatever (where the env variable
is not set, so that build behaves roughly like Zed Dev.app).


## Solution
~We'll unset that env variable from user environment when we're
reasonably confident that we're running under `cargo run` by exploiting
other env variables set by cargo:
https://doc.rust-lang.org/cargo/reference/environment-variables.html
CARGO_PKG_NAME is always set to `zed` when running it via `cargo run`,
as it's the value propagated from the build.~

~The alternative I've considered is running [via a custom
runner](https://doc.rust-lang.org/cargo/reference/config.html#targetcfgrunner),
though the problem here is that we'd have to use a shell script to unset
the env variable - that could be problematic with e.g. fish. I just
didn't want to deal with that, though admittedly it would've been
cleaner in other aspects.~

Redact all above. We'll just set MACOSX_DEPLOYMENT_TARGET regardless of
whether you have it in your OG shell environment or not.

Release Notes:

- N/A
2025-03-07 14:06:44 +00:00
Finn Evers
a887f3b340 Remove plain text file type association from default settings (#25420)
Closes #20291

This PR removes the plain text file association from the default
settings, as #21298 added a `LanguageMatcher` for Plain Text files,
which now associates "Plain Text" with `txt`-files (see
10053e2566/crates/language/src/language.rs (L127-L137)).

Thus, the association via the default settings is not required anymore,
which fixes #20291 as described in
https://github.com/zed-industries/zed/issues/20291#issuecomment-2500731743

Release Notes:

- Fixed default file type associations overriding associations provided
by extensions for `txt`-files.

Co-authored-by: Peter Tripp <peter@zed.dev>
2025-03-07 08:45:23 -05:00
Kirill Bulatov
f8deebc6db Fix inline diagnostics in the project diff (#26275)
205f9a9f03/crates/editor/src/element.rs (L1643)

Due to the snippet above, Zed is supposed to have `row` larger or equal
to `start_row` here:


205f9a9f03/crates/editor/src/element.rs (L1694)

yet the panic were reported when clicking in the project diff.

That project diff has a lot of highlighting happening already, so the PR
disables inline diagnostics within a git diff view.


Release Notes:

- N/A
2025-03-07 11:34:37 +00:00
Michael Sloan
205f9a9f03 Add lua script access to code using cx + reuse project search logic (#26269)
Access to `cx` will be needed for anything that queries entities. In
this commit this is use of `WorktreeStore::find_search_candidates`. In
the future it will be things like access to LSP / tree-sitter outlines /
etc.

Changes to support access to `cx` from functions provided to the Lua
script:

* Adds a channel of requests that require a `cx`. Work enqueued to this
channel is run on the foreground thread.

* Adds `async` and `send` features to `mlua` crate so that async rust
functions can be used from Lua.

* Changes uses of `Rc<RefCell<...>>` to `Arc<Mutex<...>>` so that the
futures are `Send`.

One benefit of reusing project search logic for search candidates is
that it properly ignores paths.

Release Notes:

- N/A
2025-03-07 10:02:49 +00:00
Cole Miller
b0d1024f66 Silence a couple of noisy logs (#26262)
Closes #ISSUE

Release Notes:

- N/A
2025-03-06 22:45:47 -05:00
loczek
622ed8a032 git: Fix git panel not using default width (#26220)
Closes #26062

Removing the width here causes zed to use the default value (inside
default settings) after restart like other panels.

Release Notes:

- Fixed issue where git panel wasn't using default width after restart

Co-authored-by: Mikayla Maki <mikayla@zed.dev>
2025-03-07 02:27:29 +00:00
0x2CA
09c51f9641 assistant2: Fix font fallbacks (#26258)
Release Notes:

- N/A
2025-03-06 18:14:53 -08:00
Mikayla Maki
8422a81d88 Add staged variants of the hunk_style controls (#26259)
This PR adds a few more hunk style settings that flips the emphasis.
Normally, the concept at Zed has been that the project diff should
emphasize what's going into the commit. However, this leads to a problem
where the default state of all diff hunks are in the non-emphasized
state, making them hard to see and interact with. Especially on light
themes. This PR is an experiment in flipping the emphasis states. Now
the project diff is more like a queue of work, with the next "job" (hunk
to be evaluated) emphasized, and the "completed" (staged) hunks
deemphasized. This fixes the default state issue but is a big jump from
how we've been thinking about it. So here we can try it out and see how
it feels :)

Release Notes:

- Git Beta: Added hunk style settings to emphasize the unstaged state,
rather than the staged state.
2025-03-07 02:13:50 +00:00
144 changed files with 5249 additions and 3984 deletions

View File

@@ -26,3 +26,6 @@ rustflags = [
"-C",
"target-feature=+crt-static", # This fixes the linking issue when compiling livekit on Windows
]
[env]
MACOSX_DEPLOYMENT_TARGET = "10.15.7"

1463
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ members = [
"crates/assistant",
"crates/assistant2",
"crates/assistant_context_editor",
"crates/assistant_scripting",
"crates/assistant_settings",
"crates/assistant_slash_command",
"crates/assistant_slash_commands",
@@ -118,7 +119,6 @@ members = [
"crates/rope",
"crates/rpc",
"crates/schema_generator",
"crates/scripting_tool",
"crates/search",
"crates/semantic_index",
"crates/semantic_version",
@@ -171,7 +171,6 @@ members = [
"extensions/emmet",
"extensions/glsl",
"extensions/haskell",
"extensions/html",
"extensions/perplexity",
"extensions/proto",
@@ -319,7 +318,7 @@ reqwest_client = { path = "crates/reqwest_client" }
rich_text = { path = "crates/rich_text" }
rope = { path = "crates/rope" }
rpc = { path = "crates/rpc" }
scripting_tool = { path = "crates/scripting_tool" }
assistant_scripting = { path = "crates/assistant_scripting" }
search = { path = "crates/search" }
semantic_index = { path = "crates/semantic_index" }
semantic_version = { path = "crates/semantic_version" }
@@ -371,7 +370,7 @@ zeta = { path = "crates/zeta" }
#
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", rev = "03c2907b44b4189aac5fdeaea331f5aab5c7072e" }
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
any_vec = "0.14"
anyhow = "1.0.86"
arrayvec = { version = "0.7.4", features = ["serde"] }
@@ -452,7 +451,7 @@ livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "
], default-features = false }
log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] }
markup5ever_rcdom = "0.3.0"
mlua = { version = "0.10", features = ["lua54", "vendored"] }
mlua = { version = "0.10", features = ["lua54", "vendored", "async", "send"] }
nanoid = "0.4"
nbformat = { version = "0.10.0" }
nix = "0.29"

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.36197 1.67985C5.3748 1.41534 4.36011 2.00117 4.0956 2.98834L2.17985 10.138C1.91534 11.1252 2.50117 12.1399 3.48833 12.4044L10.638 14.3202C11.6252 14.5847 12.6399 13.9988 12.9044 13.0117L14.8202 5.86197C15.0847 4.8748 14.4988 3.86012 13.5117 3.59561L6.36197 1.67985ZM10.0457 4.58266C9.77896 4.51119 9.50479 4.66948 9.43332 4.93621L8.76235 7.44028C8.69088 7.70701 8.84917 7.98118 9.11591 8.05265L11.62 8.72362C11.8867 8.79509 12.1609 8.6368 12.2324 8.37006L12.9033 5.86599C12.9748 5.59926 12.8165 5.32509 12.5498 5.25362L10.0457 4.58266Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 707 B

View File

@@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.5 13L1.5 5H11.5L6.5 13Z" fill="black"/>
<path d="M14 9H9L11.5 5L14 9Z" fill="black" fill-opacity="0.75"/>
<path d="M9 9L14 9L11.5 13L9 9Z" fill="black" fill-opacity="0.65"/>
<path d="M14 5L15.25 7L12.75 7L14 5Z" fill="black" fill-opacity="0.5"/>
<path d="M14 9L12.75 7H15.25L14 9Z" fill="black" fill-opacity="0.55"/>
</svg>

After

Width:  |  Height:  |  Size: 432 B

View File

@@ -475,9 +475,7 @@
"ctrl-alt-delete": "editor::DeleteToNextSubwordEnd",
"ctrl-alt-d": "editor::DeleteToNextSubwordEnd",
"ctrl-alt-left": "editor::MoveToPreviousSubwordStart",
// "ctrl-alt-b": "editor::MoveToPreviousSubwordStart",
"ctrl-alt-right": "editor::MoveToNextSubwordEnd",
"ctrl-alt-f": "editor::MoveToNextSubwordEnd",
"ctrl-alt-shift-left": "editor::SelectToPreviousSubwordStart",
"ctrl-alt-shift-b": "editor::SelectToPreviousSubwordStart",
"ctrl-alt-shift-right": "editor::SelectToNextSubwordEnd",

View File

@@ -108,8 +108,8 @@
"cmd-right": ["editor::MoveToEndOfLine", { "stop_at_soft_wraps": true }],
"ctrl-e": ["editor::MoveToEndOfLine", { "stop_at_soft_wraps": false }],
"end": ["editor::MoveToEndOfLine", { "stop_at_soft_wraps": true }],
"cmd-up": "editor::MoveToStartOfExcerpt",
"cmd-down": "editor::MoveToEndOfExcerpt",
"cmd-up": "editor::MoveToBeginning",
"cmd-down": "editor::MoveToEnd",
"cmd-home": "editor::MoveToBeginning", // Typed via `cmd-fn-left`
"cmd-end": "editor::MoveToEnd", // Typed via `cmd-fn-right`
"shift-up": "editor::SelectUp",
@@ -124,8 +124,8 @@
"alt-shift-right": "editor::SelectToNextWordEnd", // cursorWordRightSelect
"ctrl-shift-up": "editor::SelectToStartOfParagraph",
"ctrl-shift-down": "editor::SelectToEndOfParagraph",
"cmd-shift-up": "editor::SelectToStartOfExcerpt",
"cmd-shift-down": "editor::SelectToEndOfExcerpt",
"cmd-shift-up": "editor::SelectToBeginning",
"cmd-shift-down": "editor::SelectToEnd",
"cmd-a": "editor::SelectAll",
"cmd-l": "editor::SelectLine",
"cmd-shift-i": "editor::Format",
@@ -172,6 +172,16 @@
"alt-enter": "editor::OpenSelectionsInMultibuffer"
}
},
{
"context": "Editor && multibuffer",
"use_key_equivalents": true,
"bindings": {
"cmd-up": "editor::MoveToStartOfExcerpt",
"cmd-down": "editor::MoveToStartOfNextExcerpt",
"cmd-shift-up": "editor::SelectToStartOfExcerpt",
"cmd-shift-down": "editor::SelectToStartOfNextExcerpt"
}
},
{
"context": "Editor && mode == full && edit_prediction",
"use_key_equivalents": true,

View File

@@ -845,7 +845,7 @@
// "hunk_style": "transparent"
// 2. Show unstaged hunks with a pattern background:
// "hunk_style": "pattern"
"hunk_style": "transparent"
"hunk_style": "staged_border"
},
// Configuration for how direnv configuration should be loaded. May take 2 values:
// 1. Load direnv configuration using `direnv export json` directly.
@@ -1055,7 +1055,6 @@
// }
//
"file_types": {
"Plain Text": ["txt"],
"JSONC": ["**/.zed/**/*.json", "**/zed/**/*.json", "**/Zed/**/*.json", "**/.vscode/**/*.json"],
"Shell Script": [".env.*"]
},

View File

@@ -9,7 +9,10 @@ use gpui::{
};
use language::{LanguageRegistry, LanguageServerBinaryStatus, LanguageServerId};
use lsp::LanguageServerName;
use project::{EnvironmentErrorMessage, LanguageServerProgress, Project, WorktreeId};
use project::{
EnvironmentErrorMessage, LanguageServerProgress, LspStoreEvent, Project,
ProjectEnvironmentEvent, WorktreeId,
};
use smallvec::SmallVec;
use std::{cmp::Reverse, fmt::Write, sync::Arc, time::Duration};
use ui::{prelude::*, ButtonLike, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip};
@@ -73,7 +76,22 @@ impl ActivityIndicator {
})
.detach();
cx.observe(&project, |_, _, cx| cx.notify()).detach();
cx.subscribe(
&project.read(cx).lsp_store(),
|_, _, event, cx| match event {
LspStoreEvent::LanguageServerUpdate { .. } => cx.notify(),
_ => {}
},
)
.detach();
cx.subscribe(
&project.read(cx).environment().clone(),
|_, _, event, cx| match event {
ProjectEnvironmentEvent::ErrorsUpdated => cx.notify(),
},
)
.detach();
if let Some(auto_updater) = auto_updater.as_ref() {
cx.observe(auto_updater, |_, _, cx| cx.notify()).detach();
@@ -204,7 +222,7 @@ impl ActivityIndicator {
message: error.0.clone(),
on_click: Some(Arc::new(move |this, window, cx| {
this.project.update(cx, |project, cx| {
project.remove_environment_error(cx, worktree_id);
project.remove_environment_error(worktree_id, cx);
});
window.dispatch_action(Box::new(workspace::OpenLog), cx);
})),

View File

@@ -38,7 +38,7 @@ use language_model::{
use language_model_selector::{LanguageModelSelector, LanguageModelSelectorPopoverMenu};
use multi_buffer::MultiBufferRow;
use parking_lot::Mutex;
use project::{ActionVariant, CodeAction, ProjectTransaction};
use project::{CodeAction, LspAction, ProjectTransaction};
use prompt_store::PromptBuilder;
use rope::Rope;
use settings::{update_settings_file, Settings, SettingsStore};
@@ -3569,7 +3569,7 @@ impl CodeActionProvider for AssistantCodeActionProvider {
Task::ready(Ok(vec![CodeAction {
server_id: language::LanguageServerId(0),
range: snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end),
lsp_action: ActionVariant::Action(Box::new(lsp::CodeAction {
lsp_action: LspAction::Action(Box::new(lsp::CodeAction {
title: "Fix with Assistant".into(),
..Default::default()
})),

View File

@@ -21,6 +21,7 @@ test-support = [
[dependencies]
anyhow.workspace = true
assistant_context_editor.workspace = true
assistant_scripting.workspace = true
assistant_settings.workspace = true
assistant_slash_command.workspace = true
assistant_tool.workspace = true
@@ -63,6 +64,7 @@ serde.workspace = true
serde_json.workspace = true
settings.workspace = true
smol.workspace = true
rich_text.workspace = true
streaming_diff.workspace = true
telemetry_events.workspace = true
terminal.workspace = true
@@ -81,8 +83,8 @@ zed_actions.workspace = true
[dev-dependencies]
editor = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, "features" = ["test-support"] }
indoc.workspace = true
language = { workspace = true, "features" = ["test-support"] }
language_model = { workspace = true, "features" = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
rand.workspace = true
indoc.workspace = true

View File

@@ -1,8 +1,7 @@
use std::sync::Arc;
use assistant_tool::ToolWorkingSet;
use collections::HashMap;
use assistant_scripting::{ScriptId, ScriptState};
use collections::{HashMap, HashSet};
use editor::{Editor, MultiBuffer};
use futures::FutureExt;
use gpui::{
list, AbsoluteLength, AnyElement, App, ClickEvent, DefiniteLength, EdgesRefinement, Empty,
Entity, Focusable, Length, ListAlignment, ListOffset, ListState, StyleRefinement, Subscription,
@@ -12,6 +11,8 @@ use language::{Buffer, LanguageRegistry};
use language_model::{LanguageModelRegistry, LanguageModelToolUseId, Role};
use markdown::{Markdown, MarkdownStyle};
use settings::Settings as _;
use std::ops::Range;
use std::sync::Arc;
use theme::ThemeSettings;
use ui::{prelude::*, Disclosure, KeyBinding};
use util::ResultExt as _;
@@ -21,11 +22,12 @@ use crate::thread::{MessageId, RequestKind, Thread, ThreadError, ThreadEvent};
use crate::thread_store::ThreadStore;
use crate::tool_use::{ToolUse, ToolUseStatus};
use crate::ui::ContextPill;
use gpui::{HighlightStyle, StyledText};
use rich_text::{self, Highlight};
pub struct ActiveThread {
workspace: WeakEntity<Workspace>,
language_registry: Arc<LanguageRegistry>,
tools: Arc<ToolWorkingSet>,
thread_store: Entity<ThreadStore>,
thread: Entity<Thread>,
save_thread_task: Option<Task<()>>,
@@ -34,6 +36,7 @@ pub struct ActiveThread {
rendered_messages_by_id: HashMap<MessageId, Entity<Markdown>>,
editing_message: Option<(MessageId, EditMessageState)>,
expanded_tool_uses: HashMap<LanguageModelToolUseId, bool>,
expanded_scripts: HashSet<ScriptId>,
last_error: Option<ThreadError>,
_subscriptions: Vec<Subscription>,
}
@@ -44,11 +47,10 @@ struct EditMessageState {
impl ActiveThread {
pub fn new(
workspace: WeakEntity<Workspace>,
thread: Entity<Thread>,
thread_store: Entity<ThreadStore>,
workspace: WeakEntity<Workspace>,
language_registry: Arc<LanguageRegistry>,
tools: Arc<ToolWorkingSet>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -60,13 +62,13 @@ impl ActiveThread {
let mut this = Self {
workspace,
language_registry,
tools,
thread_store,
thread: thread.clone(),
save_thread_task: None,
messages: Vec::new(),
rendered_messages_by_id: HashMap::default(),
expanded_tool_uses: HashMap::default(),
expanded_scripts: HashSet::default(),
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.), {
let this = cx.entity().downgrade();
move |ix, window: &mut Window, cx: &mut App| {
@@ -173,6 +175,8 @@ impl ActiveThread {
text_style.refine(&TextStyleRefinement {
font_family: Some(theme_settings.ui_font.family.clone()),
font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
font_features: Some(theme_settings.ui_font.features.clone()),
font_size: Some(ui_font_size.into()),
color: Some(cx.theme().colors().text),
..Default::default()
@@ -207,6 +211,8 @@ impl ActiveThread {
},
text: Some(TextStyleRefinement {
font_family: Some(theme_settings.buffer_font.family.clone()),
font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
font_features: Some(theme_settings.buffer_font.features.clone()),
font_size: Some(buffer_font_size.into()),
..Default::default()
}),
@@ -214,6 +220,8 @@ impl ActiveThread {
},
inline_code: TextStyleRefinement {
font_family: Some(theme_settings.buffer_font.family.clone()),
font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
font_features: Some(theme_settings.buffer_font.features.clone()),
font_size: Some(buffer_font_size.into()),
background_color: Some(colors.editor_foreground.opacity(0.1)),
..Default::default()
@@ -243,7 +251,7 @@ impl ActiveThread {
fn handle_thread_event(
&mut self,
_: &Entity<Thread>,
_thread: &Entity<Thread>,
event: &ThreadEvent,
window: &mut Window,
cx: &mut Context<Self>,
@@ -294,48 +302,26 @@ impl ActiveThread {
cx.notify();
}
ThreadEvent::UsePendingTools => {
let pending_tool_uses = self
.thread
.read(cx)
.pending_tool_uses()
.into_iter()
.filter(|tool_use| tool_use.status.is_idle())
.cloned()
.collect::<Vec<_>>();
for tool_use in pending_tool_uses {
if let Some(tool) = self.tools.tool(&tool_use.name, cx) {
let task = tool.run(tool_use.input, self.workspace.clone(), window, cx);
self.thread.update(cx, |thread, cx| {
thread.use_pending_tools(cx);
});
}
ThreadEvent::ToolFinished { .. } => {
if self.thread.read(cx).all_tools_finished() {
let model_registry = LanguageModelRegistry::read_global(cx);
if let Some(model) = model_registry.active_model() {
self.thread.update(cx, |thread, cx| {
thread.insert_tool_output(tool_use.id.clone(), task, cx);
thread.send_tool_results_to_model(model, cx);
});
}
}
}
ThreadEvent::ToolFinished { .. } => {
let all_tools_finished = self
.thread
.read(cx)
.pending_tool_uses()
.into_iter()
.all(|tool_use| tool_use.status.is_error());
if all_tools_finished {
let model_registry = LanguageModelRegistry::read_global(cx);
if let Some(model) = model_registry.active_model() {
self.thread.update(cx, |thread, cx| {
// Insert a user message to contain the tool results.
thread.insert_user_message(
// TODO: Sending up a user message without any content results in the model sending back
// responses that also don't have any content. We currently don't handle this case well,
// so for now we provide some text to keep the model on track.
"Here are the tool results.",
Vec::new(),
cx,
);
thread.send_to_model(model, RequestKind::Chat, true, cx);
});
}
ThreadEvent::ScriptFinished => {
let model_registry = LanguageModelRegistry::read_global(cx);
if let Some(model) = model_registry.active_model() {
self.thread.update(cx, |thread, cx| {
thread.send_to_model(model, RequestKind::Chat, false, cx);
});
}
}
}
@@ -477,12 +463,16 @@ impl ActiveThread {
return Empty.into_any();
};
let context = self.thread.read(cx).context_for_message(message_id);
let tool_uses = self.thread.read(cx).tool_uses_for_message(message_id);
let colors = cx.theme().colors();
let thread = self.thread.read(cx);
let context = thread.context_for_message(message_id);
let tool_uses = thread.tool_uses_for_message(message_id);
// Don't render user messages that are just there for returning tool results.
if message.role == Role::User && self.thread.read(cx).message_has_tool_results(message_id) {
if message.role == Role::User
&& (thread.message_has_tool_results(message_id)
|| thread.message_has_script_output(message_id))
{
return Empty.into_any();
}
@@ -495,6 +485,8 @@ impl ActiveThread {
.filter(|(id, _)| *id == message_id)
.map(|(_, state)| state.editor.clone());
let colors = cx.theme().colors();
let message_content = v_flex()
.child(
if let Some(edit_message_editor) = edit_message_editor.clone() {
@@ -629,6 +621,7 @@ impl ActiveThread {
Role::Assistant => div()
.id(("message-container", ix))
.child(message_content)
.children(self.render_script(message_id, cx))
.map(|parent| {
if tool_uses.is_empty() {
return parent;
@@ -748,6 +741,191 @@ impl ActiveThread {
}),
)
}
fn render_script(&self, message_id: MessageId, cx: &mut Context<Self>) -> Option<AnyElement> {
let script = self.thread.read(cx).script_for_message(message_id, cx)?;
let is_open = self.expanded_scripts.contains(&script.id);
let colors = cx.theme().colors();
let element = div().px_2p5().child(
v_flex()
.gap_1()
.rounded_lg()
.border_1()
.border_color(colors.border)
.child(
h_flex()
.justify_between()
.py_0p5()
.pl_1()
.pr_2()
.bg(colors.editor_foreground.opacity(0.02))
.when(is_open, |element| element.border_b_1().rounded_t(px(6.)))
.when(!is_open, |element| element.rounded_md())
.border_color(colors.border)
.child(
h_flex()
.gap_1()
.child(Disclosure::new("script-disclosure", is_open).on_click(
cx.listener({
let script_id = script.id;
move |this, _event, _window, _cx| {
if this.expanded_scripts.contains(&script_id) {
this.expanded_scripts.remove(&script_id);
} else {
this.expanded_scripts.insert(script_id);
}
}
}),
))
// TODO: Generate script description
.child(Label::new("Script")),
)
.child(
h_flex()
.gap_1()
.child(
Label::new(match script.state {
ScriptState::Generating => "Generating",
ScriptState::Running { .. } => "Running",
ScriptState::Succeeded { .. } => "Finished",
ScriptState::Failed { .. } => "Error",
})
.size(LabelSize::XSmall)
.buffer_font(cx),
)
.child(
IconButton::new("view-source", IconName::Eye)
.icon_color(Color::Muted)
.disabled(matches!(script.state, ScriptState::Generating))
.on_click(cx.listener({
let source = script.source.clone();
move |this, _event, window, cx| {
this.open_script_source(source.clone(), window, cx);
}
})),
),
),
)
.when(is_open, |parent| {
let stdout = script.stdout_snapshot();
let error = script.error();
let lua_language =
async { self.language_registry.language_for_name("Lua").await.ok() }
.now_or_never()
.flatten();
let source_display = if let Some(lua_language) = &lua_language {
let mut highlights = Vec::new();
let mut buf = String::new();
rich_text::render_code(
&mut buf,
&mut highlights,
&script.source,
lua_language,
);
let theme = cx.theme();
let gpui_highlights: Vec<(Range<usize>, HighlightStyle)> = highlights
.iter()
.map(|(range, highlight)| {
let style = match highlight {
Highlight::Code => Default::default(),
Highlight::Id(id) => {
id.style(theme.syntax()).unwrap_or_default()
}
Highlight::InlineCode(_) => Default::default(),
Highlight::Highlight(highlight) => *highlight,
_ => HighlightStyle::default(),
};
(range.clone(), style)
})
.collect();
StyledText::new(buf)
.with_highlights(gpui_highlights)
.into_any_element()
} else {
Label::new(script.source.clone())
.size(LabelSize::Small)
.buffer_font(cx)
.into_any_element()
};
parent.child(
v_flex()
.p_2()
.bg(colors.editor_background)
.gap_2()
.child(
div()
.border_1()
.border_color(colors.border)
.p_2()
.bg(colors.editor_foreground.opacity(0.025))
.rounded_md()
.child(source_display),
)
.child(if stdout.is_empty() && error.is_none() {
Label::new("No output yet")
.size(LabelSize::Small)
.color(Color::Muted)
} else {
Label::new(stdout).size(LabelSize::Small).buffer_font(cx)
})
.children(script.error().map(|err| {
Label::new(err.to_string())
.size(LabelSize::Small)
.color(Color::Error)
})),
)
}),
);
Some(element.into_any())
}
fn open_script_source(
&mut self,
source: SharedString,
window: &mut Window,
cx: &mut Context<'_, ActiveThread>,
) {
let language_registry = self.language_registry.clone();
let workspace = self.workspace.clone();
let source = source.clone();
cx.spawn_in(window, |_, mut cx| async move {
let lua = language_registry.language_for_name("Lua").await.log_err();
workspace.update_in(&mut cx, |workspace, window, cx| {
let project = workspace.project().clone();
let buffer = project.update(cx, |project, cx| {
project.create_local_buffer(&source.trim(), lua, cx)
});
let buffer = cx.new(|cx| {
MultiBuffer::singleton(buffer, cx)
// TODO: Generate script description
.with_title("Assistant script".into())
});
let editor = cx.new(|cx| {
let mut editor =
Editor::for_multibuffer(buffer, Some(project), true, window, cx);
editor.set_read_only(true);
editor
});
workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx);
})
})
.detach_and_log_err(cx);
}
}
impl Render for ActiveThread {

View File

@@ -92,7 +92,6 @@ pub struct AssistantPanel {
context_editor: Option<Entity<ContextEditor>>,
configuration: Option<Entity<AssistantConfiguration>>,
configuration_subscription: Option<Subscription>,
tools: Arc<ToolWorkingSet>,
local_timezone: UtcOffset,
active_view: ActiveView,
history_store: Entity<HistoryStore>,
@@ -133,7 +132,7 @@ impl AssistantPanel {
log::info!("[assistant2-debug] finished initializing ContextStore");
workspace.update_in(&mut cx, |workspace, window, cx| {
cx.new(|cx| Self::new(workspace, thread_store, context_store, tools, window, cx))
cx.new(|cx| Self::new(workspace, thread_store, context_store, window, cx))
})
})
}
@@ -142,7 +141,6 @@ impl AssistantPanel {
workspace: &Workspace,
thread_store: Entity<ThreadStore>,
context_store: Entity<assistant_context_editor::ContextStore>,
tools: Arc<ToolWorkingSet>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -168,30 +166,30 @@ impl AssistantPanel {
let history_store =
cx.new(|cx| HistoryStore::new(thread_store.clone(), context_store.clone(), cx));
let thread = cx.new(|cx| {
ActiveThread::new(
workspace.clone(),
thread.clone(),
thread_store.clone(),
language_registry.clone(),
window,
cx,
)
});
Self {
active_view: ActiveView::Thread,
workspace: workspace.clone(),
project,
workspace,
project: project.clone(),
fs: fs.clone(),
language_registry: language_registry.clone(),
language_registry,
thread_store: thread_store.clone(),
thread: cx.new(|cx| {
ActiveThread::new(
thread.clone(),
thread_store.clone(),
workspace,
language_registry,
tools.clone(),
window,
cx,
)
}),
thread,
message_editor,
context_store,
context_editor: None,
configuration: None,
configuration_subscription: None,
tools,
local_timezone: UtcOffset::from_whole_seconds(
chrono::Local::now().offset().local_minus_utc(),
)
@@ -244,11 +242,10 @@ impl AssistantPanel {
self.active_view = ActiveView::Thread;
self.thread = cx.new(|cx| {
ActiveThread::new(
self.workspace.clone(),
thread.clone(),
self.thread_store.clone(),
self.workspace.clone(),
self.language_registry.clone(),
self.tools.clone(),
window,
cx,
)
@@ -379,11 +376,10 @@ impl AssistantPanel {
this.active_view = ActiveView::Thread;
this.thread = cx.new(|cx| {
ActiveThread::new(
this.workspace.clone(),
thread.clone(),
this.thread_store.clone(),
this.workspace.clone(),
this.language_registry.clone(),
this.tools.clone(),
window,
cx,
)

View File

@@ -27,7 +27,7 @@ use language::{Buffer, Point, Selection, TransactionId};
use language_model::{report_assistant_event, LanguageModelRegistry};
use multi_buffer::MultiBufferRow;
use parking_lot::Mutex;
use project::ActionVariant;
use project::LspAction;
use project::{CodeAction, ProjectTransaction};
use prompt_store::PromptBuilder;
use settings::{Settings, SettingsStore};
@@ -1728,7 +1728,7 @@ impl CodeActionProvider for AssistantCodeActionProvider {
Task::ready(Ok(vec![CodeAction {
server_id: language::LanguageServerId(0),
range: snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end),
lsp_action: ActionVariant::Action(Box::new(lsp::CodeAction {
lsp_action: LspAction::Action(Box::new(lsp::CodeAction {
title: "Fix with Assistant".into(),
..Default::default()
})),

View File

@@ -389,6 +389,7 @@ impl Render for MessageEditor {
let text_style = TextStyle {
color: cx.theme().colors().text,
font_family: settings.ui_font.family.clone(),
font_fallbacks: settings.ui_font.fallbacks.clone(),
font_features: settings.ui_font.features.clone(),
font_size: font_size.into(),
font_weight: settings.ui_font.weight,

View File

@@ -1,17 +1,21 @@
use std::sync::Arc;
use anyhow::Result;
use assistant_scripting::{
Script, ScriptEvent, ScriptId, ScriptSession, ScriptTagParser, SCRIPTING_PROMPT,
};
use assistant_tool::ToolWorkingSet;
use chrono::{DateTime, Utc};
use collections::{BTreeMap, HashMap, HashSet};
use futures::StreamExt as _;
use gpui::{App, Context, EventEmitter, SharedString, Task};
use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task};
use language_model::{
LanguageModel, LanguageModelCompletionEvent, LanguageModelRegistry, LanguageModelRequest,
LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult,
LanguageModelToolUseId, MaxMonthlySpendReachedError, MessageContent, PaymentRequiredError,
Role, StopReason,
};
use project::Project;
use serde::{Deserialize, Serialize};
use util::{post_inc, TryFutureExt as _};
use uuid::Uuid;
@@ -71,12 +75,24 @@ pub struct Thread {
context_by_message: HashMap<MessageId, Vec<ContextId>>,
completion_count: usize,
pending_completions: Vec<PendingCompletion>,
project: Entity<Project>,
tools: Arc<ToolWorkingSet>,
tool_use: ToolUseState,
scripts_by_assistant_message: HashMap<MessageId, ScriptId>,
script_output_messages: HashSet<MessageId>,
script_session: Entity<ScriptSession>,
_script_session_subscription: Subscription,
}
impl Thread {
pub fn new(tools: Arc<ToolWorkingSet>, _cx: &mut Context<Self>) -> Self {
pub fn new(
project: Entity<Project>,
tools: Arc<ToolWorkingSet>,
cx: &mut Context<Self>,
) -> Self {
let script_session = cx.new(|cx| ScriptSession::new(project.clone(), cx));
let script_session_subscription = cx.subscribe(&script_session, Self::handle_script_event);
Self {
id: ThreadId::new(),
updated_at: Utc::now(),
@@ -88,16 +104,22 @@ impl Thread {
context_by_message: HashMap::default(),
completion_count: 0,
pending_completions: Vec::new(),
project,
tools,
tool_use: ToolUseState::new(),
scripts_by_assistant_message: HashMap::default(),
script_output_messages: HashSet::default(),
script_session,
_script_session_subscription: script_session_subscription,
}
}
pub fn from_saved(
id: ThreadId,
saved: SavedThread,
project: Entity<Project>,
tools: Arc<ToolWorkingSet>,
_cx: &mut Context<Self>,
cx: &mut Context<Self>,
) -> Self {
let next_message_id = MessageId(
saved
@@ -107,6 +129,8 @@ impl Thread {
.unwrap_or(0),
);
let tool_use = ToolUseState::from_saved_messages(&saved.messages);
let script_session = cx.new(|cx| ScriptSession::new(project.clone(), cx));
let script_session_subscription = cx.subscribe(&script_session, Self::handle_script_event);
Self {
id,
@@ -127,8 +151,13 @@ impl Thread {
context_by_message: HashMap::default(),
completion_count: 0,
pending_completions: Vec::new(),
project,
tools,
tool_use,
scripts_by_assistant_message: HashMap::default(),
script_output_messages: HashSet::default(),
script_session,
_script_session_subscription: script_session_subscription,
}
}
@@ -193,6 +222,15 @@ impl Thread {
self.tool_use.pending_tool_uses()
}
/// Returns whether all of the tool uses have finished running.
pub fn all_tools_finished(&self) -> bool {
// If the only pending tool uses left are the ones with errors, then that means that we've finished running all
// of the pending tools.
self.pending_tool_uses()
.into_iter()
.all(|tool_use| tool_use.status.is_error())
}
pub fn tool_uses_for_message(&self, id: MessageId) -> Vec<ToolUse> {
self.tool_use.tool_uses_for_message(id)
}
@@ -205,17 +243,22 @@ impl Thread {
self.tool_use.message_has_tool_results(message_id)
}
pub fn message_has_script_output(&self, message_id: MessageId) -> bool {
self.script_output_messages.contains(&message_id)
}
pub fn insert_user_message(
&mut self,
text: impl Into<String>,
context: Vec<ContextSnapshot>,
cx: &mut Context<Self>,
) {
) -> MessageId {
let message_id = self.insert_message(Role::User, text, cx);
let context_ids = context.iter().map(|context| context.id).collect::<Vec<_>>();
self.context
.extend(context.into_iter().map(|context| (context.id, context)));
self.context_by_message.insert(message_id, context_ids);
message_id
}
pub fn insert_message(
@@ -284,6 +327,39 @@ impl Thread {
text
}
pub fn script_for_message<'a>(
&'a self,
message_id: MessageId,
cx: &'a App,
) -> Option<&'a Script> {
self.scripts_by_assistant_message
.get(&message_id)
.map(|script_id| self.script_session.read(cx).get(*script_id))
}
fn handle_script_event(
&mut self,
_script_session: Entity<ScriptSession>,
event: &ScriptEvent,
cx: &mut Context<Self>,
) {
match event {
ScriptEvent::Spawned(_) => {}
ScriptEvent::Exited(script_id) => {
if let Some(output_message) = self
.script_session
.read(cx)
.get(*script_id)
.output_message_for_llm()
{
let message_id = self.insert_user_message(output_message, vec![], cx);
self.script_output_messages.insert(message_id);
cx.emit(ThreadEvent::ScriptFinished)
}
}
}
}
pub fn send_to_model(
&mut self,
model: Arc<dyn LanguageModel>,
@@ -312,7 +388,7 @@ impl Thread {
pub fn to_completion_request(
&self,
request_kind: RequestKind,
_cx: &App,
cx: &App,
) -> LanguageModelRequest {
let mut request = LanguageModelRequest {
messages: vec![],
@@ -321,6 +397,12 @@ impl Thread {
temperature: None,
};
request.messages.push(LanguageModelRequestMessage {
role: Role::System,
content: vec![SCRIPTING_PROMPT.to_string().into()],
cache: true,
});
let mut referenced_context_ids = HashSet::default();
for message in &self.messages {
@@ -333,6 +415,7 @@ impl Thread {
content: Vec::new(),
cache: false,
};
match request_kind {
RequestKind::Chat => {
self.tool_use
@@ -353,11 +436,20 @@ impl Thread {
RequestKind::Chat => {
self.tool_use
.attach_tool_uses(message.id, &mut request_message);
if matches!(message.role, Role::Assistant) {
if let Some(script_id) = self.scripts_by_assistant_message.get(&message.id)
{
let script = self.script_session.read(cx).get(*script_id);
request_message.content.push(script.source_tag().into());
}
}
}
RequestKind::Summarize => {
// We don't care about tool use during summarization.
}
}
};
request.messages.push(request_message);
}
@@ -394,6 +486,8 @@ impl Thread {
let stream_completion = async {
let mut events = stream.await?;
let mut stop_reason = StopReason::EndTurn;
let mut script_tag_parser = ScriptTagParser::new();
let mut script_id = None;
while let Some(event) = events.next().await {
let event = event?;
@@ -408,19 +502,43 @@ impl Thread {
}
LanguageModelCompletionEvent::Text(chunk) => {
if let Some(last_message) = thread.messages.last_mut() {
if last_message.role == Role::Assistant {
last_message.text.push_str(&chunk);
let chunk = script_tag_parser.parse_chunk(&chunk);
let message_id = if last_message.role == Role::Assistant {
last_message.text.push_str(&chunk.content);
cx.emit(ThreadEvent::StreamedAssistantText(
last_message.id,
chunk,
chunk.content,
));
last_message.id
} else {
// If we won't have an Assistant message yet, assume this chunk marks the beginning
// of a new Assistant response.
//
// Importantly: We do *not* want to emit a `StreamedAssistantText` event here, as it
// will result in duplicating the text of the chunk in the rendered Markdown.
thread.insert_message(Role::Assistant, chunk, cx);
thread.insert_message(Role::Assistant, chunk.content, cx)
};
if script_id.is_none() && script_tag_parser.found_script() {
let id = thread
.script_session
.update(cx, |session, _cx| session.new_script());
thread.scripts_by_assistant_message.insert(message_id, id);
script_id = Some(id);
}
if let (Some(script_source), Some(script_id)) =
(chunk.script_source, script_id)
{
// TODO: move buffer to script and run as it streams
thread
.script_session
.update(cx, |this, cx| {
this.run_script(script_id, script_source, cx)
})
.detach_and_log_err(cx);
}
}
}
@@ -550,6 +668,23 @@ impl Thread {
});
}
pub fn use_pending_tools(&mut self, cx: &mut Context<Self>) {
let pending_tool_uses = self
.pending_tool_uses()
.into_iter()
.filter(|tool_use| tool_use.status.is_idle())
.cloned()
.collect::<Vec<_>>();
for tool_use in pending_tool_uses {
if let Some(tool) = self.tools.tool(&tool_use.name, cx) {
let task = tool.run(tool_use.input, self.project.clone(), cx);
self.insert_tool_output(tool_use.id.clone(), task, cx);
}
}
}
pub fn insert_tool_output(
&mut self,
tool_use_id: LanguageModelToolUseId,
@@ -576,6 +711,23 @@ impl Thread {
.run_pending_tool(tool_use_id, insert_output_task);
}
pub fn send_tool_results_to_model(
&mut self,
model: Arc<dyn LanguageModel>,
cx: &mut Context<Self>,
) {
// Insert a user message to contain the tool results.
self.insert_user_message(
// TODO: Sending up a user message without any content results in the model sending back
// responses that also don't have any content. We currently don't handle this case well,
// so for now we provide some text to keep the model on track.
"Here are the tool results.",
Vec::new(),
cx,
);
self.send_to_model(model, RequestKind::Chat, true, cx);
}
/// Cancels the last pending completion, if there are any pending.
///
/// Returns whether a completion was canceled.
@@ -609,6 +761,7 @@ pub enum ThreadEvent {
#[allow(unused)]
tool_use_id: LanguageModelToolUseId,
},
ScriptFinished,
}
impl EventEmitter<ThreadEvent> for Thread {}

View File

@@ -26,7 +26,6 @@ pub fn init(cx: &mut App) {
}
pub struct ThreadStore {
#[allow(unused)]
project: Entity<Project>,
tools: Arc<ToolWorkingSet>,
context_server_manager: Entity<ContextServerManager>,
@@ -78,7 +77,7 @@ impl ThreadStore {
}
pub fn create_thread(&mut self, cx: &mut Context<Self>) -> Entity<Thread> {
cx.new(|cx| Thread::new(self.tools.clone(), cx))
cx.new(|cx| Thread::new(self.project.clone(), self.tools.clone(), cx))
}
pub fn open_thread(
@@ -96,7 +95,15 @@ impl ThreadStore {
.ok_or_else(|| anyhow!("no thread found with ID: {id:?}"))?;
this.update(&mut cx, |this, cx| {
cx.new(|cx| Thread::from_saved(id.clone(), thread, this.tools.clone(), cx))
cx.new(|cx| {
Thread::from_saved(
id.clone(),
thread,
this.project.clone(),
this.tools.clone(),
cx,
)
})
})
})
}

View File

@@ -104,49 +104,53 @@ impl ContextStore {
const CONTEXT_WATCH_DURATION: Duration = Duration::from_millis(100);
let (mut events, _) = fs.watch(contexts_dir(), CONTEXT_WATCH_DURATION).await;
let this = cx.new(|cx: &mut Context<Self>| {
let context_server_factory_registry =
ContextServerFactoryRegistry::default_global(cx);
let context_server_manager = cx.new(|cx| {
ContextServerManager::new(context_server_factory_registry, project.clone(), cx)
});
let mut this = Self {
contexts: Vec::new(),
contexts_metadata: Vec::new(),
context_server_manager,
context_server_slash_command_ids: HashMap::default(),
host_contexts: Vec::new(),
fs,
languages,
slash_commands,
telemetry,
_watch_updates: cx.spawn(|this, mut cx| {
async move {
while events.next().await.is_some() {
this.update(&mut cx, |this, cx| this.reload(cx))?
.await
.log_err();
let this =
cx.new(|cx: &mut Context<Self>| {
let context_server_factory_registry =
ContextServerFactoryRegistry::default_global(cx);
let context_server_manager = cx.new(|cx| {
ContextServerManager::new(
context_server_factory_registry,
project.clone(),
cx,
)
});
let mut this = Self {
contexts: Vec::new(),
contexts_metadata: Vec::new(),
context_server_manager,
context_server_slash_command_ids: HashMap::default(),
host_contexts: Vec::new(),
fs,
languages,
slash_commands,
telemetry,
_watch_updates: cx.spawn(|this, mut cx| {
async move {
while events.next().await.is_some() {
this.update(&mut cx, |this, cx| this.reload(cx))?
.await
.log_err();
}
anyhow::Ok(())
}
anyhow::Ok(())
}
.log_err()
}),
client_subscription: None,
_project_subscriptions: vec![
cx.observe(&project, Self::handle_project_changed),
cx.subscribe(&project, Self::handle_project_event),
],
project_is_shared: false,
client: project.read(cx).client(),
project: project.clone(),
prompt_builder,
};
this.handle_project_changed(project.clone(), cx);
this.synchronize_contexts(cx);
this.register_context_server_handlers(cx);
this.reload(cx).detach_and_log_err(cx);
this
})?;
.log_err()
}),
client_subscription: None,
_project_subscriptions: vec![
cx.subscribe(&project, Self::handle_project_event)
],
project_is_shared: false,
client: project.read(cx).client(),
project: project.clone(),
prompt_builder,
};
this.handle_project_shared(project.clone(), cx);
this.synchronize_contexts(cx);
this.register_context_server_handlers(cx);
this.reload(cx).detach_and_log_err(cx);
this
})?;
Ok(this)
})
@@ -288,7 +292,7 @@ impl ContextStore {
})?
}
fn handle_project_changed(&mut self, _: Entity<Project>, cx: &mut Context<Self>) {
fn handle_project_shared(&mut self, _: Entity<Project>, cx: &mut Context<Self>) {
let is_shared = self.project.read(cx).is_shared();
let was_shared = mem::replace(&mut self.project_is_shared, is_shared);
if is_shared == was_shared {
@@ -318,11 +322,14 @@ impl ContextStore {
fn handle_project_event(
&mut self,
_: Entity<Project>,
project: Entity<Project>,
event: &project::Event,
cx: &mut Context<Self>,
) {
match event {
project::Event::RemoteIdChanged(_) => {
self.handle_project_shared(project, cx);
}
project::Event::Reshared => {
self.advertise_contexts(cx);
}

View File

@@ -5,9 +5,9 @@ use assistant_slash_command::{AfterCompletion, SlashCommandLine, SlashCommandWor
use editor::{CompletionProvider, Editor};
use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity, Window};
use language::{Anchor, Buffer, LanguageServerId, ToPoint};
use language::{Anchor, Buffer, ToPoint};
use parking_lot::Mutex;
use project::{lsp_store::CompletionDocumentation, CompletionIntent};
use project::{lsp_store::CompletionDocumentation, CompletionIntent, CompletionSource};
use rope::Point;
use std::{
cell::RefCell,
@@ -125,10 +125,8 @@ impl SlashCommandCompletionProvider {
)),
new_text,
label: command.label(cx),
server_id: LanguageServerId(0),
lsp_completion: Default::default(),
confirm,
resolved: true,
source: CompletionSource::Custom,
})
})
.collect()
@@ -225,10 +223,8 @@ impl SlashCommandCompletionProvider {
label: new_argument.label,
new_text,
documentation: None,
server_id: LanguageServerId(0),
lsp_completion: Default::default(),
confirm,
resolved: true,
source: CompletionSource::Custom,
}
})
.collect())

View File

@@ -0,0 +1,35 @@
[package]
name = "assistant_scripting"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/assistant_scripting.rs"
doctest = false
[dependencies]
anyhow.workspace = true
collections.workspace = true
futures.workspace = true
gpui.workspace = true
log.workspace = true
mlua.workspace = true
parking_lot.workspace = true
project.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
util.workspace = true
[dev-dependencies]
collections = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
rand.workspace = true
settings = { workspace = true, features = ["test-support"] }

View File

@@ -0,0 +1,7 @@
mod session;
mod tag;
pub use session::*;
pub use tag::*;
pub const SCRIPTING_PROMPT: &str = include_str!("./system_prompt.txt");

View File

@@ -3,6 +3,16 @@
-- Create a sandbox environment
local sandbox = {}
-- For now, add all globals to `sandbox` (so there effectively is no sandbox).
-- We still need the logic below so that we can do things like overriding print() to write
-- to our in-memory log rather than to stdout, we will delete this loop (and re-enable
-- the I/O module being sandboxed below) to have things be sandboxed again.
for k, v in pairs(_G) do
if sandbox[k] == nil then
sandbox[k] = v
end
end
-- Allow access to standard libraries (safe subset)
sandbox.string = string
sandbox.table = table
@@ -13,7 +23,10 @@ sandbox.tostring = tostring
sandbox.tonumber = tonumber
sandbox.pairs = pairs
sandbox.ipairs = ipairs
-- Access to custom functions
sandbox.search = search
sandbox.outline = outline
-- Create a sandboxed version of LuaFileIO
local io = {}
@@ -22,8 +35,7 @@ local io = {}
io.open = sb_io_open
-- Add the sandboxed io library to the sandbox environment
sandbox.io = io
-- sandbox.io = io -- Uncomment this line to re-enable sandboxed file I/O.
-- Load the script with the sandbox environment
local user_script_fn, err = load(user_script, nil, "t", sandbox)

View File

@@ -0,0 +1,953 @@
use anyhow::anyhow;
use collections::{HashMap, HashSet};
use futures::{
channel::{mpsc, oneshot},
pin_mut, SinkExt, StreamExt,
};
use gpui::{AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity};
use mlua::{ExternalResult, Lua, MultiValue, Table, UserData, UserDataMethods};
use parking_lot::Mutex;
use project::{search::SearchQuery, Fs, Project};
use regex::Regex;
use std::{
cell::RefCell,
path::{Path, PathBuf},
sync::Arc,
};
use util::{paths::PathMatcher, ResultExt};
use crate::{SCRIPT_END_TAG, SCRIPT_START_TAG};
struct ForegroundFn(Box<dyn FnOnce(WeakEntity<ScriptSession>, AsyncApp) + Send>);
pub struct ScriptSession {
project: Entity<Project>,
// TODO Remove this
fs_changes: Arc<Mutex<HashMap<PathBuf, Vec<u8>>>>,
foreground_fns_tx: mpsc::Sender<ForegroundFn>,
_invoke_foreground_fns: Task<()>,
scripts: Vec<Script>,
}
impl ScriptSession {
pub fn new(project: Entity<Project>, cx: &mut Context<Self>) -> Self {
let (foreground_fns_tx, mut foreground_fns_rx) = mpsc::channel(128);
ScriptSession {
project,
fs_changes: Arc::new(Mutex::new(HashMap::default())),
foreground_fns_tx,
_invoke_foreground_fns: cx.spawn(|this, cx| async move {
while let Some(foreground_fn) = foreground_fns_rx.next().await {
foreground_fn.0(this.clone(), cx.clone());
}
}),
scripts: Vec::new(),
}
}
pub fn new_script(&mut self) -> ScriptId {
let id = ScriptId(self.scripts.len() as u32);
let script = Script {
id,
state: ScriptState::Generating,
source: SharedString::new_static(""),
};
self.scripts.push(script);
id
}
pub fn run_script(
&mut self,
script_id: ScriptId,
script_src: String,
cx: &mut Context<Self>,
) -> Task<anyhow::Result<()>> {
let script = self.get_mut(script_id);
let stdout = Arc::new(Mutex::new(String::new()));
script.source = script_src.clone().into();
script.state = ScriptState::Running {
stdout: stdout.clone(),
};
let task = self.run_lua(script_src, stdout, cx);
cx.emit(ScriptEvent::Spawned(script_id));
cx.spawn(|session, mut cx| async move {
let result = task.await;
session.update(&mut cx, |session, cx| {
let script = session.get_mut(script_id);
let stdout = script.stdout_snapshot();
script.state = match result {
Ok(()) => ScriptState::Succeeded { stdout },
Err(error) => ScriptState::Failed { stdout, error },
};
cx.emit(ScriptEvent::Exited(script_id))
})
})
}
fn run_lua(
&mut self,
script: String,
stdout: Arc<Mutex<String>>,
cx: &mut Context<Self>,
) -> Task<anyhow::Result<()>> {
const SANDBOX_PREAMBLE: &str = include_str!("sandbox_preamble.lua");
// TODO Remove fs_changes
let fs_changes = self.fs_changes.clone();
// TODO Honor all worktrees instead of the first one
let root_dir = self
.project
.read(cx)
.visible_worktrees(cx)
.next()
.map(|worktree| worktree.read(cx).abs_path());
let fs = self.project.read(cx).fs().clone();
let foreground_fns_tx = self.foreground_fns_tx.clone();
let task = cx.background_spawn({
let stdout = stdout.clone();
async move {
let lua = Lua::new();
lua.set_memory_limit(2 * 1024 * 1024 * 1024)?; // 2 GB
let globals = lua.globals();
// Use the project root dir as the script's current working dir.
if let Some(root_dir) = &root_dir {
if let Some(root_dir) = root_dir.to_str() {
globals.set("cwd", root_dir)?;
}
}
globals.set(
"sb_print",
lua.create_function({
let stdout = stdout.clone();
move |_, args: MultiValue| Self::print(args, &stdout)
})?,
)?;
globals.set(
"search",
lua.create_async_function({
let foreground_fns_tx = foreground_fns_tx.clone();
move |lua, regex| {
let mut foreground_fns_tx = foreground_fns_tx.clone();
let fs = fs.clone();
async move {
Self::search(&lua, &mut foreground_fns_tx, fs, regex)
.await
.into_lua_err()
}
}
})?,
)?;
globals.set(
"outline",
lua.create_async_function({
let root_dir = root_dir.clone();
move |_lua, path| {
let mut foreground_fns_tx = foreground_fns_tx.clone();
let root_dir = root_dir.clone();
async move {
Self::outline(root_dir, &mut foreground_fns_tx, path)
.await
.into_lua_err()
}
}
})?,
)?;
globals.set(
"sb_io_open",
lua.create_function({
let fs_changes = fs_changes.clone();
let root_dir = root_dir.clone();
move |lua, (path_str, mode)| {
Self::io_open(&lua, &fs_changes, root_dir.as_ref(), path_str, mode)
}
})?,
)?;
globals.set("user_script", script)?;
lua.load(SANDBOX_PREAMBLE).exec_async().await?;
// Drop Lua instance to decrement reference count.
drop(lua);
anyhow::Ok(())
}
});
task
}
pub fn get(&self, script_id: ScriptId) -> &Script {
&self.scripts[script_id.0 as usize]
}
fn get_mut(&mut self, script_id: ScriptId) -> &mut Script {
&mut self.scripts[script_id.0 as usize]
}
/// Sandboxed print() function in Lua.
fn print(args: MultiValue, stdout: &Mutex<String>) -> mlua::Result<()> {
for (index, arg) in args.into_iter().enumerate() {
// Lua's `print()` prints tab characters between each argument.
if index > 0 {
stdout.lock().push('\t');
}
// If the argument's to_string() fails, have the whole function call fail.
stdout.lock().push_str(&arg.to_string()?);
}
stdout.lock().push('\n');
Ok(())
}
/// Sandboxed io.open() function in Lua.
fn io_open(
lua: &Lua,
fs_changes: &Arc<Mutex<HashMap<PathBuf, Vec<u8>>>>,
root_dir: Option<&Arc<Path>>,
path_str: String,
mode: Option<String>,
) -> mlua::Result<(Option<Table>, String)> {
let root_dir = root_dir
.ok_or_else(|| mlua::Error::runtime("cannot open file without a root directory"))?;
let mode = mode.unwrap_or_else(|| "r".to_string());
// Parse the mode string to determine read/write permissions
let read_perm = mode.contains('r');
let write_perm = mode.contains('w') || mode.contains('a') || mode.contains('+');
let append = mode.contains('a');
let truncate = mode.contains('w');
// This will be the Lua value returned from the `open` function.
let file = lua.create_table()?;
// Store file metadata in the file
file.set("__path", path_str.clone())?;
file.set("__mode", mode.clone())?;
file.set("__read_perm", read_perm)?;
file.set("__write_perm", write_perm)?;
let path = match Self::parse_abs_path_in_root_dir(&root_dir, &path_str) {
Ok(path) => path,
Err(err) => return Ok((None, format!("{err}"))),
};
// close method
let close_fn = {
let fs_changes = fs_changes.clone();
lua.create_function(move |_lua, file_userdata: mlua::Table| {
let write_perm = file_userdata.get::<bool>("__write_perm")?;
let path = file_userdata.get::<String>("__path")?;
if write_perm {
// When closing a writable file, record the content
let content = file_userdata.get::<mlua::AnyUserData>("__content")?;
let content_ref = content.borrow::<FileContent>()?;
let content_vec = content_ref.0.borrow();
// Don't actually write to disk; instead, just update fs_changes.
let path_buf = PathBuf::from(&path);
fs_changes
.lock()
.insert(path_buf.clone(), content_vec.clone());
}
Ok(true)
})?
};
file.set("close", close_fn)?;
// If it's a directory, give it a custom read() and return early.
if path.is_dir() {
// TODO handle the case where we changed it in the in-memory fs
// Create a special directory handle
file.set("__is_directory", true)?;
// Store directory entries
let entries = match std::fs::read_dir(&path) {
Ok(entries) => {
let mut entry_names = Vec::new();
for entry in entries.flatten() {
entry_names.push(entry.file_name().to_string_lossy().into_owned());
}
entry_names
}
Err(e) => return Ok((None, format!("Error reading directory: {}", e))),
};
// Save the list of entries
file.set("__dir_entries", entries)?;
file.set("__dir_position", 0usize)?;
// Create a directory-specific read function
let read_fn = lua.create_function(|_lua, file_userdata: mlua::Table| {
let position = file_userdata.get::<usize>("__dir_position")?;
let entries = file_userdata.get::<Vec<String>>("__dir_entries")?;
if position >= entries.len() {
return Ok(None); // No more entries
}
let entry = entries[position].clone();
file_userdata.set("__dir_position", position + 1)?;
Ok(Some(entry))
})?;
file.set("read", read_fn)?;
// If we got this far, the directory was opened successfully
return Ok((Some(file), String::new()));
}
let fs_changes_map = fs_changes.lock();
let is_in_changes = fs_changes_map.contains_key(&path);
let file_exists = is_in_changes || path.exists();
let mut file_content = Vec::new();
if file_exists && !truncate {
if is_in_changes {
file_content = fs_changes_map.get(&path).unwrap().clone();
} else {
// Try to read existing content if file exists and we're not truncating
match std::fs::read(&path) {
Ok(content) => file_content = content,
Err(e) => return Ok((None, format!("Error reading file: {}", e))),
}
}
}
drop(fs_changes_map); // Unlock the fs_changes mutex.
// If in append mode, position should be at the end
let position = if append && file_exists {
file_content.len()
} else {
0
};
file.set("__position", position)?;
file.set(
"__content",
lua.create_userdata(FileContent(RefCell::new(file_content)))?,
)?;
// Create file methods
// read method
let read_fn = {
lua.create_function(
|_lua, (file_userdata, format): (mlua::Table, Option<mlua::Value>)| {
let read_perm = file_userdata.get::<bool>("__read_perm")?;
if !read_perm {
return Err(mlua::Error::runtime("File not open for reading"));
}
let content = file_userdata.get::<mlua::AnyUserData>("__content")?;
let mut position = file_userdata.get::<usize>("__position")?;
let content_ref = content.borrow::<FileContent>()?;
let content_vec = content_ref.0.borrow();
if position >= content_vec.len() {
return Ok(None); // EOF
}
match format {
Some(mlua::Value::String(s)) => {
let lossy_string = s.to_string_lossy();
let format_str: &str = lossy_string.as_ref();
// Only consider the first 2 bytes, since it's common to pass e.g. "*all" instead of "*a"
match &format_str[0..2] {
"*a" => {
// Read entire file from current position
let result = String::from_utf8_lossy(&content_vec[position..])
.to_string();
position = content_vec.len();
file_userdata.set("__position", position)?;
Ok(Some(result))
}
"*l" => {
// Read next line
let mut line = Vec::new();
let mut found_newline = false;
while position < content_vec.len() {
let byte = content_vec[position];
position += 1;
if byte == b'\n' {
found_newline = true;
break;
}
// Skip \r in \r\n sequence but add it if it's alone
if byte == b'\r' {
if position < content_vec.len()
&& content_vec[position] == b'\n'
{
position += 1;
found_newline = true;
break;
}
}
line.push(byte);
}
file_userdata.set("__position", position)?;
if !found_newline
&& line.is_empty()
&& position >= content_vec.len()
{
return Ok(None); // EOF
}
let result = String::from_utf8_lossy(&line).to_string();
Ok(Some(result))
}
"*n" => {
// Try to parse as a number (number of bytes to read)
match format_str.parse::<usize>() {
Ok(n) => {
let end =
std::cmp::min(position + n, content_vec.len());
let bytes = &content_vec[position..end];
let result = String::from_utf8_lossy(bytes).to_string();
position = end;
file_userdata.set("__position", position)?;
Ok(Some(result))
}
Err(_) => Err(mlua::Error::runtime(format!(
"Invalid format: {}",
format_str
))),
}
}
"*L" => {
// Read next line keeping the end of line
let mut line = Vec::new();
while position < content_vec.len() {
let byte = content_vec[position];
position += 1;
line.push(byte);
if byte == b'\n' {
break;
}
// If we encounter a \r, add it and check if the next is \n
if byte == b'\r'
&& position < content_vec.len()
&& content_vec[position] == b'\n'
{
line.push(content_vec[position]);
position += 1;
break;
}
}
file_userdata.set("__position", position)?;
if line.is_empty() && position >= content_vec.len() {
return Ok(None); // EOF
}
let result = String::from_utf8_lossy(&line).to_string();
Ok(Some(result))
}
_ => Err(mlua::Error::runtime(format!(
"Unsupported format: {}",
format_str
))),
}
}
Some(mlua::Value::Number(n)) => {
// Read n bytes
let n = n as usize;
let end = std::cmp::min(position + n, content_vec.len());
let bytes = &content_vec[position..end];
let result = String::from_utf8_lossy(bytes).to_string();
position = end;
file_userdata.set("__position", position)?;
Ok(Some(result))
}
Some(_) => Err(mlua::Error::runtime("Invalid format")),
None => {
// Default is to read a line
let mut line = Vec::new();
let mut found_newline = false;
while position < content_vec.len() {
let byte = content_vec[position];
position += 1;
if byte == b'\n' {
found_newline = true;
break;
}
// Handle \r\n
if byte == b'\r' {
if position < content_vec.len()
&& content_vec[position] == b'\n'
{
position += 1;
found_newline = true;
break;
}
}
line.push(byte);
}
file_userdata.set("__position", position)?;
if !found_newline && line.is_empty() && position >= content_vec.len() {
return Ok(None); // EOF
}
let result = String::from_utf8_lossy(&line).to_string();
Ok(Some(result))
}
}
},
)?
};
file.set("read", read_fn)?;
// write method
let write_fn = {
let fs_changes = fs_changes.clone();
lua.create_function(move |_lua, (file_userdata, text): (mlua::Table, String)| {
let write_perm = file_userdata.get::<bool>("__write_perm")?;
if !write_perm {
return Err(mlua::Error::runtime("File not open for writing"));
}
let content = file_userdata.get::<mlua::AnyUserData>("__content")?;
let position = file_userdata.get::<usize>("__position")?;
let content_ref = content.borrow::<FileContent>()?;
let mut content_vec = content_ref.0.borrow_mut();
let bytes = text.as_bytes();
// Ensure the vector has enough capacity
if position + bytes.len() > content_vec.len() {
content_vec.resize(position + bytes.len(), 0);
}
// Write the bytes
for (i, &byte) in bytes.iter().enumerate() {
content_vec[position + i] = byte;
}
// Update position
let new_position = position + bytes.len();
file_userdata.set("__position", new_position)?;
// Update fs_changes
let path = file_userdata.get::<String>("__path")?;
let path_buf = PathBuf::from(path);
fs_changes.lock().insert(path_buf, content_vec.clone());
Ok(true)
})?
};
file.set("write", write_fn)?;
// If we got this far, the file was opened successfully
Ok((Some(file), String::new()))
}
async fn search(
lua: &Lua,
foreground_tx: &mut mpsc::Sender<ForegroundFn>,
fs: Arc<dyn Fs>,
regex: String,
) -> anyhow::Result<Table> {
// TODO: Allow specification of these options.
let search_query = SearchQuery::regex(
&regex,
false,
false,
false,
PathMatcher::default(),
PathMatcher::default(),
None,
);
let search_query = match search_query {
Ok(query) => query,
Err(e) => return Err(anyhow!("Invalid search query: {}", e)),
};
// TODO: Should use `search_query.regex`. The tool description should also be updated,
// as it specifies standard regex.
let search_regex = match Regex::new(&regex) {
Ok(re) => re,
Err(e) => return Err(anyhow!("Invalid regex: {}", e)),
};
let mut abs_paths_rx = Self::find_search_candidates(search_query, foreground_tx).await?;
let mut search_results: Vec<Table> = Vec::new();
while let Some(path) = abs_paths_rx.next().await {
// Skip files larger than 1MB
if let Ok(Some(metadata)) = fs.metadata(&path).await {
if metadata.len > 1_000_000 {
continue;
}
}
// Attempt to read the file as text
if let Ok(content) = fs.load(&path).await {
let mut matches = Vec::new();
// Find all regex matches in the content
for capture in search_regex.find_iter(&content) {
matches.push(capture.as_str().to_string());
}
// If we found matches, create a result entry
if !matches.is_empty() {
let result_entry = lua.create_table()?;
result_entry.set("path", path.to_string_lossy().to_string())?;
let matches_table = lua.create_table()?;
for (ix, m) in matches.iter().enumerate() {
matches_table.set(ix + 1, m.clone())?;
}
result_entry.set("matches", matches_table)?;
search_results.push(result_entry);
}
}
}
// Create a table to hold our results
let results_table = lua.create_table()?;
for (ix, entry) in search_results.into_iter().enumerate() {
results_table.set(ix + 1, entry)?;
}
Ok(results_table)
}
async fn find_search_candidates(
search_query: SearchQuery,
foreground_tx: &mut mpsc::Sender<ForegroundFn>,
) -> anyhow::Result<mpsc::UnboundedReceiver<PathBuf>> {
Self::run_foreground_fn(
"finding search file candidates",
foreground_tx,
Box::new(move |session, mut cx| {
session.update(&mut cx, |session, cx| {
session.project.update(cx, |project, cx| {
project.worktree_store().update(cx, |worktree_store, cx| {
// TODO: Better limit? For now this is the same as
// MAX_SEARCH_RESULT_FILES.
let limit = 5000;
// TODO: Providing non-empty open_entries can make this a bit more
// efficient as it can skip checking that these paths are textual.
let open_entries = HashSet::default();
let candidates = worktree_store.find_search_candidates(
search_query,
limit,
open_entries,
project.fs().clone(),
cx,
);
let (abs_paths_tx, abs_paths_rx) = mpsc::unbounded();
cx.spawn(|worktree_store, cx| async move {
pin_mut!(candidates);
while let Some(project_path) = candidates.next().await {
worktree_store.read_with(&cx, |worktree_store, cx| {
if let Some(worktree) = worktree_store
.worktree_for_id(project_path.worktree_id, cx)
{
if let Some(abs_path) = worktree
.read(cx)
.absolutize(&project_path.path)
.log_err()
{
abs_paths_tx.unbounded_send(abs_path)?;
}
}
anyhow::Ok(())
})??;
}
anyhow::Ok(())
})
.detach();
abs_paths_rx
})
})
})
}),
)
.await?
}
async fn outline(
root_dir: Option<Arc<Path>>,
foreground_tx: &mut mpsc::Sender<ForegroundFn>,
path_str: String,
) -> anyhow::Result<String> {
let root_dir = root_dir
.ok_or_else(|| mlua::Error::runtime("cannot get outline without a root directory"))?;
let path = Self::parse_abs_path_in_root_dir(&root_dir, &path_str)?;
let outline = Self::run_foreground_fn(
"getting code outline",
foreground_tx,
Box::new(move |session, cx| {
cx.spawn(move |mut cx| async move {
// TODO: This will not use file content from `fs_changes`. It will also reflect
// user changes that have not been saved.
let buffer = session
.update(&mut cx, |session, cx| {
session
.project
.update(cx, |project, cx| project.open_local_buffer(&path, cx))
})?
.await?;
buffer.update(&mut cx, |buffer, _cx| {
if let Some(outline) = buffer.snapshot().outline(None) {
Ok(outline)
} else {
Err(anyhow!("No outline for file {path_str}"))
}
})
})
}),
)
.await?
.await??;
Ok(outline
.items
.into_iter()
.map(|item| {
if item.text.contains('\n') {
log::error!("Outline item unexpectedly contains newline");
}
format!("{}{}", " ".repeat(item.depth), item.text)
})
.collect::<Vec<String>>()
.join("\n"))
}
async fn run_foreground_fn<R: Send + 'static>(
description: &str,
foreground_tx: &mut mpsc::Sender<ForegroundFn>,
function: Box<dyn FnOnce(WeakEntity<Self>, AsyncApp) -> R + Send>,
) -> anyhow::Result<R> {
let (response_tx, response_rx) = oneshot::channel();
let send_result = foreground_tx
.send(ForegroundFn(Box::new(move |this, cx| {
response_tx.send(function(this, cx)).ok();
})))
.await;
match send_result {
Ok(()) => (),
Err(err) => {
return Err(anyhow::Error::new(err).context(format!(
"Internal error while enqueuing work for {description}"
)));
}
}
match response_rx.await {
Ok(result) => Ok(result),
Err(oneshot::Canceled) => Err(anyhow!(
"Internal error: response oneshot was canceled while {description}."
)),
}
}
fn parse_abs_path_in_root_dir(root_dir: &Path, path_str: &str) -> anyhow::Result<PathBuf> {
let path = Path::new(&path_str);
if path.is_absolute() {
// Check if path starts with root_dir prefix without resolving symlinks
if path.starts_with(&root_dir) {
Ok(path.to_path_buf())
} else {
Err(anyhow!(
"Error: Absolute path {} is outside the current working directory",
path_str
))
}
} else {
// TODO: Does use of `../` break sandbox - is path canonicalization needed?
Ok(root_dir.join(path))
}
}
}
struct FileContent(RefCell<Vec<u8>>);
impl UserData for FileContent {
fn add_methods<M: UserDataMethods<Self>>(_methods: &mut M) {
// FileContent doesn't have any methods so far.
}
}
#[derive(Debug)]
pub enum ScriptEvent {
Spawned(ScriptId),
Exited(ScriptId),
}
impl EventEmitter<ScriptEvent> for ScriptSession {}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ScriptId(u32);
pub struct Script {
pub id: ScriptId,
pub state: ScriptState,
pub source: SharedString,
}
pub enum ScriptState {
Generating,
Running {
stdout: Arc<Mutex<String>>,
},
Succeeded {
stdout: String,
},
Failed {
stdout: String,
error: anyhow::Error,
},
}
impl Script {
pub fn source_tag(&self) -> String {
format!("{}{}{}", SCRIPT_START_TAG, self.source, SCRIPT_END_TAG)
}
/// If exited, returns a message with the output for the LLM
pub fn output_message_for_llm(&self) -> Option<String> {
match &self.state {
ScriptState::Generating { .. } => None,
ScriptState::Running { .. } => None,
ScriptState::Succeeded { stdout } => {
format!("Here's the script output:\n{}", stdout).into()
}
ScriptState::Failed { stdout, error } => format!(
"The script failed with:\n{}\n\nHere's the output it managed to print:\n{}",
error, stdout
)
.into(),
}
}
/// Get a snapshot of the script's stdout
pub fn stdout_snapshot(&self) -> String {
match &self.state {
ScriptState::Generating { .. } => String::new(),
ScriptState::Running { stdout } => stdout.lock().clone(),
ScriptState::Succeeded { stdout } => stdout.clone(),
ScriptState::Failed { stdout, .. } => stdout.clone(),
}
}
/// Returns the error if the script failed, otherwise None
pub fn error(&self) -> Option<&anyhow::Error> {
match &self.state {
ScriptState::Generating { .. } => None,
ScriptState::Running { .. } => None,
ScriptState::Succeeded { .. } => None,
ScriptState::Failed { error, .. } => Some(error),
}
}
}
#[cfg(test)]
mod tests {
use gpui::TestAppContext;
use project::FakeFs;
use serde_json::json;
use settings::SettingsStore;
use super::*;
#[gpui::test]
async fn test_print(cx: &mut TestAppContext) {
let script = r#"
print("Hello", "world!")
print("Goodbye", "moon!")
"#;
let output = test_script(script, cx).await.unwrap();
assert_eq!(output, "Hello\tworld!\nGoodbye\tmoon!\n");
}
#[gpui::test]
async fn test_search(cx: &mut TestAppContext) {
let script = r#"
local results = search("world")
for i, result in ipairs(results) do
print("File: " .. result.path)
print("Matches:")
for j, match in ipairs(result.matches) do
print(" " .. match)
end
end
"#;
let output = test_script(script, cx).await.unwrap();
assert_eq!(output, "File: /file1.txt\nMatches:\n world\n");
}
async fn test_script(source: &str, cx: &mut TestAppContext) -> anyhow::Result<String> {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/",
json!({
"file1.txt": "Hello world!",
"file2.txt": "Goodbye moon!"
}),
)
.await;
let project = Project::test(fs, [Path::new("/")], cx).await;
let session = cx.new(|cx| ScriptSession::new(project, cx));
let (script_id, task) = session.update(cx, |session, cx| {
let script_id = session.new_script();
let task = session.run_script(script_id, source.to_string(), cx);
(script_id, task)
});
task.await?;
Ok(session.read_with(cx, |session, _cx| session.get(script_id).stdout_snapshot()))
}
fn init_test(cx: &mut TestAppContext) {
let settings_store = cx.update(SettingsStore::test);
cx.set_global(settings_store);
cx.update(Project::init_settings);
}
}

View File

@@ -0,0 +1,36 @@
You can write a Lua script and I'll run it on my codebase and tell you what its
output was, including both stdout as well as the git diff of changes it made to
the filesystem. That way, you can get more information about the code base, or
make changes to the code base directly.
Put the Lua script inside of an `<eval>` tag like so:
<eval type="lua">
print("Hello, world!")
</eval>
The Lua script will have access to `io` and it will run with the current working
directory being in the root of the code base, so you can use it to explore,
search, make changes, etc. You can also have the script print things, and I'll
tell you what the output was. Note that `io` only has `open`, and then the file
it returns only has the methods read, write, and close - it doesn't have popen
or anything else.
There is a function called `search` which accepts a regex (it's implemented
using Rust's regex crate, so use that regex syntax) and runs that regex on the
contents of every file in the code base (aside from gitignored files), then
returns an array of tables with two fields: "path" (the path to the file that
had the matches) and "matches" (an array of strings, with each string being a
match that was found within the file).
There is a function called `outline` which accepts the path to a source file,
and returns a string where each line is a declaration. These lines are indented
with 2 spaces to indicate when a declaration is inside another.
When I send you the script output, do not thank me for running it,
act as if you ran it yourself.
IMPORTANT!
Only include a maximum of one Lua script at the very end of your message
DO NOT WRITE ANYTHING ELSE AFTER THE SCRIPT. Wait for my response with the script
output to continue.

View File

@@ -0,0 +1,260 @@
pub const SCRIPT_START_TAG: &str = "<eval type=\"lua\">";
pub const SCRIPT_END_TAG: &str = "</eval>";
const START_TAG: &[u8] = SCRIPT_START_TAG.as_bytes();
const END_TAG: &[u8] = SCRIPT_END_TAG.as_bytes();
/// Parses a script tag in an assistant message as it is being streamed.
pub struct ScriptTagParser {
state: State,
buffer: Vec<u8>,
tag_match_ix: usize,
}
enum State {
Unstarted,
Streaming,
Ended,
}
#[derive(Debug, PartialEq)]
pub struct ChunkOutput {
/// The chunk with script tags removed.
pub content: String,
/// The full script tag content. `None` until closed.
pub script_source: Option<String>,
}
impl ScriptTagParser {
/// Create a new script tag parser.
pub fn new() -> Self {
Self {
state: State::Unstarted,
buffer: Vec::new(),
tag_match_ix: 0,
}
}
/// Returns true if the parser has found a script tag.
pub fn found_script(&self) -> bool {
match self.state {
State::Unstarted => false,
State::Streaming | State::Ended => true,
}
}
/// Process a new chunk of input, splitting it into surrounding content and script source.
pub fn parse_chunk(&mut self, input: &str) -> ChunkOutput {
let mut content = Vec::with_capacity(input.len());
for byte in input.bytes() {
match self.state {
State::Unstarted => {
if collect_until_tag(byte, START_TAG, &mut self.tag_match_ix, &mut content) {
self.state = State::Streaming;
self.buffer = Vec::with_capacity(1024);
self.tag_match_ix = 0;
}
}
State::Streaming => {
if collect_until_tag(byte, END_TAG, &mut self.tag_match_ix, &mut self.buffer) {
self.state = State::Ended;
}
}
State::Ended => content.push(byte),
}
}
let content = unsafe { String::from_utf8_unchecked(content) };
let script_source = if matches!(self.state, State::Ended) && !self.buffer.is_empty() {
let source = unsafe { String::from_utf8_unchecked(std::mem::take(&mut self.buffer)) };
Some(source)
} else {
None
};
ChunkOutput {
content,
script_source,
}
}
}
fn collect_until_tag(byte: u8, tag: &[u8], tag_match_ix: &mut usize, buffer: &mut Vec<u8>) -> bool {
// this can't be a method because it'd require a mutable borrow on both self and self.buffer
if match_tag_byte(byte, tag, tag_match_ix) {
*tag_match_ix >= tag.len()
} else {
if *tag_match_ix > 0 {
// push the partially matched tag to the buffer
buffer.extend_from_slice(&tag[..*tag_match_ix]);
*tag_match_ix = 0;
// the tag might start to match again
if match_tag_byte(byte, tag, tag_match_ix) {
return *tag_match_ix >= tag.len();
}
}
buffer.push(byte);
false
}
}
fn match_tag_byte(byte: u8, tag: &[u8], tag_match_ix: &mut usize) -> bool {
if byte == tag[*tag_match_ix] {
*tag_match_ix += 1;
true
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_complete_tag() {
let mut parser = ScriptTagParser::new();
let input = "<eval type=\"lua\">print(\"Hello, World!\")</eval>";
let result = parser.parse_chunk(input);
assert_eq!(result.content, "");
assert_eq!(
result.script_source,
Some("print(\"Hello, World!\")".to_string())
);
}
#[test]
fn test_no_tag() {
let mut parser = ScriptTagParser::new();
let input = "No tags here, just plain text";
let result = parser.parse_chunk(input);
assert_eq!(result.content, "No tags here, just plain text");
assert_eq!(result.script_source, None);
}
#[test]
fn test_partial_end_tag() {
let mut parser = ScriptTagParser::new();
// Start the tag
let result = parser.parse_chunk("<eval type=\"lua\">let x = '</e");
assert_eq!(result.content, "");
assert_eq!(result.script_source, None);
// Finish with the rest
let result = parser.parse_chunk("val' + 'not the end';</eval>");
assert_eq!(result.content, "");
assert_eq!(
result.script_source,
Some("let x = '</eval' + 'not the end';".to_string())
);
}
#[test]
fn test_text_before_and_after_tag() {
let mut parser = ScriptTagParser::new();
let input = "Before tag <eval type=\"lua\">print(\"Hello\")</eval> After tag";
let result = parser.parse_chunk(input);
assert_eq!(result.content, "Before tag After tag");
assert_eq!(result.script_source, Some("print(\"Hello\")".to_string()));
}
#[test]
fn test_multiple_chunks_with_surrounding_text() {
let mut parser = ScriptTagParser::new();
// First chunk with text before
let result = parser.parse_chunk("Before script <eval type=\"lua\">local x = 10");
assert_eq!(result.content, "Before script ");
assert_eq!(result.script_source, None);
// Second chunk with script content
let result = parser.parse_chunk("\nlocal y = 20");
assert_eq!(result.content, "");
assert_eq!(result.script_source, None);
// Last chunk with text after
let result = parser.parse_chunk("\nprint(x + y)</eval> After script");
assert_eq!(result.content, " After script");
assert_eq!(
result.script_source,
Some("local x = 10\nlocal y = 20\nprint(x + y)".to_string())
);
let result = parser.parse_chunk(" there's more text");
assert_eq!(result.content, " there's more text");
assert_eq!(result.script_source, None);
}
#[test]
fn test_partial_start_tag_matching() {
let mut parser = ScriptTagParser::new();
// partial match of start tag...
let result = parser.parse_chunk("<ev");
assert_eq!(result.content, "");
// ...that's abandandoned when the < of a real tag is encountered
let result = parser.parse_chunk("<eval type=\"lua\">script content</eval>");
// ...so it gets pushed to content
assert_eq!(result.content, "<ev");
// ...and the real tag is parsed correctly
assert_eq!(result.script_source, Some("script content".to_string()));
}
#[test]
fn test_random_chunked_parsing() {
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use std::time::{SystemTime, UNIX_EPOCH};
let test_inputs = [
"Before <eval type=\"lua\">print(\"Hello\")</eval> After",
"No tags here at all",
"<eval type=\"lua\">local x = 10\nlocal y = 20\nprint(x + y)</eval>",
"Text <eval type=\"lua\">if true then\nprint(\"nested </e\")\nend</eval> more",
];
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
eprintln!("Using random seed: {}", seed);
let mut rng = StdRng::seed_from_u64(seed);
for test_input in &test_inputs {
let mut reference_parser = ScriptTagParser::new();
let expected = reference_parser.parse_chunk(test_input);
let mut chunked_parser = ScriptTagParser::new();
let mut remaining = test_input.as_bytes();
let mut actual_content = String::new();
let mut actual_script = None;
while !remaining.is_empty() {
let chunk_size = rng.gen_range(1..=remaining.len().min(5));
let (chunk, rest) = remaining.split_at(chunk_size);
remaining = rest;
let chunk_str = std::str::from_utf8(chunk).unwrap();
let result = chunked_parser.parse_chunk(chunk_str);
actual_content.push_str(&result.content);
if result.script_source.is_some() {
actual_script = result.script_source;
}
}
assert_eq!(actual_content, expected.content);
assert_eq!(actual_script, expected.script_source);
}
}
}

View File

@@ -17,6 +17,6 @@ collections.workspace = true
derive_more.workspace = true
gpui.workspace = true
parking_lot.workspace = true
project.workspace = true
serde.workspace = true
serde_json.workspace = true
workspace.workspace = true

View File

@@ -4,8 +4,8 @@ mod tool_working_set;
use std::sync::Arc;
use anyhow::Result;
use gpui::{App, Task, WeakEntity, Window};
use workspace::Workspace;
use gpui::{App, Entity, Task};
use project::Project;
pub use crate::tool_registry::*;
pub use crate::tool_working_set::*;
@@ -31,8 +31,7 @@ pub trait Tool: 'static + Send + Sync {
fn run(
self: Arc<Self>,
input: serde_json::Value,
workspace: WeakEntity<Workspace>,
window: &mut Window,
project: Entity<Project>,
cx: &mut App,
) -> Task<Result<String>>;
}

View File

@@ -20,4 +20,3 @@ project.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
workspace.workspace = true

View File

@@ -1,11 +1,11 @@
use std::sync::Arc;
use anyhow::{anyhow, Result};
use anyhow::Result;
use assistant_tool::Tool;
use gpui::{App, Task, WeakEntity, Window};
use gpui::{App, Entity, Task};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use workspace::Workspace;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct ListWorktreesToolInput {}
@@ -34,16 +34,9 @@ impl Tool for ListWorktreesTool {
fn run(
self: Arc<Self>,
_input: serde_json::Value,
workspace: WeakEntity<Workspace>,
_window: &mut Window,
project: Entity<Project>,
cx: &mut App,
) -> Task<Result<String>> {
let Some(workspace) = workspace.upgrade() else {
return Task::ready(Err(anyhow!("workspace dropped")));
};
let project = workspace.read(cx).project().clone();
cx.spawn(|cx| async move {
cx.update(|cx| {
#[derive(Debug, Serialize)]

View File

@@ -3,7 +3,8 @@ use std::sync::Arc;
use anyhow::{anyhow, Result};
use assistant_tool::Tool;
use chrono::{Local, Utc};
use gpui::{App, Task, WeakEntity, Window};
use gpui::{App, Entity, Task};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -41,8 +42,7 @@ impl Tool for NowTool {
fn run(
self: Arc<Self>,
input: serde_json::Value,
_workspace: WeakEntity<workspace::Workspace>,
_window: &mut Window,
_project: Entity<Project>,
_cx: &mut App,
) -> Task<Result<String>> {
let input: NowToolInput = match serde_json::from_value(input) {

View File

@@ -3,11 +3,10 @@ use std::sync::Arc;
use anyhow::{anyhow, Result};
use assistant_tool::Tool;
use gpui::{App, Task, WeakEntity, Window};
use project::{ProjectPath, WorktreeId};
use gpui::{App, Entity, Task};
use project::{Project, ProjectPath, WorktreeId};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use workspace::Workspace;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct ReadFileToolInput {
@@ -38,20 +37,14 @@ impl Tool for ReadFileTool {
fn run(
self: Arc<Self>,
input: serde_json::Value,
workspace: WeakEntity<Workspace>,
_window: &mut Window,
project: Entity<Project>,
cx: &mut App,
) -> Task<Result<String>> {
let Some(workspace) = workspace.upgrade() else {
return Task::ready(Err(anyhow!("workspace dropped")));
};
let input = match serde_json::from_value::<ReadFileToolInput>(input) {
Ok(input) => input,
Err(err) => return Task::ready(Err(anyhow!(err))),
};
let project = workspace.read(cx).project().clone();
let project_path = ProjectPath {
worktree_id: WorktreeId::from_usize(input.worktree_id),
path: input.path,

View File

@@ -10,9 +10,9 @@ use gpui::{
};
use language::{
language_settings::SoftWrap, Anchor, Buffer, BufferSnapshot, CodeLabel, LanguageRegistry,
LanguageServerId, ToOffset,
ToOffset,
};
use project::{search::SearchQuery, Completion};
use project::{search::SearchQuery, Completion, CompletionSource};
use settings::Settings;
use std::{
cell::RefCell,
@@ -309,11 +309,9 @@ impl MessageEditor {
old_range: range.clone(),
new_text,
label,
documentation: None,
server_id: LanguageServerId(0), // TODO: Make this optional or something?
lsp_completion: Default::default(), // TODO: Make this optional or something?
confirm: None,
resolved: true,
documentation: None,
source: CompletionSource::Custom,
}
})
.collect()

View File

@@ -78,6 +78,7 @@ pub struct ComponentId(pub &'static str);
#[derive(Clone)]
pub struct ComponentMetadata {
id: ComponentId,
name: SharedString,
scope: Option<ComponentScope>,
description: Option<SharedString>,
@@ -85,6 +86,10 @@ pub struct ComponentMetadata {
}
impl ComponentMetadata {
pub fn id(&self) -> ComponentId {
self.id.clone()
}
pub fn name(&self) -> SharedString {
self.name.clone()
}
@@ -156,9 +161,11 @@ pub fn components() -> AllComponents {
for (ref scope, name, description) in &data.components {
let preview = data.previews.get(name).cloned();
let component_name = SharedString::new_static(name);
let id = ComponentId(name);
all_components.insert(
ComponentId(name),
id.clone(),
ComponentMetadata {
id,
name: component_name,
scope: scope.clone(),
description: description.map(Into::into),

View File

@@ -23,3 +23,4 @@ project.workspace = true
ui.workspace = true
workspace.workspace = true
notifications.workspace = true
collections.workspace = true

View File

@@ -6,12 +6,14 @@ use std::iter::Iterator;
use std::sync::Arc;
use client::UserStore;
use component::{components, ComponentMetadata};
use component::{components, ComponentId, ComponentMetadata};
use gpui::{
list, prelude::*, uniform_list, App, Entity, EventEmitter, FocusHandle, Focusable, Task,
WeakEntity, Window,
};
use collections::HashMap;
use gpui::{ListState, ScrollHandle, UniformListScrollHandle};
use languages::LanguageRegistry;
use notifications::status_toast::{StatusToast, ToastIcon};
@@ -59,6 +61,8 @@ pub fn init(app_state: Arc<AppState>, cx: &mut App) {
}
enum PreviewEntry {
AllComponents,
Separator,
Component(ComponentMetadata),
SectionHeader(SharedString),
}
@@ -75,13 +79,22 @@ impl From<SharedString> for PreviewEntry {
}
}
#[derive(Default, Debug, Clone, PartialEq, Eq)]
enum PreviewPage {
#[default]
AllComponents,
Component(ComponentId),
}
struct ComponentPreview {
focus_handle: FocusHandle,
_view_scroll_handle: ScrollHandle,
nav_scroll_handle: UniformListScrollHandle,
component_map: HashMap<ComponentId, ComponentMetadata>,
active_page: PreviewPage,
components: Vec<ComponentMetadata>,
component_list: ListState,
selected_index: usize,
cursor_index: usize,
language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>,
user_store: Entity<UserStore>,
@@ -95,22 +108,25 @@ impl ComponentPreview {
selected_index: impl Into<Option<usize>>,
cx: &mut Context<Self>,
) -> Self {
let components = components().all_sorted();
let initial_length = components.len();
let sorted_components = components().all_sorted();
let selected_index = selected_index.into().unwrap_or(0);
let component_list =
ListState::new(initial_length, gpui::ListAlignment::Top, px(1500.0), {
let component_list = ListState::new(
sorted_components.len(),
gpui::ListAlignment::Top,
px(1500.0),
{
let this = cx.entity().downgrade();
move |ix, window: &mut Window, cx: &mut App| {
this.update(cx, |this, cx| {
let component = this.get_component(ix);
this.render_preview(ix, &component, window, cx)
this.render_preview(&component, window, cx)
.into_any_element()
})
.unwrap()
}
});
},
);
let mut component_preview = Self {
focus_handle: cx.focus_handle(),
@@ -119,13 +135,15 @@ impl ComponentPreview {
language_registry,
user_store,
workspace,
components,
active_page: PreviewPage::AllComponents,
component_map: components().0,
components: sorted_components,
component_list,
selected_index,
cursor_index: selected_index,
};
if component_preview.selected_index > 0 {
component_preview.scroll_to_preview(component_preview.selected_index, cx);
if component_preview.cursor_index > 0 {
component_preview.scroll_to_preview(component_preview.cursor_index, cx);
}
component_preview.update_component_list(cx);
@@ -135,7 +153,12 @@ impl ComponentPreview {
fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context<Self>) {
self.component_list.scroll_to_reveal_item(ix);
self.selected_index = ix;
self.cursor_index = ix;
cx.notify();
}
fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context<Self>) {
self.active_page = page;
cx.notify();
}
@@ -146,7 +169,6 @@ impl ComponentPreview {
fn scope_ordered_entries(&self) -> Vec<PreviewEntry> {
use std::collections::HashMap;
// Group components by scope
let mut scope_groups: HashMap<Option<ComponentScope>, Vec<ComponentMetadata>> =
HashMap::default();
@@ -157,15 +179,12 @@ impl ComponentPreview {
.push(component.clone());
}
// Sort components within each scope by name
for components in scope_groups.values_mut() {
components.sort_by_key(|c| c.name().to_lowercase());
}
// Build entries with scopes in a defined order
let mut entries = Vec::new();
// Define scope order (we want Unknown at the end)
let known_scopes = [
ComponentScope::Layout,
ComponentScope::Input,
@@ -175,15 +194,16 @@ impl ComponentPreview {
ComponentScope::VersionControl,
];
// First add components with known scopes
// Always show all components first
entries.push(PreviewEntry::AllComponents);
entries.push(PreviewEntry::Separator);
for scope in known_scopes.iter() {
let scope_key = Some(scope.clone());
if let Some(components) = scope_groups.remove(&scope_key) {
if !components.is_empty() {
// Add section header
entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
// Add all components under this scope
for component in components {
entries.push(PreviewEntry::Component(component));
}
@@ -191,16 +211,13 @@ impl ComponentPreview {
}
}
// Handle components with Unknown scope
for (scope, components) in &scope_groups {
if let Some(ComponentScope::Unknown(_)) = scope {
if !components.is_empty() {
// Add the unknown scope header
if let Some(scope_value) = scope {
entries.push(PreviewEntry::SectionHeader(scope_value.to_string().into()));
}
// Add all components under this unknown scope
for component in components {
entries.push(PreviewEntry::Component(component.clone()));
}
@@ -208,9 +225,9 @@ impl ComponentPreview {
}
}
// Handle components with no scope
if let Some(components) = scope_groups.get(&None) {
if !components.is_empty() {
entries.push(PreviewEntry::Separator);
entries.push(PreviewEntry::SectionHeader("Uncategorized".into()));
for component in components {
@@ -226,22 +243,42 @@ impl ComponentPreview {
&self,
ix: usize,
entry: &PreviewEntry,
selected: bool,
cx: &Context<Self>,
) -> impl IntoElement {
match entry {
PreviewEntry::Component(component_metadata) => ListItem::new(ix)
.child(Label::new(component_metadata.name().clone()).color(Color::Default))
.selectable(true)
.toggle_state(selected)
.inset(true)
.on_click(cx.listener(move |this, _, _, cx| {
this.scroll_to_preview(ix, cx);
}))
.into_any_element(),
PreviewEntry::Component(component_metadata) => {
let id = component_metadata.id();
let selected = self.active_page == PreviewPage::Component(id.clone());
ListItem::new(ix)
.child(Label::new(component_metadata.name().clone()).color(Color::Default))
.selectable(true)
.toggle_state(selected)
.inset(true)
.on_click(cx.listener(move |this, _, _, cx| {
let id = id.clone();
this.set_active_page(PreviewPage::Component(id), cx);
}))
.into_any_element()
}
PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
.inset(true)
.into_any_element(),
PreviewEntry::AllComponents => {
let selected = self.active_page == PreviewPage::AllComponents;
ListItem::new(ix)
.child(Label::new("All Components").color(Color::Default))
.selectable(true)
.toggle_state(selected)
.inset(true)
.on_click(cx.listener(move |this, _, _, cx| {
this.set_active_page(PreviewPage::AllComponents, cx);
}))
.into_any_element()
}
PreviewEntry::Separator => ListItem::new(ix)
.child(h_flex().pt_3().child(Divider::horizontal_dashed()))
.into_any_element(),
}
}
@@ -260,11 +297,13 @@ impl ComponentPreview {
weak_entity
.update(cx, |this, cx| match entry {
PreviewEntry::Component(component) => this
.render_preview(ix, component, window, cx)
.render_preview(component, window, cx)
.into_any_element(),
PreviewEntry::SectionHeader(shared_string) => this
.render_scope_header(ix, shared_string.clone(), window, cx)
.into_any_element(),
PreviewEntry::AllComponents => div().w_full().h_0().into_any_element(),
PreviewEntry::Separator => div().w_full().h_0().into_any_element(),
})
.unwrap()
},
@@ -290,7 +329,6 @@ impl ComponentPreview {
fn render_preview(
&self,
_ix: usize,
component: &ComponentMetadata,
window: &mut Window,
cx: &mut App,
@@ -341,6 +379,44 @@ impl ComponentPreview {
.into_any_element()
}
fn render_all_components(&self) -> impl IntoElement {
v_flex()
.id("component-list")
.px_8()
.pt_4()
.size_full()
.child(
list(self.component_list.clone())
.flex_grow()
.with_sizing_behavior(gpui::ListSizingBehavior::Auto),
)
}
fn render_component_page(
&mut self,
component_id: &ComponentId,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let component = self.component_map.get(&component_id);
if let Some(component) = component {
v_flex()
.w_full()
.flex_initial()
.min_h_full()
.child(self.render_preview(component, window, cx))
.into_any_element()
} else {
v_flex()
.size_full()
.items_center()
.justify_center()
.child("Component not found")
.into_any_element()
}
}
fn test_status_toast(&self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(workspace) = self.workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
@@ -363,8 +439,9 @@ impl ComponentPreview {
}
impl Render for ComponentPreview {
fn render(&mut self, _window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let sidebar_entries = self.scope_ordered_entries();
let active_page = self.active_page.clone();
h_flex()
.id("component-preview")
@@ -386,12 +463,7 @@ impl Render for ComponentPreview {
move |this, range, _window, cx| {
range
.map(|ix| {
this.render_sidebar_entry(
ix,
&sidebar_entries[ix],
ix == this.selected_index,
cx,
)
this.render_sidebar_entry(ix, &sidebar_entries[ix], cx)
})
.collect()
},
@@ -415,18 +487,12 @@ impl Render for ComponentPreview {
),
),
)
.child(
v_flex()
.id("component-list")
.px_8()
.pt_4()
.size_full()
.child(
list(self.component_list.clone())
.flex_grow()
.with_sizing_behavior(gpui::ListSizingBehavior::Auto),
),
)
.child(match active_page {
PreviewPage::AllComponents => self.render_all_components().into_any_element(),
PreviewPage::Component(id) => self
.render_component_page(&id, window, cx)
.into_any_element(),
})
}
}
@@ -465,7 +531,7 @@ impl Item for ComponentPreview {
let language_registry = self.language_registry.clone();
let user_store = self.user_store.clone();
let weak_workspace = self.workspace.clone();
let selected_index = self.selected_index;
let selected_index = self.cursor_index;
Some(cx.new(|cx| {
Self::new(

View File

@@ -31,4 +31,3 @@ settings.workspace = true
smol.workspace = true
url = { workspace = true, features = ["serde"] }
util.workspace = true
workspace.workspace = true

View File

@@ -1,8 +1,9 @@
use std::sync::Arc;
use anyhow::{anyhow, bail};
use anyhow::{anyhow, bail, Result};
use assistant_tool::Tool;
use gpui::{App, Entity, Task, Window};
use gpui::{App, Entity, Task};
use project::Project;
use crate::manager::ContextServerManager;
use crate::types;
@@ -49,12 +50,11 @@ impl Tool for ContextServerTool {
}
fn run(
self: std::sync::Arc<Self>,
self: Arc<Self>,
input: serde_json::Value,
_workspace: gpui::WeakEntity<workspace::Workspace>,
_: &mut Window,
_project: Entity<Project>,
cx: &mut App,
) -> gpui::Task<gpui::Result<String>> {
) -> Task<Result<String>> {
if let Some(server) = self.server_manager.read(cx).get_server(&self.server_id) {
cx.foreground_executor().spawn({
let tool_name = self.tool.name.clone();

View File

@@ -623,16 +623,21 @@ impl Copilot {
pub fn sign_out(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
self.update_sign_in_status(request::SignInStatus::NotSignedIn, cx);
if let CopilotServer::Running(RunningCopilotServer { lsp: server, .. }) = &self.server {
let server = server.clone();
cx.background_spawn(async move {
server
.request::<request::SignOut>(request::SignOutParams {})
.await?;
match &self.server {
CopilotServer::Running(RunningCopilotServer { lsp: server, .. }) => {
let server = server.clone();
cx.background_spawn(async move {
server
.request::<request::SignOut>(request::SignOutParams {})
.await?;
anyhow::Ok(())
})
}
CopilotServer::Disabled => cx.background_spawn(async move {
clear_copilot_config_dir().await;
anyhow::Ok(())
})
} else {
Task::ready(Err(anyhow!("copilot hasn't started yet")))
}),
_ => Task::ready(Err(anyhow!("copilot hasn't started yet"))),
}
}
@@ -1016,6 +1021,10 @@ async fn clear_copilot_dir() {
remove_matching(paths::copilot_dir(), |_| true).await
}
async fn clear_copilot_config_dir() {
remove_matching(copilot_chat::copilot_chat_config_dir(), |_| true).await
}
async fn get_copilot_lsp(http: Arc<dyn HttpClient>) -> anyhow::Result<PathBuf> {
const SERVER_PATH: &str = "dist/language-server.js";

View File

@@ -4,13 +4,14 @@ use std::sync::OnceLock;
use anyhow::{anyhow, Result};
use chrono::DateTime;
use collections::HashSet;
use fs::Fs;
use futures::{io::BufReader, stream::BoxStream, AsyncBufReadExt, AsyncReadExt, StreamExt};
use gpui::{prelude::*, App, AsyncApp, Global};
use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest};
use paths::home_dir;
use serde::{Deserialize, Serialize};
use settings::watch_config_file;
use settings::watch_config_dir;
use strum::EnumIter;
pub const COPILOT_CHAT_COMPLETION_URL: &str = "https://api.githubcopilot.com/chat/completions";
@@ -212,7 +213,7 @@ pub fn init(fs: Arc<dyn Fs>, client: Arc<dyn HttpClient>, cx: &mut App) {
cx.set_global(GlobalCopilotChat(copilot_chat));
}
fn copilot_chat_config_dir() -> &'static PathBuf {
pub fn copilot_chat_config_dir() -> &'static PathBuf {
static COPILOT_CHAT_CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
COPILOT_CHAT_CONFIG_DIR.get_or_init(|| {
@@ -237,27 +238,18 @@ impl CopilotChat {
}
pub fn new(fs: Arc<dyn Fs>, client: Arc<dyn HttpClient>, cx: &App) -> Self {
let config_paths = copilot_chat_config_paths();
let resolve_config_path = {
let fs = fs.clone();
async move {
for config_path in config_paths.iter() {
if fs.metadata(config_path).await.is_ok_and(|v| v.is_some()) {
return config_path.clone();
}
}
config_paths[0].clone()
}
};
let config_paths: HashSet<PathBuf> = copilot_chat_config_paths().into_iter().collect();
let dir_path = copilot_chat_config_dir();
cx.spawn(|cx| async move {
let config_file = resolve_config_path.await;
let mut config_file_rx = watch_config_file(cx.background_executor(), fs, config_file);
while let Some(contents) = config_file_rx.next().await {
let mut parent_watch_rx = watch_config_dir(
cx.background_executor(),
fs.clone(),
dir_path.clone(),
config_paths,
);
while let Some(contents) = parent_watch_rx.next().await {
let oauth_token = extract_oauth_token(contents);
cx.update(|cx| {
if let Some(this) = Self::global(cx).as_ref() {
this.update(cx, |this, cx| {

View File

@@ -311,7 +311,10 @@ impl ProjectDiagnosticsEditor {
cx: &mut Context<Workspace>,
) {
if let Some(existing) = workspace.item_of_type::<ProjectDiagnosticsEditor>(cx) {
workspace.activate_item(&existing, true, true, window, cx);
let is_active = workspace
.active_item(cx)
.is_some_and(|item| item.item_id() == existing.item_id());
workspace.activate_item(&existing, true, !is_active, window, cx);
} else {
let workspace_handle = cx.entity().downgrade();

View File

@@ -340,7 +340,9 @@ gpui::actions!(
MoveToPreviousWordStart,
MoveToStartOfParagraph,
MoveToStartOfExcerpt,
MoveToStartOfNextExcerpt,
MoveToEndOfExcerpt,
MoveToEndOfPreviousExcerpt,
MoveUp,
Newline,
NewlineAbove,
@@ -378,7 +380,9 @@ gpui::actions!(
SelectAll,
SelectAllMatches,
SelectToStartOfExcerpt,
SelectToStartOfNextExcerpt,
SelectToEndOfExcerpt,
SelectToEndOfPreviousExcerpt,
SelectDown,
SelectEnclosingSymbol,
SelectLargerSyntaxNode,

View File

@@ -6,11 +6,11 @@ use gpui::{
};
use language::Buffer;
use language::CodeLabel;
use lsp::LanguageServerId;
use markdown::Markdown;
use multi_buffer::{Anchor, ExcerptId};
use ordered_float::OrderedFloat;
use project::lsp_store::CompletionDocumentation;
use project::CompletionSource;
use project::{CodeAction, Completion, TaskSourceKind};
use std::{
@@ -233,11 +233,9 @@ impl CompletionsMenu {
runs: Default::default(),
filter_range: Default::default(),
},
server_id: LanguageServerId(usize::MAX),
documentation: None,
lsp_completion: Default::default(),
confirm: None,
resolved: true,
source: CompletionSource::Custom,
})
.collect();
@@ -500,7 +498,12 @@ impl CompletionsMenu {
// Ignore font weight for syntax highlighting, as we'll use it
// for fuzzy matches.
highlight.font_weight = None;
if completion.lsp_completion.deprecated.unwrap_or(false) {
if completion
.source
.lsp_completion(false)
.and_then(|lsp_completion| lsp_completion.deprecated)
.unwrap_or(false)
{
highlight.strikethrough = Some(StrikethroughStyle {
thickness: 1.0.into(),
..Default::default()
@@ -708,7 +711,12 @@ impl CompletionsMenu {
let completion = &completions[mat.candidate_id];
let sort_key = completion.sort_key();
let sort_text = completion.lsp_completion.sort_text.as_deref();
let sort_text =
if let CompletionSource::Lsp { lsp_completion, .. } = &completion.source {
lsp_completion.sort_text.as_deref()
} else {
None
};
let score = Reverse(OrderedFloat(mat.score));
if mat.score >= 0.2 {

View File

@@ -138,8 +138,9 @@ use multi_buffer::{
use project::{
lsp_store::{CompletionDocumentation, FormatTrigger, LspFormatTarget, OpenLspBufferHandle},
project_settings::{GitGutterSetting, ProjectSettings},
CodeAction, Completion, CompletionIntent, DocumentHighlight, InlayHint, Location, LocationLink,
PrepareRenameResponse, Project, ProjectItem, ProjectTransaction, TaskSourceKind,
CodeAction, Completion, CompletionIntent, CompletionSource, DocumentHighlight, InlayHint,
Location, LocationLink, PrepareRenameResponse, Project, ProjectItem, ProjectTransaction,
TaskSourceKind,
};
use rand::prelude::*;
use rpc::{proto::*, ErrorExt};
@@ -1250,11 +1251,6 @@ impl Editor {
let mut project_subscriptions = Vec::new();
if mode == EditorMode::Full {
if let Some(project) = project.as_ref() {
if buffer.read(cx).is_singleton() {
project_subscriptions.push(cx.observe_in(project, window, |_, _, _, cx| {
cx.emit(EditorEvent::TitleChanged);
}));
}
project_subscriptions.push(cx.subscribe_in(
project,
window,
@@ -1577,13 +1573,16 @@ impl Editor {
}
}
if let Some(extension) = self
.buffer
.read(cx)
.as_singleton()
.and_then(|buffer| buffer.read(cx).file()?.path().extension()?.to_str())
{
key_context.set("extension", extension.to_string());
if let Some(singleton_buffer) = self.buffer.read(cx).as_singleton() {
if let Some(extension) = singleton_buffer
.read(cx)
.file()
.and_then(|file| file.path().extension()?.to_str())
{
key_context.set("extension", extension.to_string());
}
} else {
key_context.add("multibuffer");
}
if has_active_edit_prediction {
@@ -9849,6 +9848,31 @@ impl Editor {
})
}
pub fn move_to_start_of_next_excerpt(
&mut self,
_: &MoveToStartOfNextExcerpt,
window: &mut Window,
cx: &mut Context<Self>,
) {
if matches!(self.mode, EditorMode::SingleLine { .. }) {
cx.propagate();
return;
}
self.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
s.move_with(|map, selection| {
selection.collapse_to(
movement::start_of_excerpt(
map,
selection.head(),
workspace::searchable::Direction::Next,
),
SelectionGoal::None,
)
});
})
}
pub fn move_to_end_of_excerpt(
&mut self,
_: &MoveToEndOfExcerpt,
@@ -9874,6 +9898,31 @@ impl Editor {
})
}
pub fn move_to_end_of_previous_excerpt(
&mut self,
_: &MoveToEndOfPreviousExcerpt,
window: &mut Window,
cx: &mut Context<Self>,
) {
if matches!(self.mode, EditorMode::SingleLine { .. }) {
cx.propagate();
return;
}
self.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
s.move_with(|map, selection| {
selection.collapse_to(
movement::end_of_excerpt(
map,
selection.head(),
workspace::searchable::Direction::Prev,
),
SelectionGoal::None,
)
});
})
}
pub fn select_to_start_of_excerpt(
&mut self,
_: &SelectToStartOfExcerpt,
@@ -9895,6 +9944,27 @@ impl Editor {
})
}
pub fn select_to_start_of_next_excerpt(
&mut self,
_: &SelectToStartOfNextExcerpt,
window: &mut Window,
cx: &mut Context<Self>,
) {
if matches!(self.mode, EditorMode::SingleLine { .. }) {
cx.propagate();
return;
}
self.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
s.move_heads_with(|map, head, _| {
(
movement::start_of_excerpt(map, head, workspace::searchable::Direction::Next),
SelectionGoal::None,
)
});
})
}
pub fn select_to_end_of_excerpt(
&mut self,
_: &SelectToEndOfExcerpt,
@@ -9916,6 +9986,27 @@ impl Editor {
})
}
pub fn select_to_end_of_previous_excerpt(
&mut self,
_: &SelectToEndOfPreviousExcerpt,
window: &mut Window,
cx: &mut Context<Self>,
) {
if matches!(self.mode, EditorMode::SingleLine { .. }) {
cx.propagate();
return;
}
self.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
s.move_heads_with(|map, head, _| {
(
movement::end_of_excerpt(map, head, workspace::searchable::Direction::Prev),
SelectionGoal::None,
)
});
})
}
pub fn move_to_beginning(
&mut self,
_: &MoveToBeginning,
@@ -11548,7 +11639,7 @@ impl Editor {
fn go_to_next_hunk(&mut self, _: &GoToHunk, window: &mut Window, cx: &mut Context<Self>) {
let snapshot = self.snapshot(window, cx);
let selection = self.selections.newest::<Point>(cx);
self.go_to_hunk_after_or_before_position(
self.go_to_hunk_before_or_after_position(
&snapshot,
selection.head(),
Direction::Next,
@@ -11557,7 +11648,7 @@ impl Editor {
);
}
fn go_to_hunk_after_or_before_position(
fn go_to_hunk_before_or_after_position(
&mut self,
snapshot: &EditorSnapshot,
position: Point,
@@ -11608,7 +11699,7 @@ impl Editor {
) {
let snapshot = self.snapshot(window, cx);
let selection = self.selections.newest::<Point>(cx);
self.go_to_hunk_after_or_before_position(
self.go_to_hunk_before_or_after_position(
&snapshot,
selection.head(),
Direction::Prev,
@@ -13770,21 +13861,6 @@ impl Editor {
return;
}
let snapshot = self.snapshot(window, cx);
let newest_range = self.selections.newest::<Point>(cx).range();
let run_twice = snapshot
.hunks_for_ranges([newest_range])
.first()
.is_some_and(|hunk| {
let next_line = Point::new(hunk.row_range.end.0 + 1, 0);
self.hunk_after_position(&snapshot, next_line)
.is_some_and(|other| other.row_range == hunk.row_range)
});
if run_twice {
self.go_to_next_hunk(&GoToHunk, window, cx);
}
self.stage_or_unstage_diff_hunks(stage, ranges, cx);
self.go_to_next_hunk(&GoToHunk, window, cx);
}
@@ -14212,6 +14288,13 @@ impl Editor {
EditorSettings::override_global(editor_settings, cx);
}
pub fn line_numbers_enabled(&self, cx: &App) -> bool {
if let Some(show_line_numbers) = self.show_line_numbers {
return show_line_numbers;
}
EditorSettings::get_global(cx).gutter.line_numbers
}
pub fn should_use_relative_line_numbers(&self, cx: &mut App) -> bool {
self.use_relative_line_numbers
.unwrap_or(EditorSettings::get_global(cx).relative_line_numbers)
@@ -14887,14 +14970,14 @@ impl Editor {
&self,
window: &mut Window,
cx: &mut App,
) -> BTreeMap<DisplayRow, Background> {
) -> BTreeMap<DisplayRow, LineHighlight> {
let snapshot = self.snapshot(window, cx);
let mut used_highlight_orders = HashMap::default();
self.highlighted_rows
.iter()
.flat_map(|(_, highlighted_rows)| highlighted_rows.iter())
.fold(
BTreeMap::<DisplayRow, Background>::new(),
BTreeMap::<DisplayRow, LineHighlight>::new(),
|mut unique_rows, highlight| {
let start = highlight.range.start.to_display_point(&snapshot);
let end = highlight.range.end.to_display_point(&snapshot);
@@ -15442,14 +15525,9 @@ impl Editor {
}
multi_buffer::Event::DirtyChanged => cx.emit(EditorEvent::DirtyChanged),
multi_buffer::Event::Saved => cx.emit(EditorEvent::Saved),
multi_buffer::Event::FileHandleChanged | multi_buffer::Event::Reloaded => {
cx.emit(EditorEvent::TitleChanged)
}
// multi_buffer::Event::DiffBaseChanged => {
// self.scrollbar_marker_state.dirty = true;
// cx.emit(EditorEvent::DiffBaseChanged);
// cx.notify();
// }
multi_buffer::Event::FileHandleChanged
| multi_buffer::Event::Reloaded
| multi_buffer::Event::BufferDiffChanged => cx.emit(EditorEvent::TitleChanged),
multi_buffer::Event::Closed => cx.emit(EditorEvent::Closed),
multi_buffer::Event::DiagnosticsUpdated => {
self.refresh_active_diagnostics(cx);
@@ -16907,38 +16985,41 @@ fn snippet_completions(
Some(Completion {
old_range: range,
new_text: snippet.body.clone(),
resolved: false,
source: CompletionSource::Lsp {
server_id: LanguageServerId(usize::MAX),
resolved: true,
lsp_completion: Box::new(lsp::CompletionItem {
label: snippet.prefix.first().unwrap().clone(),
kind: Some(CompletionItemKind::SNIPPET),
label_details: snippet.description.as_ref().map(|description| {
lsp::CompletionItemLabelDetails {
detail: Some(description.clone()),
description: None,
}
}),
insert_text_format: Some(InsertTextFormat::SNIPPET),
text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace(
lsp::InsertReplaceEdit {
new_text: snippet.body.clone(),
insert: lsp_range,
replace: lsp_range,
},
)),
filter_text: Some(snippet.body.clone()),
sort_text: Some(char::MAX.to_string()),
..lsp::CompletionItem::default()
}),
lsp_defaults: None,
},
label: CodeLabel {
text: matching_prefix.clone(),
runs: vec![],
runs: Vec::new(),
filter_range: 0..matching_prefix.len(),
},
server_id: LanguageServerId(usize::MAX),
documentation: snippet
.description
.clone()
.map(|description| CompletionDocumentation::SingleLine(description.into())),
lsp_completion: lsp::CompletionItem {
label: snippet.prefix.first().unwrap().clone(),
kind: Some(CompletionItemKind::SNIPPET),
label_details: snippet.description.as_ref().map(|description| {
lsp::CompletionItemLabelDetails {
detail: Some(description.clone()),
description: None,
}
}),
insert_text_format: Some(InsertTextFormat::SNIPPET),
text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace(
lsp::InsertReplaceEdit {
new_text: snippet.body.clone(),
insert: lsp_range,
replace: lsp_range,
},
)),
filter_text: Some(snippet.body.clone()),
sort_text: Some(char::MAX.to_string()),
..Default::default()
},
confirm: None,
})
})
@@ -18436,3 +18517,27 @@ impl Render for MissingEditPredictionKeybindingTooltip {
})
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LineHighlight {
pub background: Background,
pub border: Option<gpui::Hsla>,
}
impl From<Hsla> for LineHighlight {
fn from(hsla: Hsla) -> Self {
Self {
background: hsla.into(),
border: None,
}
}
}
impl From<Background> for LineHighlight {
fn from(background: Background) -> Self {
Self {
background,
border: None,
}
}
}

View File

@@ -12334,24 +12334,6 @@ async fn test_completions_default_resolve_data_handling(cx: &mut TestAppContext)
},
};
let item_0_out = lsp::CompletionItem {
commit_characters: Some(default_commit_characters.clone()),
insert_text_format: Some(default_insert_text_format),
..item_0
};
let items_out = iter::once(item_0_out)
.chain(items[1..].iter().map(|item| lsp::CompletionItem {
commit_characters: Some(default_commit_characters.clone()),
data: Some(default_data.clone()),
insert_text_mode: Some(default_insert_text_mode),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: default_edit_range,
new_text: item.label.clone(),
})),
..item.clone()
}))
.collect::<Vec<lsp::CompletionItem>>();
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions {
@@ -12370,10 +12352,11 @@ async fn test_completions_default_resolve_data_handling(cx: &mut TestAppContext)
let completion_data = default_data.clone();
let completion_characters = default_commit_characters.clone();
let completion_items = items.clone();
cx.handle_request::<lsp::request::Completion, _, _>(move |_, _, _| {
let default_data = completion_data.clone();
let default_commit_characters = completion_characters.clone();
let items = items.clone();
let items = completion_items.clone();
async move {
Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList {
items,
@@ -12422,7 +12405,7 @@ async fn test_completions_default_resolve_data_handling(cx: &mut TestAppContext)
.iter()
.map(|mat| mat.string.clone())
.collect::<Vec<String>>(),
items_out
items
.iter()
.map(|completion| completion.label.clone())
.collect::<Vec<String>>()
@@ -12435,14 +12418,18 @@ async fn test_completions_default_resolve_data_handling(cx: &mut TestAppContext)
// with 4 from the end.
assert_eq!(
*resolved_items.lock(),
[
&items_out[0..16],
&items_out[items_out.len() - 4..items_out.len()]
]
.concat()
.iter()
.cloned()
.collect::<Vec<lsp::CompletionItem>>()
[&items[0..16], &items[items.len() - 4..items.len()]]
.concat()
.iter()
.cloned()
.map(|mut item| {
if item.data.is_none() {
item.data = Some(default_data.clone());
}
item
})
.collect::<Vec<lsp::CompletionItem>>(),
"Items sent for resolve should be unchanged modulo resolve `data` filled with default if missing"
);
resolved_items.lock().clear();
@@ -12453,9 +12440,15 @@ async fn test_completions_default_resolve_data_handling(cx: &mut TestAppContext)
// Completions that have already been resolved are skipped.
assert_eq!(
*resolved_items.lock(),
items_out[items_out.len() - 16..items_out.len() - 4]
items[items.len() - 16..items.len() - 4]
.iter()
.cloned()
.map(|mut item| {
if item.data.is_none() {
item.data = Some(default_data.clone());
}
item
})
.collect::<Vec<lsp::CompletionItem>>()
);
resolved_items.lock().clear();
@@ -16413,6 +16406,199 @@ async fn test_folding_buffer_when_multibuffer_has_only_one_excerpt(cx: &mut Test
);
}
#[gpui::test]
async fn test_multi_buffer_navigation_with_folded_buffers(cx: &mut TestAppContext) {
init_test(cx, |_| {});
cx.update(|cx| {
let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
"keymaps/default-linux.json",
cx,
)
.unwrap();
cx.bind_keys(default_key_bindings);
});
let (editor, cx) = cx.add_window_view(|window, cx| {
let multi_buffer = MultiBuffer::build_multi(
[
("a0\nb0\nc0\nd0\ne0\n", vec![Point::row_range(0..2)]),
("a1\nb1\nc1\nd1\ne1\n", vec![Point::row_range(0..2)]),
("a2\nb2\nc2\nd2\ne2\n", vec![Point::row_range(0..2)]),
("a3\nb3\nc3\nd3\ne3\n", vec![Point::row_range(0..2)]),
],
cx,
);
let mut editor = Editor::new(
EditorMode::Full,
multi_buffer.clone(),
None,
true,
window,
cx,
);
let buffer_ids = multi_buffer.read(cx).excerpt_buffer_ids();
// fold all but the second buffer, so that we test navigating between two
// adjacent folded buffers, as well as folded buffers at the start and
// end the multibuffer
editor.fold_buffer(buffer_ids[0], cx);
editor.fold_buffer(buffer_ids[2], cx);
editor.fold_buffer(buffer_ids[3], cx);
editor
});
cx.simulate_resize(size(px(1000.), px(1000.)));
let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await;
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
ˇ[FOLDED]
[EXCERPT]
a1
b1
[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
"
});
cx.simulate_keystroke("down");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
ˇa1
b1
[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
"
});
cx.simulate_keystroke("down");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
a1
ˇb1
[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
"
});
cx.simulate_keystroke("down");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
a1
b1
ˇ[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
"
});
cx.simulate_keystroke("down");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
a1
b1
[EXCERPT]
ˇ[FOLDED]
[EXCERPT]
[FOLDED]
"
});
for _ in 0..5 {
cx.simulate_keystroke("down");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
a1
b1
[EXCERPT]
[FOLDED]
[EXCERPT]
ˇ[FOLDED]
"
});
}
cx.simulate_keystroke("up");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
a1
b1
[EXCERPT]
ˇ[FOLDED]
[EXCERPT]
[FOLDED]
"
});
cx.simulate_keystroke("up");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
a1
b1
ˇ[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
"
});
cx.simulate_keystroke("up");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
a1
ˇb1
[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
"
});
cx.simulate_keystroke("up");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
ˇa1
b1
[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
"
});
for _ in 0..5 {
cx.simulate_keystroke("up");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
ˇ[FOLDED]
[EXCERPT]
a1
b1
[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
"
});
}
}
#[gpui::test]
async fn test_inline_completion_text(cx: &mut TestAppContext) {
init_test(cx, |_| {});

View File

@@ -20,10 +20,10 @@ use crate::{
DisplayRow, DocumentHighlightRead, DocumentHighlightWrite, EditDisplayMode, Editor, EditorMode,
EditorSettings, EditorSnapshot, EditorStyle, ExpandExcerpts, FocusedBlock, GoToHunk,
GoToPreviousHunk, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor,
InlayHintRefreshReason, InlineCompletion, JumpData, LineDown, LineUp, OpenExcerpts, PageDown,
PageUp, Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight, Selection, SoftWrap,
StickyHeaderExcerpt, ToPoint, ToggleFold, COLUMNAR_SELECTION_MODIFIERS, CURSORS_VISIBLE_FOR,
FILE_HEADER_HEIGHT, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN,
InlayHintRefreshReason, InlineCompletion, JumpData, LineDown, LineHighlight, LineUp,
OpenExcerpts, PageDown, PageUp, Point, RowExt, RowRangeExt, SelectPhase, SelectedTextHighlight,
Selection, SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, COLUMNAR_SELECTION_MODIFIERS,
CURSORS_VISIBLE_FOR, FILE_HEADER_HEIGHT, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN,
MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
};
use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
@@ -282,7 +282,9 @@ impl EditorElement {
register_action(editor, window, Editor::move_to_beginning);
register_action(editor, window, Editor::move_to_end);
register_action(editor, window, Editor::move_to_start_of_excerpt);
register_action(editor, window, Editor::move_to_start_of_next_excerpt);
register_action(editor, window, Editor::move_to_end_of_excerpt);
register_action(editor, window, Editor::move_to_end_of_previous_excerpt);
register_action(editor, window, Editor::select_up);
register_action(editor, window, Editor::select_down);
register_action(editor, window, Editor::select_left);
@@ -296,7 +298,9 @@ impl EditorElement {
register_action(editor, window, Editor::select_to_start_of_paragraph);
register_action(editor, window, Editor::select_to_end_of_paragraph);
register_action(editor, window, Editor::select_to_start_of_excerpt);
register_action(editor, window, Editor::select_to_start_of_next_excerpt);
register_action(editor, window, Editor::select_to_end_of_excerpt);
register_action(editor, window, Editor::select_to_end_of_previous_excerpt);
register_action(editor, window, Editor::select_to_beginning);
register_action(editor, window, Editor::select_to_end);
register_action(editor, window, Editor::select_all);
@@ -1691,7 +1695,7 @@ impl EditorElement {
let pos_y = content_origin.y
+ line_height * (row.0 as f32 - scroll_pixel_position.y / line_height);
let window_ix = row.minus(start_row) as usize;
let window_ix = row.0.saturating_sub(start_row.0) as usize;
let pos_x = {
let crease_trailer_layout = &crease_trailers[window_ix];
let line_layout = &line_layouts[window_ix];
@@ -4132,46 +4136,74 @@ impl EditorElement {
}
}
let mut paint_highlight =
|highlight_row_start: DisplayRow, highlight_row_end: DisplayRow, color| {
let origin = point(
layout.hitbox.origin.x,
layout.hitbox.origin.y
+ (highlight_row_start.as_f32() - scroll_top)
* layout.position_map.line_height,
);
let size = size(
layout.hitbox.size.width,
layout.position_map.line_height
* highlight_row_end.next_row().minus(highlight_row_start) as f32,
);
window.paint_quad(fill(Bounds { origin, size }, color));
};
let mut paint_highlight = |highlight_row_start: DisplayRow,
highlight_row_end: DisplayRow,
highlight: crate::LineHighlight,
edges| {
let origin = point(
layout.hitbox.origin.x,
layout.hitbox.origin.y
+ (highlight_row_start.as_f32() - scroll_top)
* layout.position_map.line_height,
);
let size = size(
layout.hitbox.size.width,
layout.position_map.line_height
* highlight_row_end.next_row().minus(highlight_row_start) as f32,
);
let mut quad = fill(Bounds { origin, size }, highlight.background);
if let Some(border_color) = highlight.border {
quad.border_color = border_color;
quad.border_widths = edges
}
window.paint_quad(quad);
};
let mut current_paint: Option<(gpui::Background, Range<DisplayRow>)> = None;
let mut current_paint: Option<(LineHighlight, Range<DisplayRow>, Edges<Pixels>)> =
None;
for (&new_row, &new_background) in &layout.highlighted_rows {
match &mut current_paint {
Some((current_background, current_range)) => {
Some((current_background, current_range, mut edges)) => {
let current_background = *current_background;
let new_range_started = current_background != new_background
|| current_range.end.next_row() != new_row;
if new_range_started {
if current_range.end.next_row() == new_row {
edges.bottom = px(0.);
};
paint_highlight(
current_range.start,
current_range.end,
current_background,
edges,
);
current_paint = Some((new_background, new_row..new_row));
let edges = Edges {
top: if current_range.end.next_row() != new_row {
px(1.)
} else {
px(0.)
},
bottom: px(1.),
..Default::default()
};
current_paint = Some((new_background, new_row..new_row, edges));
continue;
} else {
current_range.end = current_range.end.next_row();
}
}
None => current_paint = Some((new_background, new_row..new_row)),
None => {
let edges = Edges {
top: px(1.),
bottom: px(1.),
..Default::default()
};
current_paint = Some((new_background, new_row..new_row, edges))
}
};
}
if let Some((color, range)) = current_paint {
paint_highlight(range.start, range.end, color);
if let Some((color, range, edges)) = current_paint {
paint_highlight(range.start, range.end, color, edges);
}
let scroll_left =
@@ -4349,6 +4381,11 @@ impl EditorElement {
fn paint_gutter_diff_hunks(layout: &mut EditorLayout, window: &mut Window, cx: &mut App) {
let is_light = cx.theme().appearance().is_light();
let hunk_style = ProjectSettings::get_global(cx)
.git
.hunk_style
.unwrap_or_default();
if layout.display_hunks.is_empty() {
return;
}
@@ -4412,9 +4449,23 @@ impl EditorElement {
if let Some((hunk_bounds, mut background_color, corner_radii, secondary_status)) =
hunk_to_paint
{
if secondary_status.has_secondary_hunk() {
background_color =
background_color.opacity(if is_light { 0.2 } else { 0.32 });
match hunk_style {
GitHunkStyleSetting::Transparent | GitHunkStyleSetting::Pattern => {
if secondary_status.has_secondary_hunk() {
background_color =
background_color.opacity(if is_light { 0.2 } else { 0.32 });
}
}
GitHunkStyleSetting::StagedPattern
| GitHunkStyleSetting::StagedTransparent => {
if !secondary_status.has_secondary_hunk() {
background_color =
background_color.opacity(if is_light { 0.2 } else { 0.32 });
}
}
GitHunkStyleSetting::StagedBorder | GitHunkStyleSetting::Border => {
// Don't change the background color
}
}
// Flatten the background color with the editor color to prevent
@@ -6734,10 +6785,10 @@ impl Element for EditorElement {
.update(cx, |editor, cx| editor.highlighted_display_rows(window, cx));
let is_light = cx.theme().appearance().is_light();
let use_pattern = ProjectSettings::get_global(cx)
let hunk_style = ProjectSettings::get_global(cx)
.git
.hunk_style
.map_or(false, |style| matches!(style, GitHunkStyleSetting::Pattern));
.unwrap_or_default();
for (ix, row_info) in row_infos.iter().enumerate() {
let Some(diff_status) = row_info.diff_status else {
@@ -6757,26 +6808,74 @@ impl Element for EditorElement {
let unstaged = diff_status.has_secondary_hunk();
let hunk_opacity = if is_light { 0.16 } else { 0.12 };
let slash_width = line_height.0 / 1.5; // ~16 by default
let staged_background =
solid_background(background_color.opacity(hunk_opacity));
let unstaged_background = if use_pattern {
pattern_slash(
background_color.opacity(hunk_opacity),
window.rem_size().0 * 1.125, // ~18 by default
)
} else {
solid_background(background_color.opacity(if is_light {
0.08
} else {
0.04
}))
let staged_highlight: LineHighlight = match hunk_style {
GitHunkStyleSetting::Transparent
| GitHunkStyleSetting::Pattern
| GitHunkStyleSetting::Border => {
solid_background(background_color.opacity(hunk_opacity)).into()
}
GitHunkStyleSetting::StagedPattern => {
pattern_slash(background_color.opacity(hunk_opacity), slash_width)
.into()
}
GitHunkStyleSetting::StagedTransparent => {
solid_background(background_color.opacity(if is_light {
0.08
} else {
0.04
}))
.into()
}
GitHunkStyleSetting::StagedBorder => LineHighlight {
background: (background_color.opacity(if is_light {
0.08
} else {
0.06
}))
.into(),
border: Some(if is_light {
background_color.opacity(0.48)
} else {
background_color.opacity(0.36)
}),
},
};
let unstaged_highlight = match hunk_style {
GitHunkStyleSetting::Transparent => {
solid_background(background_color.opacity(if is_light {
0.08
} else {
0.04
}))
.into()
}
GitHunkStyleSetting::Pattern => {
pattern_slash(background_color.opacity(hunk_opacity), slash_width)
.into()
}
GitHunkStyleSetting::Border => LineHighlight {
background: (background_color.opacity(if is_light {
0.08
} else {
0.02
}))
.into(),
border: Some(background_color.opacity(0.5)),
},
GitHunkStyleSetting::StagedPattern
| GitHunkStyleSetting::StagedTransparent
| GitHunkStyleSetting::StagedBorder => {
solid_background(background_color.opacity(hunk_opacity)).into()
}
};
let background = if unstaged {
unstaged_background
unstaged_highlight
} else {
staged_background
staged_highlight
};
highlighted_rows
@@ -7625,7 +7724,7 @@ pub struct EditorLayout {
indent_guides: Option<Vec<IndentGuideLayout>>,
visible_display_row_range: Range<DisplayRow>,
active_rows: BTreeMap<DisplayRow, bool>,
highlighted_rows: BTreeMap<DisplayRow, gpui::Background>,
highlighted_rows: BTreeMap<DisplayRow, LineHighlight>,
line_elements: SmallVec<[AnyElement; 1]>,
line_numbers: Arc<HashMap<MultiBufferRow, LineNumberLayout>>,
display_hunks: Vec<(DisplayDiffHunk, Option<Hitbox>)>,
@@ -8797,14 +8896,16 @@ fn diff_hunk_controls(
.h(line_height)
.mr_1()
.gap_1()
.px_1()
.px_0p5()
.pb_1()
.border_x_1()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.rounded_b_lg()
.bg(cx.theme().colors().editor_background)
.gap_1()
.occlude()
.shadow_md()
.child(if status.has_secondary_hunk() {
Button::new(("stage", row as u64), "Stage")
.alpha(if status.is_pending() { 0.66 } else { 1.0 })
@@ -8861,7 +8962,7 @@ fn diff_hunk_controls(
})
})
.child(
Button::new("discard", "Restore")
Button::new("restore", "Restore")
.tooltip({
let focus_handle = editor.focus_handle(cx);
move |window, cx| {
@@ -8913,7 +9014,7 @@ fn diff_hunk_controls(
let snapshot = editor.snapshot(window, cx);
let position =
hunk_range.end.to_point(&snapshot.buffer_snapshot);
editor.go_to_hunk_after_or_before_position(
editor.go_to_hunk_before_or_after_position(
&snapshot,
position,
Direction::Next,
@@ -8949,7 +9050,7 @@ fn diff_hunk_controls(
let snapshot = editor.snapshot(window, cx);
let point =
hunk_range.start.to_point(&snapshot.buffer_snapshot);
editor.go_to_hunk_after_or_before_position(
editor.go_to_hunk_before_or_after_position(
&snapshot,
point,
Direction::Prev,

View File

@@ -448,7 +448,9 @@ pub fn end_of_excerpt(
if start.row() > DisplayRow(0) {
*start.row_mut() -= 1;
}
map.clip_point(start, Bias::Left)
start = map.clip_point(start, Bias::Left);
*start.column_mut() = 0;
start
}
Direction::Next => {
let mut end = excerpt.end_anchor().to_display_point(&map);

View File

@@ -429,12 +429,14 @@ impl EditorTestContext {
if expected_selections.len() > 0 {
assert!(
is_selected,
"excerpt {} should be selected. Got {:?}",
ix,
self.editor_state()
"excerpt {ix} should be selected. got {:?}",
self.editor_state(),
);
} else {
assert!(!is_selected, "excerpt {} should not be selected", ix);
assert!(
!is_selected,
"excerpt {ix} should not be selected, got: {selections:?}",
);
}
continue;
}

View File

@@ -17,6 +17,7 @@ async-compression.workspace = true
async-tar.workspace = true
async-trait.workspace = true
collections.workspace = true
convert_case.workspace = true
fs.workspace = true
futures.workspace = true
gpui.workspace = true

View File

@@ -4,6 +4,7 @@ use crate::{
use anyhow::{anyhow, bail, Context as _, Result};
use async_compression::futures::bufread::GzipDecoder;
use async_tar::Archive;
use convert_case::{Case, Casing as _};
use futures::io::BufReader;
use futures::AsyncReadExt;
use http_client::{self, AsyncBody, HttpClient};
@@ -97,6 +98,11 @@ impl ExtensionBuilder {
}
for (grammar_name, grammar_metadata) in &extension_manifest.grammars {
let snake_cased_grammar_name = grammar_name.to_case(Case::Snake);
if grammar_name.as_ref() != snake_cased_grammar_name.as_str() {
bail!("grammar name '{grammar_name}' must be written in snake_case: {snake_cased_grammar_name}");
}
log::info!(
"compiling grammar {grammar_name} for extension {}",
extension_dir.display()

View File

@@ -692,7 +692,9 @@ impl GitRepository for RealGitRepository {
PushOptions::Force => "--force-with-lease",
}))
.arg(remote_name)
.arg(format!("{}:{}", branch_name, branch_name));
.arg(format!("{}:{}", branch_name, branch_name))
.stdout(smol::process::Stdio::piped())
.stderr(smol::process::Stdio::piped());
let git_process = command.spawn()?;
run_remote_command(ask_pass, git_process)
@@ -714,7 +716,9 @@ impl GitRepository for RealGitRepository {
.current_dir(&working_directory)
.args(["pull"])
.arg(remote_name)
.arg(branch_name);
.arg(branch_name)
.stdout(smol::process::Stdio::piped())
.stderr(smol::process::Stdio::piped());
let git_process = command.spawn()?;
run_remote_command(ask_pass, git_process)
@@ -729,7 +733,9 @@ impl GitRepository for RealGitRepository {
.env("SSH_ASKPASS", ask_pass.script_path())
.env("SSH_ASKPASS_REQUIRE", "force")
.current_dir(&working_directory)
.args(["fetch", "--all"]);
.args(["fetch", "--all"])
.stdout(smol::process::Stdio::piped())
.stderr(smol::process::Stdio::piped());
let git_process = command.spawn()?;
run_remote_command(ask_pass, git_process)

View File

@@ -54,6 +54,39 @@ impl From<TrackedStatus> for FileStatus {
}
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum StageStatus {
Staged,
Unstaged,
PartiallyStaged,
}
impl StageStatus {
pub fn is_fully_staged(&self) -> bool {
matches!(self, StageStatus::Staged)
}
pub fn is_fully_unstaged(&self) -> bool {
matches!(self, StageStatus::Unstaged)
}
pub fn has_staged(&self) -> bool {
matches!(self, StageStatus::Staged | StageStatus::PartiallyStaged)
}
pub fn has_unstaged(&self) -> bool {
matches!(self, StageStatus::Unstaged | StageStatus::PartiallyStaged)
}
pub fn as_bool(self) -> Option<bool> {
match self {
StageStatus::Staged => Some(true),
StageStatus::Unstaged => Some(false),
StageStatus::PartiallyStaged => None,
}
}
}
impl FileStatus {
pub const fn worktree(worktree_status: StatusCode) -> Self {
FileStatus::Tracked(TrackedStatus {
@@ -106,15 +139,15 @@ impl FileStatus {
Ok(status)
}
pub fn is_staged(self) -> Option<bool> {
pub fn staging(self) -> StageStatus {
match self {
FileStatus::Untracked | FileStatus::Ignored | FileStatus::Unmerged { .. } => {
Some(false)
StageStatus::Unstaged
}
FileStatus::Tracked(tracked) => match (tracked.index_status, tracked.worktree_status) {
(StatusCode::Unmodified, _) => Some(false),
(_, StatusCode::Unmodified) => Some(true),
_ => None,
(StatusCode::Unmodified, _) => StageStatus::Unstaged,
(_, StatusCode::Unmodified) => StageStatus::Staged,
_ => StageStatus::PartiallyStaged,
},
}
}

View File

@@ -18,10 +18,30 @@ use workspace::{ModalView, Workspace};
pub fn init(cx: &mut App) {
cx.observe_new(|workspace: &mut Workspace, _, _| {
workspace.register_action(open);
workspace.register_action(switch);
workspace.register_action(checkout_branch);
})
.detach();
}
pub fn checkout_branch(
workspace: &mut Workspace,
_: &zed_actions::git::CheckoutBranch,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
open(workspace, &zed_actions::git::Branch, window, cx);
}
pub fn switch(
workspace: &mut Workspace,
_: &zed_actions::git::Switch,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
open(workspace, &zed_actions::git::Branch, window, cx);
}
pub fn open(
workspace: &mut Workspace,
_: &zed_actions::git::Branch,

View File

@@ -4,7 +4,7 @@ If you can accurately express the change in just the subject line, don't include
Don't repeat information from the subject line in the message body.
Only return the commit message in your response. Do not include any additional meta-commentary about the task.
Only return the commit message in your response. Do not include any additional meta-commentary about the task. Do not include the raw diff output in the commit message.
Follow good Git style:

View File

@@ -2,7 +2,7 @@
use crate::branch_picker::{self, BranchList};
use crate::git_panel::{commit_message_editor, GitPanel};
use git::Commit;
use git::{Commit, GenerateCommitMessage};
use panel::{panel_button, panel_editor_style, panel_filled_button};
use ui::{prelude::*, KeybindingHint, PopoverMenu, Tooltip};
@@ -372,11 +372,24 @@ impl Render for CommitModal {
.key_context("GitCommit")
.on_action(cx.listener(Self::dismiss))
.on_action(cx.listener(Self::commit))
.on_action(cx.listener(|this, _: &GenerateCommitMessage, _, cx| {
this.git_panel.update(cx, |panel, cx| {
panel.generate_commit_message(cx);
})
}))
.on_action(
cx.listener(|this, _: &zed_actions::git::Branch, window, cx| {
this.branch_list.update(cx, |branch_list, cx| {
branch_list.popover_handle.toggle(window, cx);
})
toggle_branch_picker(this, window, cx);
}),
)
.on_action(
cx.listener(|this, _: &zed_actions::git::CheckoutBranch, window, cx| {
toggle_branch_picker(this, window, cx);
}),
)
.on_action(
cx.listener(|this, _: &zed_actions::git::Switch, window, cx| {
toggle_branch_picker(this, window, cx);
}),
)
.elevation_3(cx)
@@ -415,3 +428,13 @@ impl Render for CommitModal {
)
}
}
fn toggle_branch_picker(
this: &mut CommitModal,
window: &mut Window,
cx: &mut Context<'_, CommitModal>,
) {
this.branch_list.update(cx, |branch_list, cx| {
branch_list.popover_handle.toggle(window, cx);
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,13 @@
use ::settings::Settings;
use git::status::FileStatus;
use git::{
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
status::FileStatus,
};
use git_panel_settings::GitPanelSettings;
use gpui::App;
use gpui::{App, Entity, FocusHandle};
use project::Project;
use project_diff::ProjectDiff;
use ui::{ActiveTheme, Color, Icon, IconName, IntoElement};
use ui::{ActiveTheme, Color, Icon, IconName, IntoElement, SharedString};
use workspace::Workspace;
mod askpass_modal;
@@ -60,6 +64,22 @@ pub fn init(cx: &mut App) {
panel.pull(window, cx);
});
});
workspace.register_action(|workspace, action: &git::StageAll, window, cx| {
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
return;
};
panel.update(cx, |panel, cx| {
panel.stage_all(action, window, cx);
});
});
workspace.register_action(|workspace, action: &git::UnstageAll, window, cx| {
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
return;
};
panel.update(cx, |panel, cx| {
panel.unstage_all(action, window, cx);
});
});
})
.detach();
}
@@ -89,3 +109,343 @@ pub fn git_status_icon(status: FileStatus, cx: &App) -> impl IntoElement {
};
Icon::new(icon_name).color(Color::Custom(color))
}
fn can_push_and_pull(project: &Entity<Project>, cx: &App) -> bool {
!project.read(cx).is_via_collab()
}
fn render_remote_button(
id: impl Into<SharedString>,
branch: &Branch,
keybinding_target: Option<FocusHandle>,
show_fetch_button: bool,
) -> Option<impl IntoElement> {
let id = id.into();
let upstream = branch.upstream.as_ref();
match upstream {
Some(Upstream {
tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
..
}) => match (*ahead, *behind) {
(0, 0) if show_fetch_button => {
Some(remote_button::render_fetch_button(keybinding_target, id))
}
(0, 0) => None,
(ahead, 0) => Some(remote_button::render_push_button(
keybinding_target.clone(),
id,
ahead,
)),
(ahead, behind) => Some(remote_button::render_pull_button(
keybinding_target.clone(),
id,
ahead,
behind,
)),
},
Some(Upstream {
tracking: UpstreamTracking::Gone,
..
}) => Some(remote_button::render_republish_button(
keybinding_target,
id,
)),
None => Some(remote_button::render_publish_button(keybinding_target, id)),
}
}
mod remote_button {
use gpui::{hsla, point, Action, AnyView, BoxShadow, ClickEvent, Corner, FocusHandle};
use ui::{
div, h_flex, px, rems, ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, Clickable,
ContextMenu, ElementId, ElevationIndex, FluentBuilder, Icon, IconName, IconSize,
IntoElement, Label, LabelCommon, LabelSize, LineHeightStyle, ParentElement, PopoverMenu,
RenderOnce, SharedString, Styled, Tooltip, Window,
};
pub fn render_fetch_button(
keybinding_target: Option<FocusHandle>,
id: SharedString,
) -> SplitButton {
SplitButton::new(
id,
"Fetch",
0,
0,
Some(IconName::ArrowCircle),
move |_, window, cx| {
window.dispatch_action(Box::new(git::Fetch), cx);
},
move |window, cx| {
git_action_tooltip(
"Fetch updates from remote",
&git::Fetch,
"git fetch",
keybinding_target.clone(),
window,
cx,
)
},
)
}
pub fn render_push_button(
keybinding_target: Option<FocusHandle>,
id: SharedString,
ahead: u32,
) -> SplitButton {
SplitButton::new(
id,
"Push",
ahead as usize,
0,
None,
move |_, window, cx| {
window.dispatch_action(Box::new(git::Push), cx);
},
move |window, cx| {
git_action_tooltip(
"Push committed changes to remote",
&git::Push,
"git push",
keybinding_target.clone(),
window,
cx,
)
},
)
}
pub fn render_pull_button(
keybinding_target: Option<FocusHandle>,
id: SharedString,
ahead: u32,
behind: u32,
) -> SplitButton {
SplitButton::new(
id,
"Pull",
ahead as usize,
behind as usize,
None,
move |_, window, cx| {
window.dispatch_action(Box::new(git::Pull), cx);
},
move |window, cx| {
git_action_tooltip(
"Pull",
&git::Pull,
"git pull",
keybinding_target.clone(),
window,
cx,
)
},
)
}
pub fn render_publish_button(
keybinding_target: Option<FocusHandle>,
id: SharedString,
) -> SplitButton {
SplitButton::new(
id,
"Publish",
0,
0,
Some(IconName::ArrowUpFromLine),
move |_, window, cx| {
window.dispatch_action(Box::new(git::Push), cx);
},
move |window, cx| {
git_action_tooltip(
"Publish branch to remote",
&git::Push,
"git push --set-upstream",
keybinding_target.clone(),
window,
cx,
)
},
)
}
pub fn render_republish_button(
keybinding_target: Option<FocusHandle>,
id: SharedString,
) -> SplitButton {
SplitButton::new(
id,
"Republish",
0,
0,
Some(IconName::ArrowUpFromLine),
move |_, window, cx| {
window.dispatch_action(Box::new(git::Push), cx);
},
move |window, cx| {
git_action_tooltip(
"Re-publish branch to remote",
&git::Push,
"git push --set-upstream",
keybinding_target.clone(),
window,
cx,
)
},
)
}
fn git_action_tooltip(
label: impl Into<SharedString>,
action: &dyn Action,
command: impl Into<SharedString>,
focus_handle: Option<FocusHandle>,
window: &mut Window,
cx: &mut App,
) -> AnyView {
let label = label.into();
let command = command.into();
if let Some(handle) = focus_handle {
Tooltip::with_meta_in(
label.clone(),
Some(action),
command.clone(),
&handle,
window,
cx,
)
} else {
Tooltip::with_meta(label.clone(), Some(action), command.clone(), window, cx)
}
}
fn render_git_action_menu(id: impl Into<ElementId>) -> impl IntoElement {
PopoverMenu::new(id.into())
.trigger(
ui::ButtonLike::new_rounded_right("split-button-right")
.layer(ui::ElevationIndex::ModalSurface)
.size(ui::ButtonSize::None)
.child(
div()
.px_1()
.child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
),
)
.menu(move |window, cx| {
Some(ContextMenu::build(window, cx, |context_menu, _, _| {
context_menu
.action("Fetch", git::Fetch.boxed_clone())
.action("Pull", git::Pull.boxed_clone())
.separator()
.action("Push", git::Push.boxed_clone())
.action("Force Push", git::ForcePush.boxed_clone())
}))
})
.anchor(Corner::TopRight)
}
#[derive(IntoElement)]
pub struct SplitButton {
pub left: ButtonLike,
pub right: AnyElement,
}
impl SplitButton {
fn new(
id: impl Into<SharedString>,
left_label: impl Into<SharedString>,
ahead_count: usize,
behind_count: usize,
left_icon: Option<IconName>,
left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
) -> Self {
let id = id.into();
fn count(count: usize) -> impl IntoElement {
h_flex()
.ml_neg_px()
.h(rems(0.875))
.items_center()
.overflow_hidden()
.px_0p5()
.child(
Label::new(count.to_string())
.size(LabelSize::XSmall)
.line_height_style(LineHeightStyle::UiLabel),
)
}
let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
format!("split-button-left-{}", id).into(),
))
.layer(ui::ElevationIndex::ModalSurface)
.size(ui::ButtonSize::Compact)
.when(should_render_counts, |this| {
this.child(
h_flex()
.ml_neg_0p5()
.mr_1()
.when(behind_count > 0, |this| {
this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
.child(count(behind_count))
})
.when(ahead_count > 0, |this| {
this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
.child(count(ahead_count))
}),
)
})
.when_some(left_icon, |this, left_icon| {
this.child(
h_flex()
.ml_neg_0p5()
.mr_1()
.child(Icon::new(left_icon).size(IconSize::XSmall)),
)
})
.child(
div()
.child(Label::new(left_label).size(LabelSize::Small))
.mr_0p5(),
)
.on_click(left_on_click)
.tooltip(tooltip);
let right = render_git_action_menu(ElementId::Name(
format!("split-button-right-{}", id).into(),
))
.into_any_element();
Self { left, right }
}
}
impl RenderOnce for SplitButton {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
h_flex()
.rounded_sm()
.border_1()
.border_color(cx.theme().colors().text_muted.alpha(0.12))
.child(div().flex_grow().child(self.left))
.child(
div()
.h_full()
.w_px()
.bg(cx.theme().colors().text_muted.alpha(0.16)),
)
.child(self.right)
.bg(ElevationIndex::Surface.on_elevation_bg(cx))
.shadow(smallvec::smallvec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.16),
offset: point(px(0.), px(1.)),
blur_radius: px(0.),
spread_radius: px(0.),
}])
}
}
}

View File

@@ -10,7 +10,8 @@ use editor::{
use feature_flags::FeatureFlagViewExt;
use futures::StreamExt;
use git::{
status::FileStatus, Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext,
repository::Branch, status::FileStatus, Commit, StageAll, StageAndNext, ToggleStaged,
UnstageAll, UnstageAndNext,
};
use gpui::{
actions, Action, AnyElement, AnyView, App, AppContext as _, AsyncWindowContext, Entity,
@@ -24,27 +25,27 @@ use project::{
};
use std::any::{Any, TypeId};
use theme::ActiveTheme;
use ui::{prelude::*, vertical_divider, Tooltip};
use ui::{prelude::*, vertical_divider, KeyBinding, Tooltip};
use util::ResultExt as _;
use workspace::{
item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
searchable::SearchableItemHandle,
ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
Workspace,
CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
ToolbarItemView, Workspace,
};
actions!(git, [Diff]);
actions!(git, [Diff, Add]);
pub struct ProjectDiff {
project: Entity<Project>,
multibuffer: Entity<MultiBuffer>,
editor: Entity<Editor>,
project: Entity<Project>,
git_store: Entity<GitStore>,
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
update_needed: postage::watch::Sender<()>,
pending_scroll: Option<PathKey>,
current_branch: Option<Branch>,
_task: Task<Result<()>>,
_subscription: Subscription,
}
@@ -70,6 +71,9 @@ impl ProjectDiff {
let Some(window) = window else { return };
cx.when_flag_enabled::<feature_flags::GitUiFeatureFlag>(window, |workspace, _, _cx| {
workspace.register_action(Self::deploy);
workspace.register_action(|workspace, _: &Add, window, cx| {
Self::deploy(workspace, &Diff, window, cx);
});
});
workspace::register_serializable_item::<ProjectDiff>(cx);
@@ -138,6 +142,7 @@ impl ProjectDiff {
window,
cx,
);
diff_display_editor.disable_inline_diagnostics();
diff_display_editor.set_expand_all_diff_hunks(cx);
diff_display_editor.register_addon(GitPanelAddon {
workspace: workspace.downgrade(),
@@ -178,6 +183,7 @@ impl ProjectDiff {
multibuffer,
pending_scroll: None,
update_needed: send,
current_branch: None,
_task: worker,
_subscription: git_store_subscription,
}
@@ -443,6 +449,20 @@ impl ProjectDiff {
mut cx: AsyncWindowContext,
) -> Result<()> {
while let Some(_) = recv.next().await {
this.update(&mut cx, |this, cx| {
let new_branch =
this.git_store
.read(cx)
.active_repository()
.and_then(|active_repository| {
active_repository.read(cx).current_branch().cloned()
});
if new_branch != this.current_branch {
this.current_branch = new_branch;
cx.notify();
}
})?;
let buffers_to_load = this.update(&mut cx, |this, cx| this.load_buffers(cx))?;
for buffer_to_load in buffers_to_load {
if let Some(buffer) = buffer_to_load.await.log_err() {
@@ -641,9 +661,11 @@ impl Item for ProjectDiff {
}
impl Render for ProjectDiff {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_empty = self.multibuffer.read(cx).is_empty();
let can_push_and_pull = crate::can_push_and_pull(&self.project, cx);
div()
.track_focus(&self.focus_handle)
.key_context(if is_empty { "EmptyPane" } else { "GitDiff" })
@@ -653,7 +675,61 @@ impl Render for ProjectDiff {
.justify_center()
.size_full()
.when(is_empty, |el| {
el.child(Label::new("No uncommitted changes"))
el.child(
v_flex()
.gap_1()
.child(
h_flex()
.justify_around()
.child(Label::new("No uncommitted changes")),
)
.when(can_push_and_pull, |this_div| {
let keybinding_focus_handle = self.focus_handle(cx);
this_div.when_some(self.current_branch.as_ref(), |this_div, branch| {
let remote_button = crate::render_remote_button(
"project-diff-remote-button",
branch,
Some(keybinding_focus_handle.clone()),
false,
);
match remote_button {
Some(button) => {
this_div.child(h_flex().justify_around().child(button))
}
None => this_div.child(
h_flex()
.justify_around()
.child(Label::new("Remote up to date")),
),
}
})
})
.map(|this| {
let keybinding_focus_handle = self.focus_handle(cx).clone();
this.child(
h_flex().justify_around().mt_1().child(
Button::new("project-diff-close-button", "Close")
// .style(ButtonStyle::Transparent)
.key_binding(KeyBinding::for_action_in(
&CloseActiveItem::default(),
&keybinding_focus_handle,
window,
cx,
))
.on_click(move |_, window, cx| {
window.focus(&keybinding_focus_handle);
window.dispatch_action(
Box::new(CloseActiveItem::default()),
cx,
);
}),
),
)
}),
)
})
.when(!is_empty, |el| el.child(self.editor.clone()))
}

View File

@@ -152,7 +152,10 @@ impl GoToLine {
cx: &mut Context<Self>,
) {
match event {
editor::EditorEvent::Blurred => cx.emit(DismissEvent),
editor::EditorEvent::Blurred => {
self.prev_scroll_position.take();
cx.emit(DismissEvent)
}
editor::EditorEvent::BufferEdited { .. } => self.highlight_current_line(cx),
_ => {}
}

View File

@@ -634,7 +634,7 @@ impl Display for ColorSpace {
}
/// A background color, which can be either a solid color or a linear gradient.
#[derive(Debug, Clone, Copy, PartialEq)]
#[derive(Clone, Copy, PartialEq)]
#[repr(C)]
pub struct Background {
pub(crate) tag: BackgroundTag,
@@ -646,6 +646,28 @@ pub struct Background {
pad: u32,
}
impl std::fmt::Debug for Background {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self.tag {
BackgroundTag::Solid => write!(f, "Solid({:?})", self.solid),
BackgroundTag::LinearGradient => {
write!(
f,
"LinearGradient({}, {:?}, {:?})",
self.gradient_angle_or_pattern_height, self.colors[0], self.colors[1]
)
}
BackgroundTag::PatternSlash => {
write!(
f,
"PatternSlash({:?}, {})",
self.solid, self.gradient_angle_or_pattern_height
)
}
}
}
}
impl Eq for Background {}
impl Default for Background {
fn default() -> Self {

View File

@@ -49,7 +49,7 @@ use std::{
num::NonZeroU32,
ops::{Deref, DerefMut, Range},
path::{Path, PathBuf},
str,
rc, str,
sync::{Arc, LazyLock},
time::{Duration, Instant},
vec,
@@ -125,6 +125,7 @@ pub struct Buffer {
/// Memoize calls to has_changes_since(saved_version).
/// The contents of a cell are (self.version, has_changes) at the time of a last call.
has_unsaved_edits: Cell<(clock::Global, bool)>,
change_bits: Vec<rc::Weak<Cell<bool>>>,
_subscriptions: Vec<gpui::Subscription>,
}
@@ -978,6 +979,7 @@ impl Buffer {
completion_triggers_timestamp: Default::default(),
deferred_ops: OperationQueue::new(),
has_conflict: false,
change_bits: Default::default(),
_subscriptions: Vec::new(),
}
}
@@ -1252,6 +1254,7 @@ impl Buffer {
self.non_text_state_update_count += 1;
self.syntax_map.lock().clear(&self.text);
self.language = language;
self.was_changed();
self.reparse(cx);
cx.emit(BufferEvent::LanguageChanged);
}
@@ -1286,6 +1289,7 @@ impl Buffer {
.set((self.saved_version().clone(), false));
self.has_conflict = false;
self.saved_mtime = mtime;
self.was_changed();
cx.emit(BufferEvent::Saved);
cx.notify();
}
@@ -1381,6 +1385,7 @@ impl Buffer {
self.file = Some(new_file);
if file_changed {
self.was_changed();
self.non_text_state_update_count += 1;
if was_dirty != self.is_dirty() {
cx.emit(BufferEvent::DirtyChanged);
@@ -1522,6 +1527,7 @@ impl Buffer {
}
fn did_finish_parsing(&mut self, syntax_snapshot: SyntaxSnapshot, cx: &mut Context<Self>) {
self.was_changed();
self.non_text_state_update_count += 1;
self.syntax_map.lock().did_parse(syntax_snapshot);
self.request_autoindent(cx);
@@ -1958,6 +1964,28 @@ impl Buffer {
self.text.subscribe()
}
/// Adds a bit to the list of bits that are set when the buffer's text changes.
///
/// This allows downstream code to check if the buffer's text has changed without
/// waiting for an effect cycle, which would be required if using eents.
pub fn record_changes(&mut self, bit: rc::Weak<Cell<bool>>) {
if let Err(ix) = self
.change_bits
.binary_search_by_key(&rc::Weak::as_ptr(&bit), rc::Weak::as_ptr)
{
self.change_bits.insert(ix, bit);
}
}
fn was_changed(&mut self) {
self.change_bits.retain(|change_bit| {
change_bit.upgrade().map_or(false, |bit| {
bit.replace(true);
true
})
});
}
/// Starts a transaction, if one is not already in-progress. When undoing or
/// redoing edits, all of the edits performed within a transaction are undone
/// or redone together.
@@ -2251,12 +2279,13 @@ impl Buffer {
}
fn did_edit(&mut self, old_version: &clock::Global, was_dirty: bool, cx: &mut Context<Self>) {
self.was_changed();
if self.edits_since::<usize>(old_version).next().is_none() {
return;
}
self.reparse(cx);
cx.emit(BufferEvent::Edited);
if was_dirty != self.is_dirty() {
cx.emit(BufferEvent::DirtyChanged);
@@ -2502,7 +2531,8 @@ impl Buffer {
}
}
fn send_operation(&self, operation: Operation, is_local: bool, cx: &mut Context<Self>) {
fn send_operation(&mut self, operation: Operation, is_local: bool, cx: &mut Context<Self>) {
self.was_changed();
cx.emit(BufferEvent::Operation {
operation,
is_local,

View File

@@ -11,8 +11,8 @@ use futures::future::BoxFuture;
use futures::stream::BoxStream;
use futures::{FutureExt, StreamExt};
use gpui::{
percentage, svg, Animation, AnimationExt, AnyView, App, AsyncApp, Entity, Render, Subscription,
Task, Transformation,
percentage, svg, Action, Animation, AnimationExt, AnyView, App, AsyncApp, Entity, Render,
Subscription, Task, Transformation,
};
use language_model::{
AuthenticateError, LanguageModel, LanguageModelCompletionEvent, LanguageModelId,
@@ -337,9 +337,20 @@ impl Render for ConfigurationView {
if self.state.read(cx).is_authenticated(cx) {
const LABEL: &str = "Authorized.";
h_flex()
.gap_1()
.child(Icon::new(IconName::Check).color(Color::Success))
.child(Label::new(LABEL))
.justify_between()
.child(
h_flex()
.gap_1()
.child(Icon::new(IconName::Check).color(Color::Success))
.child(Label::new(LABEL)),
)
.child(
Button::new("sign_out", "Sign Out")
.style(ui::ButtonStyle::Filled)
.on_click(|_, window, cx| {
window.dispatch_action(copilot::SignOut.boxed_clone(), cx);
}),
)
} else {
let loading_icon = svg()
.size_8()

View File

@@ -1,6 +1,6 @@
name = "C++"
grammar = "cpp"
path_suffixes = ["cc", "hh", "cpp", "h", "hpp", "cxx", "hxx", "c++", "ipp", "inl", "cu", "cuh", "C", "H"]
path_suffixes = ["cc", "hh", "cpp", "h", "hpp", "cxx", "hxx", "c++", "ipp", "inl", "ixx", "cu", "cuh", "C", "H"]
line_comments = ["// ", "/// ", "//! "]
autoclose_before = ";:.,=}])>"
brackets = [

View File

@@ -62,8 +62,8 @@
; Literals
(this) @keyword
(super) @keyword
(this) @variable.special
(super) @variable.special
[
(null)

View File

@@ -62,8 +62,8 @@
; Literals
(this) @keyword
(super) @keyword
(this) @variable.special
(super) @variable.special
[
(null)

View File

@@ -80,8 +80,8 @@
; Literals
(this) @keyword
(super) @keyword
(this) @variable.special
(super) @variable.special
[
(null)

View File

@@ -736,7 +736,7 @@ impl Element for MarkdownElement {
markdown_end,
);
}
_ => log::error!("unsupported markdown tag {:?}", tag),
_ => log::debug!("unsupported markdown tag {:?}", tag),
}
}
MarkdownEvent::End(tag) => match tag {
@@ -853,7 +853,7 @@ impl Element for MarkdownElement {
MarkdownTagEnd::TableCell => {
builder.pop_div();
}
_ => log::error!("unsupported markdown tag end: {:?}", tag),
_ => log::debug!("unsupported markdown tag end: {:?}", tag),
},
MarkdownEvent::Text(parsed) => {
builder.push_text(parsed, range.start);

View File

@@ -31,7 +31,7 @@ use smol::future::yield_now;
use std::{
any::type_name,
borrow::Cow,
cell::{Ref, RefCell},
cell::{Cell, Ref, RefCell},
cmp, fmt,
future::Future,
io,
@@ -39,6 +39,7 @@ use std::{
mem,
ops::{Range, RangeBounds, Sub},
path::Path,
rc::Rc,
str,
sync::Arc,
time::{Duration, Instant},
@@ -76,6 +77,7 @@ pub struct MultiBuffer {
history: History,
title: Option<String>,
capability: Capability,
buffer_changed_since_sync: Rc<Cell<bool>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -121,6 +123,7 @@ pub enum Event {
Discarded,
DirtyChanged,
DiagnosticsUpdated,
BufferDiffChanged,
}
/// A diff hunk, representing a range of consequent lines in a multibuffer.
@@ -253,6 +256,7 @@ impl DiffState {
if let Some(changed_range) = changed_range.clone() {
this.buffer_diff_changed(diff, changed_range, cx)
}
cx.emit(Event::BufferDiffChanged);
}
BufferDiffEvent::LanguageChanged => this.buffer_diff_language_changed(diff, cx),
_ => {}
@@ -566,6 +570,7 @@ impl MultiBuffer {
capability,
title: None,
buffers_by_path: Default::default(),
buffer_changed_since_sync: Default::default(),
history: History {
next_transaction_id: clock::Lamport::default(),
undo_stack: Vec::new(),
@@ -585,6 +590,7 @@ impl MultiBuffer {
subscriptions: Default::default(),
singleton: false,
capability,
buffer_changed_since_sync: Default::default(),
history: History {
next_transaction_id: Default::default(),
undo_stack: Default::default(),
@@ -598,7 +604,11 @@ impl MultiBuffer {
pub fn clone(&self, new_cx: &mut Context<Self>) -> Self {
let mut buffers = HashMap::default();
let buffer_changed_since_sync = Rc::new(Cell::new(false));
for (buffer_id, buffer_state) in self.buffers.borrow().iter() {
buffer_state.buffer.update(new_cx, |buffer, _| {
buffer.record_changes(Rc::downgrade(&buffer_changed_since_sync));
});
buffers.insert(
*buffer_id,
BufferState {
@@ -627,6 +637,7 @@ impl MultiBuffer {
capability: self.capability,
history: self.history.clone(),
title: self.title.clone(),
buffer_changed_since_sync,
}
}
@@ -1726,19 +1737,25 @@ impl MultiBuffer {
self.sync(cx);
let buffer_id = buffer.read(cx).remote_id();
let buffer_snapshot = buffer.read(cx).snapshot();
let buffer_id = buffer_snapshot.remote_id();
let mut buffers = self.buffers.borrow_mut();
let buffer_state = buffers.entry(buffer_id).or_insert_with(|| BufferState {
last_version: buffer_snapshot.version().clone(),
last_non_text_state_update_count: buffer_snapshot.non_text_state_update_count(),
excerpts: Default::default(),
_subscriptions: [
cx.observe(&buffer, |_, _, cx| cx.notify()),
cx.subscribe(&buffer, Self::on_buffer_event),
],
buffer: buffer.clone(),
let buffer_state = buffers.entry(buffer_id).or_insert_with(|| {
self.buffer_changed_since_sync.replace(true);
buffer.update(cx, |buffer, _| {
buffer.record_changes(Rc::downgrade(&self.buffer_changed_since_sync));
});
BufferState {
last_version: buffer_snapshot.version().clone(),
last_non_text_state_update_count: buffer_snapshot.non_text_state_update_count(),
excerpts: Default::default(),
_subscriptions: [
cx.observe(&buffer, |_, _, cx| cx.notify()),
cx.subscribe(&buffer, Self::on_buffer_event),
],
buffer: buffer.clone(),
}
});
let mut snapshot = self.snapshot.borrow_mut();
@@ -2234,6 +2251,7 @@ impl MultiBuffer {
cx: &mut Context<Self>,
) {
self.sync(cx);
self.buffer_changed_since_sync.replace(true);
let diff = diff.read(cx);
let buffer_id = diff.buffer_id;
@@ -2712,6 +2730,11 @@ impl MultiBuffer {
}
fn sync(&self, cx: &App) {
let changed = self.buffer_changed_since_sync.replace(false);
if !changed {
return;
}
let mut snapshot = self.snapshot.borrow_mut();
let mut excerpts_to_edit = Vec::new();
let mut non_text_state_updated = false;
@@ -3523,10 +3546,7 @@ impl MultiBufferSnapshot {
) -> impl Iterator<Item = MultiBufferDiffHunk> + '_ {
let query_range = range.start.to_point(self)..range.end.to_point(self);
self.lift_buffer_metadata(query_range.clone(), move |buffer, buffer_range| {
let Some(diff) = self.diffs.get(&buffer.remote_id()) else {
log::debug!("no diff found for {:?}", buffer.remote_id());
return None;
};
let diff = self.diffs.get(&buffer.remote_id())?;
let buffer_start = buffer.anchor_before(buffer_range.start);
let buffer_end = buffer.anchor_after(buffer_range.end);
Some(

View File

@@ -3,7 +3,7 @@ use std::{path::Path, sync::Arc};
use util::ResultExt;
use collections::HashMap;
use gpui::{App, AppContext as _, Context, Entity, Task};
use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task};
use settings::Settings as _;
use worktree::WorktreeId;
@@ -19,6 +19,12 @@ pub struct ProjectEnvironment {
environment_error_messages: HashMap<WorktreeId, EnvironmentErrorMessage>,
}
pub enum ProjectEnvironmentEvent {
ErrorsUpdated,
}
impl EventEmitter<ProjectEnvironmentEvent> for ProjectEnvironment {}
impl ProjectEnvironment {
pub fn new(
worktree_store: &Entity<WorktreeStore>,
@@ -65,8 +71,13 @@ impl ProjectEnvironment {
self.environment_error_messages.iter()
}
pub(crate) fn remove_environment_error(&mut self, worktree_id: WorktreeId) {
pub(crate) fn remove_environment_error(
&mut self,
worktree_id: WorktreeId,
cx: &mut Context<Self>,
) {
self.environment_error_messages.remove(&worktree_id);
cx.emit(ProjectEnvironmentEvent::ErrorsUpdated);
}
/// Returns the project environment, if possible.
@@ -158,8 +169,9 @@ impl ProjectEnvironment {
}
if let Some(error) = error_message {
this.update(&mut cx, |this, _| {
this.update(&mut cx, |this, cx| {
this.environment_error_messages.insert(worktree_id, error);
cx.emit(ProjectEnvironmentEvent::ErrorsUpdated)
})
.log_err();
}

View File

@@ -1353,7 +1353,7 @@ impl Repository {
let to_stage = self
.repository_entry
.status()
.filter(|entry| !entry.status.is_staged().unwrap_or(false))
.filter(|entry| !entry.status.staging().is_fully_staged())
.map(|entry| entry.repo_path.clone())
.collect();
self.stage_entries(to_stage, cx)
@@ -1363,7 +1363,7 @@ impl Repository {
let to_unstage = self
.repository_entry
.status()
.filter(|entry| entry.status.is_staged().unwrap_or(true))
.filter(|entry| entry.status.staging().has_staged())
.map(|entry| entry.repo_path.clone())
.collect();
self.unstage_entries(to_unstage, cx)

View File

@@ -2,9 +2,9 @@ mod signature_help;
use crate::{
lsp_store::{LocalLspStore, LspStore},
ActionVariant, CodeAction, CoreCompletion, DocumentHighlight, Hover, HoverBlock,
CodeAction, CompletionSource, CoreCompletion, DocumentHighlight, Hover, HoverBlock,
HoverBlockKind, InlayHint, InlayHintLabel, InlayHintLabelPart, InlayHintLabelPartTooltip,
InlayHintTooltip, Location, LocationLink, MarkupContent, PrepareRenameResponse,
InlayHintTooltip, Location, LocationLink, LspAction, MarkupContent, PrepareRenameResponse,
ProjectTransaction, ResolveState,
};
use anyhow::{anyhow, Context as _, Result};
@@ -1847,7 +1847,6 @@ impl LspCommand for GetCompletions {
let mut completions = if let Some(completions) = completions {
match completions {
lsp::CompletionResponse::Array(completions) => completions,
lsp::CompletionResponse::List(mut list) => {
let items = std::mem::take(&mut list.items);
response_list = Some(list);
@@ -1855,74 +1854,19 @@ impl LspCommand for GetCompletions {
}
}
} else {
Default::default()
Vec::new()
};
let language_server_adapter = lsp_store
.update(&mut cx, |lsp_store, _| {
lsp_store.language_server_adapter_for_id(server_id)
})?
.ok_or_else(|| anyhow!("no such language server"))?;
.with_context(|| format!("no language server with id {server_id}"))?;
let item_defaults = response_list
let lsp_defaults = response_list
.as_ref()
.and_then(|list| list.item_defaults.as_ref());
if let Some(item_defaults) = item_defaults {
let default_data = item_defaults.data.as_ref();
let default_commit_characters = item_defaults.commit_characters.as_ref();
let default_edit_range = item_defaults.edit_range.as_ref();
let default_insert_text_format = item_defaults.insert_text_format.as_ref();
let default_insert_text_mode = item_defaults.insert_text_mode.as_ref();
if default_data.is_some()
|| default_commit_characters.is_some()
|| default_edit_range.is_some()
|| default_insert_text_format.is_some()
|| default_insert_text_mode.is_some()
{
for item in completions.iter_mut() {
if item.data.is_none() && default_data.is_some() {
item.data = default_data.cloned()
}
if item.commit_characters.is_none() && default_commit_characters.is_some() {
item.commit_characters = default_commit_characters.cloned()
}
if item.text_edit.is_none() {
if let Some(default_edit_range) = default_edit_range {
match default_edit_range {
CompletionListItemDefaultsEditRange::Range(range) => {
item.text_edit =
Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: *range,
new_text: item.label.clone(),
}))
}
CompletionListItemDefaultsEditRange::InsertAndReplace {
insert,
replace,
} => {
item.text_edit =
Some(lsp::CompletionTextEdit::InsertAndReplace(
lsp::InsertReplaceEdit {
new_text: item.label.clone(),
insert: *insert,
replace: *replace,
},
))
}
}
}
}
if item.insert_text_format.is_none() && default_insert_text_format.is_some() {
item.insert_text_format = default_insert_text_format.cloned()
}
if item.insert_text_mode.is_none() && default_insert_text_mode.is_some() {
item.insert_text_mode = default_insert_text_mode.cloned()
}
}
}
}
.and_then(|list| list.item_defaults.clone())
.map(Arc::new);
let mut completion_edits = Vec::new();
buffer.update(&mut cx, |buffer, _cx| {
@@ -1930,12 +1874,34 @@ impl LspCommand for GetCompletions {
let clipped_position = buffer.clip_point_utf16(Unclipped(self.position), Bias::Left);
let mut range_for_token = None;
completions.retain_mut(|lsp_completion| {
let edit = match lsp_completion.text_edit.as_ref() {
completions.retain(|lsp_completion| {
let lsp_edit = lsp_completion.text_edit.clone().or_else(|| {
let default_text_edit = lsp_defaults.as_deref()?.edit_range.as_ref()?;
match default_text_edit {
CompletionListItemDefaultsEditRange::Range(range) => {
Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: *range,
new_text: lsp_completion.label.clone(),
}))
}
CompletionListItemDefaultsEditRange::InsertAndReplace {
insert,
replace,
} => Some(lsp::CompletionTextEdit::InsertAndReplace(
lsp::InsertReplaceEdit {
new_text: lsp_completion.label.clone(),
insert: *insert,
replace: *replace,
},
)),
}
});
let edit = match lsp_edit {
// If the language server provides a range to overwrite, then
// check that the range is valid.
Some(completion_text_edit) => {
match parse_completion_text_edit(completion_text_edit, &snapshot) {
match parse_completion_text_edit(&completion_text_edit, &snapshot) {
Some(edit) => edit,
None => return false,
}
@@ -1949,14 +1915,15 @@ impl LspCommand for GetCompletions {
return false;
}
let default_edit_range = response_list
.as_ref()
.and_then(|list| list.item_defaults.as_ref())
.and_then(|defaults| defaults.edit_range.as_ref())
.and_then(|range| match range {
CompletionListItemDefaultsEditRange::Range(r) => Some(r),
_ => None,
});
let default_edit_range = lsp_defaults.as_ref().and_then(|lsp_defaults| {
lsp_defaults
.edit_range
.as_ref()
.and_then(|range| match range {
CompletionListItemDefaultsEditRange::Range(r) => Some(r),
_ => None,
})
});
let range = if let Some(range) = default_edit_range {
let range = range_from_lsp(*range);
@@ -2006,14 +1973,27 @@ impl LspCommand for GetCompletions {
Ok(completions
.into_iter()
.zip(completion_edits)
.map(|(lsp_completion, (old_range, mut new_text))| {
.map(|(mut lsp_completion, (old_range, mut new_text))| {
LineEnding::normalize(&mut new_text);
if lsp_completion.data.is_none() {
if let Some(default_data) = lsp_defaults
.as_ref()
.and_then(|item_defaults| item_defaults.data.clone())
{
// Servers (e.g. JDTLS) prefer unchanged completions, when resolving the items later,
// so we do not insert the defaults here, but `data` is needed for resolving, so this is an exception.
lsp_completion.data = Some(default_data);
}
}
CoreCompletion {
old_range,
new_text,
server_id,
lsp_completion,
resolved: false,
source: CompletionSource::Lsp {
server_id,
lsp_completion: Box::new(lsp_completion),
lsp_defaults: lsp_defaults.clone(),
resolved: false,
},
}
})
.collect())
@@ -2256,11 +2236,11 @@ impl LspCommand for GetCodeActions {
return None;
}
}
ActionVariant::Action(Box::new(lsp_action))
LspAction::Action(Box::new(lsp_action))
}
lsp::CodeActionOrCommand::Command(command) => {
if available_commands.contains(&command.command) {
ActionVariant::Command(command)
LspAction::Command(command)
} else {
return None;
}

View File

@@ -14,8 +14,8 @@ use crate::{
toolchain_store::{EmptyToolchainStore, ToolchainStoreEvent},
worktree_store::{WorktreeStore, WorktreeStoreEvent},
yarn::YarnPathStore,
ActionVariant, CodeAction, Completion, CoreCompletion, Hover, InlayHint, ProjectItem as _,
ProjectPath, ProjectTransaction, ResolveState, Symbol, ToolchainStore,
CodeAction, Completion, CompletionSource, CoreCompletion, Hover, InlayHint, LspAction,
ProjectItem as _, ProjectPath, ProjectTransaction, ResolveState, Symbol, ToolchainStore,
};
use anyhow::{anyhow, Context as _, Result};
use async_trait::async_trait;
@@ -49,10 +49,9 @@ use lsp::{
notification::DidRenameFiles, CodeActionKind, CompletionContext, DiagnosticSeverity,
DiagnosticTag, DidChangeWatchedFilesRegistrationOptions, Edit, FileOperationFilter,
FileOperationPatternKind, FileOperationRegistrationOptions, FileRename, FileSystemWatcher,
InsertTextFormat, LanguageServer, LanguageServerBinary, LanguageServerBinaryOptions,
LanguageServerId, LanguageServerName, LspRequestFuture, MessageActionItem, MessageType, OneOf,
RenameFilesParams, SymbolKind, TextEdit, WillRenameFiles, WorkDoneProgressCancelParams,
WorkspaceFolder,
LanguageServer, LanguageServerBinary, LanguageServerBinaryOptions, LanguageServerId,
LanguageServerName, LspRequestFuture, MessageActionItem, MessageType, OneOf, RenameFilesParams,
SymbolKind, TextEdit, WillRenameFiles, WorkDoneProgressCancelParams, WorkspaceFolder,
};
use node_runtime::read_package_installed_version;
use parking_lot::Mutex;
@@ -70,6 +69,7 @@ use smol::channel::Sender;
use snippet::Snippet;
use std::{
any::Any,
borrow::Cow,
cell::RefCell,
cmp::Ordering,
convert::TryInto,
@@ -1629,7 +1629,7 @@ impl LocalLspStore {
action: &mut CodeAction,
) -> anyhow::Result<()> {
match &mut action.lsp_action {
ActionVariant::Action(lsp_action) => {
LspAction::Action(lsp_action) => {
if GetCodeActions::can_resolve_actions(&lang_server.capabilities())
&& lsp_action.data.is_some()
&& (lsp_action.command.is_none() || lsp_action.edit.is_none())
@@ -1641,7 +1641,7 @@ impl LocalLspStore {
);
}
}
ActionVariant::Command(_) => {}
LspAction::Command(_) => {}
}
anyhow::Ok(())
}
@@ -4401,26 +4401,33 @@ impl LspStore {
let mut did_resolve = false;
if let Some((client, project_id)) = client {
for completion_index in completion_indices {
let server_id = completions.borrow()[completion_index].server_id;
if Self::resolve_completion_remote(
project_id,
server_id,
buffer_id,
completions.clone(),
completion_index,
client.clone(),
)
.await
.log_err()
.is_some()
{
did_resolve = true;
let server_id = {
let completion = &completions.borrow()[completion_index];
completion.source.server_id()
};
if let Some(server_id) = server_id {
if Self::resolve_completion_remote(
project_id,
server_id,
buffer_id,
completions.clone(),
completion_index,
client.clone(),
)
.await
.log_err()
.is_some()
{
did_resolve = true;
}
}
}
} else {
for completion_index in completion_indices {
let server_id = completions.borrow()[completion_index].server_id;
let Some(server_id) = completions.borrow()[completion_index].source.server_id()
else {
continue;
};
let server_and_adapter = this
.read_with(&cx, |lsp_store, _| {
@@ -4468,6 +4475,7 @@ impl LspStore {
completions: Rc<RefCell<Box<[Completion]>>>,
completion_index: usize,
) -> Result<()> {
let server_id = server.server_id();
let can_resolve = server
.capabilities()
.completion_provider
@@ -4480,14 +4488,28 @@ impl LspStore {
let request = {
let completion = &completions.borrow()[completion_index];
if completion.resolved {
return Ok(());
match &completion.source {
CompletionSource::Lsp {
lsp_completion,
resolved,
server_id: completion_server_id,
..
} => {
if *resolved {
return Ok(());
}
anyhow::ensure!(
server_id == *completion_server_id,
"server_id mismatch, querying completion resolve for {server_id} but completion server id is {completion_server_id}"
);
server.request::<lsp::request::ResolveCompletionItem>(*lsp_completion.clone())
}
CompletionSource::Custom => return Ok(()),
}
server.request::<lsp::request::ResolveCompletionItem>(completion.lsp_completion.clone())
};
let completion_item = request.await?;
let resolved_completion = request.await?;
if let Some(text_edit) = completion_item.text_edit.as_ref() {
if let Some(text_edit) = resolved_completion.text_edit.as_ref() {
// Technically we don't have to parse the whole `text_edit`, since the only
// language server we currently use that does update `text_edit` in `completionItem/resolve`
// is `typescript-language-server` and they only update `text_edit.new_text`.
@@ -4504,19 +4526,26 @@ impl LspStore {
completion.old_range = old_range;
}
}
if completion_item.insert_text_format == Some(InsertTextFormat::SNIPPET) {
// vtsls might change the type of completion after resolution.
let mut completions = completions.borrow_mut();
let completion = &mut completions[completion_index];
if completion_item.insert_text_format != completion.lsp_completion.insert_text_format {
completion.lsp_completion.insert_text_format = completion_item.insert_text_format;
}
}
let mut completions = completions.borrow_mut();
let completion = &mut completions[completion_index];
completion.lsp_completion = completion_item;
completion.resolved = true;
if let CompletionSource::Lsp {
lsp_completion,
resolved,
server_id: completion_server_id,
..
} = &mut completion.source
{
if *resolved {
return Ok(());
}
anyhow::ensure!(
server_id == *completion_server_id,
"server_id mismatch, applying completion resolve for {server_id} but completion server id is {completion_server_id}"
);
*lsp_completion = Box::new(resolved_completion);
*resolved = true;
}
Ok(())
}
@@ -4527,9 +4556,13 @@ impl LspStore {
completion_index: usize,
) -> Result<()> {
let completion_item = completions.borrow()[completion_index]
.lsp_completion
.clone();
if let Some(lsp_documentation) = completion_item.documentation.clone() {
.source
.lsp_completion(true)
.map(Cow::into_owned);
if let Some(lsp_documentation) = completion_item
.as_ref()
.and_then(|completion_item| completion_item.documentation.clone())
{
let mut completions = completions.borrow_mut();
let completion = &mut completions[completion_index];
completion.documentation = Some(lsp_documentation.into());
@@ -4539,25 +4572,33 @@ impl LspStore {
completion.documentation = Some(CompletionDocumentation::Undocumented);
}
// NB: Zed does not have `details` inside the completion resolve capabilities, but certain language servers violate the spec and do not return `details` immediately, e.g. https://github.com/yioneko/vtsls/issues/213
// So we have to update the label here anyway...
let language = snapshot.language();
let mut new_label = match language {
Some(language) => {
adapter
.labels_for_completions(&[completion_item.clone()], language)
.await?
let mut new_label = match completion_item {
Some(completion_item) => {
// NB: Zed does not have `details` inside the completion resolve capabilities, but certain language servers violate the spec and do not return `details` immediately, e.g. https://github.com/yioneko/vtsls/issues/213
// So we have to update the label here anyway...
let language = snapshot.language();
match language {
Some(language) => {
adapter
.labels_for_completions(&[completion_item.clone()], language)
.await?
}
None => Vec::new(),
}
.pop()
.flatten()
.unwrap_or_else(|| {
CodeLabel::fallback_for_completion(
&completion_item,
language.map(|language| language.as_ref()),
)
})
}
None => Vec::new(),
}
.pop()
.flatten()
.unwrap_or_else(|| {
CodeLabel::fallback_for_completion(
&completion_item,
language.map(|language| language.as_ref()),
)
});
None => CodeLabel::plain(
completions.borrow()[completion_index].new_text.clone(),
None,
),
};
ensure_uniform_list_compatible_label(&mut new_label);
let mut completions = completions.borrow_mut();
@@ -4589,12 +4630,24 @@ impl LspStore {
) -> Result<()> {
let lsp_completion = {
let completion = &completions.borrow()[completion_index];
if completion.resolved {
return Ok(());
match &completion.source {
CompletionSource::Lsp {
lsp_completion,
resolved,
server_id: completion_server_id,
..
} => {
anyhow::ensure!(
server_id == *completion_server_id,
"remote server_id mismatch, querying completion resolve for {server_id} but completion server id is {completion_server_id}"
);
if *resolved {
return Ok(());
}
serde_json::to_string(lsp_completion).unwrap().into_bytes()
}
CompletionSource::Custom => return Ok(()),
}
serde_json::to_string(&completion.lsp_completion)
.unwrap()
.into_bytes()
};
let request = proto::ResolveCompletionDocumentation {
project_id,
@@ -4607,7 +4660,7 @@ impl LspStore {
.request(request)
.await
.context("completion documentation resolve proto request")?;
let lsp_completion = serde_json::from_slice(&response.lsp_completion)?;
let resolved_lsp_completion = serde_json::from_slice(&response.lsp_completion)?;
let documentation = if response.documentation.is_empty() {
CompletionDocumentation::Undocumented
@@ -4622,8 +4675,23 @@ impl LspStore {
let mut completions = completions.borrow_mut();
let completion = &mut completions[completion_index];
completion.documentation = Some(documentation);
completion.lsp_completion = lsp_completion;
completion.resolved = true;
if let CompletionSource::Lsp {
lsp_completion,
resolved,
server_id: completion_server_id,
lsp_defaults: _,
} = &mut completion.source
{
if *resolved {
return Ok(());
}
anyhow::ensure!(
server_id == *completion_server_id,
"remote server_id mismatch, applying completion resolve for {server_id} but completion server id is {completion_server_id}"
);
*lsp_completion = Box::new(resolved_lsp_completion);
*resolved = true;
}
let old_range = response
.old_start
@@ -4659,17 +4727,12 @@ impl LspStore {
completion: Some(Self::serialize_completion(&CoreCompletion {
old_range: completion.old_range,
new_text: completion.new_text,
server_id: completion.server_id,
lsp_completion: completion.lsp_completion,
resolved: completion.resolved,
source: completion.source,
})),
}
};
let response = client.request(request).await?;
completions.borrow_mut()[completion_index].resolved = true;
if let Some(transaction) = response.transaction {
if let Some(transaction) = client.request(request).await?.transaction {
let transaction = language::proto::deserialize_transaction(transaction)?;
buffer_handle
.update(&mut cx, |buffer, _| {
@@ -4687,8 +4750,9 @@ impl LspStore {
}
})
} else {
let server_id = completions.borrow()[completion_index].server_id;
let Some(server) = buffer_handle.update(cx, |buffer, cx| {
let completion = &completions.borrow()[completion_index];
let server_id = completion.source.server_id()?;
Some(
self.language_server_for_local_buffer(buffer, server_id, cx)?
.1
@@ -4709,7 +4773,11 @@ impl LspStore {
.await
.context("resolving completion")?;
let completion = completions.borrow()[completion_index].clone();
let additional_text_edits = completion.lsp_completion.additional_text_edits;
let additional_text_edits = completion
.source
.lsp_completion(true)
.as_ref()
.and_then(|lsp_completion| lsp_completion.additional_text_edits.clone());
if let Some(edits) = additional_text_edits {
let edits = this
.update(&mut cx, |this, cx| {
@@ -6667,33 +6735,19 @@ impl LspStore {
cx,
);
}
lsp::WorkDoneProgress::Report(report) => {
if self.on_lsp_work_progress(
language_server_id,
token.clone(),
LanguageServerProgress {
title: None,
is_disk_based_diagnostics_progress,
is_cancellable: report.cancellable.unwrap_or(false),
message: report.message.clone(),
percentage: report.percentage.map(|p| p as usize),
last_update_at: cx.background_executor().now(),
},
cx,
) {
cx.emit(LspStoreEvent::LanguageServerUpdate {
language_server_id,
message: proto::update_language_server::Variant::WorkProgress(
proto::LspWorkProgress {
token,
message: report.message,
percentage: report.percentage,
is_cancellable: report.cancellable,
},
),
})
}
}
lsp::WorkDoneProgress::Report(report) => self.on_lsp_work_progress(
language_server_id,
token,
LanguageServerProgress {
title: None,
is_disk_based_diagnostics_progress,
is_cancellable: report.cancellable.unwrap_or(false),
message: report.message,
percentage: report.percentage.map(|p| p as usize),
last_update_at: cx.background_executor().now(),
},
cx,
),
lsp::WorkDoneProgress::End(_) => {
language_server_status.progress_tokens.remove(&token);
self.on_lsp_work_end(language_server_id, token.clone(), cx);
@@ -6733,13 +6787,13 @@ impl LspStore {
token: String,
progress: LanguageServerProgress,
cx: &mut Context<Self>,
) -> bool {
) {
let mut did_update = false;
if let Some(status) = self.language_server_statuses.get_mut(&language_server_id) {
match status.pending_work.entry(token) {
match status.pending_work.entry(token.clone()) {
btree_map::Entry::Vacant(entry) => {
entry.insert(progress);
cx.notify();
return true;
entry.insert(progress.clone());
did_update = true;
}
btree_map::Entry::Occupied(mut entry) => {
let entry = entry.get_mut();
@@ -6748,7 +6802,7 @@ impl LspStore {
{
entry.last_update_at = progress.last_update_at;
if progress.message.is_some() {
entry.message = progress.message;
entry.message = progress.message.clone();
}
if progress.percentage.is_some() {
entry.percentage = progress.percentage;
@@ -6756,14 +6810,25 @@ impl LspStore {
if progress.is_cancellable != entry.is_cancellable {
entry.is_cancellable = progress.is_cancellable;
}
cx.notify();
return true;
did_update = true;
}
}
}
}
false
if did_update {
cx.emit(LspStoreEvent::LanguageServerUpdate {
language_server_id,
message: proto::update_language_server::Variant::WorkProgress(
proto::LspWorkProgress {
token,
message: progress.message,
percentage: progress.percentage.map(|p| p as u32),
is_cancellable: Some(progress.is_cancellable),
},
),
})
}
}
fn on_lsp_work_end(
@@ -7142,8 +7207,7 @@ impl LspStore {
Rc::new(RefCell::new(Box::new([Completion {
old_range: completion.old_range,
new_text: completion.new_text,
lsp_completion: completion.lsp_completion,
server_id: completion.server_id,
source: completion.source,
documentation: None,
label: CodeLabel {
text: Default::default(),
@@ -7151,7 +7215,6 @@ impl LspStore {
filter_range: Default::default(),
},
confirm: None,
resolved: completion.resolved,
}]))),
0,
false,
@@ -8115,13 +8178,39 @@ impl LspStore {
}
pub(crate) fn serialize_completion(completion: &CoreCompletion) -> proto::Completion {
let (source, server_id, lsp_completion, lsp_defaults, resolved) = match &completion.source {
CompletionSource::Lsp {
server_id,
lsp_completion,
lsp_defaults,
resolved,
} => (
proto::completion::Source::Lsp as i32,
server_id.0 as u64,
serde_json::to_vec(lsp_completion).unwrap(),
lsp_defaults
.as_deref()
.map(|lsp_defaults| serde_json::to_vec(lsp_defaults).unwrap()),
*resolved,
),
CompletionSource::Custom => (
proto::completion::Source::Custom as i32,
0,
Vec::new(),
None,
true,
),
};
proto::Completion {
old_start: Some(serialize_anchor(&completion.old_range.start)),
old_end: Some(serialize_anchor(&completion.old_range.end)),
new_text: completion.new_text.clone(),
server_id: completion.server_id.0 as u64,
lsp_completion: serde_json::to_vec(&completion.lsp_completion).unwrap(),
resolved: completion.resolved,
server_id,
lsp_completion,
lsp_defaults,
resolved,
source,
}
}
@@ -8134,24 +8223,33 @@ impl LspStore {
.old_end
.and_then(deserialize_anchor)
.ok_or_else(|| anyhow!("invalid old end"))?;
let lsp_completion = serde_json::from_slice(&completion.lsp_completion)?;
Ok(CoreCompletion {
old_range: old_start..old_end,
new_text: completion.new_text,
server_id: LanguageServerId(completion.server_id as usize),
lsp_completion,
resolved: completion.resolved,
source: match proto::completion::Source::from_i32(completion.source) {
Some(proto::completion::Source::Custom) => CompletionSource::Custom,
Some(proto::completion::Source::Lsp) => CompletionSource::Lsp {
server_id: LanguageServerId::from_proto(completion.server_id),
lsp_completion: serde_json::from_slice(&completion.lsp_completion)?,
lsp_defaults: completion
.lsp_defaults
.as_deref()
.map(serde_json::from_slice)
.transpose()?,
resolved: completion.resolved,
},
_ => anyhow::bail!("Unexpected completion source {}", completion.source),
},
})
}
pub(crate) fn serialize_code_action(action: &CodeAction) -> proto::CodeAction {
let (kind, lsp_action) = match &action.lsp_action {
ActionVariant::Action(code_action) => (
LspAction::Action(code_action) => (
proto::code_action::Kind::Action as i32,
serde_json::to_vec(code_action).unwrap(),
),
ActionVariant::Command(command) => (
LspAction::Command(command) => (
proto::code_action::Kind::Command as i32,
serde_json::to_vec(command).unwrap(),
),
@@ -8177,10 +8275,10 @@ impl LspStore {
.ok_or_else(|| anyhow!("invalid end"))?;
let lsp_action = match proto::code_action::Kind::from_i32(action.kind) {
Some(proto::code_action::Kind::Action) => {
ActionVariant::Action(serde_json::from_slice(&action.lsp_action)?)
LspAction::Action(serde_json::from_slice(&action.lsp_action)?)
}
Some(proto::code_action::Kind::Command) => {
ActionVariant::Command(serde_json::from_slice(&action.lsp_action)?)
LspAction::Command(serde_json::from_slice(&action.lsp_action)?)
}
None => anyhow::bail!("Unknown action kind {}", action.kind),
};
@@ -8218,17 +8316,23 @@ fn remove_empty_hover_blocks(mut hover: Hover) -> Option<Hover> {
}
async fn populate_labels_for_completions(
mut new_completions: Vec<CoreCompletion>,
new_completions: Vec<CoreCompletion>,
language: Option<Arc<Language>>,
lsp_adapter: Option<Arc<CachedLspAdapter>>,
completions: &mut Vec<Completion>,
) {
let lsp_completions = new_completions
.iter_mut()
.map(|completion| mem::take(&mut completion.lsp_completion))
.iter()
.filter_map(|new_completion| {
if let Some(lsp_completion) = new_completion.source.lsp_completion(true) {
Some(lsp_completion.into_owned())
} else {
None
}
})
.collect::<Vec<_>>();
let labels = if let Some((language, lsp_adapter)) = language.as_ref().zip(lsp_adapter) {
let mut labels = if let Some((language, lsp_adapter)) = language.as_ref().zip(lsp_adapter) {
lsp_adapter
.labels_for_completions(&lsp_completions, language)
.await
@@ -8236,34 +8340,45 @@ async fn populate_labels_for_completions(
.unwrap_or_default()
} else {
Vec::new()
};
}
.into_iter()
.fuse();
for ((completion, lsp_completion), label) in new_completions
.into_iter()
.zip(lsp_completions)
.zip(labels.into_iter().chain(iter::repeat(None)))
{
let documentation = if let Some(docs) = lsp_completion.documentation.clone() {
Some(docs.into())
} else {
None
};
for completion in new_completions {
match completion.source.lsp_completion(true) {
Some(lsp_completion) => {
let documentation = if let Some(docs) = lsp_completion.documentation.clone() {
Some(docs.into())
} else {
None
};
let mut label = label.unwrap_or_else(|| {
CodeLabel::fallback_for_completion(&lsp_completion, language.as_deref())
});
ensure_uniform_list_compatible_label(&mut label);
completions.push(Completion {
old_range: completion.old_range,
new_text: completion.new_text,
label,
server_id: completion.server_id,
documentation,
lsp_completion,
confirm: None,
resolved: false,
})
let mut label = labels.next().flatten().unwrap_or_else(|| {
CodeLabel::fallback_for_completion(&lsp_completion, language.as_deref())
});
ensure_uniform_list_compatible_label(&mut label);
completions.push(Completion {
label,
documentation,
old_range: completion.old_range,
new_text: completion.new_text,
source: completion.source,
confirm: None,
});
}
None => {
let mut label = CodeLabel::plain(completion.new_text.clone(), None);
ensure_uniform_list_compatible_label(&mut label);
completions.push(Completion {
label,
documentation: None,
old_range: completion.old_range,
new_text: completion.new_text,
source: completion.source,
confirm: None,
});
}
}
}
}

View File

@@ -22,7 +22,7 @@ mod project_tests;
mod direnv;
mod environment;
use buffer_diff::BufferDiff;
pub use environment::EnvironmentErrorMessage;
pub use environment::{EnvironmentErrorMessage, ProjectEnvironmentEvent};
use git::Repository;
pub mod search_history;
mod yarn;
@@ -364,14 +364,10 @@ pub struct Completion {
pub new_text: String,
/// A label for this completion that is shown in the menu.
pub label: CodeLabel,
/// The id of the language server that produced this completion.
pub server_id: LanguageServerId,
/// The documentation for this completion.
pub documentation: Option<CompletionDocumentation>,
/// The raw completion provided by the language server.
pub lsp_completion: lsp::CompletionItem,
/// Whether this completion has been resolved, to ensure it happens once per completion.
pub resolved: bool,
/// Completion data source which it was constructed from.
pub source: CompletionSource,
/// An optional callback to invoke when this completion is confirmed.
/// Returns, whether new completions should be retriggered after the current one.
/// If `true` is returned, the editor will show a new completion menu after this completion is confirmed.
@@ -379,15 +375,114 @@ pub struct Completion {
pub confirm: Option<Arc<dyn Send + Sync + Fn(CompletionIntent, &mut Window, &mut App) -> bool>>,
}
#[derive(Debug, Clone)]
pub enum CompletionSource {
Lsp {
/// The id of the language server that produced this completion.
server_id: LanguageServerId,
/// The raw completion provided by the language server.
lsp_completion: Box<lsp::CompletionItem>,
/// A set of defaults for this completion item.
lsp_defaults: Option<Arc<lsp::CompletionListItemDefaults>>,
/// Whether this completion has been resolved, to ensure it happens once per completion.
resolved: bool,
},
Custom,
}
impl CompletionSource {
pub fn server_id(&self) -> Option<LanguageServerId> {
if let CompletionSource::Lsp { server_id, .. } = self {
Some(*server_id)
} else {
None
}
}
pub fn lsp_completion(&self, apply_defaults: bool) -> Option<Cow<lsp::CompletionItem>> {
if let Self::Lsp {
lsp_completion,
lsp_defaults,
..
} = self
{
if apply_defaults {
if let Some(lsp_defaults) = lsp_defaults {
let mut completion_with_defaults = *lsp_completion.clone();
let default_commit_characters = lsp_defaults.commit_characters.as_ref();
let default_edit_range = lsp_defaults.edit_range.as_ref();
let default_insert_text_format = lsp_defaults.insert_text_format.as_ref();
let default_insert_text_mode = lsp_defaults.insert_text_mode.as_ref();
if default_commit_characters.is_some()
|| default_edit_range.is_some()
|| default_insert_text_format.is_some()
|| default_insert_text_mode.is_some()
{
if completion_with_defaults.commit_characters.is_none()
&& default_commit_characters.is_some()
{
completion_with_defaults.commit_characters =
default_commit_characters.cloned()
}
if completion_with_defaults.text_edit.is_none() {
match default_edit_range {
Some(lsp::CompletionListItemDefaultsEditRange::Range(range)) => {
completion_with_defaults.text_edit =
Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: *range,
new_text: completion_with_defaults.label.clone(),
}))
}
Some(
lsp::CompletionListItemDefaultsEditRange::InsertAndReplace {
insert,
replace,
},
) => {
completion_with_defaults.text_edit =
Some(lsp::CompletionTextEdit::InsertAndReplace(
lsp::InsertReplaceEdit {
new_text: completion_with_defaults.label.clone(),
insert: *insert,
replace: *replace,
},
))
}
None => {}
}
}
if completion_with_defaults.insert_text_format.is_none()
&& default_insert_text_format.is_some()
{
completion_with_defaults.insert_text_format =
default_insert_text_format.cloned()
}
if completion_with_defaults.insert_text_mode.is_none()
&& default_insert_text_mode.is_some()
{
completion_with_defaults.insert_text_mode =
default_insert_text_mode.cloned()
}
}
return Some(Cow::Owned(completion_with_defaults));
}
}
Some(Cow::Borrowed(lsp_completion))
} else {
None
}
}
}
impl std::fmt::Debug for Completion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Completion")
.field("old_range", &self.old_range)
.field("new_text", &self.new_text)
.field("label", &self.label)
.field("server_id", &self.server_id)
.field("documentation", &self.documentation)
.field("lsp_completion", &self.lsp_completion)
.field("source", &self.source)
.finish()
}
}
@@ -397,9 +492,7 @@ impl std::fmt::Debug for Completion {
pub(crate) struct CoreCompletion {
old_range: Range<Anchor>,
new_text: String,
server_id: LanguageServerId,
lsp_completion: lsp::CompletionItem,
resolved: bool,
source: CompletionSource,
}
/// A code action provided by a language server.
@@ -411,12 +504,12 @@ pub struct CodeAction {
pub range: Range<Anchor>,
/// The raw code action provided by the language server.
/// Can be either an action or a command.
pub lsp_action: ActionVariant,
pub lsp_action: LspAction,
}
/// An action sent back by a language server.
#[derive(Clone, Debug)]
pub enum ActionVariant {
pub enum LspAction {
/// An action with the full data, may have a command or may not.
/// May require resolving.
Action(Box<lsp::CodeAction>),
@@ -424,7 +517,7 @@ pub enum ActionVariant {
Command(lsp::Command),
}
impl ActionVariant {
impl LspAction {
pub fn title(&self) -> &str {
match self {
Self::Action(action) => &action.title,
@@ -886,7 +979,6 @@ impl Project {
});
cx.subscribe(&ssh, Self::on_ssh_event).detach();
cx.observe(&ssh, |_, _, cx| cx.notify()).detach();
let this = Self {
buffer_ordered_messages_tx: tx,
@@ -1371,9 +1463,9 @@ impl Project {
self.environment.read(cx).environment_errors()
}
pub fn remove_environment_error(&mut self, cx: &mut Context<Self>, worktree_id: WorktreeId) {
self.environment.update(cx, |environment, _| {
environment.remove_environment_error(worktree_id);
pub fn remove_environment_error(&mut self, worktree_id: WorktreeId, cx: &mut Context<Self>) {
self.environment.update(cx, |environment, cx| {
environment.remove_environment_error(worktree_id, cx);
});
}
@@ -1764,7 +1856,6 @@ impl Project {
};
cx.emit(Event::RemoteIdChanged(Some(project_id)));
cx.notify();
Ok(())
}
@@ -1780,7 +1871,6 @@ impl Project {
self.worktree_store.update(cx, |worktree_store, cx| {
worktree_store.send_project_updates(cx);
});
cx.notify();
cx.emit(Event::Reshared);
Ok(())
}
@@ -1810,13 +1900,12 @@ impl Project {
self.enqueue_buffer_ordered_message(BufferOrderedMessage::Resync)
.unwrap();
cx.emit(Event::Rejoined);
cx.notify();
Ok(())
}
pub fn unshare(&mut self, cx: &mut Context<Self>) -> Result<()> {
self.unshare_internal(cx)?;
cx.notify();
cx.emit(Event::RemoteIdChanged(None));
Ok(())
}
@@ -1860,7 +1949,6 @@ impl Project {
}
self.disconnected_from_host_internal(cx);
cx.emit(Event::DisconnectedFromHost);
cx.notify();
}
pub fn set_role(&mut self, role: proto::ChannelRole, cx: &mut Context<Self>) {
@@ -2509,15 +2597,11 @@ impl Project {
}
}
fn on_worktree_added(&mut self, worktree: &Entity<Worktree>, cx: &mut Context<Self>) {
{
let mut remotely_created_models = self.remotely_created_models.lock();
if remotely_created_models.retain_count > 0 {
remotely_created_models.worktrees.push(worktree.clone())
}
fn on_worktree_added(&mut self, worktree: &Entity<Worktree>, _: &mut Context<Self>) {
let mut remotely_created_models = self.remotely_created_models.lock();
if remotely_created_models.retain_count > 0 {
remotely_created_models.worktrees.push(worktree.clone())
}
cx.observe(worktree, |_, _, cx| cx.notify()).detach();
cx.notify();
}
fn on_worktree_released(&mut self, id_to_remove: WorktreeId, cx: &mut Context<Self>) {
@@ -2529,8 +2613,6 @@ impl Project {
})
.log_err();
}
cx.notify();
}
fn on_buffer_event(
@@ -3804,7 +3886,6 @@ impl Project {
cx.emit(Event::CollaboratorJoined(collaborator.peer_id));
this.collaborators
.insert(collaborator.peer_id, collaborator);
cx.notify();
})?;
Ok(())
@@ -3848,7 +3929,6 @@ impl Project {
old_peer_id,
new_peer_id,
});
cx.notify();
Ok(())
})?
}
@@ -3876,7 +3956,6 @@ impl Project {
});
cx.emit(Event::CollaboratorLeft(peer_id));
cx.notify();
Ok(())
})?
}
@@ -4292,7 +4371,6 @@ impl Project {
worktrees: Vec<proto::WorktreeMetadata>,
cx: &mut Context<Project>,
) -> Result<()> {
cx.notify();
self.worktree_store.update(cx, |worktree_store, cx| {
worktree_store.set_worktrees_from_proto(worktrees, self.replica_id(), cx)
})
@@ -4620,27 +4698,41 @@ impl Completion {
/// A key that can be used to sort completions when displaying
/// them to the user.
pub fn sort_key(&self) -> (usize, &str) {
let kind_key = match self.lsp_completion.kind {
Some(lsp::CompletionItemKind::KEYWORD) => 0,
Some(lsp::CompletionItemKind::VARIABLE) => 1,
_ => 2,
};
const DEFAULT_KIND_KEY: usize = 2;
let kind_key = self
.source
// `lsp::CompletionListItemDefaults` has no `kind` field
.lsp_completion(false)
.and_then(|lsp_completion| lsp_completion.kind)
.and_then(|lsp_completion_kind| match lsp_completion_kind {
lsp::CompletionItemKind::KEYWORD => Some(0),
lsp::CompletionItemKind::VARIABLE => Some(1),
_ => None,
})
.unwrap_or(DEFAULT_KIND_KEY);
(kind_key, &self.label.text[self.label.filter_range.clone()])
}
/// Whether this completion is a snippet.
pub fn is_snippet(&self) -> bool {
self.lsp_completion.insert_text_format == Some(lsp::InsertTextFormat::SNIPPET)
self.source
// `lsp::CompletionListItemDefaults` has `insert_text_format` field
.lsp_completion(true)
.map_or(false, |lsp_completion| {
lsp_completion.insert_text_format == Some(lsp::InsertTextFormat::SNIPPET)
})
}
/// Returns the corresponding color for this completion.
///
/// Will return `None` if this completion's kind is not [`CompletionItemKind::COLOR`].
pub fn color(&self) -> Option<Hsla> {
match self.lsp_completion.kind {
Some(CompletionItemKind::COLOR) => color_extractor::extract_color(&self.lsp_completion),
_ => None,
// `lsp::CompletionListItemDefaults` has no `kind` field
let lsp_completion = self.source.lsp_completion(false)?;
if lsp_completion.kind? == CompletionItemKind::COLOR {
return color_extractor::extract_color(&lsp_completion);
}
None
}
}

View File

@@ -212,6 +212,15 @@ pub enum GitHunkStyleSetting {
Transparent,
/// Show unstaged hunks with a pattern background
Pattern,
/// Show unstaged hunks with a border background
Border,
/// Show staged hunks with a pattern background
StagedPattern,
/// Show staged hunks with a pattern background
StagedTransparent,
/// Show staged hunks with a pattern background
StagedBorder,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]

View File

@@ -1,6 +1,6 @@
syntax = "proto3";
package zed.messages;
import "google/protobuf/wrappers.proto";
// Looking for a number? Search "// current max"
@@ -999,6 +999,13 @@ message Completion {
uint64 server_id = 4;
bytes lsp_completion = 5;
bool resolved = 6;
Source source = 7;
optional bytes lsp_defaults = 8;
enum Source {
Custom = 0;
Lsp = 1;
}
}
message GetCodeActions {

View File

@@ -1,27 +0,0 @@
[package]
name = "scripting_tool"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/scripting_tool.rs"
doctest = false
[dependencies]
anyhow.workspace = true
assistant_tool.workspace = true
gpui.workspace = true
mlua.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
workspace.workspace = true
regex.workspace = true
[dev-dependencies]
editor = { workspace = true, features = ["test-support"] }

View File

@@ -1,785 +0,0 @@
mod streaming_json;
mod streaming_lua;
use anyhow::anyhow;
use assistant_tool::{Tool, ToolRegistry};
use gpui::{App, AppContext as _, Task, WeakEntity, Window};
use mlua::{Function, Lua, MultiValue, Result, UserData, UserDataMethods};
use schemars::JsonSchema;
use serde::Deserialize;
use std::{
cell::RefCell,
collections::HashMap,
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
};
use workspace::Workspace;
pub fn init(cx: &App) {
let registry = ToolRegistry::global(cx);
registry.register_tool(ScriptingTool);
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ScriptingToolInput {
lua_script: String,
}
struct ScriptingTool;
impl Tool for ScriptingTool {
fn name(&self) -> String {
"lua-interpreter".into()
}
fn description(&self) -> String {
r#"You can write a Lua script and I'll run it on my code base and tell you what its output was,
including both stdout as well as the git diff of changes it made to the filesystem. That way,
you can get more information about the code base, or make changes to the code base directly.
The lua script will have access to `io` and it will run with the current working directory being in
the root of the code base, so you can use it to explore, search, make changes, etc. You can also have
the script print things, and I'll tell you what the output was. Note that `io` only has `open`, and
then the file it returns only has the methods read, write, and close - it doesn't have popen or
anything else. Also, I'm going to be putting this Lua script into JSON, so please don't use Lua's
double quote syntax for string literals - use one of Lua's other syntaxes for string literals, so I
don't have to escape the double quotes. There will be a global called `search` which accepts a regex
(it's implemented using Rust's regex crate, so use that regex syntax) and runs that regex on the contents
of every file in the code base (aside from gitignored files), then returns an array of tables with two
fields: "path" (the path to the file that had the matches) and "matches" (an array of strings, with each
string being a match that was found within the file)."#.into()
}
fn input_schema(&self) -> serde_json::Value {
let schema = schemars::schema_for!(ScriptingToolInput);
serde_json::to_value(&schema).unwrap()
}
fn run(
self: Arc<Self>,
input: serde_json::Value,
workspace: WeakEntity<Workspace>,
_window: &mut Window,
cx: &mut App,
) -> Task<anyhow::Result<String>> {
let root_dir = workspace.update(cx, |workspace, cx| {
let first_worktree = workspace
.visible_worktrees(cx)
.next()
.ok_or_else(|| anyhow!("no worktrees"))?;
workspace
.absolute_path_of_worktree(first_worktree.read(cx).id(), cx)
.ok_or_else(|| anyhow!("no worktree root"))
});
let root_dir = match root_dir {
Ok(root_dir) => root_dir,
Err(err) => return Task::ready(Err(err)),
};
let root_dir = match root_dir {
Ok(root_dir) => root_dir,
Err(err) => return Task::ready(Err(err)),
};
let input = match serde_json::from_value::<ScriptingToolInput>(input) {
Err(err) => return Task::ready(Err(err.into())),
Ok(input) => input,
};
let lua_script = input.lua_script;
cx.background_spawn(async move {
let fs_changes = HashMap::new();
let output = run_sandboxed_lua(&lua_script, fs_changes, root_dir)
.map_err(|err| anyhow!(format!("{err}")))?;
let output = output.printed_lines.join("\n");
Ok(format!("The script output the following:\n{output}"))
})
}
}
const SANDBOX_PREAMBLE: &str = include_str!("sandbox_preamble.lua");
struct FileContent(RefCell<Vec<u8>>);
impl UserData for FileContent {
fn add_methods<M: UserDataMethods<Self>>(_methods: &mut M) {
// FileContent doesn't have any methods so far.
}
}
/// Sandboxed print() function in Lua.
fn print(lua: &Lua, printed_lines: Rc<RefCell<Vec<String>>>) -> Result<Function> {
lua.create_function(move |_, args: MultiValue| {
let mut string = String::new();
for arg in args.into_iter() {
// Lua's `print()` prints tab characters between each argument.
if !string.is_empty() {
string.push('\t');
}
// If the argument's to_string() fails, have the whole function call fail.
string.push_str(arg.to_string()?.as_str())
}
printed_lines.borrow_mut().push(string);
Ok(())
})
}
fn search(
lua: &Lua,
_fs_changes: Rc<RefCell<HashMap<PathBuf, Vec<u8>>>>,
root_dir: PathBuf,
) -> Result<Function> {
lua.create_function(move |lua, regex: String| {
use mlua::Table;
use regex::Regex;
use std::fs;
// Function to recursively search directory
let search_regex = match Regex::new(&regex) {
Ok(re) => re,
Err(e) => return Err(mlua::Error::runtime(format!("Invalid regex: {}", e))),
};
let mut search_results: Vec<Result<Table>> = Vec::new();
// Create an explicit stack for directories to process
let mut dir_stack = vec![root_dir.clone()];
while let Some(current_dir) = dir_stack.pop() {
// Process each entry in the current directory
let entries = match fs::read_dir(&current_dir) {
Ok(entries) => entries,
Err(e) => return Err(e.into()),
};
for entry_result in entries {
let entry = match entry_result {
Ok(e) => e,
Err(e) => return Err(e.into()),
};
let path = entry.path();
if path.is_dir() {
// Skip .git directory and other common directories to ignore
let dir_name = path.file_name().unwrap_or_default().to_string_lossy();
if !dir_name.starts_with('.')
&& dir_name != "node_modules"
&& dir_name != "target"
{
// Instead of recursive call, add to stack
dir_stack.push(path);
}
} else if path.is_file() {
// Skip binary files and very large files
if let Ok(metadata) = fs::metadata(&path) {
if metadata.len() > 1_000_000 {
// Skip files larger than 1MB
continue;
}
}
// Attempt to read the file as text
if let Ok(content) = fs::read_to_string(&path) {
let mut matches = Vec::new();
// Find all regex matches in the content
for capture in search_regex.find_iter(&content) {
matches.push(capture.as_str().to_string());
}
// If we found matches, create a result entry
if !matches.is_empty() {
let result_entry = lua.create_table()?;
result_entry.set("path", path.to_string_lossy().to_string())?;
let matches_table = lua.create_table()?;
for (i, m) in matches.iter().enumerate() {
matches_table.set(i + 1, m.clone())?;
}
result_entry.set("matches", matches_table)?;
search_results.push(Ok(result_entry));
}
}
}
}
}
// Create a table to hold our results
let results_table = lua.create_table()?;
for (i, result) in search_results.into_iter().enumerate() {
match result {
Ok(entry) => results_table.set(i + 1, entry)?,
Err(e) => return Err(e),
}
}
Ok(results_table)
})
}
/// Sandboxed io.open() function in Lua.
fn io_open(
lua: &Lua,
fs_changes: Rc<RefCell<HashMap<PathBuf, Vec<u8>>>>,
root_dir: PathBuf,
) -> Result<Function> {
lua.create_function(move |lua, (path_str, mode): (String, Option<String>)| {
let mode = mode.unwrap_or_else(|| "r".to_string());
// Parse the mode string to determine read/write permissions
let read_perm = mode.contains('r');
let write_perm = mode.contains('w') || mode.contains('a') || mode.contains('+');
let append = mode.contains('a');
let truncate = mode.contains('w');
// This will be the Lua value returned from the `open` function.
let file = lua.create_table()?;
// Store file metadata in the file
file.set("__path", path_str.clone())?;
file.set("__mode", mode.clone())?;
file.set("__read_perm", read_perm)?;
file.set("__write_perm", write_perm)?;
// Sandbox the path; it must be within root_dir
let path: PathBuf = {
let rust_path = Path::new(&path_str);
// Get absolute path
if rust_path.is_absolute() {
// Check if path starts with root_dir prefix without resolving symlinks
if !rust_path.starts_with(&root_dir) {
return Ok((
None,
format!(
"Error: Absolute path {} is outside the current working directory",
path_str
),
));
}
rust_path.to_path_buf()
} else {
// Make relative path absolute relative to cwd
root_dir.join(rust_path)
}
};
// close method
let close_fn = {
let fs_changes = fs_changes.clone();
lua.create_function(move |_lua, file_userdata: mlua::Table| {
let write_perm = file_userdata.get::<bool>("__write_perm")?;
let path = file_userdata.get::<String>("__path")?;
if write_perm {
// When closing a writable file, record the content
let content = file_userdata.get::<mlua::AnyUserData>("__content")?;
let content_ref = content.borrow::<FileContent>()?;
let content_vec = content_ref.0.borrow();
// Don't actually write to disk; instead, just update fs_changes.
let path_buf = PathBuf::from(&path);
fs_changes
.borrow_mut()
.insert(path_buf.clone(), content_vec.clone());
}
Ok(true)
})?
};
file.set("close", close_fn)?;
// If it's a directory, give it a custom read() and return early.
if path.is_dir() {
// TODO handle the case where we changed it in the in-memory fs
// Create a special directory handle
file.set("__is_directory", true)?;
// Store directory entries
let entries = match std::fs::read_dir(&path) {
Ok(entries) => {
let mut entry_names = Vec::new();
for entry in entries.flatten() {
entry_names.push(entry.file_name().to_string_lossy().into_owned());
}
entry_names
}
Err(e) => return Ok((None, format!("Error reading directory: {}", e))),
};
// Save the list of entries
file.set("__dir_entries", entries)?;
file.set("__dir_position", 0usize)?;
// Create a directory-specific read function
let read_fn = lua.create_function(|_lua, file_userdata: mlua::Table| {
let position = file_userdata.get::<usize>("__dir_position")?;
let entries = file_userdata.get::<Vec<String>>("__dir_entries")?;
if position >= entries.len() {
return Ok(None); // No more entries
}
let entry = entries[position].clone();
file_userdata.set("__dir_position", position + 1)?;
Ok(Some(entry))
})?;
file.set("read", read_fn)?;
// If we got this far, the directory was opened successfully
return Ok((Some(file), String::new()));
}
let is_in_changes = fs_changes.borrow().contains_key(&path);
let file_exists = is_in_changes || path.exists();
let mut file_content = Vec::new();
if file_exists && !truncate {
if is_in_changes {
file_content = fs_changes.borrow().get(&path).unwrap().clone();
} else {
// Try to read existing content if file exists and we're not truncating
match std::fs::read(&path) {
Ok(content) => file_content = content,
Err(e) => return Ok((None, format!("Error reading file: {}", e))),
}
}
}
// If in append mode, position should be at the end
let position = if append && file_exists {
file_content.len()
} else {
0
};
file.set("__position", position)?;
file.set(
"__content",
lua.create_userdata(FileContent(RefCell::new(file_content)))?,
)?;
// Create file methods
// read method
let read_fn = {
lua.create_function(
|_lua, (file_userdata, format): (mlua::Table, Option<mlua::Value>)| {
let read_perm = file_userdata.get::<bool>("__read_perm")?;
if !read_perm {
return Err(mlua::Error::runtime("File not open for reading"));
}
let content = file_userdata.get::<mlua::AnyUserData>("__content")?;
let mut position = file_userdata.get::<usize>("__position")?;
let content_ref = content.borrow::<FileContent>()?;
let content_vec = content_ref.0.borrow();
if position >= content_vec.len() {
return Ok(None); // EOF
}
match format {
Some(mlua::Value::String(s)) => {
let lossy_string = s.to_string_lossy();
let format_str: &str = lossy_string.as_ref();
// Only consider the first 2 bytes, since it's common to pass e.g. "*all" instead of "*a"
match &format_str[0..2] {
"*a" => {
// Read entire file from current position
let result = String::from_utf8_lossy(&content_vec[position..])
.to_string();
position = content_vec.len();
file_userdata.set("__position", position)?;
Ok(Some(result))
}
"*l" => {
// Read next line
let mut line = Vec::new();
let mut found_newline = false;
while position < content_vec.len() {
let byte = content_vec[position];
position += 1;
if byte == b'\n' {
found_newline = true;
break;
}
// Skip \r in \r\n sequence but add it if it's alone
if byte == b'\r' {
if position < content_vec.len()
&& content_vec[position] == b'\n'
{
position += 1;
found_newline = true;
break;
}
}
line.push(byte);
}
file_userdata.set("__position", position)?;
if !found_newline
&& line.is_empty()
&& position >= content_vec.len()
{
return Ok(None); // EOF
}
let result = String::from_utf8_lossy(&line).to_string();
Ok(Some(result))
}
"*n" => {
// Try to parse as a number (number of bytes to read)
match format_str.parse::<usize>() {
Ok(n) => {
let end =
std::cmp::min(position + n, content_vec.len());
let bytes = &content_vec[position..end];
let result = String::from_utf8_lossy(bytes).to_string();
position = end;
file_userdata.set("__position", position)?;
Ok(Some(result))
}
Err(_) => Err(mlua::Error::runtime(format!(
"Invalid format: {}",
format_str
))),
}
}
"*L" => {
// Read next line keeping the end of line
let mut line = Vec::new();
while position < content_vec.len() {
let byte = content_vec[position];
position += 1;
line.push(byte);
if byte == b'\n' {
break;
}
// If we encounter a \r, add it and check if the next is \n
if byte == b'\r'
&& position < content_vec.len()
&& content_vec[position] == b'\n'
{
line.push(content_vec[position]);
position += 1;
break;
}
}
file_userdata.set("__position", position)?;
if line.is_empty() && position >= content_vec.len() {
return Ok(None); // EOF
}
let result = String::from_utf8_lossy(&line).to_string();
Ok(Some(result))
}
_ => Err(mlua::Error::runtime(format!(
"Unsupported format: {}",
format_str
))),
}
}
Some(mlua::Value::Number(n)) => {
// Read n bytes
let n = n as usize;
let end = std::cmp::min(position + n, content_vec.len());
let bytes = &content_vec[position..end];
let result = String::from_utf8_lossy(bytes).to_string();
position = end;
file_userdata.set("__position", position)?;
Ok(Some(result))
}
Some(_) => Err(mlua::Error::runtime("Invalid format")),
None => {
// Default is to read a line
let mut line = Vec::new();
let mut found_newline = false;
while position < content_vec.len() {
let byte = content_vec[position];
position += 1;
if byte == b'\n' {
found_newline = true;
break;
}
// Handle \r\n
if byte == b'\r' {
if position < content_vec.len()
&& content_vec[position] == b'\n'
{
position += 1;
found_newline = true;
break;
}
}
line.push(byte);
}
file_userdata.set("__position", position)?;
if !found_newline && line.is_empty() && position >= content_vec.len() {
return Ok(None); // EOF
}
let result = String::from_utf8_lossy(&line).to_string();
Ok(Some(result))
}
}
},
)?
};
file.set("read", read_fn)?;
// write method
let write_fn = {
let fs_changes = fs_changes.clone();
lua.create_function(move |_lua, (file_userdata, text): (mlua::Table, String)| {
let write_perm = file_userdata.get::<bool>("__write_perm")?;
if !write_perm {
return Err(mlua::Error::runtime("File not open for writing"));
}
let content = file_userdata.get::<mlua::AnyUserData>("__content")?;
let position = file_userdata.get::<usize>("__position")?;
let content_ref = content.borrow::<FileContent>()?;
let mut content_vec = content_ref.0.borrow_mut();
let bytes = text.as_bytes();
// Ensure the vector has enough capacity
if position + bytes.len() > content_vec.len() {
content_vec.resize(position + bytes.len(), 0);
}
// Write the bytes
for (i, &byte) in bytes.iter().enumerate() {
content_vec[position + i] = byte;
}
// Update position
let new_position = position + bytes.len();
file_userdata.set("__position", new_position)?;
// Update fs_changes
let path = file_userdata.get::<String>("__path")?;
let path_buf = PathBuf::from(path);
fs_changes
.borrow_mut()
.insert(path_buf, content_vec.clone());
Ok(true)
})?
};
file.set("write", write_fn)?;
// If we got this far, the file was opened successfully
Ok((Some(file), String::new()))
})
}
/// Runs a Lua script in a sandboxed environment and returns the printed lines
pub fn run_sandboxed_lua(
script: &str,
fs_changes: HashMap<PathBuf, Vec<u8>>,
root_dir: PathBuf,
) -> Result<ScriptOutput> {
let lua = Lua::new();
lua.set_memory_limit(2 * 1024 * 1024 * 1024)?; // 2 GB
let globals = lua.globals();
// Track the lines the Lua script prints out.
let printed_lines = Rc::new(RefCell::new(Vec::new()));
let fs = Rc::new(RefCell::new(fs_changes));
globals.set("sb_print", print(&lua, printed_lines.clone())?)?;
globals.set("search", search(&lua, fs.clone(), root_dir.clone())?)?;
globals.set("sb_io_open", io_open(&lua, fs.clone(), root_dir)?)?;
globals.set("user_script", script)?;
lua.load(SANDBOX_PREAMBLE).exec()?;
drop(lua); // Necessary so the Rc'd values get decremented.
Ok(ScriptOutput {
printed_lines: Rc::try_unwrap(printed_lines)
.expect("There are still other references to printed_lines")
.into_inner(),
fs_changes: Rc::try_unwrap(fs)
.expect("There are still other references to fs_changes")
.into_inner(),
})
}
pub struct ScriptOutput {
printed_lines: Vec<String>,
#[allow(dead_code)]
fs_changes: HashMap<PathBuf, Vec<u8>>,
}
#[allow(dead_code)]
impl ScriptOutput {
fn fs_diff(&self) -> HashMap<PathBuf, String> {
let mut diff_map = HashMap::new();
for (path, content) in &self.fs_changes {
let diff = if path.exists() {
// Read the current file content
match std::fs::read(path) {
Ok(current_content) => {
// Convert both to strings for diffing
let new_content = String::from_utf8_lossy(content).to_string();
let old_content = String::from_utf8_lossy(&current_content).to_string();
// Generate a git-style diff
let new_lines: Vec<&str> = new_content.lines().collect();
let old_lines: Vec<&str> = old_content.lines().collect();
let path_str = path.to_string_lossy();
let mut diff = format!("diff --git a/{} b/{}\n", path_str, path_str);
diff.push_str(&format!("--- a/{}\n", path_str));
diff.push_str(&format!("+++ b/{}\n", path_str));
// Very basic diff algorithm - this is simplified
let mut i = 0;
let mut j = 0;
while i < old_lines.len() || j < new_lines.len() {
if i < old_lines.len()
&& j < new_lines.len()
&& old_lines[i] == new_lines[j]
{
i += 1;
j += 1;
continue;
}
// Find next matching line
let mut next_i = i;
let mut next_j = j;
let mut found = false;
// Look ahead for matches
for look_i in i..std::cmp::min(i + 10, old_lines.len()) {
for look_j in j..std::cmp::min(j + 10, new_lines.len()) {
if old_lines[look_i] == new_lines[look_j] {
next_i = look_i;
next_j = look_j;
found = true;
break;
}
}
if found {
break;
}
}
// Output the hunk header
diff.push_str(&format!(
"@@ -{},{} +{},{} @@\n",
i + 1,
if found {
next_i - i
} else {
old_lines.len() - i
},
j + 1,
if found {
next_j - j
} else {
new_lines.len() - j
}
));
// Output removed lines
for k in i..next_i {
diff.push_str(&format!("-{}\n", old_lines[k]));
}
// Output added lines
for k in j..next_j {
diff.push_str(&format!("+{}\n", new_lines[k]));
}
i = next_i;
j = next_j;
if found {
i += 1;
j += 1;
} else {
break;
}
}
diff
}
Err(_) => format!("Error reading current file: {}", path.display()),
}
} else {
// New file
let content_str = String::from_utf8_lossy(content).to_string();
let path_str = path.to_string_lossy();
let mut diff = format!("diff --git a/{} b/{}\n", path_str, path_str);
diff.push_str("new file mode 100644\n");
diff.push_str("--- /dev/null\n");
diff.push_str(&format!("+++ b/{}\n", path_str));
let lines: Vec<&str> = content_str.lines().collect();
diff.push_str(&format!("@@ -0,0 +1,{} @@\n", lines.len()));
for line in lines {
diff.push_str(&format!("+{}\n", line));
}
diff
};
diff_map.insert(path.clone(), diff);
}
diff_map
}
fn diff_to_string(&self) -> String {
let mut answer = String::new();
let diff_map = self.fs_diff();
if diff_map.is_empty() {
return "No changes to files".to_string();
}
// Sort the paths for consistent output
let mut paths: Vec<&PathBuf> = diff_map.keys().collect();
paths.sort();
for path in paths {
if !answer.is_empty() {
answer.push_str("\n");
}
answer.push_str(&diff_map[path]);
}
answer
}
}

View File

@@ -1,152 +0,0 @@
/// This module works with streaming_lua to allow us to run fragments of
/// Lua scripts that come back from LLM JSON tool calls immediately as they arrive,
/// even when the full script (and the full JSON) has not been received yet.
pub fn from_json(json_str: &str) {
// The JSON structure we're looking for is very simple:
// 1. Open curly bracket
// 2. Optional whitespace
// 3. Quoted key - either "lua_script" or "description" (if description, just parse it)
// 4. Colon
// 5. Optional whitespace
// 6. Open quote
// 7. Now we start streaming until we see a closed quote
// TODO all of this needs to be stored in state in a struct instead of in variables,
// and that includes the iterator part.
let mut chars = json_str.trim_start().chars().peekable();
// Skip the opening curly brace
if chars.next() != Some('{') {
return;
}
let key = parse_key(&mut chars);
if key.map(|k| k.as_str()) == Some("description") {
// TODO parse the description here
parse_comma_then_quote(&mut chars);
if parse_key(&mut chars).map(|k| k.as_str()) != Some("lua_script") {
return; // This was the only remaining valid option.
}
// TODO parse the script here, remembering to s/backslash//g to unescape everything.
} else if key.map(|k| k.as_str()) == Some("lua_script") {
// TODO parse the script here, remembering to s/backslash//g to unescape everything.
parse_comma_then_quote(&mut chars);
if parse_key(&mut chars).map(|k| k.as_str()) != Some("description") {
return; // This was the only remaining valid option.
}
// TODO parse the description here
} else {
// The key wasn't one of the two valid options.
return;
}
// Parse value
let mut value = String::new();
let mut escape_next = false;
while let Some(c) = chars.next() {
if escape_next {
value.push(match c {
'n' => '\n',
't' => '\t',
'r' => '\r',
'\\' => '\\',
'"' => '"',
_ => c,
});
escape_next = false;
} else if c == '\\' {
escape_next = true;
} else if c == '"' {
break; // End of value
} else {
value.push(c);
}
}
// Process the parsed key-value pair
match key.as_str() {
"lua_script" => {
// Handle the lua script
println!("Found lua script: {}", value);
}
"description" => {
// Handle the description
println!("Found description: {}", value);
}
_ => {} // Should not reach here due to earlier check
}
}
fn parse_key(chars: &mut impl Iterator<Item = char>) -> Option<String> {
// Skip whitespace until we reach the start of the key
while let Some(c) = chars.next() {
if c.is_whitespace() {
// Consume the whitespace and continue
} else if c == '"' {
break; // Found the start of the key
} else {
return None; // Invalid format - expected a quote to start the key
}
}
// Parse the key. We don't need to escape backslashes because the exact key
// we expect does not include backslashes or quotes.
let mut key = String::new();
while let Some(c) = chars.next() {
if c == '"' {
break; // End of key
}
key.push(c);
}
// Skip colon and whitespace and next opening quote.
let mut found_colon = false;
while let Some(c) = chars.next() {
if c == ':' {
found_colon = true;
} else if found_colon && !c.is_whitespace() {
if c == '"' {
break; // Found the opening quote
}
return None; // Invalid format - expected a quote after colon and whitespace
} else if !c.is_whitespace() {
return None; // Invalid format - expected whitespace or colon
}
}
Some(key)
}
fn parse_comma_then_quote(chars: &mut impl Iterator<Item = char>) -> bool {
// Skip any whitespace
while let Some(&c) = chars.peek() {
if !c.is_whitespace() {
break;
}
chars.next();
}
// Check for comma
if chars.next() != Some(',') {
return false;
}
// Skip any whitespace after the comma
while let Some(&c) = chars.peek() {
if !c.is_whitespace() {
break;
}
chars.next();
}
// Check for opening quote
if chars.next() != Some('"') {
return false;
}
true
}

View File

@@ -1,268 +0,0 @@
/// This module accepts fragments of Lua code from LLM responses, and executes
/// them as they come in (to the extent possible) rather than having to wait
/// for the entire script to arrive to execute it. (Since these are tool calls,
/// they will presumably come back in JSON; it's up to the caller to deal with
/// parsing the JSON, escaping `\\` and `\"` in the JSON-quoted Lua, etc.)
///
/// By design, Lua does not preserve top-level locals across chunks ("chunk" is a
/// Lua term for a chunk of Lua code that can be executed), and chunks are the
/// smallest unit of execution you can run in Lua. To make sure that top-level
/// locals the LLM writes are preserved across multiple silently translates
/// locals to globals. This should be harmless for our use case, because we only
/// have a single "file" and not multiple files where the distinction could matter.
///
/// Since fragments will invariably arrive that don't happen to correspond to valid
/// Lua chunks (e.g. maybe they have an opening quote for a string literal and the
/// close quote will be coming in the next fragment), we use a simple heuristic to
/// split them up: we take each fragment and split it into lines, and then whenever
/// we have a complete line, we send it to Lua to process as a chunk. If it comes back
/// with a syntax error due to it being incomplete (which mlua tells us), then we
/// know to keep waiting for more lines and try again.
///
/// Eventually we'll either succeed, or else the response will end and we'll know it
/// had an actual syntax error. (Again, it's the caller's responsibility to deal
/// with detecting when the response ends due to the JSON quote having finally closed.)
///
/// This heuristic relies on the assumption that the LLM is generating normal-looking
/// Lua code where statements are split using newlines rather than semicolons.
/// In practice, this is a safe assumption.
#[derive(Default)]
struct ChunkBuffer {
buffer: String,
incomplete_multiline_string: bool,
last_newline_index: usize,
}
impl ChunkBuffer {
pub fn receive_chunk(
&mut self,
src_chunk: &str,
exec_chunk: &mut impl FnMut(&str) -> mlua::Result<()>,
) -> mlua::Result<()> {
self.buffer.push_str(src_chunk);
// Execute each line until we hit an incomplete parse
while let Some(index) = &self.buffer[self.last_newline_index..].find('\n') {
let mut index = *index;
// LLMs can produce incredibly long multiline strings. We don't want to keep
// attempting to re-parse those every time a new line of the string comes in.
// that would be extremely wasteful! Instead, just keep waiting until it ends.
{
let line = &self.buffer[self.last_newline_index..index];
const LOCAL_PREFIX: &str = "local ";
// It's safe to assume we'll never see a line which
// includes both "]]" and "[[" other than single-line
// assignments which are just using them to escape quotes.
//
// If that assumption turns out not to hold, we can always
// make this more robust.
if line.contains("[[") && !line.contains("]]") {
self.incomplete_multiline_string = true;
}
// In practice, LLMs produce multiline strings that always end
// with the ]] at the start of the line.
if line.starts_with("]]") {
self.incomplete_multiline_string = false;
} else if line.starts_with("local ") {
// We can't have top-level locals because they don't preserve
// across chunk executions. So just turn locals into globals.
// Since this is just one script, they're the same anyway.
self.buffer
.replace_range(self.last_newline_index..LOCAL_PREFIX.len(), "");
index -= LOCAL_PREFIX.len();
}
}
self.last_newline_index = index;
if self.incomplete_multiline_string {
continue;
}
// Execute all lines up to (and including) this one.
match exec_chunk(&self.buffer[..index]) {
Ok(()) => {
// The chunk executed successfully. Advance the buffer
// to reflect the fact that we've executed that code.
self.buffer = self.buffer[index + 1..].to_string();
self.last_newline_index = 0;
}
Err(mlua::Error::SyntaxError {
incomplete_input: true,
message: _,
}) => {
// If it errored specifically because the input was incomplete, no problem.
// We'll keep trying with more and more lines until eventually we find a
// sequence of lines that are valid together!
}
Err(other) => {
return Err(other);
}
}
}
Ok(())
}
pub fn finish(
&mut self,
exec_chunk: &mut impl FnMut(&str) -> mlua::Result<()>,
) -> mlua::Result<()> {
if !self.buffer.is_empty() {
// Execute whatever is left in the buffer
match exec_chunk(&self.buffer) {
Ok(()) => {
// Clear the buffer as everything has been executed
self.buffer.clear();
self.last_newline_index = 0;
self.incomplete_multiline_string = false;
}
Err(err) => {
return Err(err);
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use mlua::Lua;
use std::cell::RefCell;
use std::rc::Rc;
#[test]
fn test_lua_runtime_receive_chunk() {
let mut chunk_buffer = ChunkBuffer::default();
let output = Rc::new(RefCell::new(String::new()));
let mut exec_chunk = |chunk: &str| -> mlua::Result<()> {
let lua = Lua::new();
// Clone the Rc to share ownership of the same RefCell
let output_ref = output.clone();
lua.globals().set(
"print",
lua.create_function(move |_, msg: String| {
let mut output = output_ref.borrow_mut();
output.push_str(&msg);
output.push('\n');
Ok(())
})?,
)?;
lua.load(chunk).exec()
};
exec_chunk("print('Hello, World!')").unwrap();
chunk_buffer
.receive_chunk("print('Hello, World!')", &mut exec_chunk)
.unwrap();
assert_eq!(*output.borrow(), "Hello, World!\n");
}
#[test]
fn test_lua_runtime_receive_chunk_shared_lua() {
let mut chunk_buffer = ChunkBuffer::default();
let output = Rc::new(RefCell::new(String::new()));
let lua = Lua::new();
// Set up the print function once for the shared Lua instance
{
let output_ref = output.clone();
lua.globals()
.set(
"print",
lua.create_function(move |_, msg: String| {
let mut output = output_ref.borrow_mut();
output.push_str(&msg);
output.push('\n');
Ok(())
})
.unwrap(),
)
.unwrap();
}
let mut exec_chunk = |chunk: &str| -> mlua::Result<()> { lua.load(chunk).exec() };
// Send first incomplete chunk
chunk_buffer
.receive_chunk("local message = 'Hello, '\n", &mut exec_chunk)
.unwrap();
// Send second chunk that completes the code
chunk_buffer
.receive_chunk(
"message = message .. 'World!'\nprint(message)",
&mut exec_chunk,
)
.unwrap();
chunk_buffer.finish(&mut exec_chunk).unwrap();
assert_eq!(*output.borrow(), "Hello, World!\n");
}
#[test]
fn test_multiline_string_across_chunks() {
let mut chunk_buffer = ChunkBuffer::default();
let output = Rc::new(RefCell::new(String::new()));
let lua = Lua::new();
// Set up the print function for the shared Lua instance
{
let output_ref = output.clone();
lua.globals()
.set(
"print",
lua.create_function(move |_, msg: String| {
let mut output = output_ref.borrow_mut();
output.push_str(&msg);
output.push('\n');
Ok(())
})
.unwrap(),
)
.unwrap();
}
let mut exec_chunk = |chunk: &str| -> mlua::Result<()> { lua.load(chunk).exec() };
// Send first chunk with the beginning of a multiline string
chunk_buffer
.receive_chunk("local multiline = [[This is the start\n", &mut exec_chunk)
.unwrap();
// Send second chunk with more lines
chunk_buffer
.receive_chunk("of a very long\nmultiline string\n", &mut exec_chunk)
.unwrap();
// Send third chunk with more content
chunk_buffer
.receive_chunk("that spans across\n", &mut exec_chunk)
.unwrap();
// Send final chunk that completes the multiline string
chunk_buffer
.receive_chunk("multiple chunks]]\nprint(multiline)", &mut exec_chunk)
.unwrap();
chunk_buffer.finish(&mut exec_chunk).unwrap();
let expected = "This is the start\nof a very long\nmultiline string\nthat spans across\nmultiple chunks\n";
assert_eq!(*output.borrow(), expected);
}
}

View File

@@ -1,5 +1,6 @@
use crate::{settings_store::SettingsStore, Settings};
use fs::Fs;
use collections::HashSet;
use fs::{Fs, PathEventKind};
use futures::{channel::mpsc, StreamExt};
use gpui::{App, BackgroundExecutor, ReadGlobal};
use std::{path::PathBuf, sync::Arc, time::Duration};
@@ -78,6 +79,55 @@ pub fn watch_config_file(
rx
}
pub fn watch_config_dir(
executor: &BackgroundExecutor,
fs: Arc<dyn Fs>,
dir_path: PathBuf,
config_paths: HashSet<PathBuf>,
) -> mpsc::UnboundedReceiver<String> {
let (tx, rx) = mpsc::unbounded();
executor
.spawn(async move {
for file_path in &config_paths {
if fs.metadata(file_path).await.is_ok_and(|v| v.is_some()) {
if let Ok(contents) = fs.load(file_path).await {
if tx.unbounded_send(contents).is_err() {
return;
}
}
}
}
let (events, _) = fs.watch(&dir_path, Duration::from_millis(100)).await;
futures::pin_mut!(events);
while let Some(event_batch) = events.next().await {
for event in event_batch {
if config_paths.contains(&event.path) {
match event.kind {
Some(PathEventKind::Removed) => {
if tx.unbounded_send(String::new()).is_err() {
return;
}
}
Some(PathEventKind::Created) | Some(PathEventKind::Changed) => {
if let Ok(contents) = fs.load(&event.path).await {
if tx.unbounded_send(contents).is_err() {
return;
}
}
}
_ => {}
}
}
}
}
})
.detach();
rx
}
pub fn update_settings_file<T: Settings>(
fs: Arc<dyn Fs>,
cx: &App,

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Tom Hale, 2016. MIT Licence.
# Print out 256 colours, with each number printed in its corresponding colour

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Copied from: https://unix.stackexchange.com/a/696756
# Based on: https://gist.github.com/XVilka/8346728 and https://unix.stackexchange.com/a/404415/395213

View File

@@ -85,7 +85,7 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[
("coffeescript", &["coffee"]),
(
"cpp",
&["c++", "cc", "cpp", "cxx", "hh", "hpp", "hxx", "inl"],
&["c++", "cc", "cpp", "cxx", "hh", "hpp", "hxx", "inl", "ixx"],
),
("crystal", &["cr", "ecr"]),
("csharp", &["cs"]),
@@ -264,6 +264,7 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[
("vs_sln", &["sln"]),
("vs_suo", &["suo"]),
("vue", &["vue"]),
("wgsl", &["wgsl"]),
("zig", &["zig"]),
];
@@ -311,7 +312,7 @@ const FILE_ICONS: &[(&str, &str)] = &[
("lock", "icons/file_icons/lock.svg"),
("log", "icons/file_icons/info.svg"),
("lua", "icons/file_icons/lua.svg"),
("luau", "icons/file_icons/file.svg"),
("luau", "icons/file_icons/luau.svg"),
("markdown", "icons/file_icons/book.svg"),
("metal", "icons/file_icons/metal.svg"),
("nim", "icons/file_icons/nim.svg"),
@@ -348,6 +349,7 @@ const FILE_ICONS: &[(&str, &str)] = &[
("vs_sln", "icons/file_icons/file.svg"),
("vs_suo", "icons/file_icons/file.svg"),
("vue", "icons/file_icons/vue.svg"),
("wgsl", "icons/file_icons/wgsl.svg"),
("zig", "icons/file_icons/zig.svg"),
];

View File

@@ -307,7 +307,7 @@ impl TitleBar {
cx.notify()
}),
);
subscriptions.push(cx.observe(&project, |_, _, cx| cx.notify()));
subscriptions.push(cx.subscribe(&project, |_, _, _, cx| cx.notify()));
subscriptions.push(cx.observe(&active_call, |this, _, cx| this.active_call_changed(cx)));
subscriptions.push(cx.observe_window_activation(window, Self::window_activation_changed));
subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify()));

View File

@@ -852,6 +852,7 @@ impl<T: Item> ItemHandle for Entity<T> {
.detach();
let item_id = self.item_id();
workspace.update_item_dirty_state(self, window, cx);
cx.observe_release_in(self, window, move |workspace, _, _, _| {
workspace.panes_by_item.remove(&item_id);
event_subscription.take();

View File

@@ -2424,14 +2424,10 @@ impl Pane {
.child(label),
);
let single_entry_to_resolve = {
let item_entries = self.items[ix].project_entry_ids(cx);
if item_entries.len() == 1 {
Some(item_entries[0])
} else {
None
}
};
let single_entry_to_resolve = self.items[ix]
.is_singleton(cx)
.then(|| self.items[ix].project_entry_ids(cx).get(0).copied())
.flatten();
let total_items = self.items.len();
let has_items_to_left = ix > 0;

View File

@@ -879,8 +879,6 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
cx.observe_in(&project, window, |_, _, _, cx| cx.notify())
.detach();
cx.subscribe_in(&project, window, move |this, _, event, window, cx| {
match event {
project::Event::RemoteIdChanged(_) => {

View File

@@ -1570,7 +1570,6 @@ impl LocalWorktree {
this.update_abs_path_and_refresh(new_path, cx);
}
}
cx.notify();
})
.ok();
}
@@ -3425,6 +3424,7 @@ impl BackgroundScannerState {
}
fn remove_path(&mut self, path: &Path) {
log::info!("background scanner removing path {path:?}");
let mut new_entries;
let removed_entries;
{
@@ -3480,7 +3480,14 @@ impl BackgroundScannerState {
.git_repositories
.retain(|id, _| removed_ids.binary_search(id).is_err());
self.snapshot.repositories.retain(&(), |repository| {
!repository.work_directory.path_key().0.starts_with(path)
let retain = !repository.work_directory.path_key().0.starts_with(path);
if !retain {
log::info!(
"dropping repository entry for {:?}",
repository.work_directory
);
}
retain
});
#[cfg(test)]
@@ -3535,12 +3542,14 @@ impl BackgroundScannerState {
fs: &dyn Fs,
watcher: &dyn Watcher,
) -> Option<LocalRepositoryEntry> {
log::info!("insert git reposiutory for {dot_git_path:?}");
let work_dir_id = self
.snapshot
.entry_for_path(work_directory.path_key().0)
.map(|entry| entry.id)?;
if self.snapshot.git_repositories.get(&work_dir_id).is_some() {
log::info!("existing git repository for {work_directory:?}");
return None;
}
@@ -3548,6 +3557,7 @@ impl BackgroundScannerState {
let t0 = Instant::now();
let repository = fs.open_repo(&dot_git_abs_path)?;
log::info!("opened git repo for {dot_git_abs_path:?}");
let repository_path = repository.path();
watcher.add(&repository_path).log_err()?;
@@ -3606,6 +3616,7 @@ impl BackgroundScannerState {
.git_repositories
.insert(work_dir_id, local_repository.clone());
log::info!("inserting new local git repository");
Some(local_repository)
}
}
@@ -3949,10 +3960,6 @@ pub struct StatusEntry {
}
impl StatusEntry {
pub fn is_staged(&self) -> Option<bool> {
self.status.is_staged()
}
fn to_proto(&self) -> proto::StatusEntry {
let simple_status = match self.status {
FileStatus::Ignored | FileStatus::Untracked => proto::GitStatus::Added as i32,
@@ -4353,7 +4360,7 @@ impl BackgroundScanner {
}
let ancestor_dot_git = ancestor.join(*DOT_GIT);
log::debug!("considering ancestor: {ancestor_dot_git:?}");
log::info!("considering ancestor: {ancestor_dot_git:?}");
// Check whether the directory or file called `.git` exists (in the
// case of worktrees it's a file.)
if self
@@ -4362,7 +4369,6 @@ impl BackgroundScanner {
.await
.is_ok_and(|metadata| metadata.is_some())
{
log::debug!(".git path exists");
if index != 0 {
// We canonicalize, since the FS events use the canonicalized path.
if let Some(ancestor_dot_git) =
@@ -4373,7 +4379,7 @@ impl BackgroundScanner {
.strip_prefix(ancestor)
.unwrap()
.into();
log::debug!(
log::info!(
"inserting parent git repo for this worktree: {location_in_repo:?}"
);
// We associate the external git repo with our root folder and
@@ -4396,12 +4402,10 @@ impl BackgroundScanner {
// Reached root of git repository.
break;
} else {
log::debug!(".git path doesn't exist");
}
}
log::debug!("containing git repository: {containing_git_repository:?}");
log::info!("containing git repository: {containing_git_repository:?}");
let (scan_job_tx, scan_job_rx) = channel::unbounded();
{
@@ -4826,7 +4830,7 @@ impl BackgroundScanner {
log::error!("skipping excluded directory {:?}", job.path);
return Ok(());
}
log::debug!("scanning directory {:?}", job.path);
log::info!("scanning directory {:?}", job.path);
root_abs_path = snapshot.abs_path().clone();
root_char_bag = snapshot.root_char_bag;
}
@@ -5408,7 +5412,7 @@ impl BackgroundScanner {
}
fn update_git_repositories(&self, dot_git_paths: Vec<PathBuf>) -> Task<()> {
log::debug!("reloading repositories: {dot_git_paths:?}");
log::info!("reloading repositories: {dot_git_paths:?}");
let mut status_updates = Vec::new();
{
@@ -5889,14 +5893,21 @@ impl WorktreeModelHandle for Entity<Worktree> {
.await
.unwrap();
cx.condition(&tree, |tree, _| tree.entry_for_path(file_name).is_some())
.await;
let mut events = cx.events(&tree);
while events.next().await.is_some() {
if tree.update(cx, |tree, _| tree.entry_for_path(file_name).is_some()) {
break;
}
}
fs.remove_file(&root_path.join(file_name), Default::default())
.await
.unwrap();
cx.condition(&tree, |tree, _| tree.entry_for_path(file_name).is_none())
.await;
while events.next().await.is_some() {
if tree.update(cx, |tree, _| tree.entry_for_path(file_name).is_none()) {
break;
}
}
cx.update(|cx| tree.read(cx).as_local().unwrap().scan_complete())
.await;
@@ -5950,19 +5961,22 @@ impl WorktreeModelHandle for Entity<Worktree> {
.await
.unwrap();
cx.condition(&tree, |tree, _| {
scan_id_increased(tree, &mut git_dir_scan_id)
})
.await;
let mut events = cx.events(&tree);
while events.next().await.is_some() {
if tree.update(cx, |tree, _| scan_id_increased(tree, &mut git_dir_scan_id)) {
break;
}
}
fs.remove_file(&root_path.join(file_name), Default::default())
.await
.unwrap();
cx.condition(&tree, |tree, _| {
scan_id_increased(tree, &mut git_dir_scan_id)
})
.await;
while events.next().await.is_some() {
if tree.update(cx, |tree, _| scan_id_increased(tree, &mut git_dir_scan_id)) {
break;
}
}
cx.update(|cx| tree.read(cx).as_local().unwrap().scan_complete())
.await;

View File

@@ -12,6 +12,7 @@ use git::{
},
GITIGNORE,
};
use git2::RepositoryInitOptions;
use gpui::{AppContext as _, BorrowAppContext, Context, Task, TestAppContext};
use parking_lot::Mutex;
use postage::stream::Stream;
@@ -855,7 +856,7 @@ async fn test_write_file(cx: &mut TestAppContext) {
"ignored-dir": {}
}));
let tree = Worktree::local(
let worktree = Worktree::local(
dir.path(),
true,
Arc::new(RealFs::default()),
@@ -868,32 +869,34 @@ async fn test_write_file(cx: &mut TestAppContext) {
#[cfg(not(target_os = "macos"))]
fs::fs_watcher::global(|_| {}).unwrap();
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
cx.read(|cx| worktree.read(cx).as_local().unwrap().scan_complete())
.await;
tree.flush_fs_events(cx).await;
worktree.flush_fs_events(cx).await;
tree.update(cx, |tree, cx| {
tree.write_file(
Path::new("tracked-dir/file.txt"),
"hello".into(),
Default::default(),
cx,
)
})
.await
.unwrap();
tree.update(cx, |tree, cx| {
tree.write_file(
Path::new("ignored-dir/file.txt"),
"world".into(),
Default::default(),
cx,
)
})
.await
.unwrap();
worktree
.update(cx, |tree, cx| {
tree.write_file(
Path::new("tracked-dir/file.txt"),
"hello".into(),
Default::default(),
cx,
)
})
.await
.unwrap();
worktree
.update(cx, |tree, cx| {
tree.write_file(
Path::new("ignored-dir/file.txt"),
"world".into(),
Default::default(),
cx,
)
})
.await
.unwrap();
tree.read_with(cx, |tree, _| {
worktree.read_with(cx, |tree, _| {
let tracked = tree.entry_for_path("tracked-dir/file.txt").unwrap();
let ignored = tree.entry_for_path("ignored-dir/file.txt").unwrap();
assert!(!tracked.is_ignored);
@@ -3349,7 +3352,7 @@ async fn test_conflicted_cherry_pick(cx: &mut TestAppContext) {
.expect("Failed to get HEAD")
.peel_to_commit()
.expect("HEAD is not a commit");
git_checkout("refs/heads/master", &repo);
git_checkout("refs/heads/main", &repo);
std::fs::write(root_path.join("project/a.txt"), "b").unwrap();
git_add("a.txt", &repo);
git_commit("improve letter", &repo);
@@ -3479,7 +3482,9 @@ const MODIFIED: GitSummary = GitSummary {
#[track_caller]
fn git_init(path: &Path) -> git2::Repository {
git2::Repository::init(path).expect("Failed to initialize git repository")
let mut init_opts = RepositoryInitOptions::new();
init_opts.initial_head("main");
git2::Repository::init_opts(path, &init_opts).expect("Failed to initialize git repository")
}
#[track_caller]

View File

@@ -98,7 +98,6 @@ remote.workspace = true
repl.workspace = true
reqwest_client.workspace = true
rope.workspace = true
scripting_tool.workspace = true
search.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -9,8 +9,9 @@
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.text</string>
<string>public.folder</string>
<string>public.plain-text</string>
<string>public.text</string>
<string>public.utf8-plain-text</string>
</array>
</dict>

View File

@@ -326,6 +326,7 @@ fn main() {
.or_else(read_proxy_from_env);
let http = {
let _guard = Tokio::handle(cx).enter();
ReqwestClient::proxy_and_user_agent(proxy_url, &user_agent)
.expect("could not start HTTP client")
};
@@ -475,7 +476,6 @@ fn main() {
cx,
);
assistant_tools::init(cx);
scripting_tool::init(cx);
repl::init(app_state.fs.clone(), cx);
extension_host::init(
extension_host_proxy,

View File

@@ -96,6 +96,7 @@ impl Render for QuickActionBar {
let git_blame_inline_enabled = editor_value.git_blame_inline_enabled();
let show_git_blame_gutter = editor_value.show_git_blame_gutter();
let auto_signature_help_enabled = editor_value.auto_signature_help_enabled(cx);
let show_line_numbers = editor_value.line_numbers_enabled(cx);
let has_edit_prediction_provider = editor_value.edit_prediction_provider().is_some();
let show_edit_predictions = editor_value.edit_predictions_enabled();
let edit_predictions_enabled_at_cursor =
@@ -261,6 +262,58 @@ impl Render for QuickActionBar {
);
}
if has_edit_prediction_provider {
let mut inline_completion_entry = ContextMenuEntry::new("Edit Predictions")
.toggleable(IconPosition::Start, edit_predictions_enabled_at_cursor && show_edit_predictions)
.disabled(!edit_predictions_enabled_at_cursor)
.action(
editor::actions::ToggleEditPrediction.boxed_clone(),
).handler({
let editor = editor.clone();
move |window, cx| {
editor
.update(cx, |editor, cx| {
editor.toggle_edit_predictions(
&editor::actions::ToggleEditPrediction,
window,
cx,
);
})
.ok();
}
});
if !edit_predictions_enabled_at_cursor {
inline_completion_entry = inline_completion_entry.documentation_aside(|_| {
Label::new("You can't toggle edit predictions for this file as it is within the excluded files list.").into_any_element()
});
}
menu = menu.item(inline_completion_entry);
}
menu = menu.separator();
menu = menu.toggleable_entry(
"Line Numbers",
show_line_numbers,
IconPosition::Start,
Some(editor::actions::ToggleLineNumbers.boxed_clone()),
{
let editor = editor.clone();
move |window, cx| {
editor
.update(cx, |editor, cx| {
editor.toggle_line_numbers(
&editor::actions::ToggleLineNumbers,
window,
cx,
);
})
.ok();
}
},
);
menu = menu.toggleable_entry(
"Selection Menu",
selection_menu_enabled,
@@ -303,35 +356,6 @@ impl Render for QuickActionBar {
},
);
if has_edit_prediction_provider {
let mut inline_completion_entry = ContextMenuEntry::new("Edit Predictions")
.toggleable(IconPosition::Start, edit_predictions_enabled_at_cursor && show_edit_predictions)
.disabled(!edit_predictions_enabled_at_cursor)
.action(
editor::actions::ToggleEditPrediction.boxed_clone(),
).handler({
let editor = editor.clone();
move |window, cx| {
editor
.update(cx, |editor, cx| {
editor.toggle_edit_predictions(
&editor::actions::ToggleEditPrediction,
window,
cx,
);
})
.ok();
}
});
if !edit_predictions_enabled_at_cursor {
inline_completion_entry = inline_completion_entry.documentation_aside(|_| {
Label::new("You can't toggle edit predictions for this file as it is within the excluded files list.").into_any_element()
});
}
menu = menu.item(inline_completion_entry);
}
menu = menu.separator();
menu = menu.toggleable_entry(

View File

@@ -114,8 +114,9 @@ pub mod workspace {
}
pub mod git {
use gpui::action_with_deprecated_aliases;
use gpui::{action_with_deprecated_aliases, actions};
actions!(git, [CheckoutBranch, Switch]);
action_with_deprecated_aliases!(git, Branch, ["branches::OpenRecent"]);
}

View File

@@ -1,14 +1,11 @@
(
import
(
let
lock = builtins.fromJSON (builtins.readFile ./flake.lock);
in
fetchTarball {
url = lock.nodes.flake-compat.locked.url or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
sha256 = lock.nodes.flake-compat.locked.narHash;
}
)
{src = ./.;}
)
.defaultNix
(import (
let
lock = builtins.fromJSON (builtins.readFile ./flake.lock);
in
fetchTarball {
url =
lock.nodes.flake-compat.locked.url
or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
sha256 = lock.nodes.flake-compat.locked.narHash;
}
) { src = ./.; }).defaultNix

View File

@@ -115,6 +115,7 @@
- [Rust](./languages/rust.md)
- [Scala](./languages/scala.md)
- [Scheme](./languages/scheme.md)
- [Shell Script](./languages/sh.md)
- [Svelte](./languages/svelte.md)
- [Swift](./languages/swift.md)
- [Tailwind CSS](./languages/tailwindcss.md)

View File

@@ -8,6 +8,7 @@ If you're used to a specific editor's defaults you can set a `base_keymap` in yo
- VSCode (default)
- Atom
- Emacs (Beta)
- JetBrains
- SublimeText
- TextMate
@@ -52,7 +53,7 @@ If you want to debug problems with custom keymaps you can use `debug: Open Key C
Zed has the ability to match against not just a single keypress, but a sequence of keys typed in order. Each key in the `"bindings"` map is a sequence of keypresses separated with a space.
Each key press is a sequence of modifiers followed by a key. The modifiers are:
Each keypress is a sequence of modifiers followed by a key. The modifiers are:
- `ctrl-` The control key
- `cmd-`, `win-` or `super-` for the platform modifier (Command on macOS, Windows key on Windows, and the Super key on Linux).
@@ -77,7 +78,7 @@ The `shift-` modifier can only be used in combination with a letter to indicate
The `alt-` modifier can be used on many layouts to generate a different key. For example on macOS US keyboard the combination `alt-c` types `ç`. You can match against either in your keymap file, though by convention Zed spells this combination as `alt-c`.
It is possible to match against typing a modifier key on its own. For example `shift shift` can be used to implement JetBrains search everywhere shortcut. In this case the binding happens on key release instead of key press.
It is possible to match against typing a modifier key on its own. For example `shift shift` can be used to implement JetBrains search everywhere shortcut. In this case the binding happens on key release instead of keypress.
### Contexts
@@ -138,13 +139,13 @@ As of Zed 0.162.0, Zed has some support for non-QWERTY keyboards on macOS. Bette
There are roughly three categories of keyboard to consider:
Keyboards that support full ASCII (QWERTY, DVORAK, COLEMAK, etc.). On these keyboards bindings are resolved based on the character that would be generated by the key. So to type `cmd-[`, find the key labelled `[` and press it with command.
Keyboards that support full ASCII (QWERTY, DVORAK, COLEMAK, etc.). On these keyboards bindings are resolved based on the character that would be generated by the key. So to type `cmd-[`, find the key labeled `[` and press it with command.
Keyboards that are mostly non-ASCII, but support full ASCII when the command key is pressed. For example Cyrillic keyboards, Armenian, Hebrew, etc. On these keyboards bindings are resolved based on the character that would be generated by typing the key with command pressed. So to type `ctrl-a`, find the key that generates `cmd-a`. For these keyboards, keyboard shortcuts are displayed in the app using their ASCII equivalents. If the ASCII-equivalents are not printed on your keyboard, you can use the macOS keyboard viewer and holding down the `cmd` key to find things (though often the ASCII equivalents are in a QWERTY layout).
Finally keyboards that support extended Latin alphabets (usually ISO keyboards) require the most support. For example French AZERTY, German QWERTZ, etc. On these keyboards it is often not possible to type the entire ASCII range without option. To ensure that shortcuts _can_ be typed without option, keyboard shortcuts are mapped to "key equivalents" in the same way as [macOS](). This mapping is defined per layout, and is a compromise between leaving keyboard shortcuts triggered by the same character they are defined with, keeping shortcuts in the same place as a QWERTY layout, and moving shortcuts out of the way of system shortcuts.
For example on a German QWERTZ keyboard, the `cmd->` shortcut is moved to `cmd-:` because `cmd->` is the system window switcher and this is where that shortcut is typed on a QWERTY keyboard. `cmd-+` stays the same because + is still typable without option, and as a result, `cmd-[` and `cmd-]` become `cmd-ö` and `cmd-ä`, moving out of the way of the `+` key.
For example on a German QWERTZ keyboard, the `cmd->` shortcut is moved to `cmd-:` because `cmd->` is the system window switcher and this is where that shortcut is typed on a QWERTY keyboard. `cmd-+` stays the same because + is still typeable without option, and as a result, `cmd-[` and `cmd-]` become `cmd-ö` and `cmd-ä`, moving out of the way of the `+` key.
If you are defining shortcuts in your personal keymap, you can opt into the key equivalent mapping by setting `use_key_equivalents` to `true` in your keymap:
@@ -208,7 +209,7 @@ There are some limitations to this, notably:
The argument to `SendKeystrokes` is a space-separated list of keystrokes (using the same syntax as above). Due to the way that keystrokes are parsed, any segment that is not recognized as a keypress will be sent verbatim to the currently focused input field.
If the argument to `SendKeystrokes` contains the binding used to trigger it, it will use the next-highest-precedence definition of that binding. This allows you to extend the default behaviour of a key binding.
If the argument to `SendKeystrokes` contains the binding used to trigger it, it will use the next-highest-precedence definition of that binding. This allows you to extend the default behavior of a key binding.
### Forward keys to terminal

View File

@@ -28,6 +28,7 @@ Zed supports hundreds of programming languages and text formats. Some work out-o
- [Go](./languages/go.md)
- [Groovy](./languages/groovy.md)
- [Haskell](./languages/haskell.md)
- [Helm](./languages/helm.md)
- [HTML](./languages/html.md)
- [Java](./languages/java.md)
- [JavaScript](./languages/javascript.md)
@@ -58,7 +59,7 @@ Zed supports hundreds of programming languages and text formats. Some work out-o
- [Shell Script](./languages/sh.md)
- [Svelte](./languages/svelte.md)
- [Swift](./languages/swift.md)
- [TailwindCSS](./languages/tailwindcss.md)
- [Tailwind CSS](./languages/tailwindcss.md)
- [Terraform](./languages/terraform.md)
- [TOML](./languages/toml.md)
- [TypeScript](./languages/typescript.md)

View File

@@ -1,6 +1,6 @@
# Haskell
Haskell support is available through the [Haskell extension](https://github.com/zed-industries/zed/tree/main/extensions/haskell).
Haskell support is available through the [Haskell extension](https://github.com/zed-extensions/haskell).
- Tree-sitter: [tree-sitter-haskell](https://github.com/tree-sitter/tree-sitter-haskell)
- Language Server: [haskell-language-server](https://github.com/haskell/haskell-language-server)

View File

@@ -89,14 +89,12 @@ Linux works on a large variety of systems configured in many different ways. We
If you see an error like "/lib64/libc.so.6: version 'GLIBC_2.29' not found" it means that your distribution's version of glibc is too old. You can either upgrade your system, or [install Zed from source](./development/linux.md).
### Graphics issues
### Zed fails to open windows
### Zed is very slow
Zed requires a GPU to run effectively. Under the hood, we use [Vulkan](https://www.vulkan.org/) to communicate with your GPU. If you are seeing problems with performance, or Zed fails to load, it is possible that Vulkan is the culprit.
If you're using an AMD GPU, you might get a 'Broken Pipe' error. Try using the RADV or Mesa drivers. (See the following GitHub issue for more details: [#13880](https://github.com/zed-industries/zed/issues/13880)).
If you see a notification saying `Zed failed to open a window: NoSupportedDeviceFound` this means that Vulkan cannot find a compatible GPU. You can begin troubleshooting Vulkan by installing the `vulkan-tools` package and running:
```sh
@@ -105,21 +103,23 @@ vkcube
This should output a line describing your current graphics setup and show a rotating cube. If this does not work, you should be able to fix it by installing Vulkan compatible GPU drivers, however in some cases (for example running Linux on an Arm-based MacBook) there is no Vulkan support yet.
If you see errors like `ERROR_INITIALIZATION_FAILED` or `GPU Crashed` or `ERROR_SURFACE_LOST_KHR` then you may be able to work around this by installing different drivers for your GPU, or by selecting a different GPU to run on. (See the following GitHub issue for more details: [#14225](https://github.com/zed-industries/zed/issues/14225))
You can find out which graphics card Zed is using by looking in the Zed log (`~/.local/share/zed/logs/Zed.log`) for `Using GPU: ...`.
As of Zed v0.146.x we log the selected GPU driver and you should see `Using GPU: ...` in the Zed log (`~/.local/share/zed/logs/Zed.log`).
If you see errors like `ERROR_INITIALIZATION_FAILED` or `GPU Crashed` or `ERROR_SURFACE_LOST_KHR` then you may be able to work around this by installing different drivers for your GPU, or by selecting a different GPU to run on. (See [#14225](https://github.com/zed-industries/zed/issues/14225))
If Zed is selecting your integrated GPU instead of your discrete GPU, you can fix this by exporting the environment variable `DRI_PRIME=1` before running Zed.
On some systems the file `/etc/prime-discrete` can be used to enforce the use of a discrete GPU using [PRIME](https://wiki.archlinux.org/title/PRIME). Depending on the details of your setup, you may need to change the contents of this file to "on" (to force discrete graphics) or "off" (to force integrated graphics).
On others, you may be able to the environment variable `DRI_PRIME=1` when running Zed to force the use of the discrete GPU.
If you're using an AMD GPU, you might get a 'Broken Pipe' error. Try using the RADV or Mesa drivers. (See [#13880](https://github.com/zed-industries/zed/issues/13880))
If you are using Mesa, and want more control over which GPU is selected you can run `MESA_VK_DEVICE_SELECT=list zed --foreground` to get a list of available GPUs and then export `MESA_VK_DEVICE_SELECT=xxxx:yyyy` to choose a specific device.
If you are using `amdvlk` you may find that zed only opens when run with `sudo $(which zed)`. To fix this, remove the `amdvlk` and `lib32-amdvlk` packages and install mesa/vulkan instead. ([#14141](https://github.com/zed-industries/zed/issues/14141).
If you have a discrete GPU and you are using [PRIME](https://wiki.archlinux.org/title/PRIME) (e.g. Pop_OS 24.04, ArchLinux, etc) you may be able to configure Zed to work by switching `/etc/prime-discrete` from 'off' to 'on' (or the reverse).
If you are using `amdvlk` you may find that zed only opens when run with `sudo $(which zed)`. To fix this, remove the `amdvlk` and `lib32-amdvlk` packages and install mesa/vulkan instead. ([#14141](https://github.com/zed-industries/zed/issues/14141)).
For more information, the [Arch guide to Vulkan](https://wiki.archlinux.org/title/Vulkan) has some good steps that translate well to most distributions.
If Vulkan is configured correctly, and Zed is still slow for you, please [file an issue](https://github.com/zed-industries/zed) with as much information as possible.
If Vulkan is configured correctly, and Zed is still not working for you, please [file an issue](https://github.com/zed-industries/zed) with as much information as possible.
### I can't open any files
@@ -153,20 +153,3 @@ If you are seeing "too many open files" then first try `sysctl fs.inotify`.
- You should see that `max_user_watches` is 8000 or higher (you can change the limit with `sudo sysctl fs.inotify.max_user_watches=64000`). Zed needs one watch per directory in all your open projects + one per git repository + a handful more for settings, themes, keymaps, extensions.
It is also possible that you are running out of file descriptors. You can check the limits with `ulimit` and update them by editing `/etc/security/limits.conf`.
### FIPS Mode OpenSSL internal error {#fips}
If your machine is running in FIPS mode (`cat /proc/sys/crypto/fips_enabled` is set to `1`) Zed may fail to start and output the following when launched with `zed --foreground`:
```
crypto/fips/fips.c:154: OpenSSL internal error: FATAL FIPS SELFTEST FAILURE
```
As a workaround, remove the bundled `libssl` and `libcrypto` libraries from the `zed.app/lib` directory:
```
rm ~/.local/zed.app/lib/libssl.so.1.1
rm ~/.local/zed.app/lib/libcrypto.so.1.1
```
This will force zed to fallback to the system `libssl` and `libcrypto` libraries.

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