Compare commits

...

48 Commits

Author SHA1 Message Date
Conrad Irwin
decfd9877a Git askpass (#25953)
Supersedes #25848

Release Notes:

- git: Supporting push/pull/fetch when remote requires auth

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
2025-03-06 10:46:00 -07:00
Max Brunsfeld
661e36f736 Clear pending staged/unstaged diff hunks hunks when writing to the git index fails (#26173)
Release Notes:

- Git Beta: Fixed a bug where discarding a hunk in the project diff view
performed two concurrent saves of the buffer.
- Git Beta: Fixed an issue where diff hunks appeared in the wrong state
after failing to write to the git index.
2025-03-06 10:45:52 -07:00
Julia Ryan
c80d25174f Git uncommit warning (#25977)
Adds a prompt when clicking the uncommit button when the current commit
is already present on a remote branch:

![screenshot showing
prompt](https://github.com/user-attachments/assets/d6421875-588e-4db0-aee0-a92f36bce94b)

Release Notes:

- N/A

---------

Co-authored-by: Conrad <conrad@zed.dev>
2025-03-06 10:44:54 -07:00
Peter Tripp
420bb84152 ci: Less Windows CI for PRs (#26155)
Split Windows GHA CI job into `windows_clippy` and `windows_tests`
(`cargo test` and `cargo build`). `windows_clippy` will continue to run
on every PR commit, but `windows_tests` will only be run on main. Tag a
PR `windows` if you would like to run windows tests.

Added a call to the Azure metadata service to detect the Azure hardware
used by the GitHub hosted Windows runners. This is temporary and I'll
remove once I've gathered some data (adds 5-15secs to Windows CI times)

Release Notes:

- N/A

---------

Co-authored-by: Mikayla Maki <mikayla@zed.dev>
2025-03-06 10:43:14 -07:00
Conrad Irwin
b8d1c3c866 git: Make repo selector wider (#26149)
…m_item()

Closes #ISSUE

Release Notes:

- git: Fixed repository selector being too narrow
2025-03-06 10:41:36 -07:00
Mikayla Maki
090c38d872 Fix git branches in non-active repository (#26148)
Release Notes:

- Git Beta: Fixed a bug where the branch selector would only show for
the first repository opened.

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
2025-03-06 10:41:15 -07:00
Joseph T. Lyons
8a0fb9100e zed 0.177.4 2025-03-06 11:06:23 -05:00
0x2CA
664ccc48c8 git: Fix git commit font fallbacks (#26184)
Closes #ISSUE

Release Notes:

- Fixed git commit font_fallbacks
2025-03-06 10:49:57 -05:00
gcp-cherry-pick-bot[bot]
b575bc9a9d Fix panic in commit editor selections syncing (cherry-pick #26186) (#26188)
Cherry-picked Fix panic in commit editor selections syncing (#26186)

Closes #26183 

Release Notes:

- Git Beta: Fixed a panic when selecting text in one of the commit
message editors

Co-authored-by: Cole Miller <cole@zed.dev>
2025-03-05 22:43:13 -05:00
gcp-cherry-pick-bot[bot]
da895a6fd8 git: Add CHERRY_PICK_HEAD to the list of merge heads (cherry-pick #26145) (#26168)
Cherry-picked git: Add CHERRY_PICK_HEAD to the list of merge heads
(#26145)

Attempt to fix an issue where conflicts from a cherry-pick don't get
cleared out of the git panel after being resolved.

Release Notes:

- Git Beta: Fixed resolution of conflicts from cherry-picks not being
reflected in the git panel

Co-authored-by: Cole Miller <cole@zed.dev>
2025-03-05 21:26:21 -05:00
gcp-cherry-pick-bot[bot]
2309721274 Fix panic when expanding a deletion hunk with blame open (cherry-pick #26130) (#26142)
Cherry-picked Fix panic when expanding a deletion hunk with blame open
(#26130)

Closes #26118

Release Notes:

- Fixed a panic when expanding diff hunks while git blame is open

Co-authored-by: Cole Miller <cole@zed.dev>
2025-03-05 13:17:32 -05:00
Marshall Bowers
fa2f982848 Fix language model selector (#26138)
This PR fixes the language model selector.

I tried to piece together the state prior to #25697 (the state it was in
at 11838cf89e) while retaining unrelated
changes that happened since then.

Release Notes:

- Fixed an issue where language models would not be authenticated until
after the model selector was opened (Preview only).
2025-03-05 12:49:42 -05:00
Marshall Bowers
78b460f701 Clean up language model selector (#26134)
This PR does some cleanup for the language model selector after
https://github.com/zed-industries/zed/pull/26090.

Release Notes:

- N/A
2025-03-05 12:49:34 -05:00
Cole Miller
2d5063b5f5 zed 0.177.3 2025-03-05 12:47:49 -05:00
Max Brunsfeld
a87929c5fd Bump Tree-sitter to 0.25.3 for error recovery fixes (#26092)
For https://github.com/tree-sitter/tree-sitter/pull/4257

Release Notes:

- Fixed a hang that could occur when editing certain Zig files.
2025-03-05 09:44:59 -08:00
gcp-cherry-pick-bot[bot]
535c949a1a Fix performance regression in multibuffer diff syncing (cherry-pick #26137) (#26139)
Cherry-picked Fix performance regression in multibuffer diff syncing
(#26137)

This fixes a performance problem introduced in #25906 and caused by
calling `BufferDiff::snapshot` too frequently.

Release Notes:

- Fixed a performance regression related to buffer diffs

Co-authored-by: Conrad <conrad@zed.dev>

Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: Conrad <conrad@zed.dev>
2025-03-05 12:41:37 -05:00
Joseph T. Lyons
4b6fcef379 zed 0.177.2 2025-03-05 08:51:18 -05:00
gcp-cherry-pick-bot[bot]
dc374713d8 Fix diff_hunk_before in a multibuffer (cherry-pick #26059) (#26102)
Cherry-picked Fix diff_hunk_before in a multibuffer (#26059)

Also simplify it to avoid doing a bunch of unnecessary work.

Co-Authored-By: Cole <cole@zed.dev>

Closes #ISSUE

Release Notes:

- git: Fix jumping to the previous diff hunk

---------

Co-authored-by: Cole <cole@zed.dev>

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: Cole <cole@zed.dev>
2025-03-04 21:45:57 -07:00
gcp-cherry-pick-bot[bot]
c084706377 git: Improvements to fetch/push/pull (cherry-pick #26041) (#26051)
Cherry-picked git: Improvements to fetch/push/pull (#26041)

- Add global handlers so these actions can be invoked from the command
palette, etc.
- Tweak spinner to not show itself until a remote has been selected

Release Notes:

- N/A

Co-authored-by: Cole Miller <cole@zed.dev>
2025-03-04 20:09:43 -07:00
gcp-cherry-pick-bot[bot]
578c9f826b Synchronize modal commit editor with panel editor (cherry-pick #26068) (#26093)
Cherry-picked Synchronize modal commit editor with panel editor (#26068)

Release Notes:

- Git Beta: Synchronized selections between the modal editor and the
panel editor
- Git Beta: Allow opening the commit modal even if we're unable to
commit.

Co-authored-by: Mikayla Maki <mikayla@zed.dev>
2025-03-04 20:07:06 -07:00
gcp-cherry-pick-bot[bot]
f06cee40df Fix focus handle leak (cherry-pick #26090) (#26094)
Cherry-picked Fix focus handle leak (#26090)

This fixes a major performance issue in the current git beta.
This PR also removes the PopoverButton component, which was easy to
misuse.

Release Notes:

- Git Beta: Fix frame drops caused by opening the git panel

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
2025-03-04 20:06:55 -07:00
Max Brunsfeld
1f936eccc7 Fix lag when large diff hunk intersects the viewport (#26088)
We were iterating over the row range of a hunk, and inserting into a
hash map for every row.

Release Notes:

- Fixed a performance problem when a large diff hunk was displayed in an
editor.
2025-03-04 16:28:56 -08:00
Joseph T. Lyons
1516ee3e46 zed 0.177.1 2025-03-04 13:04:11 -05:00
gcp-cherry-pick-bot[bot]
53af68aa82 git: Fix project diff shortcuts (cherry-pick #26045) (#26049)
Cherry-picked git: Fix project diff shortcuts (#26045)

Release Notes:

- git: Fix keyboard shortcut display in project diff view
- vim: add git keyboard shortcuts: `d u/d U` for staging/unstaging in
the project diff view. `d o/d O` to show hide/toggle staged in the
editor and `d p` for restoring the hunk.

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-03-04 13:01:18 -05:00
gcp-cherry-pick-bot[bot]
e897f191f6 Git Beta: Fix a few cases of empty toasts showing up (cherry-pick #25985) (#25987)
Cherry-picked Git Beta: Fix a few cases of empty toasts showing up
(#25985)

Improve parsing of git remote outputs

Release Notes:

- N/A

Co-authored-by: Mikayla Maki <mikayla@zed.dev>
2025-03-04 10:36:06 -07:00
gcp-cherry-pick-bot[bot]
aba10b73d2 Git fix repo selection (cherry-pick #25996) (#25998)
Cherry-picked Git fix repo selection (#25996)

Release Notes:

- git: Fixed a bug where staging/unstaging of hunks could use the wrong
git repository if you had many open

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-03-04 10:35:53 -07:00
gcp-cherry-pick-bot[bot]
46190bd087 git: Fix race condition loading project diff (cherry-pick #25992) (#25999)
Cherry-picked git: Fix race condition loading project diff (#25992)

Release Notes:

- git: Fixed a race condition where some files would be missing from
project diff

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-03-04 10:35:42 -07:00
Nate Butler
0b360febad git: Add hunk_style setting (#26038)
This PR adds the `git.hunk_style` setting, allowing setting an alternate
style for hunks – specifically the rendering of unstaged hunks.

It has 2 options:

- `transparent` (unstaged hunks are more transparent/less opaque than
staged hunks)
- `pattern (unstaged hunks are indicated by a visual pattern)

We'll possibly explore a VSCode-style "don't show staged hunks", but the
complexity it adds is a bit out of scope for now.

Transparent:

![CleanShot 2025-03-04 at 09 07
09@2x](https://github.com/user-attachments/assets/a74c4286-8264-48a2-bd58-0c582efb4e22)

Pattern:

![CleanShot 2025-03-04 at 09 10
12@2x](https://github.com/user-attachments/assets/4dd3040e-fb36-4670-9279-fcc7a4f12ced)

Release Notes:

- Git Beta: Added `git.hunk_style` setting to allow toggling between git
hunk visual styles.
2025-03-04 11:12:36 -05:00
gcp-cherry-pick-bot[bot]
11d75c42f1 Disable diff hunks for untracked files, even w/ no newline at eof (cherry-pick #25980) (#26004)
Cherry-picked Disable diff hunks for untracked files, even w/ no newline
at eof (#25980)

This fixes an issue where diff hunks were shown for untracked files, but
only if the files did not end with a newline.

Release Notes:

- N/A

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
2025-03-04 07:56:49 -08:00
Joseph T. Lyons
b3de2bf740 Reuse existing logic used to generate commit messages to disable commit buttons (#26034)
Also
- Recomputes `suggested_commit_message` and no longer stores it, to
ensure things are always up to date
- Reduces indentation in `render_footer`

Release Notes:

- N/A
2025-03-04 09:06:17 -05:00
Cole Miller
b2f174a622 Revert "git: Use worktree paths in the panel (#25950)" (#25995)
This reverts commit e7b3b8bf03.

Release Notes:

- N/A
2025-03-04 09:06:10 -05:00
gcp-cherry-pick-bot[bot]
a3b7c1d9e3 Fix a panic on Linux theme appearance change (cherry-pick #26019) (#26028)
Cherry-picked Fix a panic on Linux theme appearance change (#26019)

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



21484a2e9d/crates/gpui/src/platform/linux/platform.rs (L517-L519)

`with_common` panicked at `borrow_mut` which is the way it's implemented
for X11, Wayland and Headless Linux counterparts.



21484a2e9d/crates/gpui/src/platform/linux/wayland/client.rs (L722-L724)

By accessing the appearance global instead of a `RefCell` with it, the
panic goes away with one notable side-effect, on Linux only: the first
global's value on `Dark` appearance would be `Light`: it becomes normal
instantly, thanks to



21484a2e9d/crates/workspace/src/workspace.rs (L1083-L1090)

Things work without flickering:



[linux_theme_toggle.webm](https://github.com/user-attachments/assets/0e39ddc0-b4ff-4475-93ff-7b2bd7233628)


Release Notes:

- Fixed a panic on Linux theme appearance change

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
2025-03-04 15:02:10 +02:00
Joseph T. Lyons
f4b83d1fba Make suggested commits placeholders and allow them to be committed (#26006)
This does not fix the bug where, when the commit editor modal is open,
changing the staged file does not update the suggested message in the
commit editor. Conrad mentioned he thought we shouldn't be allowed to
change those when the modal is open, so I'm not attempting to fix that.

Release Notes:

- Made suggested commits placeholders and allow them to be committed.
2025-03-04 02:02:50 -05:00
gcp-cherry-pick-bot[bot]
5de7f1bcd5 Skip .git/lfs FS events (cherry-pick #25927) (#26005)
Cherry-picked Skip .git/lfs FS events (#25927)

Closes https://github.com/zed-industries/zed/issues/25865
Closes https://github.com/zed-industries/zed/pull/25915

In the issue, Zed had caused `.git/lfs/tmp/466102258`-like files to
appear in the directory, which lead to background FS event listener to
handle this as an update, incrementing snapshot's `scan_id`, which lead
to git status rescan, which caused another increment to `status_scan_id`
— incrementing either of the IDs causes the related repo data to be
considered "changed:



41b45eaba7/crates/worktree/src/worktree.rs (L1590-L1605)

hence propagating events to the other parts of the system (e.g. git
blame, which was also active in the issue's case)

```
[2025-03-01T20:01:08+01:00 DEBUG worktree] ignoring event ".git/lfs/tmp/466102258" within unloaded directory
[2025-03-01T20:01:08+01:00 DEBUG worktree] received fs events []
[2025-03-01T20:01:08+01:00 DEBUG worktree] reloading repositories: ["/Users/alex/dev/monorepo/.git"]
[2025-03-01T20:01:08+01:00 DEBUG editor::git::blame] Status of git repositories updated. Regenerating blame data...
[2025-03-01T20:01:08+01:00 DEBUG editor::git::blame] Status of git repositories updated. Regenerating blame data...
[2025-03-01T20:01:08+01:00 DEBUG editor::git::blame] Status of git repositories updated. Regenerating blame data...
```

Due to repo update events sent, another `.git/lfs/tmp/` entry is
created, things start over...

The PR fixes this by ignoring any `.git/lfs/` directory-related FS
events, as needed for the current git status update heuristics.

https://github.com/zed-industries/zed/pull/25915 tried to follow further
and `scan_id` and `status_scan_id` but we do not store all git state in
memory, e.g. head


e0060b92cc/crates/editor/src/editor_tests.rs (L13686)
as

[tests](https://github.com/zed-industries/zed/actions/runs/13631960559/job/38101504549?pr=25915)
show.

Release Notes:

- Improved `.git` scan heuristics

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
2025-03-04 08:38:18 +02:00
gcp-cherry-pick-bot[bot]
375885e6ec Disable Git panel button to open commit editor in certain cases (cherry-pick #26000) (#26001)
Cherry-picked Disable Git panel button to open commit editor in certain
cases (#26000)

Also:

- Internally renames a bit of code to make it easy to identify between
when we are disabling the buttons that open and close the modal editor
(in Git Panel and Project Diff) vs when we are disabling the commit
buttons (in Git Panel and Git commit editor modal).
- Deletes some unused code.

Release Notes:

- Unified disabling / enabling the button to open the Git commit editor
modal in the Git panel with the Project Diff commit button.
- Unified disabling / enabling the commit buttons, for the same cases,
between the Git panel and Git commit editor modal.

Co-authored-by: Joseph T. Lyons <JosephTLyons@gmail.com>
2025-03-04 01:03:07 -05:00
gcp-cherry-pick-bot[bot]
7ab9ec904e git: New enter behaviour (cherry-pick #25986) (#25993)
Cherry-picked git: New enter behaviour (#25986)

Closes #25951

Release Notes:

- git: Update "enter" in the list of changed files to preserve focus. If
you want the old behaviour, hit enter twice.
- git: Follow the cursor, not the scroll anchor, in the list. Although
the scroll anchor was nice for passive scrolling, it broke if you had
changed the overflow scroll settings.

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-03-03 21:40:46 -07:00
gcp-cherry-pick-bot[bot]
94425051a1 Improve consistency with commit button tooltip between Git panel and modal (cherry-pick #25990) (#25991)
Cherry-picked Refactor more code around commit button text (#25990)

Missed this when doing https://github.com/zed-industries/zed/pull/25988

Release Notes:

- N/A

Co-authored-by: Joseph T. Lyons <JosephTLyons@gmail.com>
2025-03-03 22:55:26 -05:00
gcp-cherry-pick-bot[bot]
7bd4a85a29 Use same commit button text in panel and modal (cherry-pick #25988) (#25989)
Cherry-picked Use same commit button text in panel and modal (#25988)

Release Notes:

- Fixed inconsistencies in commit button text between Git panel and
modal.

Co-authored-by: Joseph T. Lyons <JosephTLyons@gmail.com>
2025-03-03 22:32:57 -05:00
gcp-cherry-pick-bot[bot]
7d1b50ea85 vim: Fix key navigation on folded buffer headers (cherry-pick #25944) (#25972)
Cherry-picked vim: Fix key navigation on folded buffer headers (#25944)

Closes #24243

Release Notes:

- vim: Fix j/k on folded multibuffer headers

---------

Co-authored-by: João Marcos <marcospb19@hotmail.com>

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: João Marcos <marcospb19@hotmail.com>
2025-03-03 19:40:24 -07:00
gcp-cherry-pick-bot[bot]
96ce87d2dd Fix toggle fold in deleted hunk (cherry-pick #25967) (#25982)
Cherry-picked Fix toggle fold in deleted hunk (#25967)

Updates #25835
Updates #25951

Closes #ISSUE

Release Notes:

- Fixed toggling folds from within deleted hunks

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-03-03 19:39:52 -07:00
Max Brunsfeld
1c6bf1f9b1 Show git panel footer even when on a detached HEAD (#25968)
Previously, the git panel footer would accidentally hide when not on a
branch.

Release Notes:

- N/A

Co-authored-by: Ben Kunkle <ben.kunkle@gmail.com>
2025-03-03 16:46:14 -08:00
Cole Miller
8d9d14c2b9 git: Use worktree paths in the panel (#25950)
This PR changes the git panel to use worktree-relative paths for its
entries, instead of repository-relative paths as before. Paths that lie
outside the active repository's worktree are no longer shown in the
panel. Note that in both respects this is how the project diff editor
already works, so this PR brings those two pieces of UI into harmony.

Release Notes:

- N/A
2025-03-03 19:16:25 -05:00
Nate Butler
46944b679f git_ui: horizontal is not vertical (#25961)
Fixes an issue where I was missing some brain cells and changed the git
panel's `render_entries` to a `v_flex` instead of an `h_flex`.

But actually, fixes the git panel entries from disappearing when a
scrollbar is rendered.

**Before**

![CleanShot 2025-03-03 at 16 36
52@2x](https://github.com/user-attachments/assets/9dca7b9c-318d-4b3f-ab3e-e7242fa7f73a)

**After**

![CleanShot 2025-03-03 at 16 35
59@2x](https://github.com/user-attachments/assets/c1fe5fb1-ad57-4bca-ace4-365e70a74066)


Closes #25955

Release Notes:

- Git Beta: Fixed an issue where when the git panel would need to scroll
all the items are pushed off the screen.
2025-03-03 19:15:20 -05:00
Nate Butler
b1386bff7b git_ui: Prevent button overflow due to long names (#25940)
- Fix component preview widths for git panel
- Fix buttons getting pushed off the screen in git  panel

Release Notes:

- N/A

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
2025-03-03 19:14:37 -05:00
gcp-cherry-pick-bot[bot]
02204dee06 git: Don't consider $HOME as containing git repository unless it's opened directly (cherry-pick #25948) (#25952)
Cherry-picked git: Don't consider $HOME as containing git repository
unless it's opened directly (#25948)

When a worktree is created, we walk up the ancestors of the root path
trying to find a git repository. In particular, if your `$HOME` is a git
repository and you open some subdirectory of `$HOME` that's *not* a git
repository, we end up scanning `$HOME` and everything under it looking
for changed and untracked files, which is often pretty slow. Consistency
here is not very useful and leads to a bad experience.

This PR adds a special case to not consider `$HOME` as a containing git
repository, unless you ask for it by doing the equivalent of `zed ~`.

Release Notes:

- Changed the behavior of git features to not treat `$HOME` as a git
repository unless opened directly

Co-authored-by: Cole Miller <cole@zed.dev>
2025-03-03 17:38:11 -05:00
gcp-cherry-pick-bot[bot]
a1e613805a Add some logging to debug missing parent git repositories (cherry-pick #25943) (#25946)
Cherry-picked Add some logging to debug missing parent git repositories
(#25943)

We've had some issues reported with git repositories not getting
detected when they're a strict parent of the worktree root. Add a bit
more logging to understand what's going on here.

Release Notes:

- N/A

Co-authored-by: Cole Miller <cole@zed.dev>
2025-03-03 14:25:18 -05:00
gcp-cherry-pick-bot[bot]
5852f2e0a4 Fix missing hunks in project diff after revert (cherry-pick #25906) (#25947)
Cherry-picked Fix missing hunks in project diff after revert (#25906)

Release Notes:

- N/A

Co-authored-by: Cole Miller <cole@zed.dev>
2025-03-03 14:25:05 -05:00
Joseph T. Lyons
3130b46515 v0.177.x preview 2025-03-03 12:42:43 -05:00
94 changed files with 4211 additions and 2167 deletions

View File

@@ -236,12 +236,24 @@ jobs:
if: always()
run: rm -rf ./../.cargo
windows_tests:
windows_clippy:
timeout-minutes: 60
name: (Windows) Run Clippy and tests
name: (Windows) Run Clippy
if: github.repository_owner == 'zed-industries'
runs-on: hosted-windows-2
steps:
# Temporarily Collect some metadata about the hardware behind our runners.
- name: GHA Runner Info
run: |
Invoke-RestMethod -Headers @{"Metadata"="true"} -Method GET -Uri "http://169.254.169.254/metadata/instance/compute?api-version=2023-07-01" |
ConvertTo-Json -Depth 10 |
jq "{ vm_size: .vmSize, location: .location, os_disk_gb: (.storageProfile.osDisk.diskSizeGB | tonumber), rs_disk_gb: (.storageProfile.resourceDisk.size | tonumber / 1024) }"
@{
Cores = (Get-CimInstance Win32_Processor).NumberOfCores
vCPUs = (Get-CimInstance Win32_Processor).NumberOfLogicalProcessors
RamGb = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 2)
cpuid = (Get-CimInstance Win32_Processor).Name.Trim()
} | ConvertTo-Json
# more info here:- https://github.com/rust-lang/cargo/issues/13020
- name: Enable longer pathnames for git
run: git config --system core.longpaths true
@@ -275,6 +287,69 @@ jobs:
working-directory: ${{ env.ZED_WORKSPACE }}
run: ./script/clippy.ps1
- name: Check dev drive space
working-directory: ${{ env.ZED_WORKSPACE }}
# `setup-dev-driver.ps1` creates a 100GB drive, with CI taking up ~45GB of the drive.
run: ./script/exit-ci-if-dev-drive-is-full.ps1 95
# Since the Windows runners are stateful, so we need to remove the config file to prevent potential bug.
- name: Clean CI config file
if: always()
run: |
if (Test-Path "${{ env.CARGO_HOME }}/config.toml") {
Remove-Item -Path "${{ env.CARGO_HOME }}/config.toml" -Force
}
# Windows CI takes twice as long as our other platforms and fast github hosted runners are expensive.
# But we still want to do CI, so let's only run tests on main and come back to this when we're
# ready to self host our Windows CI (e.g. during the push for full Windows support)
windows_tests:
timeout-minutes: 60
name: (Windows) Run Tests
if: ${{ github.repository_owner == 'zed-industries' && (github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'windows')) }}
runs-on: hosted-windows-2
steps:
# Temporarily Collect some metadata about the hardware behind our runners.
- name: GHA Runner Info
run: |
Invoke-RestMethod -Headers @{"Metadata"="true"} -Method GET -Uri "http://169.254.169.254/metadata/instance/compute?api-version=2023-07-01" |
ConvertTo-Json -Depth 10 |
jq "{ vm_size: .vmSize, location: .location, os_disk_gb: (.storageProfile.osDisk.diskSizeGB | tonumber), rs_disk_gb: (.storageProfile.resourceDisk.size | tonumber / 1024) }"
@{
Cores = (Get-CimInstance Win32_Processor).NumberOfCores
vCPUs = (Get-CimInstance Win32_Processor).NumberOfLogicalProcessors
RamGb = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 2)
cpuid = (Get-CimInstance Win32_Processor).Name.Trim()
} | ConvertTo-Json
# more info here:- https://github.com/rust-lang/cargo/issues/13020
- name: Enable longer pathnames for git
run: git config --system core.longpaths true
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
clean: false
- name: Create Dev Drive using ReFS
run: ./script/setup-dev-driver.ps1
# actions/checkout does not let us clone into anywhere outside ${{ github.workspace }}, so we have to copy the clone...
- name: Copy Git Repo to Dev Drive
run: |
Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.ZED_WORKSPACE }}" -Recurse
- name: Cache dependencies
uses: swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
workspaces: ${{ env.ZED_WORKSPACE }}
cache-provider: "github"
- name: Configure CI
run: |
mkdir -p ${{ env.CARGO_HOME }} -ErrorAction Ignore
cp ./.cargo/ci-config.toml ${{ env.CARGO_HOME }}/config.toml
- name: Run tests
uses: ./.github/actions/run_tests_windows
with:
@@ -292,7 +367,10 @@ jobs:
# Since the Windows runners are stateful, so we need to remove the config file to prevent potential bug.
- name: Clean CI config file
if: always()
run: Remove-Item -Path "${{ env.CARGO_HOME }}/config.toml" -Force
run: |
if (Test-Path "${{ env.CARGO_HOME }}/config.toml") {
Remove-Item -Path "${{ env.CARGO_HOME }}/config.toml" -Force
}
bundle-mac:
timeout-minutes: 120

155
Cargo.lock generated
View File

@@ -358,6 +358,19 @@ dependencies = [
"zbus",
]
[[package]]
name = "askpass"
version = "0.1.0"
dependencies = [
"anyhow",
"futures 0.3.31",
"gpui",
"smol",
"tempfile",
"util",
"which 6.0.3",
]
[[package]]
name = "assets"
version = "0.1.0"
@@ -1178,9 +1191,9 @@ dependencies = [
[[package]]
name = "aws-config"
version = "1.5.17"
version = "1.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490aa7465ee685b2ced076bb87ef654a47724a7844e2c7d3af4e749ce5b875dd"
checksum = "50236e4d60fe8458de90a71c0922c761e41755adf091b1b03de1cef537179915"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1271,9 +1284,9 @@ dependencies = [
[[package]]
name = "aws-sdk-bedrockruntime"
version = "1.75.0"
version = "1.74.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ddf7475b6f50a1a5be8edb1bcdf6e4ae00feed5b890d14a3f1f0e14d76f5a16"
checksum = "6938541d1948a543bca23303fec4cff9c36bf0e63b8fa3ae1b337bcb9d5b81af"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1295,9 +1308,9 @@ dependencies = [
[[package]]
name = "aws-sdk-kinesis"
version = "1.62.0"
version = "1.61.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31622345afd0c35d33c1cbba73ccf9fb88e09857413d8963dea2c493e00704d"
checksum = "89f2163d8704e8fdcd51ec6c2e0441c418471e422ee9690451b17a1c46344e1a"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1317,9 +1330,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.77.0"
version = "1.76.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34e87342432a3de0e94e82c99a7cbd9042f99de029ae1f4e368160f9e9929264"
checksum = "66e83401ad7287ad15244d557e35502c2a94105ca5b41d656c391f1a4fc04ca2"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1351,9 +1364,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sso"
version = "1.60.0"
version = "1.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60186fab60b24376d3e33b9ff0a43485f99efd470e3b75a9160c849741d63d56"
checksum = "16ff718c9ee45cc1ebd4774a0e086bb80a6ab752b4902edf1c9f56b86ee1f770"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1373,9 +1386,9 @@ dependencies = [
[[package]]
name = "aws-sdk-ssooidc"
version = "1.61.0"
version = "1.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7033130ce1ee13e6018905b7b976c915963755aef299c1521897679d6cd4f8ef"
checksum = "5183e088715cc135d8d396fdd3bc02f018f0da4c511f53cb8d795b6a31c55809"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1395,9 +1408,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sts"
version = "1.61.0"
version = "1.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5c1cac7677179d622b4448b0d31bcb359185295dc6fca891920cfb17e2b5156"
checksum = "c9f944ef032717596639cea4a2118a3a457268ef51bbb5fde9637e54c465da00"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -1458,9 +1471,9 @@ dependencies = [
[[package]]
name = "aws-smithy-checksums"
version = "0.63.0"
version = "0.62.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db2dc8d842d872529355c72632de49ef8c5a2949a4472f10e802f28cf925770c"
checksum = "f2f45a1c384d7a393026bc5f5c177105aa9fa68e4749653b985707ac27d77295"
dependencies = [
"aws-smithy-http",
"aws-smithy-types",
@@ -1810,7 +1823,7 @@ dependencies = [
"bitflags 2.8.0",
"cexpr",
"clang-sys",
"itertools 0.10.5",
"itertools 0.12.1",
"lazy_static",
"lazycell",
"log",
@@ -1833,7 +1846,7 @@ dependencies = [
"bitflags 2.8.0",
"cexpr",
"clang-sys",
"itertools 0.10.5",
"itertools 0.12.1",
"log",
"prettyplease",
"proc-macro2",
@@ -2404,6 +2417,25 @@ dependencies = [
"cipher",
]
[[package]]
name = "cbindgen"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fce8dd7fcfcbf3a0a87d8f515194b49d6135acab73e18bd380d1d93bb1a15eb"
dependencies = [
"clap",
"heck 0.4.1",
"indexmap",
"log",
"proc-macro2",
"quote",
"serde",
"serde_json",
"syn 2.0.90",
"tempfile",
"toml 0.8.20",
]
[[package]]
name = "cbindgen"
version = "0.28.0"
@@ -2501,9 +2533,9 @@ dependencies = [
[[package]]
name = "chrono"
version = "0.4.40"
version = "0.4.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825"
dependencies = [
"android-tzdata",
"iana-time-zone",
@@ -2511,7 +2543,7 @@ dependencies = [
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
"windows-targets 0.52.6",
]
[[package]]
@@ -3508,10 +3540,11 @@ dependencies = [
[[package]]
name = "crc64fast-nvme"
version = "1.2.0"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4955638f00a809894c947f85a024020a20815b65a5eea633798ea7924edab2b3"
checksum = "d5e2ee08013e3f228d6d2394116c4549a6df77708442c62d887d83f68ef2ee37"
dependencies = [
"cbindgen 0.27.0",
"crc",
]
@@ -5337,9 +5370,11 @@ name = "git"
version = "0.1.0"
dependencies = [
"anyhow",
"askpass",
"async-trait",
"collections",
"derive_more",
"futures 0.3.31",
"git2",
"gpui",
"http_client",
@@ -5353,7 +5388,6 @@ dependencies = [
"serde_json",
"smol",
"sum_tree",
"tempfile",
"text",
"time",
"unindent",
@@ -5397,11 +5431,14 @@ name = "git_ui"
version = "0.1.0"
dependencies = [
"anyhow",
"askpass",
"buffer_diff",
"collections",
"component",
"ctor",
"db",
"editor",
"env_logger 0.11.6",
"feature_flags",
"futures 0.3.31",
"fuzzy",
@@ -5563,7 +5600,7 @@ dependencies = [
"bytemuck",
"calloop",
"calloop-wayland-source",
"cbindgen",
"cbindgen 0.28.0",
"cocoa 0.26.0",
"collections",
"core-foundation 0.9.4",
@@ -7236,9 +7273,9 @@ checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8"
[[package]]
name = "libc"
version = "0.2.170"
version = "0.2.169"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828"
checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
[[package]]
name = "libdbus-sys"
@@ -9765,15 +9802,6 @@ dependencies = [
"indexmap",
]
[[package]]
name = "pgvector"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0e8871b6d7ca78348c6cd29b911b94851f3429f0cd403130ca17f26c1fb91a6"
dependencies = [
"serde",
]
[[package]]
name = "phf"
version = "0.11.2"
@@ -10191,6 +10219,7 @@ version = "0.1.0"
dependencies = [
"aho-corasick",
"anyhow",
"askpass",
"async-trait",
"buffer_diff",
"client",
@@ -10413,7 +10442,7 @@ checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4"
dependencies = [
"bytes 1.10.0",
"heck 0.5.0",
"itertools 0.10.5",
"itertools 0.12.1",
"log",
"multimap 0.10.0",
"once_cell",
@@ -10446,7 +10475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1"
dependencies = [
"anyhow",
"itertools 0.10.5",
"itertools 0.12.1",
"proc-macro2",
"quote",
"syn 2.0.90",
@@ -11012,6 +11041,7 @@ name = "remote"
version = "0.1.0"
dependencies = [
"anyhow",
"askpass",
"async-trait",
"collections",
"fs",
@@ -11032,7 +11062,6 @@ dependencies = [
"tempfile",
"thiserror 1.0.69",
"util",
"which 6.0.3",
]
[[package]]
@@ -11474,9 +11503,9 @@ dependencies = [
[[package]]
name = "rust-embed"
version = "8.6.0"
version = "8.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b3aba5104622db5c9fc61098de54708feb732e7763d7faa2fa625899f00bf6f"
checksum = "fa66af4a4fdd5e7ebc276f115e895611a34739a9c1c01028383d612d550953c0"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
@@ -11485,9 +11514,9 @@ dependencies = [
[[package]]
name = "rust-embed-impl"
version = "8.6.0"
version = "8.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f198c73be048d2c5aa8e12f7960ad08443e56fd39cc26336719fdb4ea0ebaae"
checksum = "6125dbc8867951125eec87294137f4e9c2c96566e61bf72c45095a7c77761478"
dependencies = [
"proc-macro2",
"quote",
@@ -11498,9 +11527,9 @@ dependencies = [
[[package]]
name = "rust-embed-utils"
version = "8.6.0"
version = "8.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a2fcdc9f40c8dc2922842ca9add611ad19f332227fc651d015881ad1552bd9a"
checksum = "2e5347777e9aacb56039b0e1f28785929a8a3b709e87482e7442c72e7c12529d"
dependencies = [
"globset",
"sha2",
@@ -11775,9 +11804,9 @@ dependencies = [
[[package]]
name = "schemars"
version = "0.8.22"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92"
dependencies = [
"dyn-clone",
"indexmap",
@@ -11788,9 +11817,9 @@ dependencies = [
[[package]]
name = "schemars_derive"
version = "0.8.22"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e"
dependencies = [
"proc-macro2",
"quote",
@@ -11853,18 +11882,17 @@ dependencies = [
[[package]]
name = "sea-orm"
version = "1.1.6"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13fba7b2c749b2d0a00303d5cb13e6761e39a4172554bdf930852cac4e7aeabd"
checksum = "00733e5418e8ae3758cdb988c3654174e716230cc53ee2cb884207cf86a23029"
dependencies = [
"async-stream",
"async-trait",
"bigdecimal",
"chrono",
"futures-util",
"futures 0.3.31",
"log",
"ouroboros",
"pgvector",
"rust_decimal",
"sea-orm-macros",
"sea-query",
@@ -11882,9 +11910,9 @@ dependencies = [
[[package]]
name = "sea-orm-macros"
version = "1.1.6"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2568cff8d35d5150b4276cc0dd766192a587f64b6ece60ae3706e0872c4eb209"
checksum = "a98408f82fb4875d41ef469a79944a7da29767c7b3e4028e22188a3dd613b10f"
dependencies = [
"heck 0.4.1",
"proc-macro2",
@@ -14234,9 +14262,9 @@ dependencies = [
[[package]]
name = "tree-sitter"
version = "0.25.2"
version = "0.25.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5168a515fe492af54c5cc8800ff8c840be09fa5168de45838afaecd3e008bce4"
checksum = "b9ac5ea5e7f2f1700842ec071401010b9c59bf735295f6e9fa079c3dc035b167"
dependencies = [
"cc",
"regex",
@@ -14804,9 +14832,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.15.1"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0f540e3240398cce6128b64ba83fdbdd86129c16a3aa1a3a252efd66eb3d587"
checksum = "8c1f41ffb7cf259f1ecc2876861a17e7142e63ead296f671f81f6ae85903e0d6"
dependencies = [
"getrandom 0.3.1",
"serde",
@@ -14908,6 +14936,7 @@ dependencies = [
"multi_buffer",
"nvim-rs",
"parking_lot",
"project",
"project_panel",
"regex",
"release_channel",
@@ -15929,12 +15958,6 @@ dependencies = [
"syn 2.0.90",
]
[[package]]
name = "windows-link"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3"
[[package]]
name = "windows-registry"
version = "0.2.0"
@@ -16770,7 +16793,7 @@ dependencies = [
[[package]]
name = "zed"
version = "0.177.0"
version = "0.177.4"
dependencies = [
"activity_indicator",
"anyhow",

View File

@@ -3,6 +3,7 @@ resolver = "2"
members = [
"crates/activity_indicator",
"crates/anthropic",
"crates/askpass",
"crates/assets",
"crates/assistant",
"crates/assistant2",
@@ -209,6 +210,7 @@ edition = "2021"
activity_indicator = { path = "crates/activity_indicator" }
ai = { path = "crates/ai" }
anthropic = { path = "crates/anthropic" }
askpass = { path = "crates/askpass" }
assets = { path = "crates/assets" }
assistant = { path = "crates/assistant" }
assistant2 = { path = "crates/assistant2" }
@@ -538,7 +540,7 @@ tiny_http = "0.8"
toml = "0.8"
tokio = { version = "1" }
tower-http = "0.4.4"
tree-sitter = { version = "0.25.2", features = ["wasm"] }
tree-sitter = { version = "0.25.3", features = ["wasm"] }
tree-sitter-bash = "0.23"
tree-sitter-c = "0.23"
tree-sitter-cpp = "0.23"

View File

@@ -370,10 +370,10 @@
"ctrl-shift-v": "markdown::OpenPreview",
"ctrl-alt-shift-c": "editor::DisplayCursorNames",
"ctrl-alt-y": "git::ToggleStaged",
"alt-y": ["git::StageAndNext", { "whole_excerpt": false }],
"alt-shift-y": ["git::UnstageAndNext", { "whole_excerpt": false }],
"alt-.": ["editor::GoToHunk", { "center_cursor": true }],
"alt-,": ["editor::GoToPreviousHunk", { "center_cursor": true }]
"alt-y": "git::StageAndNext",
"alt-shift-y": "git::UnstageAndNext",
"alt-.": "editor::GoToHunk",
"alt-,": "editor::GoToPreviousHunk"
}
},
{
@@ -564,8 +564,8 @@
"shift-enter": "editor::ExpandExcerpts",
"ctrl-alt-enter": "editor::OpenExcerptsSplit",
"ctrl-shift-e": "pane::RevealInProjectPanel",
"ctrl-f8": ["editor::GoToHunk", { "center_cursor": true }],
"ctrl-shift-f8": ["editor::GoToPreviousHunk", { "center_cursor": true }],
"ctrl-f8": "editor::GoToHunk",
"ctrl-shift-f8": "editor::GoToPreviousHunk",
"ctrl-enter": "assistant::InlineAssist",
"ctrl-:": "editor::ToggleInlayHints"
}
@@ -739,6 +739,12 @@
"ctrl-enter": "git::Commit"
}
},
{
"context": "AskPass > Editor",
"bindings": {
"enter": "menu::Confirm"
}
},
{
"context": "GitPanel > Editor",
"bindings": {
@@ -749,14 +755,6 @@
"alt-up": "git_panel::FocusChanges"
}
},
{
"context": "GitCommit > Editor",
"use_key_equivalents": true,
"bindings": {
"enter": "editor::Newline",
"ctrl-enter": "git::Commit"
}
},
{
"context": "CollabPanel && not_editing",
"bindings": {

View File

@@ -142,8 +142,8 @@
"cmd-;": "editor::ToggleLineNumbers",
"cmd-alt-z": "git::Restore",
"cmd-alt-y": "git::ToggleStaged",
"cmd-y": ["git::StageAndNext", { "whole_excerpt": false }],
"cmd-shift-y": ["git::UnstageAndNext", { "whole_excerpt": false }],
"cmd-y": "git::StageAndNext",
"cmd-shift-y": "git::UnstageAndNext",
"cmd-'": "editor::ToggleSelectedDiffHunks",
"cmd-\"": "editor::ExpandAllDiffHunks",
"cmd-alt-g b": "editor::ToggleGitBlame",
@@ -642,8 +642,8 @@
"shift-enter": "editor::ExpandExcerpts",
"cmd-alt-enter": "editor::OpenExcerptsSplit",
"cmd-shift-e": "pane::RevealInProjectPanel",
"cmd-f8": ["editor::GoToHunk", { "center_cursor": true }],
"cmd-shift-f8": ["editor::GoToPreviousHunk", { "center_cursor": true }],
"cmd-f8": "editor::GoToHunk",
"cmd-shift-f8": "editor::GoToPreviousHunk",
"ctrl-enter": "assistant::InlineAssist",
"ctrl-:": "editor::ToggleInlayHints"
}
@@ -753,6 +753,13 @@
"cmd-enter": "git::Commit"
}
},
{
"context": "AskPass > Editor",
"use_key_equivalents": true,
"bindings": {
"enter": "menu::Confirm"
}
},
{
"context": "GitPanel > Editor",
"use_key_equivalents": true,
@@ -761,7 +768,8 @@
"cmd-enter": "git::Commit",
"tab": "git_panel::FocusChanges",
"shift-tab": "git_panel::FocusChanges",
"alt-up": "git_panel::FocusChanges"
"alt-up": "git_panel::FocusChanges",
"shift-escape": "git::ExpandCommitEditor"
}
},
{

View File

@@ -42,8 +42,8 @@
"ctrl-alt-shift-b": "editor::GoToTypeDefinitionSplit",
"f2": "editor::GoToDiagnostic",
"shift-f2": "editor::GoToPreviousDiagnostic",
"ctrl-alt-shift-down": ["editor::GoToHunk", { "center_cursor": true }],
"ctrl-alt-shift-up": ["editor::GoToPreviousHunk", { "center_cursor": true }],
"ctrl-alt-shift-down": "editor::GoToHunk",
"ctrl-alt-shift-up": "editor::GoToPreviousHunk",
"ctrl-alt-z": "git::Restore",
"ctrl-home": "editor::MoveToBeginning",
"ctrl-end": "editor::MoveToEnd",

View File

@@ -43,8 +43,8 @@
"ctrl-f12": "editor::GoToDefinitionSplit",
"shift-f12": "editor::FindAllReferences",
"ctrl-shift-f12": "editor::FindAllReferences",
"ctrl-.": ["editor::GoToHunk", { "center_cursor": true }],
"ctrl-,": ["editor::GoToPreviousHunk", { "center_cursor": true }],
"ctrl-.": "editor::GoToHunk",
"ctrl-,": "editor::GoToPreviousHunk",
"ctrl-k ctrl-u": "editor::ConvertToUpperCase",
"ctrl-k ctrl-l": "editor::ConvertToLowerCase",
"shift-alt-m": "markdown::OpenPreviewToTheSide",

View File

@@ -40,8 +40,8 @@
"cmd-alt-shift-b": "editor::GoToTypeDefinitionSplit",
"f2": "editor::GoToDiagnostic",
"shift-f2": "editor::GoToPreviousDiagnostic",
"ctrl-alt-shift-down": ["editor::GoToHunk", { "center_cursor": true }],
"ctrl-alt-shift-up": ["editor::GoToPreviousHunk", { "center_cursor": true }],
"ctrl-alt-shift-down": "editor::GoToHunk",
"ctrl-alt-shift-up": "editor::GoToPreviousHunk",
"cmd-home": "editor::MoveToBeginning",
"cmd-end": "editor::MoveToEnd",
"cmd-shift-home": "editor::SelectToBeginning",

View File

@@ -44,8 +44,8 @@
"alt-cmd-down": "editor::GoToDefinition",
"ctrl-alt-cmd-down": "editor::GoToDefinitionSplit",
"alt-shift-cmd-down": "editor::FindAllReferences",
"ctrl-.": ["editor::GoToHunk", { "center_cursor": true }],
"ctrl-,": ["editor::GoToPreviousHunk", { "center_cursor": true }],
"ctrl-.": "editor::GoToHunk",
"ctrl-,": "editor::GoToPreviousHunk",
"cmd-k cmd-u": "editor::ConvertToUpperCase",
"cmd-k cmd-l": "editor::ConvertToLowerCase",
"cmd-shift-j": "editor::JoinLines",

View File

@@ -238,8 +238,8 @@
"] x": "vim::SelectSmallerSyntaxNode",
"] d": "editor::GoToDiagnostic",
"[ d": "editor::GoToPreviousDiagnostic",
"] c": ["editor::GoToHunk", { "center_cursor": true }],
"[ c": ["editor::GoToPreviousHunk", { "center_cursor": true }],
"] c": "editor::GoToHunk",
"[ c": "editor::GoToPreviousHunk",
"g c": "vim::PushToggleComments"
}
},
@@ -448,7 +448,10 @@
"d": "vim::CurrentLine",
"s": "vim::PushDeleteSurrounds",
"o": "editor::ToggleSelectedDiffHunks", // "d o"
"p": "git::Restore" // "d p"
"shift-o": "git::ToggleStaged",
"p": "git::Restore", // "d p"
"u": "git::StageAndNext", // "d u"
"shift-u": "git::UnstageAndNext" // "d shift-u"
}
},
{

View File

@@ -837,7 +837,15 @@
//
// The minimum column number to show the inline blame information at
// "min_column": 0
}
},
// How git hunks are displayed visually in the editor.
// This setting can take two values:
//
// 1. Show unstaged hunks with a transparent background (default):
// "hunk_style": "transparent"
// 2. Show unstaged hunks with a pattern background:
// "hunk_style": "pattern"
"hunk_style": "transparent"
},
// Configuration for how direnv configuration should be loaded. May take 2 values:
// 1. Load direnv configuration using `direnv export json` directly.
@@ -851,15 +859,7 @@
// Any addition to this list will be merged with the default list.
// Globs are matched relative to the worktree root,
// except when starting with a slash (/) or equivalent in Windows.
"disabled_globs": [
"**/.env*",
"**/*.pem",
"**/*.key",
"**/*.cert",
"**/*.crt",
"**/.dev.vars",
"**/secrets.yml"
],
"disabled_globs": ["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/.dev.vars", "**/secrets.yml"],
// When to show edit predictions previews in buffer.
// This setting takes two possible values:
// 1. Display predictions inline when there are no language server completions available.

21
crates/askpass/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "askpass"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/askpass.rs"
[dependencies]
anyhow.workspace = true
futures.workspace = true
gpui.workspace = true
smol.workspace = true
tempfile.workspace = true
util.workspace = true
which.workspace = true

View File

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

View File

@@ -0,0 +1,194 @@
use std::path::{Path, PathBuf};
use std::time::Duration;
#[cfg(unix)]
use anyhow::Context as _;
use futures::channel::{mpsc, oneshot};
#[cfg(unix)]
use futures::{io::BufReader, AsyncBufReadExt as _};
#[cfg(unix)]
use futures::{select_biased, AsyncWriteExt as _, FutureExt as _};
use futures::{SinkExt, StreamExt};
use gpui::{AsyncApp, BackgroundExecutor, Task};
#[cfg(unix)]
use smol::fs;
#[cfg(unix)]
use smol::{fs::unix::PermissionsExt as _, net::unix::UnixListener};
#[cfg(unix)]
use util::ResultExt as _;
#[derive(PartialEq, Eq)]
pub enum AskPassResult {
CancelledByUser,
Timedout,
}
pub struct AskPassDelegate {
tx: mpsc::UnboundedSender<(String, oneshot::Sender<String>)>,
_task: Task<()>,
}
impl AskPassDelegate {
pub fn new(
cx: &mut AsyncApp,
password_prompt: impl Fn(String, oneshot::Sender<String>, &mut AsyncApp) + Send + Sync + 'static,
) -> Self {
let (tx, mut rx) = mpsc::unbounded::<(String, oneshot::Sender<String>)>();
let task = cx.spawn(|mut cx| async move {
while let Some((prompt, channel)) = rx.next().await {
password_prompt(prompt, channel, &mut cx);
}
});
Self { tx, _task: task }
}
pub async fn ask_password(&mut self, prompt: String) -> anyhow::Result<String> {
let (tx, rx) = oneshot::channel();
self.tx.send((prompt, tx)).await?;
Ok(rx.await?)
}
}
#[cfg(unix)]
pub struct AskPassSession {
script_path: PathBuf,
_askpass_task: Task<()>,
askpass_opened_rx: Option<oneshot::Receiver<()>>,
askpass_kill_master_rx: Option<oneshot::Receiver<()>>,
}
#[cfg(unix)]
impl AskPassSession {
/// This will create a new AskPassSession.
/// You must retain this session until the master process exits.
#[must_use]
pub async fn new(
executor: &BackgroundExecutor,
mut delegate: AskPassDelegate,
) -> anyhow::Result<Self> {
let temp_dir = tempfile::Builder::new().prefix("zed-askpass").tempdir()?;
let askpass_socket = temp_dir.path().join("askpass.sock");
let askpass_script_path = temp_dir.path().join("askpass.sh");
let (askpass_opened_tx, askpass_opened_rx) = oneshot::channel::<()>();
let listener =
UnixListener::bind(&askpass_socket).context("failed to create askpass socket")?;
let (askpass_kill_master_tx, askpass_kill_master_rx) = oneshot::channel::<()>();
let mut kill_tx = Some(askpass_kill_master_tx);
let askpass_task = executor.spawn(async move {
let mut askpass_opened_tx = Some(askpass_opened_tx);
while let Ok((mut stream, _)) = listener.accept().await {
if let Some(askpass_opened_tx) = askpass_opened_tx.take() {
askpass_opened_tx.send(()).ok();
}
let mut buffer = Vec::new();
let mut reader = BufReader::new(&mut stream);
if reader.read_until(b'\0', &mut buffer).await.is_err() {
buffer.clear();
}
let prompt = String::from_utf8_lossy(&buffer);
if let Some(password) = delegate
.ask_password(prompt.to_string())
.await
.context("failed to get askpass password")
.log_err()
{
stream.write_all(password.as_bytes()).await.log_err();
} else {
if let Some(kill_tx) = kill_tx.take() {
kill_tx.send(()).log_err();
}
// note: we expect the caller to drop this task when it's done.
// We need to keep the stream open until the caller is done to avoid
// spurious errors from ssh.
std::future::pending::<()>().await;
drop(stream);
}
}
drop(temp_dir)
});
anyhow::ensure!(
which::which("nc").is_ok(),
"Cannot find `nc` command (netcat), which is required to connect over SSH."
);
// Create an askpass script that communicates back to this process.
let askpass_script = format!(
"{shebang}\n{print_args} | {nc} -U {askpass_socket} 2> /dev/null \n",
// on macOS `brew install netcat` provides the GNU netcat implementation
// which does not support -U.
nc = if cfg!(target_os = "macos") {
"/usr/bin/nc"
} else {
"nc"
},
askpass_socket = askpass_socket.display(),
print_args = "printf '%s\\0' \"$@\"",
shebang = "#!/bin/sh",
);
fs::write(&askpass_script_path, askpass_script).await?;
fs::set_permissions(&askpass_script_path, std::fs::Permissions::from_mode(0o755)).await?;
Ok(Self {
script_path: askpass_script_path,
_askpass_task: askpass_task,
askpass_kill_master_rx: Some(askpass_kill_master_rx),
askpass_opened_rx: Some(askpass_opened_rx),
})
}
pub fn script_path(&self) -> &Path {
&self.script_path
}
// This will run the askpass task forever, resolving as many authentication requests as needed.
// The caller is responsible for examining the result of their own commands and cancelling this
// future when this is no longer needed. Note that this can only be called once, but due to the
// drop order this takes an &mut, so you can `drop()` it after you're done with the master process.
pub async fn run(&mut self) -> AskPassResult {
let connection_timeout = Duration::from_secs(10);
let askpass_opened_rx = self.askpass_opened_rx.take().expect("Only call run once");
let askpass_kill_master_rx = self
.askpass_kill_master_rx
.take()
.expect("Only call run once");
select_biased! {
_ = askpass_opened_rx.fuse() => {
// Note: this await can only resolve after we are dropped.
askpass_kill_master_rx.await.ok();
return AskPassResult::CancelledByUser
}
_ = futures::FutureExt::fuse(smol::Timer::after(connection_timeout)) => {
return AskPassResult::Timedout
}
}
}
}
#[cfg(not(unix))]
pub struct AskPassSession {
path: PathBuf,
}
#[cfg(not(unix))]
impl AskPassSession {
pub async fn new(_: &BackgroundExecutor, _: AskPassDelegate) -> anyhow::Result<Self> {
Ok(Self {
path: PathBuf::new(),
})
}
pub fn script_path(&self) -> &Path {
&self.path
}
pub async fn run(&mut self) -> AskPassResult {
futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(10))).await;
AskPassResult::Timedout
}
}

View File

@@ -35,7 +35,7 @@ use language_model::{
report_assistant_event, LanguageModel, LanguageModelRegistry, LanguageModelRequest,
LanguageModelRequestMessage, LanguageModelTextStream, Role,
};
use language_model_selector::{InlineLanguageModelSelector, LanguageModelSelector};
use language_model_selector::{LanguageModelSelector, LanguageModelSelectorPopoverMenu};
use multi_buffer::MultiBufferRow;
use parking_lot::Mutex;
use project::{CodeAction, ProjectTransaction};
@@ -1589,10 +1589,29 @@ impl Render for PromptEditor {
.w(gutter_dimensions.full_width() + (gutter_dimensions.margin / 2.0))
.justify_center()
.gap_2()
.child(
InlineLanguageModelSelector::new(self.language_model_selector.clone())
.render(window, cx),
)
.child(LanguageModelSelectorPopoverMenu::new(
self.language_model_selector.clone(),
IconButton::new("context", IconName::SettingsAlt)
.shape(IconButtonShape::Square)
.icon_size(IconSize::Small)
.icon_color(Color::Muted),
move |window, cx| {
Tooltip::with_meta(
format!(
"Using {}",
LanguageModelRegistry::read_global(cx)
.active_model()
.map(|model| model.name().0)
.unwrap_or_else(|| "No model selected".into()),
),
None,
"Change Model",
window,
cx,
)
},
gpui::Corner::TopRight,
))
.map(|el| {
let CodegenStatus::Error(error) = self.codegen.read(cx).status(cx) else {
return el;

View File

@@ -19,7 +19,7 @@ use language_model::{
report_assistant_event, LanguageModelRegistry, LanguageModelRequest,
LanguageModelRequestMessage, Role,
};
use language_model_selector::{InlineLanguageModelSelector, LanguageModelSelector};
use language_model_selector::{LanguageModelSelector, LanguageModelSelectorPopoverMenu};
use prompt_store::PromptBuilder;
use settings::{update_settings_file, Settings};
use std::{
@@ -506,7 +506,7 @@ struct PromptEditor {
impl EventEmitter<PromptEditorEvent> for PromptEditor {}
impl Render for PromptEditor {
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 status = &self.codegen.read(cx).status;
let buttons = match status {
CodegenStatus::Idle => {
@@ -641,10 +641,29 @@ impl Render for PromptEditor {
.w_12()
.justify_center()
.gap_2()
.child(
InlineLanguageModelSelector::new(self.language_model_selector.clone())
.render(window, cx),
)
.child(LanguageModelSelectorPopoverMenu::new(
self.language_model_selector.clone(),
IconButton::new("change-model", IconName::SettingsAlt)
.shape(IconButtonShape::Square)
.icon_size(IconSize::Small)
.icon_color(Color::Muted),
move |window, cx| {
Tooltip::with_meta(
format!(
"Using {}",
LanguageModelRegistry::read_global(cx)
.active_model()
.map(|model| model.name().0)
.unwrap_or_else(|| "No model selected".into()),
),
None,
"Change Model",
window,
cx,
)
},
gpui::Corner::TopRight,
))
.children(
if let CodegenStatus::Error(error) = &self.codegen.read(cx).status {
let error_message = SharedString::from(error.to_string());

View File

@@ -1,19 +1,24 @@
use assistant_settings::AssistantSettings;
use fs::Fs;
use gpui::{Entity, FocusHandle};
use language_model_selector::{AssistantLanguageModelSelector, LanguageModelSelector};
use gpui::{Entity, FocusHandle, SharedString};
use language_model::LanguageModelRegistry;
use language_model_selector::{
LanguageModelSelector, LanguageModelSelectorPopoverMenu, ToggleModelSelector,
};
use settings::update_settings_file;
use std::sync::Arc;
use ui::prelude::*;
use ui::{prelude::*, ButtonLike, PopoverMenuHandle, Tooltip};
pub struct AssistantModelSelector {
pub selector: Entity<LanguageModelSelector>,
selector: Entity<LanguageModelSelector>,
menu_handle: PopoverMenuHandle<LanguageModelSelector>,
focus_handle: FocusHandle,
}
impl AssistantModelSelector {
pub(crate) fn new(
fs: Arc<dyn Fs>,
menu_handle: PopoverMenuHandle<LanguageModelSelector>,
focus_handle: FocusHandle,
window: &mut Window,
cx: &mut App,
@@ -33,14 +38,54 @@ impl AssistantModelSelector {
cx,
)
}),
menu_handle,
focus_handle,
}
}
pub fn toggle(&self, window: &mut Window, cx: &mut Context<Self>) {
self.menu_handle.toggle(window, cx);
}
}
impl Render for AssistantModelSelector {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
AssistantLanguageModelSelector::new(self.focus_handle.clone(), self.selector.clone())
.render(window, cx)
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let active_model = LanguageModelRegistry::read_global(cx).active_model();
let focus_handle = self.focus_handle.clone();
let model_name = match active_model {
Some(model) => model.name().0,
_ => SharedString::from("No model selected"),
};
LanguageModelSelectorPopoverMenu::new(
self.selector.clone(),
ButtonLike::new("active-model")
.style(ButtonStyle::Subtle)
.child(
h_flex()
.gap_0p5()
.child(
Label::new(model_name)
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
Icon::new(IconName::ChevronDown)
.color(Color::Muted)
.size(IconSize::XSmall),
),
),
move |window, cx| {
Tooltip::for_action_in(
"Change Model",
&ToggleModelSelector,
&focus_handle,
window,
cx,
)
},
gpui::Corner::BottomRight,
)
.with_handle(self.menu_handle.clone())
}
}

View File

@@ -609,7 +609,7 @@ impl AssistantPanel {
.id("title")
.overflow_x_scroll()
.px(DynamicSpacing::Base08.rems(cx))
.child(Label::new(title).text_ellipsis()),
.child(Label::new(title).truncate()),
)
.child(
h_flex()

View File

@@ -20,6 +20,7 @@ use gpui::{
EventEmitter, FocusHandle, Focusable, FontWeight, Subscription, TextStyle, WeakEntity, Window,
};
use language_model::{LanguageModel, LanguageModelRegistry};
use language_model_selector::ToggleModelSelector;
use parking_lot::Mutex;
use settings::Settings;
use std::cmp;
@@ -102,11 +103,9 @@ impl<T: 'static> Render for PromptEditor<T> {
.items_start()
.cursor(CursorStyle::Arrow)
.on_action(cx.listener(Self::toggle_context_picker))
.on_action(cx.listener(|this, action, window, cx| {
let selector = this.model_selector.read(cx).selector.clone();
selector.update(cx, |selector, cx| {
selector.toggle_model_selector(action, window, cx);
})
.on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
this.model_selector
.update(cx, |model_selector, cx| model_selector.toggle(window, cx));
}))
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::cancel))
@@ -858,6 +857,7 @@ impl PromptEditor<BufferCodegen> {
editor
});
let context_picker_menu_handle = PopoverMenuHandle::default();
let model_selector_menu_handle = PopoverMenuHandle::default();
let context_strip = cx.new(|cx| {
ContextStrip::new(
@@ -881,7 +881,13 @@ impl PromptEditor<BufferCodegen> {
context_strip,
context_picker_menu_handle,
model_selector: cx.new(|cx| {
AssistantModelSelector::new(fs, prompt_editor.focus_handle(cx), window, cx)
AssistantModelSelector::new(
fs,
model_selector_menu_handle,
prompt_editor.focus_handle(cx),
window,
cx,
)
}),
edited_since_done: false,
prompt_history,
@@ -1006,6 +1012,7 @@ impl PromptEditor<TerminalCodegen> {
editor
});
let context_picker_menu_handle = PopoverMenuHandle::default();
let model_selector_menu_handle = PopoverMenuHandle::default();
let context_strip = cx.new(|cx| {
ContextStrip::new(
@@ -1029,7 +1036,13 @@ impl PromptEditor<TerminalCodegen> {
context_strip,
context_picker_menu_handle,
model_selector: cx.new(|cx| {
AssistantModelSelector::new(fs, prompt_editor.focus_handle(cx), window, cx)
AssistantModelSelector::new(
fs,
model_selector_menu_handle.clone(),
prompt_editor.focus_handle(cx),
window,
cx,
)
}),
edited_since_done: false,
prompt_history,

View File

@@ -8,6 +8,7 @@ use gpui::{
TextStyle, WeakEntity,
};
use language_model::LanguageModelRegistry;
use language_model_selector::ToggleModelSelector;
use rope::Point;
use settings::Settings;
use std::time::Duration;
@@ -53,6 +54,7 @@ impl MessageEditor {
let context_store = cx.new(|_cx| ContextStore::new(workspace.clone()));
let context_picker_menu_handle = PopoverMenuHandle::default();
let inline_context_picker_menu_handle = PopoverMenuHandle::default();
let model_selector_menu_handle = PopoverMenuHandle::default();
let editor = cx.new(|cx| {
let mut editor = Editor::auto_height(10, window, cx);
@@ -105,8 +107,15 @@ impl MessageEditor {
context_picker_menu_handle,
inline_context_picker,
inline_context_picker_menu_handle,
model_selector: cx
.new(|cx| AssistantModelSelector::new(fs, editor.focus_handle(cx), window, cx)),
model_selector: cx.new(|cx| {
AssistantModelSelector::new(
fs,
model_selector_menu_handle,
editor.focus_handle(cx),
window,
cx,
)
}),
use_tools: false,
_subscriptions: subscriptions,
}
@@ -297,11 +306,9 @@ impl Render for MessageEditor {
v_flex()
.key_context("MessageEditor")
.on_action(cx.listener(Self::chat))
.on_action(cx.listener(|this, action, window, cx| {
let selector = this.model_selector.read(cx).selector.clone();
selector.update(cx, |this, cx| {
this.toggle_model_selector(action, window, cx);
})
.on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
this.model_selector
.update(cx, |model_selector, cx| model_selector.toggle(window, cx));
}))
.on_action(cx.listener(Self::toggle_context_picker))
.on_action(cx.listener(Self::remove_all_context))

View File

@@ -260,7 +260,7 @@ impl RenderOnce for PastThread {
.start_slot(
div()
.max_w_4_5()
.child(Label::new(summary).size(LabelSize::Small).text_ellipsis()),
.child(Label::new(summary).size(LabelSize::Small).truncate()),
)
.end_slot(
h_flex()
@@ -356,7 +356,7 @@ impl RenderOnce for PastContext {
.start_slot(
div()
.max_w_4_5()
.child(Label::new(summary).size(LabelSize::Small).text_ellipsis()),
.child(Label::new(summary).size(LabelSize::Small).truncate()),
)
.end_slot(
h_flex()

View File

@@ -37,7 +37,9 @@ use language_model::{
LanguageModelImage, LanguageModelProvider, LanguageModelProviderTosView, LanguageModelRegistry,
Role,
};
use language_model_selector::{AssistantLanguageModelSelector, LanguageModelSelector};
use language_model_selector::{
LanguageModelSelector, LanguageModelSelectorPopoverMenu, ToggleModelSelector,
};
use multi_buffer::MultiBufferRow;
use picker::Picker;
use project::lsp_store::LocalLspAdapterDelegate;
@@ -196,6 +198,7 @@ pub struct ContextEditor {
// context editor, we keep a reference here.
dragged_file_worktrees: Vec<Entity<Worktree>>,
language_model_selector: Entity<LanguageModelSelector>,
language_model_selector_menu_handle: PopoverMenuHandle<LanguageModelSelector>,
}
pub const DEFAULT_TAB_TITLE: &str = "New Chat";
@@ -249,21 +252,6 @@ impl ContextEditor {
cx.observe_global_in::<SettingsStore>(window, Self::settings_changed),
];
let fs_clone = fs.clone();
let language_model_selector = cx.new(|cx| {
LanguageModelSelector::new(
move |model, cx| {
update_settings_file::<AssistantSettings>(
fs_clone.clone(),
cx,
move |settings, _| settings.set_model(model.clone()),
);
},
window,
cx,
)
});
let sections = context.read(cx).slash_command_output_sections().to_vec();
let patch_ranges = context.read(cx).patch_ranges().collect::<Vec<_>>();
let slash_commands = context.read(cx).slash_commands().clone();
@@ -276,7 +264,7 @@ impl ContextEditor {
image_blocks: Default::default(),
scroll_position: None,
remote_id: None,
fs,
fs: fs.clone(),
workspace,
project,
pending_slash_command_creases: HashMap::default(),
@@ -288,7 +276,20 @@ impl ContextEditor {
show_accept_terms: false,
slash_menu_handle: Default::default(),
dragged_file_worktrees: Vec::new(),
language_model_selector,
language_model_selector: cx.new(|cx| {
LanguageModelSelector::new(
move |model, cx| {
update_settings_file::<AssistantSettings>(
fs.clone(),
cx,
move |settings, _| settings.set_model(model.clone()),
);
},
window,
cx,
)
}),
language_model_selector_menu_handle: PopoverMenuHandle::default(),
};
this.update_message_headers(cx);
this.update_image_blocks(cx);
@@ -2388,6 +2389,46 @@ impl ContextEditor {
)
}
fn render_language_model_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
let active_model = LanguageModelRegistry::read_global(cx).active_model();
let focus_handle = self.editor().focus_handle(cx).clone();
let model_name = match active_model {
Some(model) => model.name().0,
None => SharedString::from("No model selected"),
};
LanguageModelSelectorPopoverMenu::new(
self.language_model_selector.clone(),
ButtonLike::new("active-model")
.style(ButtonStyle::Subtle)
.child(
h_flex()
.gap_0p5()
.child(
Label::new(model_name)
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
Icon::new(IconName::ChevronDown)
.color(Color::Muted)
.size(IconSize::XSmall),
),
),
move |window, cx| {
Tooltip::for_action_in(
"Change Model",
&ToggleModelSelector,
&focus_handle,
window,
cx,
)
},
gpui::Corner::BottomLeft,
)
.with_handle(self.language_model_selector_menu_handle.clone())
}
fn render_last_error(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let last_error = self.last_error.as_ref()?;
@@ -2832,7 +2873,7 @@ impl Render for ContextEditor {
None
};
let language_model_selector = self.language_model_selector.clone();
let language_model_selector = self.language_model_selector_menu_handle.clone();
v_flex()
.key_context("ContextEditor")
.capture_action(cx.listener(ContextEditor::cancel))
@@ -2845,10 +2886,8 @@ impl Render for ContextEditor {
.on_action(cx.listener(ContextEditor::edit))
.on_action(cx.listener(ContextEditor::assist))
.on_action(cx.listener(ContextEditor::split))
.on_action(move |action, window, cx| {
language_model_selector.update(cx, |this, cx| {
this.toggle_model_selector(action, window, cx);
})
.on_action(move |_: &ToggleModelSelector, window, cx| {
language_model_selector.toggle(window, cx);
})
.size_full()
.children(self.render_notice(cx))
@@ -2887,14 +2926,11 @@ impl Render for ContextEditor {
.gap_1()
.child(self.render_inject_context_menu(cx))
.child(ui::Divider::vertical())
.child(div().pl_0p5().child({
let focus_handle = self.editor().focus_handle(cx).clone();
AssistantLanguageModelSelector::new(
focus_handle,
self.language_model_selector.clone(),
)
.render(window, cx)
})),
.child(
div()
.pl_0p5()
.child(self.render_language_model_selector(cx)),
),
)
.child(
h_flex()

View File

@@ -243,7 +243,7 @@ impl PickerDelegate for SlashCommandDelegate {
Label::new(info.description.clone())
.size(LabelSize::Small)
.color(Color::Muted)
.text_ellipsis(),
.truncate(),
),
),
),

View File

@@ -56,8 +56,8 @@ pub enum DiffHunkSecondaryStatus {
/// A diff hunk resolved to rows in the buffer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffHunk {
/// The buffer range, expressed in terms of rows.
pub row_range: Range<u32>,
/// The buffer range as points.
pub range: Range<Point>,
/// The range in the buffer to which this hunk corresponds.
pub buffer_range: Range<Anchor>,
/// The range in the buffer's diff base text to which this hunk corresponds.
@@ -362,6 +362,7 @@ impl BufferDiffInner {
pending_hunks = secondary.pending_hunks.clone();
}
let max_point = buffer.max_point();
let mut summaries = buffer.summaries_for_anchors_with_payload::<Point, _, _>(anchor_iter);
iter::from_fn(move || loop {
let (start_point, (start_anchor, start_base)) = summaries.next()?;
@@ -371,7 +372,7 @@ impl BufferDiffInner {
continue;
}
if end_point.column > 0 {
if end_point.column > 0 && end_point < max_point {
end_point.row += 1;
end_point.column = 0;
end_anchor = buffer.anchor_before(end_point);
@@ -416,7 +417,7 @@ impl BufferDiffInner {
}
return Some(DiffHunk {
row_range: start_point.row..end_point.row,
range: start_point..end_point,
diff_base_byte_range: start_base..end_base,
buffer_range: start_anchor..end_anchor,
secondary_status,
@@ -442,14 +443,9 @@ impl BufferDiffInner {
let hunk = cursor.item()?;
let range = hunk.buffer_range.to_point(buffer);
let end_row = if range.end.column > 0 {
range.end.row + 1
} else {
range.end.row
};
Some(DiffHunk {
row_range: range.start.row..end_row,
range,
diff_base_byte_range: hunk.diff_base_byte_range.clone(),
buffer_range: hunk.buffer_range.clone(),
// The secondary status is not used by callers of this method.
@@ -667,11 +663,13 @@ impl std::fmt::Debug for BufferDiff {
}
}
#[derive(Clone, Debug)]
pub enum BufferDiffEvent {
DiffChanged {
changed_range: Option<Range<text::Anchor>>,
},
LanguageChanged,
HunksStagedOrUnstaged(Option<Rope>),
}
impl EventEmitter<BufferDiffEvent> for BufferDiff {}
@@ -766,6 +764,17 @@ impl BufferDiff {
self.secondary_diff.clone()
}
pub fn clear_pending_hunks(&mut self, cx: &mut Context<Self>) {
if let Some(secondary_diff) = &self.secondary_diff {
secondary_diff.update(cx, |diff, _| {
diff.inner.pending_hunks.clear();
});
cx.emit(BufferDiffEvent::DiffChanged {
changed_range: Some(Anchor::MIN..Anchor::MAX),
});
}
}
pub fn stage_or_unstage_hunks(
&mut self,
stage: bool,
@@ -788,6 +797,9 @@ impl BufferDiff {
}
});
}
cx.emit(BufferDiffEvent::HunksStagedOrUnstaged(
new_index_text.clone(),
));
if let Some((first, last)) = hunks.first().zip(hunks.last()) {
let changed_range = first.buffer_range.start..last.buffer_range.end;
cx.emit(BufferDiffEvent::DiffChanged {
@@ -904,6 +916,14 @@ impl BufferDiff {
}
}
pub fn hunks<'a>(
&'a self,
buffer_snapshot: &'a text::BufferSnapshot,
cx: &'a App,
) -> impl 'a + Iterator<Item = DiffHunk> {
self.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer_snapshot, cx)
}
pub fn hunks_intersecting_range<'a>(
&'a self,
range: Range<text::Anchor>,
@@ -1136,12 +1156,10 @@ pub fn assert_hunks<Iter>(
let actual_hunks = diff_hunks
.map(|hunk| {
(
hunk.row_range.clone(),
hunk.range.clone(),
&diff_base[hunk.diff_base_byte_range.clone()],
buffer
.text_for_range(
Point::new(hunk.row_range.start, 0)..Point::new(hunk.row_range.end, 0),
)
.text_for_range(hunk.range.clone())
.collect::<String>(),
hunk.status(),
)
@@ -1150,7 +1168,14 @@ pub fn assert_hunks<Iter>(
let expected_hunks: Vec<_> = expected_hunks
.iter()
.map(|(r, s, h, status)| (r.clone(), *s, h.to_string(), *status))
.map(|(r, old_text, new_text, status)| {
(
Point::new(r.start, 0)..Point::new(r.end, 0),
*old_text,
new_text.to_string(),
*status,
)
})
.collect();
assert_eq!(actual_hunks, expected_hunks);

View File

@@ -308,7 +308,7 @@ impl Server {
.add_request_handler(forward_read_only_project_request::<proto::InlayHints>)
.add_request_handler(forward_read_only_project_request::<proto::ResolveInlayHint>)
.add_request_handler(forward_read_only_project_request::<proto::OpenBufferByPath>)
.add_request_handler(forward_read_only_project_request::<proto::GitBranches>)
.add_request_handler(forward_read_only_project_request::<proto::GitGetBranches>)
.add_request_handler(forward_read_only_project_request::<proto::OpenUnstagedDiff>)
.add_request_handler(forward_read_only_project_request::<proto::OpenUncommittedDiff>)
.add_request_handler(
@@ -393,9 +393,6 @@ impl Server {
.add_request_handler(forward_mutating_project_request::<proto::OpenContext>)
.add_request_handler(forward_mutating_project_request::<proto::CreateContext>)
.add_request_handler(forward_mutating_project_request::<proto::SynchronizeContexts>)
.add_request_handler(forward_mutating_project_request::<proto::Push>)
.add_request_handler(forward_mutating_project_request::<proto::Pull>)
.add_request_handler(forward_mutating_project_request::<proto::Fetch>)
.add_request_handler(forward_mutating_project_request::<proto::Stage>)
.add_request_handler(forward_mutating_project_request::<proto::Unstage>)
.add_request_handler(forward_mutating_project_request::<proto::Commit>)
@@ -405,6 +402,9 @@ impl Server {
.add_request_handler(forward_read_only_project_request::<proto::GitCheckoutFiles>)
.add_request_handler(forward_mutating_project_request::<proto::SetIndexText>)
.add_request_handler(forward_mutating_project_request::<proto::OpenCommitMessageBuffer>)
.add_request_handler(forward_mutating_project_request::<proto::GitCreateBranch>)
.add_request_handler(forward_mutating_project_request::<proto::GitChangeBranch>)
.add_request_handler(forward_mutating_project_request::<proto::CheckForPushedCommits>)
.add_message_handler(broadcast_project_message_from_host::<proto::AdvertiseContexts>)
.add_message_handler(update_context)
.add_request_handler({

View File

@@ -2027,6 +2027,15 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
.unwrap()
.downcast::<Editor>()
.unwrap();
let buffer_id_b = editor_b.update(cx_b, |editor_b, cx| {
editor_b
.buffer()
.read(cx)
.as_singleton()
.unwrap()
.read(cx)
.remote_id()
});
// client_b now requests git blame for the open buffer
editor_b.update_in(cx_b, |editor_b, window, cx| {
@@ -2045,6 +2054,7 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
&(0..4)
.map(|row| RowInfo {
buffer_row: Some(row),
buffer_id: Some(buffer_id_b),
..Default::default()
})
.collect::<Vec<_>>(),
@@ -2092,6 +2102,7 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
&(0..4)
.map(|row| RowInfo {
buffer_row: Some(row),
buffer_id: Some(buffer_id_b),
..Default::default()
})
.collect::<Vec<_>>(),
@@ -2127,6 +2138,7 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
&(0..4)
.map(|row| RowInfo {
buffer_row: Some(row),
buffer_id: Some(buffer_id_b),
..Default::default()
})
.collect::<Vec<_>>(),

View File

@@ -6741,19 +6741,24 @@ async fn test_remote_git_branches(
.collect::<HashSet<_>>();
let (project_a, worktree_id) = client_a.build_local_project("/project", cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let root_path = ProjectPath::root_path(worktree_id);
// Client A sees that a guest has joined.
// Client A sees that a guest has joined and the repo has been populated
executor.run_until_parked();
let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap());
let root_path = ProjectPath::root_path(worktree_id);
let branches_b = cx_b
.update(|cx| project_b.update(cx, |project, cx| project.branches(root_path.clone(), cx)))
.update(|cx| repo_b.update(cx, |repository, _| repository.branches()))
.await
.unwrap()
.unwrap();
let new_branch = branches[2];
@@ -6765,13 +6770,10 @@ async fn test_remote_git_branches(
assert_eq!(branches_b, branches_set);
cx_b.update(|cx| {
project_b.update(cx, |project, cx| {
project.update_or_create_branch(root_path.clone(), new_branch.to_string(), cx)
})
})
.await
.unwrap();
cx_b.update(|cx| repo_b.read(cx).change_branch(new_branch.to_string()))
.await
.unwrap()
.unwrap();
executor.run_until_parked();
@@ -6789,11 +6791,21 @@ async fn test_remote_git_branches(
// Also try creating a new branch
cx_b.update(|cx| {
project_b.update(cx, |project, cx| {
project.update_or_create_branch(root_path.clone(), "totally-new-branch".to_string(), cx)
})
repo_b
.read(cx)
.create_branch("totally-new-branch".to_string())
})
.await
.unwrap()
.unwrap();
cx_b.update(|cx| {
repo_b
.read(cx)
.change_branch("totally-new-branch".to_string())
})
.await
.unwrap()
.unwrap();
executor.run_until_parked();

View File

@@ -276,11 +276,13 @@ async fn test_ssh_collaboration_git_branches(
// has some git repositories
executor.run_until_parked();
let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap());
let root_path = ProjectPath::root_path(worktree_id);
let branches_b = cx_b
.update(|cx| project_b.update(cx, |project, cx| project.branches(root_path.clone(), cx)))
.update(|cx| repo_b.read(cx).branches())
.await
.unwrap()
.unwrap();
let new_branch = branches[2];
@@ -292,13 +294,10 @@ async fn test_ssh_collaboration_git_branches(
assert_eq!(&branches_b, &branches_set);
cx_b.update(|cx| {
project_b.update(cx, |project, cx| {
project.update_or_create_branch(root_path.clone(), new_branch.to_string(), cx)
})
})
.await
.unwrap();
cx_b.update(|cx| repo_b.read(cx).change_branch(new_branch.to_string()))
.await
.unwrap()
.unwrap();
executor.run_until_parked();
@@ -318,11 +317,21 @@ async fn test_ssh_collaboration_git_branches(
// Also try creating a new branch
cx_b.update(|cx| {
project_b.update(cx, |project, cx| {
project.update_or_create_branch(root_path.clone(), "totally-new-branch".to_string(), cx)
})
repo_b
.read(cx)
.create_branch("totally-new-branch".to_string())
})
.await
.unwrap()
.unwrap();
cx_b.update(|cx| {
repo_b
.read(cx)
.change_branch("totally-new-branch".to_string())
})
.await
.unwrap()
.unwrap();
executor.run_until_parked();

View File

@@ -226,3 +226,7 @@ impl Item for ComponentPreview {
f(*event)
}
}
// TODO: impl serializable item for component preview so it will restore with the workspace
// ref: https://github.com/zed-industries/zed/blob/32201ac70a501e63dfa2ade9c00f85aea2d4dd94/crates/image_viewer/src/image_viewer.rs#L199
// Use `ImageViewer` as a model for how to do it, except it'll be even simpler

View File

@@ -196,20 +196,6 @@ pub struct DeleteToPreviousWordStart {
pub ignore_newlines: bool,
}
#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GoToHunk {
#[serde(default)]
pub center_cursor: bool,
}
#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GoToPreviousHunk {
#[serde(default)]
pub center_cursor: bool,
}
#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)]
pub struct FoldAtLevel(pub u32);
@@ -240,8 +226,6 @@ impl_actions!(
ExpandExcerptsDown,
ExpandExcerptsUp,
FoldAt,
GoToHunk,
GoToPreviousHunk,
HandleInput,
MoveDownByLines,
MovePageDown,
@@ -323,6 +307,8 @@ gpui::actions!(
GoToDefinition,
GoToDefinitionSplit,
GoToDiagnostic,
GoToHunk,
GoToPreviousHunk,
GoToImplementation,
GoToImplementationSplit,
GoToPreviousDiagnostic,

View File

@@ -1124,6 +1124,11 @@ impl DisplaySnapshot {
self.block_snapshot.is_block_line(BlockRow(display_row.0))
}
pub fn is_folded_buffer_header(&self, display_row: DisplayRow) -> bool {
self.block_snapshot
.is_folded_buffer_header(BlockRow(display_row.0))
}
pub fn soft_wrap_indent(&self, display_row: DisplayRow) -> Option<u32> {
let wrap_row = self
.block_snapshot

View File

@@ -1618,6 +1618,15 @@ impl BlockSnapshot {
cursor.item().map_or(false, |t| t.block.is_some())
}
pub(super) fn is_folded_buffer_header(&self, row: BlockRow) -> bool {
let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&());
cursor.seek(&row, Bias::Right, &());
let Some(transform) = cursor.item() else {
return false;
};
matches!(transform.block, Some(Block::FoldedBuffer { .. }))
}
pub(super) fn is_line_replaced(&self, row: MultiBufferRow) -> bool {
let wrap_point = self
.wrap_snapshot

View File

@@ -73,7 +73,7 @@ use futures::{
};
use fuzzy::StringMatchCandidate;
use ::git::{status::FileStatus, Restore};
use ::git::Restore;
use code_context_menus::{
AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu,
CompletionsMenu, ContextMenuOrigin,
@@ -2233,6 +2233,49 @@ impl Editor {
cx.notify();
}
pub fn sync_selections(
&mut self,
other: Entity<Editor>,
cx: &mut Context<Self>,
) -> gpui::Subscription {
let other_selections = other.read(cx).selections.disjoint.to_vec();
self.selections.change_with(cx, |selections| {
selections.select_anchors(other_selections);
});
let other_subscription =
cx.subscribe(&other, |this, other, other_evt, cx| match other_evt {
EditorEvent::SelectionsChanged { local: true } => {
let other_selections = other.read(cx).selections.disjoint.to_vec();
if other_selections.is_empty() {
return;
}
this.selections.change_with(cx, |selections| {
selections.select_anchors(other_selections);
});
}
_ => {}
});
let this_subscription =
cx.subscribe_self::<EditorEvent>(move |this, this_evt, cx| match this_evt {
EditorEvent::SelectionsChanged { local: true } => {
let these_selections = this.selections.disjoint.to_vec();
if these_selections.is_empty() {
return;
}
other.update(cx, |other_editor, cx| {
other_editor.selections.change_with(cx, |selections| {
selections.select_anchors(these_selections);
})
});
}
_ => {}
});
Subscription::join(other_subscription, this_subscription)
}
pub fn change_selections<R>(
&mut self,
autoscroll: Option<Autoscroll>,
@@ -7731,7 +7774,7 @@ impl Editor {
for hunk in &hunks {
self.prepare_restore_change(&mut revert_changes, hunk, cx);
}
self.do_stage_or_unstage(false, buffer_id, hunks.into_iter(), window, cx);
self.do_stage_or_unstage(false, buffer_id, hunks.into_iter(), cx);
}
drop(chunk_by);
if !revert_changes.is_empty() {
@@ -11412,14 +11455,13 @@ impl Editor {
}
}
fn go_to_next_hunk(&mut self, action: &GoToHunk, window: &mut Window, cx: &mut Context<Self>) {
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(
&snapshot,
selection.head(),
true,
action.center_cursor,
Direction::Next,
window,
cx,
);
@@ -11429,32 +11471,26 @@ impl Editor {
&mut self,
snapshot: &EditorSnapshot,
position: Point,
after: bool,
scroll_center: bool,
direction: Direction,
window: &mut Window,
cx: &mut Context<Editor>,
) -> Option<MultiBufferDiffHunk> {
let hunk = if after {
) {
let row = if direction == Direction::Next {
self.hunk_after_position(snapshot, position)
.map(|hunk| hunk.row_range.start)
} else {
self.hunk_before_position(snapshot, position)
};
if let Some(hunk) = &hunk {
let destination = Point::new(hunk.row_range.start.0, 0);
let autoscroll = if scroll_center {
Autoscroll::center()
} else {
Autoscroll::fit()
};
if let Some(row) = row {
let destination = Point::new(row.0, 0);
let autoscroll = Autoscroll::center();
self.unfold_ranges(&[destination..destination], false, false, cx);
self.change_selections(Some(autoscroll), window, cx, |s| {
s.select_ranges([destination..destination]);
});
}
hunk
}
fn hunk_after_position(
@@ -11476,7 +11512,7 @@ impl Editor {
fn go_to_prev_hunk(
&mut self,
action: &GoToPreviousHunk,
_: &GoToPreviousHunk,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -11485,8 +11521,7 @@ impl Editor {
self.go_to_hunk_after_or_before_position(
&snapshot,
selection.head(),
false,
action.center_cursor,
Direction::Prev,
window,
cx,
);
@@ -11496,7 +11531,7 @@ impl Editor {
&mut self,
snapshot: &EditorSnapshot,
position: Point,
) -> Option<MultiBufferDiffHunk> {
) -> Option<MultiBufferRow> {
snapshot
.buffer_snapshot
.diff_hunk_before(position)
@@ -12965,13 +13000,18 @@ impl Editor {
}
} else {
let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx);
let buffer_ids: HashSet<_> = multi_buffer_snapshot
.ranges_to_buffer_ranges(self.selections.disjoint_anchor_ranges())
.map(|(snapshot, _, _)| snapshot.remote_id())
let buffer_ids: HashSet<_> = self
.selections
.disjoint_anchor_ranges()
.flat_map(|range| multi_buffer_snapshot.buffer_ids_for_range(range))
.collect();
let should_unfold = buffer_ids
.iter()
.any(|buffer_id| self.is_buffer_folded(*buffer_id, cx));
for buffer_id in buffer_ids {
if self.is_buffer_folded(buffer_id, cx) {
if should_unfold {
self.unfold_buffer(buffer_id, cx);
} else {
self.fold_buffer(buffer_id, cx);
@@ -13547,148 +13587,116 @@ impl Editor {
pub fn toggle_staged_selected_diff_hunks(
&mut self,
_: &::git::ToggleStaged,
window: &mut Window,
_: &mut Window,
cx: &mut Context<Self>,
) {
let snapshot = self.buffer.read(cx).snapshot(cx);
let ranges: Vec<_> = self.selections.disjoint.iter().map(|s| s.range()).collect();
let stage = self.has_stageable_diff_hunks_in_ranges(&ranges, &snapshot);
self.stage_or_unstage_diff_hunks(stage, &ranges, window, cx);
self.stage_or_unstage_diff_hunks(stage, ranges, cx);
}
pub fn stage_and_next(
&mut self,
action: &::git::StageAndNext,
_: &::git::StageAndNext,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.do_stage_or_unstage_and_next(true, action.whole_excerpt, window, cx);
self.do_stage_or_unstage_and_next(true, window, cx);
}
pub fn unstage_and_next(
&mut self,
action: &::git::UnstageAndNext,
_: &::git::UnstageAndNext,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.do_stage_or_unstage_and_next(false, action.whole_excerpt, window, cx);
self.do_stage_or_unstage_and_next(false, window, cx);
}
pub fn stage_or_unstage_diff_hunks(
&mut self,
stage: bool,
ranges: &[Range<Anchor>],
window: &mut Window,
ranges: Vec<Range<Anchor>>,
cx: &mut Context<Self>,
) {
let snapshot = self.buffer.read(cx).snapshot(cx);
let chunk_by = self
.diff_hunks_in_ranges(&ranges, &snapshot)
.chunk_by(|hunk| hunk.buffer_id);
for (buffer_id, hunks) in &chunk_by {
self.do_stage_or_unstage(stage, buffer_id, hunks, window, cx);
let task = self.save_buffers_for_ranges_if_needed(&ranges, cx);
cx.spawn(|this, mut cx| async move {
task.await?;
this.update(&mut cx, |this, cx| {
let snapshot = this.buffer.read(cx).snapshot(cx);
let chunk_by = this
.diff_hunks_in_ranges(&ranges, &snapshot)
.chunk_by(|hunk| hunk.buffer_id);
for (buffer_id, hunks) in &chunk_by {
this.do_stage_or_unstage(stage, buffer_id, hunks, cx);
}
})
})
.detach_and_log_err(cx);
}
fn save_buffers_for_ranges_if_needed(
&mut self,
ranges: &[Range<Anchor>],
cx: &mut Context<'_, Editor>,
) -> Task<Result<()>> {
let multibuffer = self.buffer.read(cx);
let snapshot = multibuffer.read(cx);
let buffer_ids: HashSet<_> = ranges
.iter()
.flat_map(|range| snapshot.buffer_ids_for_range(range.clone()))
.collect();
drop(snapshot);
let mut buffers = HashSet::default();
for buffer_id in buffer_ids {
if let Some(buffer_entity) = multibuffer.buffer(buffer_id) {
let buffer = buffer_entity.read(cx);
if buffer.file().is_some_and(|file| file.disk_state().exists()) && buffer.is_dirty()
{
buffers.insert(buffer_entity);
}
}
}
if let Some(project) = &self.project {
project.update(cx, |project, cx| project.save_buffers(buffers, cx))
} else {
Task::ready(Ok(()))
}
}
fn do_stage_or_unstage_and_next(
&mut self,
stage: bool,
whole_excerpt: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let mut ranges = self.selections.disjoint_anchor_ranges().collect::<Vec<_>>();
let ranges = self.selections.disjoint_anchor_ranges().collect::<Vec<_>>();
if ranges.iter().any(|range| range.start != range.end) {
self.stage_or_unstage_diff_hunks(stage, &ranges[..], window, cx);
self.stage_or_unstage_diff_hunks(stage, ranges, cx);
return;
}
if !whole_excerpt {
let snapshot = self.snapshot(window, cx);
let newest_range = self.selections.newest::<Point>(cx).range();
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)
});
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 {
center_cursor: true,
},
window,
cx,
);
}
} else if !self.buffer().read(cx).is_singleton() {
self.stage_or_unstage_diff_hunks(stage, &ranges[..], window, cx);
if let Some((excerpt_id, buffer, range)) = self.active_excerpt(cx) {
if buffer.read(cx).is_empty() {
let buffer = buffer.read(cx);
let Some(file) = buffer.file() else {
return;
};
let project_path = project::ProjectPath {
worktree_id: file.worktree_id(cx),
path: file.path().clone(),
};
let Some(project) = self.project.as_ref() else {
return;
};
let Some(repo) = project.read(cx).git_store().read(cx).active_repository()
else {
return;
};
repo.update(cx, |repo, cx| {
let Some(repo_path) = repo.project_path_to_repo_path(&project_path) else {
return;
};
let Some(status) = repo.repository_entry.status_for_path(&repo_path) else {
return;
};
if stage && status.status == FileStatus::Untracked {
repo.stage_entries(vec![repo_path], cx)
.detach_and_log_err(cx);
return;
}
})
}
ranges = vec![multi_buffer::Anchor::range_in_buffer(
excerpt_id,
buffer.read(cx).remote_id(),
range,
)];
self.stage_or_unstage_diff_hunks(stage, &ranges[..], window, cx);
let snapshot = self.buffer().read(cx).snapshot(cx);
let mut point = ranges.last().unwrap().end.to_point(&snapshot);
if point.row < snapshot.max_row().0 {
point.row += 1;
point.column = 0;
point = snapshot.clip_point(point, Bias::Right);
self.change_selections(Some(Autoscroll::top_relative(6)), window, cx, |s| {
s.select_ranges([point..point]);
});
}
return;
}
if run_twice {
self.go_to_next_hunk(&GoToHunk, window, cx);
}
self.stage_or_unstage_diff_hunks(stage, &ranges[..], window, cx);
self.go_to_next_hunk(
&GoToHunk {
center_cursor: true,
},
window,
cx,
);
self.stage_or_unstage_diff_hunks(stage, ranges, cx);
self.go_to_next_hunk(&GoToHunk, window, cx);
}
fn do_stage_or_unstage(
@@ -13696,31 +13704,16 @@ impl Editor {
stage: bool,
buffer_id: BufferId,
hunks: impl Iterator<Item = MultiBufferDiffHunk>,
window: &mut Window,
cx: &mut App,
) {
let Some(project) = self.project.as_ref() else {
return;
};
let Some(buffer) = project.read(cx).buffer_for_id(buffer_id, cx) else {
return;
};
let Some(diff) = self.buffer.read(cx).diff_for(buffer_id) else {
return;
};
) -> Option<()> {
let project = self.project.as_ref()?;
let buffer = project.read(cx).buffer_for_id(buffer_id, cx)?;
let diff = self.buffer.read(cx).diff_for(buffer_id)?;
let buffer_snapshot = buffer.read(cx).snapshot();
let file_exists = buffer_snapshot
.file()
.is_some_and(|file| file.disk_state().exists());
let Some((repo, path)) = project
.read(cx)
.repository_and_path_for_buffer_id(buffer_id, cx)
else {
log::debug!("no git repo for buffer id");
return;
};
let new_index_text = diff.update(cx, |diff, cx| {
diff.update(cx, |diff, cx| {
diff.stage_or_unstage_hunks(
stage,
&hunks
@@ -13728,7 +13721,7 @@ impl Editor {
buffer_range: hunk.buffer_range,
diff_base_byte_range: hunk.diff_base_byte_range,
secondary_status: hunk.secondary_status,
row_range: 0..0, // unused
range: Point::zero()..Point::zero(), // unused
})
.collect::<Vec<_>>(),
&buffer_snapshot,
@@ -13736,20 +13729,7 @@ impl Editor {
cx,
)
});
if file_exists {
let buffer_store = project.read(cx).buffer_store().clone();
buffer_store
.update(cx, |buffer_store, cx| buffer_store.save_buffer(buffer, cx))
.detach_and_log_err(cx);
}
let recv = repo
.read(cx)
.set_index_text(&path, new_index_text.map(|rope| rope.to_string()));
cx.background_spawn(async move { recv.await? })
.detach_and_notify_err(window, cx);
None
}
pub fn expand_selected_diff_hunks(&mut self, cx: &mut Context<Self>) {
@@ -16036,9 +16016,9 @@ impl Editor {
if let Some(buffer) = multi_buffer.buffer(buffer_id) {
buffer.update(cx, |buffer, cx| {
buffer.edit(
changes.into_iter().map(|(range, text)| {
(range, text.to_string().map(Arc::<str>::from))
}),
changes
.into_iter()
.map(|(range, text)| (range, text.to_string())),
None,
cx,
);
@@ -16260,7 +16240,7 @@ fn get_uncommitted_diff_for_buffer(
}
});
cx.spawn(|mut cx| async move {
let diffs = futures::future::join_all(tasks).await;
let diffs = future::join_all(tasks).await;
buffer
.update(&mut cx, |buffer, cx| {
for diff in diffs.into_iter().flatten() {
@@ -17156,17 +17136,14 @@ impl EditorSnapshot {
for hunk in self.buffer_snapshot.diff_hunks_in_range(
Point::new(query_rows.start.0, 0)..Point::new(query_rows.end.0, 0),
) {
// Deleted hunk is an empty row range, no caret can be placed there and Zed allows to revert it
// when the caret is just above or just below the deleted hunk.
let allow_adjacent = hunk.status().is_deleted();
let related_to_selection = if allow_adjacent {
hunk.row_range.overlaps(&query_rows)
|| hunk.row_range.start == query_rows.end
|| hunk.row_range.end == query_rows.start
} else {
hunk.row_range.overlaps(&query_rows)
};
if related_to_selection {
// Include deleted hunks that are adjacent to the query range, because
// otherwise they would be missed.
let mut intersects_range = hunk.row_range.overlaps(&query_rows);
if hunk.status().is_deleted() {
intersects_range |= hunk.row_range.start == query_rows.end;
intersects_range |= hunk.row_range.end == query_rows.start;
}
if intersects_range {
if !processed_buffer_rows
.entry(hunk.buffer_id)
.or_default()

View File

@@ -11413,7 +11413,7 @@ async fn test_go_to_hunk(executor: BackgroundExecutor, cx: &mut TestAppContext)
cx.update_editor(|editor, window, cx| {
//Wrap around the bottom of the buffer
for _ in 0..3 {
editor.go_to_next_hunk(&GoToHunk::default(), window, cx);
editor.go_to_next_hunk(&GoToHunk, window, cx);
}
});
@@ -11435,7 +11435,7 @@ async fn test_go_to_hunk(executor: BackgroundExecutor, cx: &mut TestAppContext)
cx.update_editor(|editor, window, cx| {
//Wrap around the top of the buffer
for _ in 0..2 {
editor.go_to_prev_hunk(&GoToPreviousHunk::default(), window, cx);
editor.go_to_prev_hunk(&GoToPreviousHunk, window, cx);
}
});
@@ -11455,7 +11455,7 @@ async fn test_go_to_hunk(executor: BackgroundExecutor, cx: &mut TestAppContext)
);
cx.update_editor(|editor, window, cx| {
editor.go_to_prev_hunk(&GoToPreviousHunk::default(), window, cx);
editor.go_to_prev_hunk(&GoToPreviousHunk, window, cx);
});
cx.assert_editor_state(
@@ -11474,7 +11474,7 @@ async fn test_go_to_hunk(executor: BackgroundExecutor, cx: &mut TestAppContext)
);
cx.update_editor(|editor, window, cx| {
editor.go_to_prev_hunk(&GoToPreviousHunk::default(), window, cx);
editor.go_to_prev_hunk(&GoToPreviousHunk, window, cx);
});
cx.assert_editor_state(
@@ -11494,7 +11494,7 @@ async fn test_go_to_hunk(executor: BackgroundExecutor, cx: &mut TestAppContext)
cx.update_editor(|editor, window, cx| {
for _ in 0..2 {
editor.go_to_prev_hunk(&GoToPreviousHunk::default(), window, cx);
editor.go_to_prev_hunk(&GoToPreviousHunk, window, cx);
}
});
@@ -11518,7 +11518,7 @@ async fn test_go_to_hunk(executor: BackgroundExecutor, cx: &mut TestAppContext)
});
cx.update_editor(|editor, window, cx| {
editor.go_to_next_hunk(&GoToHunk::default(), window, cx);
editor.go_to_next_hunk(&GoToHunk, window, cx);
});
cx.assert_editor_state(
@@ -13525,7 +13525,7 @@ async fn test_toggle_selected_diff_hunks(executor: BackgroundExecutor, cx: &mut
executor.run_until_parked();
cx.update_editor(|editor, window, cx| {
editor.go_to_next_hunk(&GoToHunk::default(), window, cx);
editor.go_to_next_hunk(&GoToHunk, window, cx);
editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx);
});
executor.run_until_parked();
@@ -13547,7 +13547,7 @@ async fn test_toggle_selected_diff_hunks(executor: BackgroundExecutor, cx: &mut
cx.update_editor(|editor, window, cx| {
for _ in 0..2 {
editor.go_to_next_hunk(&GoToHunk::default(), window, cx);
editor.go_to_next_hunk(&GoToHunk, window, cx);
editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx);
}
});
@@ -13570,7 +13570,7 @@ async fn test_toggle_selected_diff_hunks(executor: BackgroundExecutor, cx: &mut
);
cx.update_editor(|editor, window, cx| {
editor.go_to_next_hunk(&GoToHunk::default(), window, cx);
editor.go_to_next_hunk(&GoToHunk, window, cx);
editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx);
});
executor.run_until_parked();

View File

@@ -32,15 +32,17 @@ use collections::{BTreeMap, HashMap, HashSet};
use file_icons::FileIcons;
use git::{blame::BlameEntry, status::FileStatus, Oid};
use gpui::{
anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px, quad,
relative, size, svg, transparent_black, Action, AnyElement, App, AvailableSpace, Axis, Bounds,
ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase,
Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox,
Hsla, InteractiveElement, IntoElement, Keystroke, Length, ModifiersChangedEvent, MouseButton,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta,
ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled,
Subscription, TextRun, TextStyleRefinement, Window,
anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, pattern_slash,
point, px, quad, relative, size, solid_background, svg, transparent_black, Action, AnyElement,
App, AvailableSpace, Axis, Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner,
Corners, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity,
Focusable as _, FontId, GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement,
Keystroke, Length, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine,
SharedString, Size, StatefulInteractiveElement, Style, Styled, Subscription, TextRun,
TextStyleRefinement, Window,
};
use inline_completion::Direction;
use itertools::Itertools;
use language::{
language_settings::{
@@ -54,7 +56,7 @@ use multi_buffer::{
Anchor, ExcerptId, ExcerptInfo, ExpandExcerptDirection, MultiBufferPoint, MultiBufferRow,
RowInfo,
};
use project::project_settings::{self, GitGutterSetting, ProjectSettings};
use project::project_settings::{self, GitGutterSetting, GitHunkStyleSetting, ProjectSettings};
use settings::Settings;
use smallvec::{smallvec, SmallVec};
use std::{
@@ -75,7 +77,7 @@ use ui::{
POPOVER_Y_PADDING,
};
use unicode_segmentation::UnicodeSegmentation;
use util::{debug_panic, maybe, RangeExt, ResultExt};
use util::{debug_panic, RangeExt, ResultExt};
use workspace::{item::Item, notifications::NotifyTaskExt};
const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 7.;
@@ -2016,7 +2018,7 @@ impl EditorElement {
scroll_pixel_position: gpui::Point<Pixels>,
gutter_dimensions: &GutterDimensions,
gutter_hitbox: &Hitbox,
rows_with_hunk_bounds: &HashMap<DisplayRow, Bounds<Pixels>>,
display_hunks: &[(DisplayDiffHunk, Option<Hitbox>)],
snapshot: &EditorSnapshot,
window: &mut Window,
cx: &mut App,
@@ -2092,7 +2094,7 @@ impl EditorElement {
gutter_dimensions,
scroll_pixel_position,
gutter_hitbox,
rows_with_hunk_bounds,
display_hunks,
window,
cx,
);
@@ -2110,7 +2112,7 @@ impl EditorElement {
scroll_pixel_position: gpui::Point<Pixels>,
gutter_dimensions: &GutterDimensions,
gutter_hitbox: &Hitbox,
rows_with_hunk_bounds: &HashMap<DisplayRow, Bounds<Pixels>>,
display_hunks: &[(DisplayDiffHunk, Option<Hitbox>)],
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
@@ -2135,7 +2137,7 @@ impl EditorElement {
gutter_dimensions,
scroll_pixel_position,
gutter_hitbox,
rows_with_hunk_bounds,
display_hunks,
window,
cx,
);
@@ -2674,24 +2676,21 @@ impl EditorElement {
window: &mut Window,
cx: &mut App,
) -> Div {
let file_status = maybe!({
let project = self.editor.read(cx).project.as_ref()?.read(cx);
let (repo, path) =
project.repository_and_path_for_buffer_id(for_excerpt.buffer_id, cx)?;
let status = repo.read(cx).repository_entry.status_for_path(&path)?;
Some(status.status)
})
.filter(|_| {
self.editor
.read(cx)
.buffer
.read(cx)
.all_diff_hunks_expanded()
});
let include_root = self
.editor
let editor = self.editor.read(cx);
let file_status = editor
.buffer
.read(cx)
.all_diff_hunks_expanded()
.then(|| {
editor
.project
.as_ref()?
.read(cx)
.status_for_buffer_id(for_excerpt.buffer_id, cx)
})
.flatten();
let include_root = editor
.project
.as_ref()
.map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
@@ -2703,7 +2702,7 @@ impl EditorElement {
let parent_path = path.as_ref().and_then(|path| {
Some(path.parent()?.to_string_lossy().to_string() + std::path::MAIN_SEPARATOR_STR)
});
let focus_handle = self.editor.focus_handle(cx);
let focus_handle = editor.focus_handle(cx);
let colors = cx.theme().colors();
div()
@@ -2722,7 +2721,10 @@ impl EditorElement {
.shadow_md()
.border_1()
.map(|div| {
let border_color = if is_selected && is_folded {
let border_color = if is_selected
&& is_folded
&& focus_handle.contains_focused(window, cx)
{
colors.border_focused
} else {
colors.border
@@ -2773,8 +2775,7 @@ impl EditorElement {
)
})
.children(
self.editor
.read(cx)
editor
.addons
.values()
.filter_map(|addon| {
@@ -4343,7 +4344,7 @@ impl EditorElement {
}
}
fn paint_diff_hunks(layout: &mut EditorLayout, window: &mut Window, cx: &mut App) {
fn paint_gutter_diff_hunks(layout: &mut EditorLayout, window: &mut Window, cx: &mut App) {
let is_light = cx.theme().appearance().is_light();
if layout.display_hunks.is_empty() {
@@ -4413,10 +4414,19 @@ impl EditorElement {
background_color =
background_color.opacity(if is_light { 0.2 } else { 0.32 });
}
// Flatten the background color with the editor color to prevent
// elements below transparent hunks from showing through
let flattened_background_color = cx
.theme()
.colors()
.editor_background
.blend(background_color);
window.paint_quad(quad(
hunk_bounds,
corner_radii,
background_color,
flattened_background_color,
Edges::default(),
transparent_black(),
));
@@ -4544,7 +4554,7 @@ impl EditorElement {
)
});
if show_git_gutter {
Self::paint_diff_hunks(layout, window, cx)
Self::paint_gutter_diff_hunks(layout, window, cx)
}
let highlight_width = 0.275 * layout.position_map.line_height;
@@ -5675,7 +5685,7 @@ fn prepaint_gutter_button(
gutter_dimensions: &GutterDimensions,
scroll_pixel_position: gpui::Point<Pixels>,
gutter_hitbox: &Hitbox,
rows_with_hunk_bounds: &HashMap<DisplayRow, Bounds<Pixels>>,
display_hunks: &[(DisplayDiffHunk, Option<Hitbox>)],
window: &mut Window,
cx: &mut App,
) -> AnyElement {
@@ -5687,9 +5697,23 @@ fn prepaint_gutter_button(
let indicator_size = button.layout_as_root(available_space, window, cx);
let blame_width = gutter_dimensions.git_blame_entries_width;
let gutter_width = rows_with_hunk_bounds
.get(&row)
.map(|bounds| bounds.size.width);
let gutter_width = display_hunks
.binary_search_by(|(hunk, _)| match hunk {
DisplayDiffHunk::Folded { display_row } => display_row.cmp(&row),
DisplayDiffHunk::Unfolded {
display_row_range, ..
} => {
if display_row_range.end <= row {
Ordering::Less
} else if display_row_range.start > row {
Ordering::Greater
} else {
Ordering::Equal
}
}
})
.ok()
.and_then(|ix| Some(display_hunks[ix].1.as_ref()?.size.width));
let left_offset = blame_width.max(gutter_width).unwrap_or_default();
let mut x = left_offset;
@@ -6708,15 +6732,16 @@ 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)
.git
.hunk_style
.map_or(false, |style| matches!(style, GitHunkStyleSetting::Pattern));
for (ix, row_info) in row_infos.iter().enumerate() {
let Some(diff_status) = row_info.diff_status else {
continue;
};
let staged_opacity = if is_light { 0.14 } else { 0.10 };
let unstaged_opacity = 0.04;
let background_color = match diff_status.kind {
DiffHunkStatusKind::Added => cx.theme().colors().version_control_added,
DiffHunkStatusKind::Deleted => {
@@ -6727,15 +6752,34 @@ impl Element for EditorElement {
continue;
}
};
let background_color = if diff_status.has_secondary_hunk() {
background_color.opacity(unstaged_opacity)
let unstaged = diff_status.has_secondary_hunk();
let hunk_opacity = if is_light { 0.16 } else { 0.12 };
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 {
background_color.opacity(staged_opacity)
solid_background(background_color.opacity(if is_light {
0.08
} else {
0.04
}))
};
let background = if unstaged {
unstaged_background
} else {
staged_background
};
highlighted_rows
.entry(start_row + DisplayRow(ix as u32))
.or_insert(background_color.into());
.or_insert(background);
}
let highlighted_ranges = self.editor.read(cx).background_highlights_in_range(
@@ -7185,27 +7229,6 @@ impl Element for EditorElement {
let gutter_settings = EditorSettings::get_global(cx).gutter;
let rows_with_hunk_bounds = display_hunks
.iter()
.filter_map(|(hunk, hitbox)| Some((hunk, hitbox.as_ref()?.bounds)))
.fold(
HashMap::default(),
|mut rows_with_hunk_bounds, (hunk, bounds)| {
match hunk {
DisplayDiffHunk::Folded { display_row } => {
rows_with_hunk_bounds.insert(*display_row, bounds);
}
DisplayDiffHunk::Unfolded {
display_row_range, ..
} => {
for display_row in display_row_range.iter_rows() {
rows_with_hunk_bounds.insert(display_row, bounds);
}
}
}
rows_with_hunk_bounds
},
);
let mut code_actions_indicator = None;
if let Some(newest_selection_head) = newest_selection_head {
let newest_selection_point =
@@ -7255,7 +7278,7 @@ impl Element for EditorElement {
scroll_pixel_position,
&gutter_dimensions,
&gutter_hitbox,
&rows_with_hunk_bounds,
&display_hunks,
window,
cx,
);
@@ -7283,7 +7306,7 @@ impl Element for EditorElement {
scroll_pixel_position,
&gutter_dimensions,
&gutter_hitbox,
&rows_with_hunk_bounds,
&display_hunks,
&snapshot,
window,
cx,
@@ -8795,12 +8818,11 @@ fn diff_hunk_controls(
})
.on_click({
let editor = editor.clone();
move |_event, window, cx| {
move |_event, _window, cx| {
editor.update(cx, |editor, cx| {
editor.stage_or_unstage_diff_hunks(
true,
&[hunk_range.start..hunk_range.start],
window,
vec![hunk_range.start..hunk_range.start],
cx,
);
});
@@ -8823,12 +8845,11 @@ fn diff_hunk_controls(
})
.on_click({
let editor = editor.clone();
move |_event, window, cx| {
move |_event, _window, cx| {
editor.update(cx, |editor, cx| {
editor.stage_or_unstage_diff_hunks(
false,
&[hunk_range.start..hunk_range.start],
window,
vec![hunk_range.start..hunk_range.start],
cx,
);
});
@@ -8873,7 +8894,7 @@ fn diff_hunk_controls(
move |window, cx| {
Tooltip::for_action_in(
"Next Hunk",
&GoToHunk::default(),
&GoToHunk,
&focus_handle,
window,
cx,
@@ -8888,7 +8909,11 @@ fn diff_hunk_controls(
let position =
hunk_range.end.to_point(&snapshot.buffer_snapshot);
editor.go_to_hunk_after_or_before_position(
&snapshot, position, true, true, window, cx,
&snapshot,
position,
Direction::Next,
window,
cx,
);
editor.expand_selected_diff_hunks(cx);
});
@@ -8905,7 +8930,7 @@ fn diff_hunk_controls(
move |window, cx| {
Tooltip::for_action_in(
"Previous Hunk",
&GoToPreviousHunk::default(),
&GoToPreviousHunk,
&focus_handle,
window,
cx,
@@ -8920,7 +8945,11 @@ fn diff_hunk_controls(
let point =
hunk_range.start.to_point(&snapshot.buffer_snapshot);
editor.go_to_hunk_after_or_before_position(
&snapshot, point, false, true, window, cx,
&snapshot,
point,
Direction::Prev,
window,
cx,
);
editor.expand_selected_diff_hunks(cx);
});

View File

@@ -195,9 +195,12 @@ impl GitBlame {
) -> impl 'a + Iterator<Item = Option<BlameEntry>> {
self.sync(cx);
let buffer_id = self.buffer_snapshot.remote_id();
let mut cursor = self.entries.cursor::<u32>(&());
rows.into_iter().map(move |info| {
let row = info.buffer_row?;
let row = info
.buffer_row
.filter(|_| info.buffer_id == Some(buffer_id))?;
cursor.seek_forward(&row, Bias::Right, &());
cursor.item()?.blame.clone()
})
@@ -535,6 +538,7 @@ mod tests {
use serde_json::json;
use settings::SettingsStore;
use std::{cmp, env, ops::Range, path::Path};
use text::BufferId;
use unindent::Unindent as _;
use util::{path, RandomCharIter};
@@ -552,16 +556,18 @@ mod tests {
#[track_caller]
fn assert_blame_rows(
blame: &mut GitBlame,
buffer_id: BufferId,
rows: Range<u32>,
expected: Vec<Option<BlameEntry>>,
cx: &mut Context<GitBlame>,
) {
assert_eq!(
pretty_assertions::assert_eq!(
blame
.blame_for_rows(
&rows
.map(|row| RowInfo {
buffer_row: Some(row),
buffer_id: Some(buffer_id),
..Default::default()
})
.collect::<Vec<_>>(),
@@ -694,6 +700,7 @@ mod tests {
})
.await
.unwrap();
let buffer_id = buffer.update(cx, |buffer, _| buffer.remote_id());
let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
@@ -701,12 +708,13 @@ mod tests {
git_blame.update(cx, |blame, cx| {
// All lines
assert_eq!(
pretty_assertions::assert_eq!(
blame
.blame_for_rows(
&(0..8)
.map(|buffer_row| RowInfo {
buffer_row: Some(buffer_row),
buffer_id: Some(buffer_id),
..Default::default()
})
.collect::<Vec<_>>(),
@@ -725,12 +733,13 @@ mod tests {
]
);
// Subset of lines
assert_eq!(
pretty_assertions::assert_eq!(
blame
.blame_for_rows(
&(1..4)
.map(|buffer_row| RowInfo {
buffer_row: Some(buffer_row),
buffer_id: Some(buffer_id),
..Default::default()
})
.collect::<Vec<_>>(),
@@ -744,12 +753,13 @@ mod tests {
]
);
// Subset of lines, with some not displayed
assert_eq!(
pretty_assertions::assert_eq!(
blame
.blame_for_rows(
&[
RowInfo {
buffer_row: Some(1),
buffer_id: Some(buffer_id),
..Default::default()
},
Default::default(),
@@ -800,6 +810,7 @@ mod tests {
})
.await
.unwrap();
let buffer_id = buffer.update(cx, |buffer, _| buffer.remote_id());
let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
@@ -810,6 +821,7 @@ mod tests {
// lines.
assert_blame_rows(
blame,
buffer_id,
0..4,
vec![
Some(blame_entry("1b1b1b", 0..4)),
@@ -828,6 +840,7 @@ mod tests {
git_blame.update(cx, |blame, cx| {
assert_blame_rows(
blame,
buffer_id,
0..2,
vec![None, Some(blame_entry("1b1b1b", 0..4))],
cx,
@@ -840,6 +853,7 @@ mod tests {
git_blame.update(cx, |blame, cx| {
assert_blame_rows(
blame,
buffer_id,
1..4,
vec![
None,
@@ -852,7 +866,13 @@ mod tests {
// Before we insert a newline at the end, sanity check:
git_blame.update(cx, |blame, cx| {
assert_blame_rows(blame, 3..4, vec![Some(blame_entry("1b1b1b", 0..4))], cx);
assert_blame_rows(
blame,
buffer_id,
3..4,
vec![Some(blame_entry("1b1b1b", 0..4))],
cx,
);
});
// Insert a newline at the end
buffer.update(cx, |buffer, cx| {
@@ -862,6 +882,7 @@ mod tests {
git_blame.update(cx, |blame, cx| {
assert_blame_rows(
blame,
buffer_id,
3..5,
vec![Some(blame_entry("1b1b1b", 0..4)), None],
cx,
@@ -870,7 +891,13 @@ mod tests {
// Before we insert a newline at the start, sanity check:
git_blame.update(cx, |blame, cx| {
assert_blame_rows(blame, 2..3, vec![Some(blame_entry("1b1b1b", 0..4))], cx);
assert_blame_rows(
blame,
buffer_id,
2..3,
vec![Some(blame_entry("1b1b1b", 0..4))],
cx,
);
});
// Usage example
@@ -882,6 +909,7 @@ mod tests {
git_blame.update(cx, |blame, cx| {
assert_blame_rows(
blame,
buffer_id,
2..4,
vec![None, Some(blame_entry("1b1b1b", 0..4))],
cx,

View File

@@ -12,7 +12,7 @@ use gpui::{
};
use itertools::Itertools;
use language::{Buffer, BufferSnapshot, LanguageRegistry};
use multi_buffer::{ExcerptRange, MultiBufferRow};
use multi_buffer::{Anchor, ExcerptRange, MultiBufferRow};
use parking_lot::RwLock;
use project::{FakeFs, Project};
use std::{
@@ -89,6 +89,16 @@ impl EditorTestContext {
Path::new("/root")
}
pub async fn for_editor_in(editor: Entity<Editor>, cx: &mut gpui::VisualTestContext) -> Self {
cx.focus(&editor);
Self {
window: cx.windows()[0],
cx: cx.clone(),
editor,
assertion_cx: AssertionContextManager::new(),
}
}
pub async fn for_editor(editor: WindowHandle<Editor>, cx: &mut gpui::TestAppContext) -> Self {
let editor_view = editor.root(cx).unwrap();
Self {
@@ -381,6 +391,85 @@ impl EditorTestContext {
assert_state_with_diff(&self.editor, &mut self.cx, &expected_diff_text);
}
#[track_caller]
pub fn assert_excerpts_with_selections(&mut self, marked_text: &str) {
let expected_excerpts = marked_text
.strip_prefix("[EXCERPT]\n")
.unwrap()
.split("[EXCERPT]\n")
.collect::<Vec<_>>();
let (multibuffer_snapshot, selections, excerpts) = self.update_editor(|editor, _, cx| {
let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx);
let selections = editor.selections.disjoint_anchors();
let excerpts = multibuffer_snapshot
.excerpts()
.map(|(e_id, snapshot, range)| (e_id, snapshot.clone(), range))
.collect::<Vec<_>>();
(multibuffer_snapshot, selections, excerpts)
});
assert!(
excerpts.len() == expected_excerpts.len(),
"should have {} excerpts, got {}",
expected_excerpts.len(),
excerpts.len()
);
for (ix, (excerpt_id, snapshot, range)) in excerpts.into_iter().enumerate() {
let is_folded = self
.update_editor(|editor, _, cx| editor.is_buffer_folded(snapshot.remote_id(), cx));
let (expected_text, expected_selections) =
marked_text_ranges(expected_excerpts[ix], true);
if expected_text == "[FOLDED]\n" {
assert!(is_folded, "excerpt {} should be folded", ix);
let is_selected = selections.iter().any(|s| s.head().excerpt_id == excerpt_id);
if expected_selections.len() > 0 {
assert!(
is_selected,
"excerpt {} should be selected. Got {:?}",
ix,
self.editor_state()
);
} else {
assert!(!is_selected, "excerpt {} should not be selected", ix);
}
continue;
}
assert!(!is_folded, "excerpt {} should not be folded", ix);
assert_eq!(
multibuffer_snapshot
.text_for_range(Anchor::range_in_buffer(
excerpt_id,
snapshot.remote_id(),
range.context.clone()
))
.collect::<String>(),
expected_text
);
let selections = selections
.iter()
.filter(|s| s.head().excerpt_id == excerpt_id)
.map(|s| {
let head = text::ToOffset::to_offset(&s.head().text_anchor, &snapshot)
- text::ToOffset::to_offset(&range.context.start, &snapshot);
let tail = text::ToOffset::to_offset(&s.head().text_anchor, &snapshot)
- text::ToOffset::to_offset(&range.context.start, &snapshot);
tail..head
})
.collect::<Vec<_>>();
// todo: selections that cross excerpt boundaries..
assert_eq!(
selections, expected_selections,
"excerpt {} has incorrect selections",
ix,
);
}
}
/// Make an assertion about the editor's text and the ranges and directions
/// of its selections using a string containing embedded range markers.
///
@@ -392,6 +481,17 @@ impl EditorTestContext {
self.assert_selections(expected_selections, marked_text.to_string())
}
/// Make an assertion about the editor's text and the ranges and directions
/// of its selections using a string containing embedded range markers.
///
/// See the `util::test::marked_text_ranges` function for more information.
#[track_caller]
pub fn assert_display_state(&mut self, marked_text: &str) {
let (expected_text, expected_selections) = marked_text_ranges(marked_text, true);
pretty_assertions::assert_eq!(self.display_text(), expected_text, "unexpected buffer text");
self.assert_selections(expected_selections, marked_text.to_string())
}
pub fn editor_state(&mut self) -> String {
generate_marked_text(self.buffer_text().as_str(), &self.editor_selections(), true)
}

View File

@@ -522,7 +522,7 @@ impl ExtensionsPage {
extension.authors.join(", ")
))
.size(LabelSize::Small)
.text_ellipsis(),
.truncate(),
)
.child(Label::new("<>").size(LabelSize::Small)),
)
@@ -534,7 +534,7 @@ impl ExtensionsPage {
Label::new(description.clone())
.size(LabelSize::Small)
.color(Color::Default)
.text_ellipsis()
.truncate()
}))
.children(repository_url.map(|repository_url| {
IconButton::new(
@@ -665,7 +665,7 @@ impl ExtensionsPage {
extension.manifest.authors.join(", ")
))
.size(LabelSize::Small)
.text_ellipsis(),
.truncate(),
)
.child(
Label::new(format!(
@@ -683,7 +683,7 @@ impl ExtensionsPage {
Label::new(description.clone())
.size(LabelSize::Small)
.color(Color::Default)
.text_ellipsis()
.truncate()
}))
.child(
h_flex()

View File

@@ -135,6 +135,7 @@ pub trait Fs: Send + Sync {
Arc<dyn Watcher>,
);
fn home_dir(&self) -> Option<PathBuf>;
fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<dyn GitRepository>>;
fn is_fake(&self) -> bool;
async fn is_case_sensitive(&self) -> Result<bool>;
@@ -813,6 +814,10 @@ impl Fs for RealFs {
temp_dir.close()?;
case_sensitive
}
fn home_dir(&self) -> Option<PathBuf> {
Some(paths::home_dir().clone())
}
}
#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
@@ -846,6 +851,7 @@ struct FakeFsState {
metadata_call_count: usize,
read_dir_call_count: usize,
moves: std::collections::HashMap<u64, PathBuf>,
home_dir: Option<PathBuf>,
}
#[cfg(any(test, feature = "test-support"))]
@@ -1031,6 +1037,7 @@ impl FakeFs {
read_dir_call_count: 0,
metadata_call_count: 0,
moves: Default::default(),
home_dir: None,
}),
});
@@ -1441,6 +1448,12 @@ impl FakeFs {
});
}
pub fn set_error_message_for_index_write(&self, dot_git: &Path, message: Option<String>) {
self.with_git_state(dot_git, true, |state| {
state.simulated_index_write_error_message = message;
});
}
pub fn paths(&self, include_dot_git: bool) -> Vec<PathBuf> {
let mut result = Vec::new();
let mut queue = collections::VecDeque::new();
@@ -1524,6 +1537,10 @@ impl FakeFs {
fn simulate_random_delay(&self) -> impl futures::Future<Output = ()> {
self.executor.simulate_random_delay()
}
pub fn set_home_dir(&self, home_dir: PathBuf) {
self.state.lock().home_dir = Some(home_dir);
}
}
#[cfg(any(test, feature = "test-support"))]
@@ -2079,6 +2096,10 @@ impl Fs for FakeFs {
fn as_fake(&self) -> Arc<FakeFs> {
self.this.upgrade().unwrap()
}
fn home_dir(&self) -> Option<PathBuf> {
self.state.lock().home_dir.clone()
}
}
fn chunks(rope: &Rope, line_ending: LineEnding) -> impl Iterator<Item = &str> {

View File

@@ -16,6 +16,7 @@ test-support = []
[dependencies]
anyhow.workspace = true
askpass.workspace = true
async-trait.workspace = true
collections.workspace = true
derive_more.workspace = true
@@ -34,7 +35,7 @@ text.workspace = true
time.workspace = true
url.workspace = true
util.workspace = true
tempfile.workspace = true
futures.workspace = true
[dev-dependencies]
pretty_assertions.workspace = true

View File

@@ -8,9 +8,6 @@ pub mod status;
use anyhow::{anyhow, Context as _, Result};
use gpui::action_with_deprecated_aliases;
use gpui::actions;
use gpui::impl_actions;
use repository::PushOptions;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::ffi::OsStr;
use std::fmt;
@@ -31,28 +28,13 @@ pub static COMMIT_MESSAGE: LazyLock<&'static OsStr> =
LazyLock::new(|| OsStr::new("COMMIT_EDITMSG"));
pub static INDEX_LOCK: LazyLock<&'static OsStr> = LazyLock::new(|| OsStr::new("index.lock"));
#[derive(Debug, Copy, Clone, PartialEq, Deserialize, JsonSchema)]
pub struct Push {
pub options: Option<PushOptions>,
}
#[derive(Debug, Copy, Clone, PartialEq, Deserialize, JsonSchema)]
pub struct StageAndNext {
pub whole_excerpt: bool,
}
#[derive(Debug, Copy, Clone, PartialEq, Deserialize, JsonSchema)]
pub struct UnstageAndNext {
pub whole_excerpt: bool,
}
impl_actions!(git, [Push, StageAndNext, UnstageAndNext]);
actions!(
git,
[
// per-hunk
ToggleStaged,
StageAndNext,
UnstageAndNext,
// per-file
StageFile,
UnstageFile,
@@ -62,10 +44,12 @@ actions!(
RestoreTrackedFiles,
TrashUntrackedFiles,
Uncommit,
Push,
ForcePush,
Pull,
Fetch,
Commit,
ShowCommitEditor,
ExpandCommitEditor
]
);
action_with_deprecated_aliases!(git, RestoreFile, ["editor::RevertFile"]);

View File

@@ -2,7 +2,9 @@ use crate::status::FileStatus;
use crate::GitHostingProviderRegistry;
use crate::{blame::Blame, status::GitStatus};
use anyhow::{anyhow, Context, Result};
use askpass::{AskPassResult, AskPassSession};
use collections::{HashMap, HashSet};
use futures::{select_biased, FutureExt as _};
use git2::BranchType;
use gpui::SharedString;
use parking_lot::Mutex;
@@ -11,8 +13,6 @@ use schemars::JsonSchema;
use serde::Deserialize;
use std::borrow::Borrow;
use std::io::Write as _;
#[cfg(not(windows))]
use std::os::unix::fs::PermissionsExt;
use std::process::Stdio;
use std::sync::LazyLock;
use std::{
@@ -21,9 +21,11 @@ use std::{
sync::Arc,
};
use sum_tree::MapSeekTarget;
use util::command::new_std_command;
use util::command::{new_smol_command, new_std_command};
use util::ResultExt;
pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct Branch {
pub is_head: bool,
@@ -199,10 +201,21 @@ pub trait GitRepository: Send + Sync {
branch_name: &str,
upstream_name: &str,
options: Option<PushOptions>,
askpass: AskPassSession,
) -> Result<RemoteCommandOutput>;
fn pull(&self, branch_name: &str, upstream_name: &str) -> Result<RemoteCommandOutput>;
fn pull(
&self,
branch_name: &str,
upstream_name: &str,
askpass: AskPassSession,
) -> Result<RemoteCommandOutput>;
fn fetch(&self, askpass: AskPassSession) -> Result<RemoteCommandOutput>;
fn get_remotes(&self, branch_name: Option<&str>) -> Result<Vec<Remote>>;
fn fetch(&self) -> Result<RemoteCommandOutput>;
/// returns a list of remote branches that contain HEAD
fn check_for_pushed_commit(&self) -> Result<Vec<SharedString>>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
@@ -427,6 +440,15 @@ impl GitRepository for RealGitRepository {
true
})
.ok();
if let Some(oid) = self
.repository
.lock()
.find_reference("CHERRY_PICK_HEAD")
.ok()
.and_then(|reference| reference.target())
{
shas.push(oid.to_string())
}
shas
}
@@ -563,7 +585,6 @@ impl GitRepository for RealGitRepository {
.args(paths.iter().map(|p| p.as_ref()))
.output()?;
// TODO: Get remote response out of this and show it to the user
if !output.status.success() {
return Err(anyhow!(
"Failed to stage paths:\n{}",
@@ -584,7 +605,6 @@ impl GitRepository for RealGitRepository {
.args(paths.iter().map(|p| p.as_ref()))
.output()?;
// TODO: Get remote response out of this and show it to the user
if !output.status.success() {
return Err(anyhow!(
"Failed to unstage:\n{}",
@@ -610,7 +630,6 @@ impl GitRepository for RealGitRepository {
let output = cmd.output()?;
// TODO: Get remote response out of this and show it to the user
if !output.status.success() {
return Err(anyhow!(
"Failed to commit:\n{}",
@@ -625,15 +644,15 @@ impl GitRepository for RealGitRepository {
branch_name: &str,
remote_name: &str,
options: Option<PushOptions>,
ask_pass: AskPassSession,
) -> Result<RemoteCommandOutput> {
let working_directory = self.working_directory()?;
// We do this on every operation to ensure that the askpass script exists and is executable.
#[cfg(not(windows))]
let (askpass_script_path, _temp_dir) = setup_askpass()?;
let mut command = new_std_command("git");
let mut command = new_smol_command("git");
command
.env("GIT_ASKPASS", ask_pass.script_path())
.env("SSH_ASKPASS", ask_pass.script_path())
.env("SSH_ASKPASS_REQUIRE", "force")
.current_dir(&working_directory)
.args(["push"])
.args(options.map(|option| match option {
@@ -642,91 +661,46 @@ impl GitRepository for RealGitRepository {
}))
.arg(remote_name)
.arg(format!("{}:{}", branch_name, branch_name));
let git_process = command.spawn()?;
#[cfg(not(windows))]
{
command.env("GIT_ASKPASS", askpass_script_path);
}
let output = command.output()?;
if !output.status.success() {
return Err(anyhow!(
"Failed to push:\n{}",
String::from_utf8_lossy(&output.stderr)
));
} else {
return Ok(RemoteCommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
});
}
run_remote_command(ask_pass, git_process)
}
fn pull(&self, branch_name: &str, remote_name: &str) -> Result<RemoteCommandOutput> {
fn pull(
&self,
branch_name: &str,
remote_name: &str,
ask_pass: AskPassSession,
) -> Result<RemoteCommandOutput> {
let working_directory = self.working_directory()?;
// We do this on every operation to ensure that the askpass script exists and is executable.
#[cfg(not(windows))]
let (askpass_script_path, _temp_dir) = setup_askpass()?;
let mut command = new_std_command("git");
let mut command = new_smol_command("git");
command
.env("GIT_ASKPASS", ask_pass.script_path())
.env("SSH_ASKPASS", ask_pass.script_path())
.env("SSH_ASKPASS_REQUIRE", "force")
.current_dir(&working_directory)
.args(["pull"])
.arg(remote_name)
.arg(branch_name);
let git_process = command.spawn()?;
#[cfg(not(windows))]
{
command.env("GIT_ASKPASS", askpass_script_path);
}
let output = command.output()?;
if !output.status.success() {
return Err(anyhow!(
"Failed to pull:\n{}",
String::from_utf8_lossy(&output.stderr)
));
} else {
return Ok(RemoteCommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
});
}
run_remote_command(ask_pass, git_process)
}
fn fetch(&self) -> Result<RemoteCommandOutput> {
fn fetch(&self, ask_pass: AskPassSession) -> Result<RemoteCommandOutput> {
let working_directory = self.working_directory()?;
// We do this on every operation to ensure that the askpass script exists and is executable.
#[cfg(not(windows))]
let (askpass_script_path, _temp_dir) = setup_askpass()?;
let mut command = new_std_command("git");
let mut command = new_smol_command("git");
command
.env("GIT_ASKPASS", ask_pass.script_path())
.env("SSH_ASKPASS", ask_pass.script_path())
.env("SSH_ASKPASS_REQUIRE", "force")
.current_dir(&working_directory)
.args(["fetch", "--all"]);
let git_process = command.spawn()?;
#[cfg(not(windows))]
{
command.env("GIT_ASKPASS", askpass_script_path);
}
let output = command.output()?;
if !output.status.success() {
return Err(anyhow!(
"Failed to fetch:\n{}",
String::from_utf8_lossy(&output.stderr)
));
} else {
return Ok(RemoteCommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
});
}
run_remote_command(ask_pass, git_process)
}
fn get_remotes(&self, branch_name: Option<&str>) -> Result<Vec<Remote>> {
@@ -770,18 +744,88 @@ impl GitRepository for RealGitRepository {
));
}
}
fn check_for_pushed_commit(&self) -> Result<Vec<SharedString>> {
let working_directory = self.working_directory()?;
let git_cmd = |args: &[&str]| -> Result<String> {
let output = new_std_command(&self.git_binary_path)
.current_dir(&working_directory)
.args(args)
.output()?;
if output.status.success() {
Ok(String::from_utf8(output.stdout)?)
} else {
Err(anyhow!(String::from_utf8_lossy(&output.stderr).to_string()))
}
};
let head = git_cmd(&["rev-parse", "HEAD"])
.context("Failed to get HEAD")?
.trim()
.to_owned();
let mut remote_branches = vec![];
let mut add_if_matching = |remote_head: &str| {
if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]) {
if merge_base.trim() == head {
if let Some(s) = remote_head.strip_prefix("refs/remotes/") {
remote_branches.push(s.to_owned().into());
}
}
}
};
// check the main branch of each remote
let remotes = git_cmd(&["remote"]).context("Failed to get remotes")?;
for remote in remotes.lines() {
if let Ok(remote_head) =
git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")])
{
add_if_matching(remote_head.trim());
}
}
// ... and the remote branch that the checked-out one is tracking
if let Ok(remote_head) = git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]) {
add_if_matching(remote_head.trim());
}
Ok(remote_branches)
}
}
#[cfg(not(windows))]
fn setup_askpass() -> Result<(PathBuf, tempfile::TempDir), anyhow::Error> {
let temp_dir = tempfile::Builder::new()
.prefix("zed-git-askpass")
.tempdir()?;
let askpass_script = "#!/bin/sh\necho ''";
let askpass_script_path = temp_dir.path().join("git-askpass.sh");
std::fs::write(&askpass_script_path, askpass_script)?;
std::fs::set_permissions(&askpass_script_path, std::fs::Permissions::from_mode(0o755))?;
Ok((askpass_script_path, temp_dir))
fn run_remote_command(
mut ask_pass: AskPassSession,
git_process: smol::process::Child,
) -> std::result::Result<RemoteCommandOutput, anyhow::Error> {
smol::block_on(async {
select_biased! {
result = ask_pass.run().fuse() => {
match result {
AskPassResult::CancelledByUser => {
Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
}
AskPassResult::Timedout => {
Err(anyhow!("Connecting to host timed out"))?
}
}
}
output = git_process.output().fuse() => {
let output = output?;
if !output.status.success() {
Err(anyhow!(
"Operation failed:\n{}",
String::from_utf8_lossy(&output.stderr)
))
} else {
Ok(RemoteCommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
}
}
})
}
#[derive(Debug, Clone)]
@@ -799,6 +843,7 @@ pub struct FakeGitRepositoryState {
pub statuses: HashMap<RepoPath, FileStatus>,
pub current_branch_name: Option<String>,
pub branches: HashSet<String>,
pub simulated_index_write_error_message: Option<String>,
}
impl FakeGitRepository {
@@ -818,6 +863,7 @@ impl FakeGitRepositoryState {
statuses: Default::default(),
current_branch_name: Default::default(),
branches: Default::default(),
simulated_index_write_error_message: None,
}
}
}
@@ -837,6 +883,9 @@ impl GitRepository for FakeGitRepository {
fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
let mut state = self.state.lock();
if let Some(message) = state.simulated_index_write_error_message.clone() {
return Err(anyhow::anyhow!(message));
}
if let Some(content) = content {
state.index_contents.insert(path.clone(), content);
} else {
@@ -972,21 +1021,31 @@ impl GitRepository for FakeGitRepository {
_branch: &str,
_remote: &str,
_options: Option<PushOptions>,
_ask_pass: AskPassSession,
) -> Result<RemoteCommandOutput> {
unimplemented!()
}
fn pull(&self, _branch: &str, _remote: &str) -> Result<RemoteCommandOutput> {
fn pull(
&self,
_branch: &str,
_remote: &str,
_ask_pass: AskPassSession,
) -> Result<RemoteCommandOutput> {
unimplemented!()
}
fn fetch(&self) -> Result<RemoteCommandOutput> {
fn fetch(&self, _ask_pass: AskPassSession) -> Result<RemoteCommandOutput> {
unimplemented!()
}
fn get_remotes(&self, _branch: Option<&str>) -> Result<Vec<Remote>> {
unimplemented!()
}
fn check_for_pushed_commit(&self) -> Result<Vec<SharedString>> {
unimplemented!()
}
}
fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {

View File

@@ -18,6 +18,7 @@ test-support = ["multi_buffer/test-support"]
[dependencies]
anyhow.workspace = true
askpass.workspace= true
buffer_diff.workspace = true
collections.workspace = true
component.workspace = true
@@ -57,6 +58,8 @@ zed_actions.workspace = true
windows.workspace = true
[dev-dependencies]
ctor.workspace = true
env_logger.workspace = true
editor = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }

View File

@@ -0,0 +1,101 @@
use editor::Editor;
use futures::channel::oneshot;
use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Styled};
use ui::{
div, h_flex, v_flex, ActiveTheme, App, Context, DynamicSpacing, Headline, HeadlineSize, Icon,
IconName, IconSize, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StyledExt, StyledTypography, Window,
};
use workspace::ModalView;
pub(crate) struct AskPassModal {
operation: SharedString,
prompt: SharedString,
editor: Entity<Editor>,
tx: Option<oneshot::Sender<String>>,
}
impl EventEmitter<DismissEvent> for AskPassModal {}
impl ModalView for AskPassModal {}
impl Focusable for AskPassModal {
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
self.editor.focus_handle(cx)
}
}
impl AskPassModal {
pub fn new(
operation: SharedString,
prompt: SharedString,
tx: oneshot::Sender<String>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
if prompt.contains("yes/no") {
editor.set_masked(false, cx);
} else {
editor.set_masked(true, cx);
}
editor
});
Self {
operation,
prompt,
editor,
tx: Some(tx),
}
}
fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
if let Some(tx) = self.tx.take() {
tx.send(self.editor.read(cx).text(cx)).ok();
}
cx.emit(DismissEvent);
}
}
impl Render for AskPassModal {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.key_context("PasswordPrompt")
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(Self::confirm))
.elevation_2(cx)
.size_full()
.font_buffer(cx)
.child(
h_flex()
.px(DynamicSpacing::Base12.rems(cx))
.pt(DynamicSpacing::Base08.rems(cx))
.pb(DynamicSpacing::Base04.rems(cx))
.rounded_t_md()
.w_full()
.gap_1p5()
.child(Icon::new(IconName::GitBranch).size(IconSize::XSmall))
.child(h_flex().gap_1().overflow_x_hidden().child(
div().max_w_96().overflow_x_hidden().text_ellipsis().child(
Headline::new(self.operation.clone()).size(HeadlineSize::XSmall),
),
)),
)
.child(
div()
.text_buffer(cx)
.py_2()
.px_3()
.bg(cx.theme().colors().editor_background)
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.size_full()
.overflow_hidden()
.child(self.prompt.clone())
.child(self.editor.clone()),
)
}
}

View File

@@ -1,18 +1,16 @@
use anyhow::{Context as _, Result};
use anyhow::{anyhow, Context as _};
use fuzzy::{StringMatch, StringMatchCandidate};
use git::repository::Branch;
use gpui::{
rems, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
rems, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Task, Window,
};
use picker::{Picker, PickerDelegate};
use project::{Project, ProjectPath};
use project::git::Repository;
use std::sync::Arc;
use ui::{
prelude::*, HighlightedLabel, ListItem, ListItemSpacing, PopoverMenuHandle, TriggerablePopover,
};
use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing, PopoverMenuHandle};
use util::ResultExt;
use workspace::notifications::DetachAndPromptErr;
use workspace::{ModalView, Workspace};
@@ -30,36 +28,21 @@ pub fn open(
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let project = workspace.project().clone();
let this = cx.entity();
let repository = workspace.project().read(cx).active_repository(cx).clone();
let style = BranchListStyle::Modal;
cx.spawn_in(window, |_, mut cx| async move {
// Modal branch picker has a longer trailoff than a popover one.
let delegate = BranchListDelegate::new(project.clone(), style, 70, &cx).await?;
this.update_in(&mut cx, move |workspace, window, cx| {
workspace.toggle_modal(window, cx, |window, cx| {
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
cx.emit(DismissEvent);
});
let mut list = BranchList::new(project, style, 34., cx);
list._subscription = Some(_subscription);
list.picker = Some(picker);
list
})
})?;
Ok(())
workspace.toggle_modal(window, cx, |window, cx| {
BranchList::new(repository, style, 34., window, cx)
})
.detach_and_prompt_err("Failed to read branches", window, cx, |_, _, _| None)
}
pub fn popover(project: Entity<Project>, window: &mut Window, cx: &mut App) -> Entity<BranchList> {
pub fn popover(
repository: Option<Entity<Repository>>,
window: &mut Window,
cx: &mut App,
) -> Entity<BranchList> {
cx.new(|cx| {
let mut list = BranchList::new(project, BranchListStyle::Popover, 15., cx);
list.reload_branches(window, cx);
let list = BranchList::new(repository, BranchListStyle::Popover, 15., window, cx);
list.focus_handle(cx).focus(window);
list
})
}
@@ -72,59 +55,53 @@ enum BranchListStyle {
pub struct BranchList {
rem_width: f32,
popover_handle: PopoverMenuHandle<Self>,
default_focus_handle: FocusHandle,
project: Entity<Project>,
style: BranchListStyle,
pub picker: Option<Entity<Picker<BranchListDelegate>>>,
_subscription: Option<Subscription>,
}
impl TriggerablePopover for BranchList {
fn menu_handle(
&mut self,
_window: &mut Window,
_cx: &mut gpui::Context<Self>,
) -> PopoverMenuHandle<Self> {
self.popover_handle.clone()
}
pub popover_handle: PopoverMenuHandle<Self>,
pub picker: Entity<Picker<BranchListDelegate>>,
_subscription: Subscription,
}
impl BranchList {
fn new(project: Entity<Project>, style: BranchListStyle, rem_width: f32, cx: &mut App) -> Self {
fn new(
repository: Option<Entity<Repository>>,
style: BranchListStyle,
rem_width: f32,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let popover_handle = PopoverMenuHandle::default();
Self {
project,
picker: None,
rem_width,
popover_handle,
default_focus_handle: cx.focus_handle(),
style,
_subscription: None,
}
}
let all_branches_request = repository
.clone()
.map(|repository| repository.read(cx).branches());
fn reload_branches(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let project = self.project.clone();
let style = self.style;
cx.spawn_in(window, |this, mut cx| async move {
let delegate = BranchListDelegate::new(project, style, 20, &cx).await?;
let picker =
cx.new_window_entity(|window, cx| Picker::uniform_list(delegate, window, cx))?;
let all_branches = all_branches_request
.context("No active repository")?
.await??;
this.update(&mut cx, |branch_list, cx| {
let subscription =
cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| cx.emit(DismissEvent));
branch_list.picker = Some(picker);
branch_list._subscription = Some(subscription);
cx.notify();
this.update_in(&mut cx, |this, window, cx| {
this.picker.update(cx, |picker, cx| {
picker.delegate.all_branches = Some(all_branches);
picker.refresh(window, cx);
})
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
let delegate = BranchListDelegate::new(repository.clone(), style, 20);
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
cx.emit(DismissEvent);
});
Self {
picker,
rem_width,
popover_handle,
_subscription,
}
}
}
impl ModalView for BranchList {}
@@ -132,10 +109,7 @@ impl EventEmitter<DismissEvent> for BranchList {}
impl Focusable for BranchList {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.picker
.as_ref()
.map(|picker| picker.focus_handle(cx))
.unwrap_or_else(|| self.default_focus_handle.clone())
self.picker.focus_handle(cx)
}
}
@@ -143,24 +117,13 @@ impl Render for BranchList {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.w(rems(self.rem_width))
.map(|parent| match self.picker.as_ref() {
Some(picker) => parent.child(picker.clone()).on_mouse_down_out({
let picker = picker.clone();
cx.listener(move |_, _, window, cx| {
picker.update(cx, |this, cx| {
this.cancel(&Default::default(), window, cx);
})
.child(self.picker.clone())
.on_mouse_down_out({
cx.listener(move |this, _, window, cx| {
this.picker.update(cx, |this, cx| {
this.cancel(&Default::default(), window, cx);
})
}),
None => parent.child(
h_flex()
.id("branch-picker-error")
.on_click(
cx.listener(|this, _, window, cx| this.reload_branches(window, cx)),
)
.child("Could not load branches.")
.child("Click to retry"),
),
})
})
}
}
@@ -184,8 +147,8 @@ impl BranchEntry {
pub struct BranchListDelegate {
matches: Vec<BranchEntry>,
all_branches: Vec<Branch>,
project: Entity<Project>,
all_branches: Option<Vec<Branch>>,
repo: Option<Entity<Repository>>,
style: BranchListStyle,
selected_index: usize,
last_query: String,
@@ -194,33 +157,20 @@ pub struct BranchListDelegate {
}
impl BranchListDelegate {
async fn new(
project: Entity<Project>,
fn new(
repo: Option<Entity<Repository>>,
style: BranchListStyle,
branch_name_trailoff_after: usize,
cx: &AsyncApp,
) -> Result<Self> {
let all_branches_request = cx.update(|cx| {
let project = project.read(cx);
let first_worktree = project
.visible_worktrees(cx)
.next()
.context("No worktrees found")?;
let project_path = ProjectPath::root_path(first_worktree.read(cx).id());
anyhow::Ok(project.branches(project_path, cx))
})??;
let all_branches = all_branches_request.await?;
Ok(Self {
) -> Self {
Self {
matches: vec![],
project,
repo,
style,
all_branches,
all_branches: None,
selected_index: 0,
last_query: Default::default(),
branch_name_trailoff_after,
})
}
}
pub fn branch_count(&self) -> usize {
@@ -261,32 +211,31 @@ impl PickerDelegate for BranchListDelegate {
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let Some(mut all_branches) = self.all_branches.clone() else {
return Task::ready(());
};
cx.spawn_in(window, move |picker, mut cx| async move {
let candidates = picker.update(&mut cx, |picker, _| {
const RECENT_BRANCHES_COUNT: usize = 10;
let mut branches = picker.delegate.all_branches.clone();
if query.is_empty() {
if branches.len() > RECENT_BRANCHES_COUNT {
// Truncate list of recent branches
// Do a partial sort to show recent-ish branches first.
branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
rhs.priority_key().cmp(&lhs.priority_key())
});
branches.truncate(RECENT_BRANCHES_COUNT);
}
branches.sort_unstable_by(|lhs, rhs| {
rhs.is_head.cmp(&lhs.is_head).then(lhs.name.cmp(&rhs.name))
const RECENT_BRANCHES_COUNT: usize = 10;
if query.is_empty() {
if all_branches.len() > RECENT_BRANCHES_COUNT {
// Truncate list of recent branches
// Do a partial sort to show recent-ish branches first.
all_branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
rhs.priority_key().cmp(&lhs.priority_key())
});
all_branches.truncate(RECENT_BRANCHES_COUNT);
}
branches
.into_iter()
.enumerate()
.map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
.collect::<Vec<StringMatchCandidate>>()
});
let Some(candidates) = candidates.log_err() else {
return;
};
all_branches.sort_unstable_by(|lhs, rhs| {
rhs.is_head.cmp(&lhs.is_head).then(lhs.name.cmp(&rhs.name))
});
}
let candidates = all_branches
.into_iter()
.enumerate()
.map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
.collect::<Vec<StringMatchCandidate>>();
let matches: Vec<BranchEntry> = if query.is_empty() {
candidates
.into_iter()
@@ -334,14 +283,16 @@ impl PickerDelegate for BranchListDelegate {
return;
};
let current_branch = self.project.update(cx, |project, cx| {
project
.active_repository(cx)
.and_then(|repo| repo.read(cx).current_branch())
.map(|branch| branch.name.to_string())
let current_branch = self.repo.as_ref().map(|repo| {
repo.update(cx, |repo, _| {
repo.current_branch().map(|branch| branch.name.clone())
})
});
if current_branch == Some(branch.name().to_string()) {
if current_branch
.flatten()
.is_some_and(|current_branch| current_branch == branch.name())
{
cx.emit(DismissEvent);
return;
}
@@ -350,19 +301,33 @@ impl PickerDelegate for BranchListDelegate {
let branch = branch.clone();
|picker, mut cx| async move {
let branch_change_task = picker.update(&mut cx, |this, cx| {
let project = this.delegate.project.read(cx);
let branch_to_checkout = match branch {
BranchEntry::Branch(branch) => branch.string,
BranchEntry::History(string) => string,
BranchEntry::NewBranch { name: branch_name } => branch_name,
};
let worktree = project
.visible_worktrees(cx)
.next()
.context("worktree disappeared")?;
let repository = ProjectPath::root_path(worktree.read(cx).id());
let repo = this
.delegate
.repo
.as_ref()
.ok_or_else(|| anyhow!("No active repository"))?
.clone();
anyhow::Ok(project.update_or_create_branch(repository, branch_to_checkout, cx))
let cx = cx.to_async();
anyhow::Ok(async move {
match branch {
BranchEntry::Branch(StringMatch {
string: branch_name,
..
})
| BranchEntry::History(branch_name) => {
cx.update(|cx| repo.read(cx).change_branch(branch_name))?
.await?
}
BranchEntry::NewBranch { name: branch_name } => {
cx.update(|cx| repo.read(cx).create_branch(branch_name.clone()))?
.await??;
cx.update(|cx| repo.read(cx).change_branch(branch_name))?
.await?
}
}
})
})??;
branch_change_task.await?;
@@ -370,7 +335,7 @@ impl PickerDelegate for BranchListDelegate {
picker.update(&mut cx, |_, cx| {
cx.emit(DismissEvent);
Ok::<(), anyhow::Error>(())
anyhow::Ok(())
})
}
})

View File

@@ -2,10 +2,9 @@
use crate::branch_picker::{self, BranchList};
use crate::git_panel::{commit_message_editor, GitPanel};
use git::{Commit, ShowCommitEditor};
use git::Commit;
use panel::{panel_button, panel_editor_style, panel_filled_button};
use project::Project;
use ui::{prelude::*, KeybindingHint, PopoverButton, Tooltip, TriggerablePopover};
use ui::{prelude::*, KeybindingHint, PopoverMenu, Tooltip};
use editor::{Editor, EditorElement};
use gpui::*;
@@ -110,83 +109,68 @@ struct RestoreDock {
impl CommitModal {
pub fn register(workspace: &mut Workspace, _: &mut Window, _cx: &mut Context<Workspace>) {
workspace.register_action(|workspace, _: &ShowCommitEditor, window, cx| {
let Some(git_panel) = workspace.panel::<GitPanel>(cx) else {
return;
};
let (can_commit, conflict) = git_panel.update(cx, |git_panel, cx| {
let can_commit = git_panel.can_commit();
let conflict = git_panel.has_unstaged_conflicts();
if can_commit {
git_panel.set_modal_open(true, cx);
}
(can_commit, conflict)
});
if !can_commit {
let message = if conflict {
"There are still conflicts. You must stage these before committing."
} else {
"No changes to commit."
};
let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
cx.spawn(|_, _| async move {
prompt.await.ok();
})
.detach();
return;
}
let dock = workspace.dock_at_position(git_panel.position(window, cx));
let is_open = dock.read(cx).is_open();
let active_index = dock.read(cx).active_panel_index();
let dock = dock.downgrade();
let restore_dock_position = RestoreDock {
dock,
is_open,
active_index,
};
let project = workspace.project().clone();
workspace.open_panel::<GitPanel>(window, cx);
workspace.toggle_modal(window, cx, move |window, cx| {
CommitModal::new(git_panel, restore_dock_position, project, window, cx)
})
workspace.register_action(|workspace, _: &Commit, window, cx| {
CommitModal::toggle(workspace, window, cx);
});
}
pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<'_, Workspace>) {
let Some(git_panel) = workspace.panel::<GitPanel>(cx) else {
return;
};
git_panel.update(cx, |git_panel, cx| {
git_panel.set_modal_open(true, cx);
});
let dock = workspace.dock_at_position(git_panel.position(window, cx));
let is_open = dock.read(cx).is_open();
let active_index = dock.read(cx).active_panel_index();
let dock = dock.downgrade();
let restore_dock_position = RestoreDock {
dock,
is_open,
active_index,
};
workspace.open_panel::<GitPanel>(window, cx);
workspace.toggle_modal(window, cx, move |window, cx| {
CommitModal::new(git_panel, restore_dock_position, window, cx)
})
}
fn new(
git_panel: Entity<GitPanel>,
restore_dock: RestoreDock,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let panel = git_panel.read(cx);
let suggested_message = panel.suggest_commit_message();
let active_repository = panel.active_repository.clone();
let suggested_commit_message = panel.suggest_commit_message();
let commit_editor = git_panel.update(cx, |git_panel, cx| {
git_panel.set_modal_open(true, cx);
let buffer = git_panel.commit_message_buffer(cx).clone();
let panel_editor = git_panel.commit_editor.clone();
let project = git_panel.project.clone();
cx.new(|cx| commit_message_editor(buffer, None, project.clone(), false, window, cx))
cx.new(|cx| {
let mut editor =
commit_message_editor(buffer, None, project.clone(), false, window, cx);
editor.sync_selections(panel_editor, cx).detach();
editor
})
});
let commit_message = commit_editor.read(cx).text(cx);
if let Some(suggested_message) = suggested_message {
if let Some(suggested_commit_message) = suggested_commit_message {
if commit_message.is_empty() {
commit_editor.update(cx, |editor, cx| {
editor.set_text(suggested_message, window, cx);
editor.select_all(&Default::default(), window, cx);
editor.set_placeholder_text(suggested_commit_message, cx);
});
} else {
if commit_message.as_str().trim() == suggested_message.trim() {
commit_editor.update(cx, |editor, cx| {
// select the message to make it easy to delete
editor.select_all(&Default::default(), window, cx);
});
}
}
}
@@ -206,7 +190,7 @@ impl CommitModal {
let properties = ModalContainerProperties::new(window, 50);
Self {
branch_list: branch_picker::popover(project.clone(), window, cx),
branch_list: branch_picker::popover(active_repository.clone(), window, cx),
git_panel,
commit_editor,
restore_dock,
@@ -250,7 +234,7 @@ impl CommitModal {
pub fn render_footer(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let git_panel = self.git_panel.clone();
let (branch, tooltip, commit_label, co_authors) =
let (branch, can_commit, tooltip, commit_label, co_authors) =
self.git_panel.update(cx, |git_panel, cx| {
let branch = git_panel
.active_repository
@@ -262,18 +246,10 @@ impl CommitModal {
.map(|b| b.name.clone())
})
.unwrap_or_else(|| "<no branch>".into());
let tooltip = if git_panel.has_staged_changes() {
"Commit staged changes"
} else {
"Commit changes to tracked files"
};
let title = if git_panel.has_staged_changes() {
"Commit"
} else {
"Commit All"
};
let (can_commit, tooltip) = git_panel.configure_commit_button(cx);
let title = git_panel.commit_button_title();
let co_authors = git_panel.render_co_authors(cx);
(branch, tooltip, title, co_authors)
(branch, can_commit, tooltip, title, co_authors)
});
let branch_picker_button = panel_button(branch)
@@ -291,12 +267,20 @@ impl CommitModal {
}))
.style(ButtonStyle::Transparent);
let branch_picker = PopoverButton::new(
self.branch_list.clone(),
Corner::BottomLeft,
branch_picker_button,
Tooltip::for_action_title("Switch Branch", &zed_actions::git::Branch),
);
let branch_picker = PopoverMenu::new("popover-button")
.menu({
let branch_list = self.branch_list.clone();
move |_window, _cx| Some(branch_list.clone())
})
.trigger_with_tooltip(
branch_picker_button,
Tooltip::for_action_title("Switch Branch", &zed_actions::git::Branch),
)
.anchor(Corner::BottomLeft)
.offset(gpui::Point {
x: px(0.0),
y: px(-2.0),
});
let close_kb_hint =
if let Some(close_kb) = ui::KeyBinding::for_action(&menu::Cancel, window, cx) {
@@ -308,9 +292,8 @@ impl CommitModal {
None
};
let (panel_editor_focus_handle, can_commit) = git_panel.update(cx, |git_panel, cx| {
(git_panel.editor_focus_handle(cx), git_panel.can_commit())
});
let panel_editor_focus_handle =
git_panel.update(cx, |git_panel, cx| git_panel.editor_focus_handle(cx));
let commit_button = panel_filled_button(commit_label)
.tooltip(move |window, cx| {
@@ -332,12 +315,7 @@ impl CommitModal {
.w_full()
.h(px(self.properties.footer_height))
.gap_1()
.child(
h_flex()
.gap_1()
.child(branch_picker.render(window, cx))
.children(co_authors),
)
.child(h_flex().gap_1().child(branch_picker).children(co_authors))
.child(div().flex_1())
.child(
h_flex()
@@ -354,6 +332,7 @@ impl CommitModal {
fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
self.git_panel
.update(cx, |git_panel, cx| git_panel.commit_changes(window, cx));
@@ -377,7 +356,7 @@ impl Render for CommitModal {
.on_action(
cx.listener(|this, _: &zed_actions::git::Branch, window, cx| {
this.branch_list.update(cx, |branch_list, cx| {
branch_list.menu_handle(window, cx).toggle(window, cx);
branch_list.popover_handle.toggle(window, cx);
})
}),
)

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,9 @@ use git_panel_settings::GitPanelSettings;
use gpui::App;
use project_diff::ProjectDiff;
use ui::{ActiveTheme, Color, Icon, IconName, IntoElement};
use workspace::Workspace;
mod askpass_modal;
pub mod branch_picker;
mod commit_modal;
pub mod git_panel;
@@ -19,6 +21,47 @@ pub fn init(cx: &mut App) {
branch_picker::init(cx);
cx.observe_new(ProjectDiff::register).detach();
commit_modal::init(cx);
git_panel::init(cx);
cx.observe_new(|workspace: &mut Workspace, _, cx| {
let project = workspace.project().read(cx);
if project.is_via_collab() {
return;
}
workspace.register_action(|workspace, _: &git::Fetch, window, cx| {
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
return;
};
panel.update(cx, |panel, cx| {
panel.fetch(window, cx);
});
});
workspace.register_action(|workspace, _: &git::Push, window, cx| {
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
return;
};
panel.update(cx, |panel, cx| {
panel.push(false, window, cx);
});
});
workspace.register_action(|workspace, _: &git::ForcePush, window, cx| {
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
return;
};
panel.update(cx, |panel, cx| {
panel.push(true, window, cx);
});
});
workspace.register_action(|workspace, _: &git::Pull, window, cx| {
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
return;
};
panel.update(cx, |panel, cx| {
panel.pull(window, cx);
});
});
})
.detach();
}
// TODO: Add updated status colors to theme

View File

@@ -5,13 +5,12 @@ use collections::HashSet;
use editor::{
actions::{GoToHunk, GoToPreviousHunk},
scroll::Autoscroll,
Editor, EditorEvent, ToPoint,
Editor, EditorEvent,
};
use feature_flags::FeatureFlagViewExt;
use futures::StreamExt;
use git::{
status::FileStatus, ShowCommitEditor, StageAll, StageAndNext, ToggleStaged, UnstageAll,
UnstageAndNext,
status::FileStatus, Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext,
};
use gpui::{
actions, Action, AnyElement, AnyView, App, AppContext as _, AsyncWindowContext, Entity,
@@ -19,7 +18,10 @@ use gpui::{
};
use language::{Anchor, Buffer, Capability, OffsetRangeExt};
use multi_buffer::{MultiBuffer, PathKey};
use project::{git::GitStore, Project, ProjectPath};
use project::{
git::{GitEvent, GitStore},
Project, ProjectPath,
};
use std::any::{Any, TypeId};
use theme::ActiveTheme;
use ui::{prelude::*, vertical_divider, Tooltip};
@@ -141,8 +143,13 @@ impl ProjectDiff {
let git_store_subscription = cx.subscribe_in(
&git_store,
window,
move |this, _git_store, _event, _window, _cx| {
*this.update_needed.borrow_mut() = ();
move |this, _git_store, event, _window, _cx| match event {
GitEvent::ActiveRepositoryChanged
| GitEvent::FileSystemUpdated
| GitEvent::GitStateUpdated => {
*this.update_needed.borrow_mut() = ();
}
_ => {}
},
);
@@ -192,6 +199,19 @@ impl ProjectDiff {
self.move_to_path(path_key, window, cx)
}
pub fn active_path(&self, cx: &App) -> Option<ProjectPath> {
let editor = self.editor.read(cx);
let position = editor.selections.newest_anchor().head();
let multi_buffer = editor.buffer().read(cx);
let (_, buffer, _) = multi_buffer.excerpt_containing(position, cx)?;
let file = buffer.read(cx).file()?;
Some(ProjectPath {
worktree_id: file.worktree_id(cx),
path: file.path().clone(),
})
}
fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
self.editor.update(cx, |editor, cx| {
@@ -244,14 +264,12 @@ impl ProjectDiff {
}
}
}
let mut commit = false;
let mut stage_all = false;
let mut unstage_all = false;
self.workspace
.read_with(cx, |workspace, cx| {
if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
let git_panel = git_panel.read(cx);
commit = git_panel.can_commit();
stage_all = git_panel.can_stage_all();
unstage_all = git_panel.can_unstage_all();
}
@@ -263,7 +281,6 @@ impl ProjectDiff {
unstage: has_staged_hunks,
prev_next,
selection,
commit,
stage_all,
unstage_all,
};
@@ -271,41 +288,26 @@ impl ProjectDiff {
fn handle_editor_event(
&mut self,
editor: &Entity<Editor>,
_: &Entity<Editor>,
event: &EditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
EditorEvent::ScrollPositionChanged { .. } => editor.update(cx, |editor, cx| {
let anchor = editor.scroll_manager.anchor().anchor;
let multibuffer = self.multibuffer.read(cx);
let snapshot = multibuffer.snapshot(cx);
let mut point = anchor.to_point(&snapshot);
point.row = (point.row + 1).min(snapshot.max_row().0);
point.column = 0;
let Some((_, buffer, _)) = self.multibuffer.read(cx).excerpt_containing(point, cx)
else {
return;
};
let Some(project_path) = buffer
.read(cx)
.file()
.map(|file| (file.worktree_id(cx), file.path().clone()))
else {
EditorEvent::SelectionsChanged { local: true } => {
let Some(project_path) = self.active_path(cx) else {
return;
};
self.workspace
.update(cx, |workspace, cx| {
if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
git_panel.update(cx, |git_panel, cx| {
git_panel.select_entry_by_path(project_path.into(), window, cx)
git_panel.select_entry_by_path(project_path, window, cx)
})
}
})
.ok();
}),
}
_ => {}
}
}
@@ -400,6 +402,7 @@ impl ProjectDiff {
self.editor.update(cx, |editor, cx| {
if was_empty {
editor.change_selections(None, window, cx, |selections| {
// TODO select the very beginning (possibly inside a deletion)
selections.select_ranges([0..0])
});
}
@@ -774,7 +777,6 @@ struct ButtonStates {
selection: bool,
stage_all: bool,
unstage_all: bool,
commit: bool,
}
impl Render for ProjectDiffToolbar {
@@ -813,10 +815,8 @@ impl Render for ProjectDiffToolbar {
el.child(
Button::new("stage", "Stage")
.tooltip(Tooltip::for_action_title_in(
"Stage",
&StageAndNext {
whole_excerpt: false,
},
"Stage and go to next hunk",
&StageAndNext,
&focus_handle,
))
// don't actually disable the button so it's mashable
@@ -826,22 +826,14 @@ impl Render for ProjectDiffToolbar {
Color::Disabled
})
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(
&StageAndNext {
whole_excerpt: false,
},
window,
cx,
)
this.dispatch_action(&StageAndNext, window, cx)
})),
)
.child(
Button::new("unstage", "Unstage")
.tooltip(Tooltip::for_action_title_in(
"Unstage",
&UnstageAndNext {
whole_excerpt: false,
},
"Unstage and go to next hunk",
&UnstageAndNext,
&focus_handle,
))
.color(if button_states.unstage {
@@ -850,13 +842,7 @@ impl Render for ProjectDiffToolbar {
Color::Disabled
})
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(
&UnstageAndNext {
whole_excerpt: false,
},
window,
cx,
)
this.dispatch_action(&UnstageAndNext, window, cx)
})),
)
}),
@@ -870,20 +856,12 @@ impl Render for ProjectDiffToolbar {
.shape(ui::IconButtonShape::Square)
.tooltip(Tooltip::for_action_title_in(
"Go to previous hunk",
&GoToPreviousHunk {
center_cursor: false,
},
&GoToPreviousHunk,
&focus_handle,
))
.disabled(!button_states.prev_next)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(
&GoToPreviousHunk {
center_cursor: true,
},
window,
cx,
)
this.dispatch_action(&GoToPreviousHunk, window, cx)
})),
)
.child(
@@ -891,20 +869,12 @@ impl Render for ProjectDiffToolbar {
.shape(ui::IconButtonShape::Square)
.tooltip(Tooltip::for_action_title_in(
"Go to next hunk",
&GoToHunk {
center_cursor: false,
},
&GoToHunk,
&focus_handle,
))
.disabled(!button_states.prev_next)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(
&GoToHunk {
center_cursor: true,
},
window,
cx,
)
this.dispatch_action(&GoToHunk, window, cx)
})),
),
)
@@ -950,26 +920,27 @@ impl Render for ProjectDiffToolbar {
)
.child(
Button::new("commit", "Commit")
.disabled(!button_states.commit)
.tooltip(Tooltip::for_action_title_in(
"Commit",
&ShowCommitEditor,
&Commit,
&focus_handle,
))
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&ShowCommitEditor, window, cx);
this.dispatch_action(&Commit, window, cx);
})),
),
)
}
}
#[cfg(not(target_os = "windows"))]
#[cfg(test)]
mod tests {
use std::path::Path;
use collections::HashMap;
use editor::test::editor_test_context::assert_state_with_diff;
use db::indoc;
use editor::test::editor_test_context::{assert_state_with_diff, EditorTestContext};
use git::status::{StatusCode, TrackedStatus};
use gpui::TestAppContext;
use project::FakeFs;
@@ -980,6 +951,11 @@ mod tests {
use super::*;
#[ctor::ctor]
fn init_logger() {
env_logger::init();
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let store = SettingsStore::test(cx);
@@ -1048,9 +1024,6 @@ mod tests {
editor.update_in(cx, |editor, window, cx| {
editor.git_restore(&Default::default(), window, cx);
});
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
state.statuses = HashMap::default();
});
cx.run_until_parked();
assert_state_with_diff(&editor, cx, &"ˇ".unindent());
@@ -1152,4 +1125,196 @@ mod tests {
.unindent(),
);
}
#[gpui::test]
async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".git": {},
"foo": "modified\n",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/project/foo"), cx)
})
.await
.unwrap();
let buffer_editor = cx.new_window_entity(|window, cx| {
Editor::for_buffer(buffer, Some(project.clone()), window, cx)
});
let diff = cx.new_window_entity(|window, cx| {
ProjectDiff::new(project.clone(), workspace, window, cx)
});
cx.run_until_parked();
fs.set_head_for_repo(
path!("/project/.git").as_ref(),
&[("foo".into(), "original\n".into())],
);
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
state.statuses = HashMap::from_iter([(
"foo".into(),
TrackedStatus {
index_status: StatusCode::Unmodified,
worktree_status: StatusCode::Modified,
}
.into(),
)]);
});
cx.run_until_parked();
let diff_editor = diff.update(cx, |diff, _| diff.editor.clone());
assert_state_with_diff(
&diff_editor,
cx,
&"
- original
+ ˇmodified
"
.unindent(),
);
let prev_buffer_hunks =
cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
let snapshot = buffer_editor.snapshot(window, cx);
let snapshot = &snapshot.buffer_snapshot;
let prev_buffer_hunks = buffer_editor
.diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
.collect::<Vec<_>>();
buffer_editor.git_restore(&Default::default(), window, cx);
prev_buffer_hunks
});
assert_eq!(prev_buffer_hunks.len(), 1);
cx.run_until_parked();
let new_buffer_hunks =
cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
let snapshot = buffer_editor.snapshot(window, cx);
let snapshot = &snapshot.buffer_snapshot;
let new_buffer_hunks = buffer_editor
.diff_hunks_in_ranges(&[editor::Anchor::min()..editor::Anchor::max()], snapshot)
.collect::<Vec<_>>();
buffer_editor.git_restore(&Default::default(), window, cx);
new_buffer_hunks
});
assert_eq!(new_buffer_hunks.as_slice(), &[]);
cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
buffer_editor.set_text("different\n", window, cx);
buffer_editor.save(false, project.clone(), window, cx)
})
.await
.unwrap();
cx.run_until_parked();
assert_state_with_diff(
&diff_editor,
cx,
&"
- original
+ ˇdifferent
"
.unindent(),
);
}
use crate::project_diff::{self, ProjectDiff};
#[gpui::test]
async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/a",
json!({
".git":{},
"a.txt": "created\n",
"b.txt": "really changed\n",
"c.txt": "unchanged\n"
}),
)
.await;
fs.set_git_content_for_repo(
Path::new("/a/.git"),
&[
("b.txt".into(), "before\n".to_string(), None),
("c.txt".into(), "unchanged\n".to_string(), None),
("d.txt".into(), "deleted\n".to_string(), None),
],
);
let project = Project::test(fs, [Path::new("/a")], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
cx.run_until_parked();
cx.focus(&workspace);
cx.update(|window, cx| {
window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
});
cx.run_until_parked();
let item = workspace.update(cx, |workspace, cx| {
workspace.active_item_as::<ProjectDiff>(cx).unwrap()
});
cx.focus(&item);
let editor = item.update(cx, |item, _| item.editor.clone());
let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
cx.assert_excerpts_with_selections(indoc!(
"
[EXCERPT]
before
really changed
[EXCERPT]
[FOLDED]
[EXCERPT]
ˇcreated
"
));
cx.dispatch_action(editor::actions::GoToPreviousHunk);
cx.assert_excerpts_with_selections(indoc!(
"
[EXCERPT]
before
really changed
[EXCERPT]
ˇ[FOLDED]
[EXCERPT]
created
"
));
cx.dispatch_action(editor::actions::GoToPreviousHunk);
cx.assert_excerpts_with_selections(indoc!(
"
[EXCERPT]
ˇbefore
really changed
[EXCERPT]
[FOLDED]
[EXCERPT]
created
"
));
}
}

View File

@@ -71,7 +71,7 @@ impl RemoteOutputToast {
}
});
let message;
let mut message: SharedString;
let remote;
match action {
@@ -86,19 +86,32 @@ impl RemoteOutputToast {
RemoteAction::Push(remote_ref) => {
message = output.stdout.trim().to_string().into();
let remote_message = get_remote_lines(&output.stderr);
let finder = LinkFinder::new();
let links = finder
.links(&remote_message)
.filter(|link| *link.kind() == LinkKind::Url)
.map(|link| link.start()..link.end())
.collect_vec();
if message.is_empty() {
message = output.stderr.trim().to_string().into();
if message.is_empty() {
message = "Push Successful".into();
}
remote = None;
} else {
let remote_message = get_remote_lines(&output.stderr);
remote = Some(InfoFromRemote {
name: remote_ref.name,
remote_text: remote_message.into(),
links,
});
remote = if remote_message.is_empty() {
None
} else {
let finder = LinkFinder::new();
let links = finder
.links(&remote_message)
.filter(|link| *link.kind() == LinkKind::Url)
.map(|link| link.start()..link.end())
.collect_vec();
Some(InfoFromRemote {
name: remote_ref.name,
remote_text: remote_message.into(),
links,
})
}
}
}
}

View File

@@ -1,70 +1,49 @@
use gpui::{
AnyElement, AnyView, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
Subscription, Task, WeakEntity,
AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity,
};
use itertools::Itertools;
use picker::{Picker, PickerDelegate};
use project::{
git::{GitStore, Repository},
Project,
};
use project::{git::Repository, Project};
use std::sync::Arc;
use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, PopoverTrigger};
use ui::{prelude::*, ListItem, ListItemSpacing};
pub struct RepositorySelector {
picker: Entity<Picker<RepositorySelectorDelegate>>,
/// The task used to update the picker's matches when there is a change to
/// the repository list.
update_matches_task: Option<Task<()>>,
_subscriptions: Vec<Subscription>,
}
impl RepositorySelector {
pub fn new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let git_store = project.read(cx).git_store().clone();
pub fn new(
project_handle: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let project = project_handle.read(cx);
let git_store = project.git_store().clone();
let all_repositories = git_store.read(cx).all_repositories();
let filtered_repositories = all_repositories.clone();
let widest_item_ix = all_repositories.iter().position_max_by(|a, b| {
a.read(cx)
.display_name(project, cx)
.len()
.cmp(&b.read(cx).display_name(project, cx).len())
});
let delegate = RepositorySelectorDelegate {
project: project.downgrade(),
project: project_handle.downgrade(),
repository_selector: cx.entity().downgrade(),
repository_entries: all_repositories,
repository_entries: all_repositories.clone(),
filtered_repositories,
selected_index: 0,
};
let picker = cx.new(|cx| {
Picker::nonsearchable_uniform_list(delegate, window, cx)
.widest_item(widest_item_ix)
.max_height(Some(rems(20.).into()))
.width(rems(15.))
});
let _subscriptions =
vec![cx.subscribe_in(&git_store, window, Self::handle_project_git_event)];
RepositorySelector {
picker,
update_matches_task: None,
_subscriptions,
}
}
pub(crate) fn repositories_len(&self, cx: &App) -> usize {
self.picker.read(cx).delegate.repository_entries.len()
}
fn handle_project_git_event(
&mut self,
git_store: &Entity<GitStore>,
_event: &project::git::GitEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
// TODO handle events individually
let task = self.picker.update(cx, |this, cx| {
let query = this.query(cx);
this.delegate.repository_entries = git_store.read(cx).all_repositories();
this.delegate.update_matches(query, window, cx)
});
self.update_matches_task = Some(task);
RepositorySelector { picker }
}
}
@@ -82,54 +61,6 @@ impl Render for RepositorySelector {
}
}
#[derive(IntoElement)]
pub struct RepositorySelectorPopoverMenu<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
repository_selector: Entity<RepositorySelector>,
trigger: T,
tooltip: TT,
handle: Option<PopoverMenuHandle<RepositorySelector>>,
}
impl<T, TT> RepositorySelectorPopoverMenu<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
pub fn new(repository_selector: Entity<RepositorySelector>, trigger: T, tooltip: TT) -> Self {
Self {
repository_selector,
trigger,
tooltip,
handle: None,
}
}
pub fn with_handle(mut self, handle: PopoverMenuHandle<RepositorySelector>) -> Self {
self.handle = Some(handle);
self
}
}
impl<T, TT> RenderOnce for RepositorySelectorPopoverMenu<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let repository_selector = self.repository_selector.clone();
PopoverMenu::new("repository-switcher")
.menu(move |_window, _cx| Some(repository_selector.clone()))
.trigger_with_tooltip(self.trigger, self.tooltip)
.attach(gpui::Corner::BottomLeft)
.when_some(self.handle.clone(), |menu, handle| menu.with_handle(handle))
}
}
pub struct RepositorySelectorDelegate {
project: WeakEntity<Project>,
repository_selector: WeakEntity<RepositorySelector>,
@@ -238,7 +169,6 @@ impl PickerDelegate for RepositorySelectorDelegate {
let project = self.project.upgrade()?;
let repo_info = self.filtered_repositories.get(ix)?;
let display_name = repo_info.read(cx).display_name(project.read(cx), cx);
// TODO: Implement repository item rendering
Some(
ListItem::new(ix)
.inset(true)

View File

@@ -90,6 +90,21 @@ impl<'a, T: 'static> Context<'a, T> {
})
}
/// Subscribe to an event type from ourself
pub fn subscribe_self<Evt>(
&mut self,
mut on_event: impl FnMut(&mut T, &Evt, &mut Context<'_, T>) + 'static,
) -> Subscription
where
T: 'static + EventEmitter<Evt>,
Evt: 'static,
{
let this = self.entity();
self.app.subscribe(&this, move |this, evt, cx| {
this.update(cx, |this, cx| on_event(this, evt, cx))
})
}
/// Register a callback to be invoked when GPUI releases this entity.
pub fn on_release(&self, on_release: impl FnOnce(&mut T, &mut App) + 'static) -> Subscription
where

View File

@@ -670,6 +670,14 @@ pub fn pattern_slash(color: Hsla, thickness: f32) -> Background {
}
}
/// Creates a solid background color.
pub fn solid_background(color: impl Into<Hsla>) -> Background {
Background {
solid: color.into(),
..Default::default()
}
}
/// Creates a LinearGradient background color.
///
/// The gradient line's angle of direction. A value of `0.` is equivalent to to top; increasing values rotate clockwise from there.

View File

@@ -168,6 +168,23 @@ impl Subscription {
pub fn detach(mut self) {
self.unsubscribe.take();
}
/// Joins two subscriptions into a single subscription. Detach will
/// detach both interior subscriptions.
pub fn join(mut subscription_a: Self, mut subscription_b: Self) -> Self {
let a_unsubscribe = subscription_a.unsubscribe.take();
let b_unsubscribe = subscription_b.unsubscribe.take();
Self {
unsubscribe: Some(Box::new(move || {
if let Some(self_unsubscribe) = a_unsubscribe {
self_unsubscribe();
}
if let Some(other_unsubscribe) = b_unsubscribe {
other_unsubscribe();
}
})),
}
}
}
impl Drop for Subscription {

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use feature_flags::ZedPro;
use gpui::{
action_with_deprecated_aliases, Action, AnyElement, App, Corner, DismissEvent, Entity,
action_with_deprecated_aliases, Action, AnyElement, AnyView, App, Corner, DismissEvent, Entity,
EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity,
};
use language_model::{
@@ -10,10 +10,7 @@ use language_model::{
};
use picker::{Picker, PickerDelegate};
use proto::Plan;
use ui::{
prelude::*, ButtonLike, IconButtonShape, ListItem, ListItemSpacing, PopoverButton,
PopoverMenuHandle, Tooltip, TriggerablePopover,
};
use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, PopoverTrigger};
use workspace::ShowConfiguration;
action_with_deprecated_aliases!(
@@ -31,7 +28,6 @@ pub struct LanguageModelSelector {
/// The task used to update the picker's matches when there is a change to
/// the language model registry.
update_matches_task: Option<Task<()>>,
popover_menu_handle: PopoverMenuHandle<LanguageModelSelector>,
_authenticate_all_providers_task: Task<()>,
_subscriptions: Vec<Subscription>,
}
@@ -63,7 +59,6 @@ impl LanguageModelSelector {
LanguageModelSelector {
picker,
update_matches_task: None,
popover_menu_handle: PopoverMenuHandle::default(),
_authenticate_all_providers_task: Self::authenticate_all_providers(cx),
_subscriptions: vec![cx.subscribe_in(
&LanguageModelRegistry::global(cx),
@@ -73,15 +68,6 @@ impl LanguageModelSelector {
}
}
pub fn toggle_model_selector(
&mut self,
_: &ToggleModelSelector,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.popover_menu_handle.toggle(window, cx);
}
fn handle_language_model_registry_event(
&mut self,
_registry: &Entity<LanguageModelRegistry>,
@@ -201,13 +187,62 @@ impl Render for LanguageModelSelector {
}
}
impl TriggerablePopover for LanguageModelSelector {
fn menu_handle(
&mut self,
_window: &mut Window,
_cx: &mut gpui::Context<Self>,
) -> PopoverMenuHandle<Self> {
self.popover_menu_handle.clone()
#[derive(IntoElement)]
pub struct LanguageModelSelectorPopoverMenu<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
language_model_selector: Entity<LanguageModelSelector>,
trigger: T,
tooltip: TT,
handle: Option<PopoverMenuHandle<LanguageModelSelector>>,
anchor: Corner,
}
impl<T, TT> LanguageModelSelectorPopoverMenu<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
pub fn new(
language_model_selector: Entity<LanguageModelSelector>,
trigger: T,
tooltip: TT,
anchor: Corner,
) -> Self {
Self {
language_model_selector,
trigger,
tooltip,
handle: None,
anchor,
}
}
pub fn with_handle(mut self, handle: PopoverMenuHandle<LanguageModelSelector>) -> Self {
self.handle = Some(handle);
self
}
}
impl<T, TT> RenderOnce for LanguageModelSelectorPopoverMenu<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let language_model_selector = self.language_model_selector.clone();
PopoverMenu::new("model-switcher")
.menu(move |_window, _cx| Some(language_model_selector.clone()))
.trigger_with_tooltip(self.trigger, self.tooltip)
.anchor(self.anchor)
.when_some(self.handle.clone(), |menu, handle| menu.with_handle(handle))
.offset(gpui::Point {
x: px(0.0),
y: px(-2.0),
})
}
}
@@ -401,9 +436,9 @@ impl PickerDelegate for LanguageModelPickerDelegate {
.pl_0p5()
.w(px(240.))
.child(
div().max_w_40().child(
Label::new(model_info.model.name().0.clone()).text_ellipsis(),
),
div()
.max_w_40()
.child(Label::new(model_info.model.name().0.clone()).truncate()),
)
.child(
h_flex()
@@ -492,98 +527,3 @@ impl PickerDelegate for LanguageModelPickerDelegate {
)
}
}
pub struct InlineLanguageModelSelector {
selector: Entity<LanguageModelSelector>,
}
impl InlineLanguageModelSelector {
pub fn new(selector: Entity<LanguageModelSelector>) -> Self {
Self { selector }
}
}
impl RenderOnce for InlineLanguageModelSelector {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
PopoverButton::new(
self.selector,
gpui::Corner::TopRight,
IconButton::new("context", IconName::SettingsAlt)
.shape(IconButtonShape::Square)
.icon_size(IconSize::Small)
.icon_color(Color::Muted),
move |window, cx| {
Tooltip::with_meta(
format!(
"Using {}",
LanguageModelRegistry::read_global(cx)
.active_model()
.map(|model| model.name().0)
.unwrap_or_else(|| "No model selected".into()),
),
None,
"Change Model",
window,
cx,
)
},
)
.render(window, cx)
}
}
pub struct AssistantLanguageModelSelector {
focus_handle: FocusHandle,
selector: Entity<LanguageModelSelector>,
}
impl AssistantLanguageModelSelector {
pub fn new(focus_handle: FocusHandle, selector: Entity<LanguageModelSelector>) -> Self {
Self {
focus_handle,
selector,
}
}
}
impl RenderOnce for AssistantLanguageModelSelector {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let active_model = LanguageModelRegistry::read_global(cx).active_model();
let focus_handle = self.focus_handle.clone();
let model_name = match active_model {
Some(model) => model.name().0,
_ => SharedString::from("No model selected"),
};
PopoverButton::new(
self.selector.clone(),
Corner::BottomRight,
ButtonLike::new("active-model")
.style(ButtonStyle::Subtle)
.child(
h_flex()
.gap_0p5()
.child(
Label::new(model_name)
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
Icon::new(IconName::ChevronDown)
.color(Color::Muted)
.size(IconSize::XSmall),
),
),
move |window, cx| {
Tooltip::for_action_in(
"Change Model",
&ToggleModelSelector,
&focus_handle,
window,
cx,
)
},
)
.render(window, cx)
}
}

View File

@@ -250,6 +250,7 @@ impl DiffState {
}
}
BufferDiffEvent::LanguageChanged => this.buffer_diff_language_changed(diff, cx),
_ => {}
}),
diff,
}
@@ -2045,6 +2046,7 @@ impl MultiBuffer {
.cursor::<(Option<&Locator>, ExcerptOffset)>(&());
let mut edits = Vec::new();
let mut excerpt_ids = ids.iter().copied().peekable();
let mut removed_buffer_ids = Vec::new();
while let Some(excerpt_id) = excerpt_ids.next() {
// Seek to the next excerpt to remove, preserving any preceding excerpts.
@@ -2062,7 +2064,12 @@ impl MultiBuffer {
if let Some(buffer_state) = buffers.get_mut(&excerpt.buffer_id) {
buffer_state.excerpts.retain(|l| l != &excerpt.locator);
if buffer_state.excerpts.is_empty() {
log::debug!(
"removing buffer and diff for buffer {}",
excerpt.buffer_id
);
buffers.remove(&excerpt.buffer_id);
removed_buffer_ids.push(excerpt.buffer_id);
}
}
cursor.next(&());
@@ -2103,6 +2110,10 @@ impl MultiBuffer {
new_excerpts.append(suffix, &());
drop(cursor);
snapshot.excerpts = new_excerpts;
for buffer_id in removed_buffer_ids {
self.diffs.remove(&buffer_id);
snapshot.diffs.remove(&buffer_id);
}
if changed_trailing_excerpt {
snapshot.trailing_excerpt_update_count += 1;
@@ -2716,6 +2727,12 @@ impl MultiBuffer {
snapshot.has_deleted_file = has_deleted_file;
snapshot.has_conflict = has_conflict;
for (id, diff) in self.diffs.iter() {
if snapshot.diffs.get(&id).is_none() {
snapshot.diffs.insert(*id, diff.diff.read(cx).snapshot(cx));
}
}
excerpts_to_edit.sort_unstable_by_key(|(locator, _, _)| *locator);
let mut edits = Vec::new();
@@ -3476,7 +3493,10 @@ 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 diff = self.diffs.get(&buffer.remote_id())?;
let Some(diff) = self.diffs.get(&buffer.remote_id()) else {
log::debug!("no diff found for {:?}", buffer.remote_id());
return None;
};
let buffer_start = buffer.anchor_before(buffer_range.start);
let buffer_end = buffer.anchor_after(buffer_range.end);
Some(
@@ -3485,17 +3505,12 @@ impl MultiBufferSnapshot {
if hunk.is_created_file() && !self.all_diff_hunks_expanded {
return None;
}
Some((
Point::new(hunk.row_range.start, 0)..Point::new(hunk.row_range.end, 0),
hunk,
))
Some((hunk.range.clone(), hunk))
}),
)
})
.filter_map(move |(range, hunk, excerpt)| {
if range.start != range.end
&& range.end == query_range.start
&& !hunk.row_range.is_empty()
if range.start != range.end && range.end == query_range.start && !hunk.range.is_empty()
{
return None;
}
@@ -3790,104 +3805,57 @@ impl MultiBufferSnapshot {
})
}
pub fn diff_hunk_before<T: ToOffset>(&self, position: T) -> Option<MultiBufferDiffHunk> {
pub fn diff_hunk_before<T: ToOffset>(&self, position: T) -> Option<MultiBufferRow> {
let offset = position.to_offset(self);
// Go to the region containing the given offset.
let mut cursor = self.cursor::<DimensionPair<usize, Point>>();
cursor.seek(&DimensionPair {
key: offset,
value: None,
});
let mut region = cursor.region()?;
if region.range.start.key == offset || !region.is_main_buffer {
cursor.prev();
region = cursor.region()?;
cursor.seek_to_start_of_current_excerpt();
let excerpt = cursor.excerpt()?;
let excerpt_end = excerpt.range.context.end.to_offset(&excerpt.buffer);
let current_position = self
.anchor_before(offset)
.text_anchor
.to_offset(&excerpt.buffer);
let excerpt_end = excerpt
.buffer
.anchor_before(excerpt_end.min(current_position));
if let Some(diff) = self.diffs.get(&excerpt.buffer_id) {
for hunk in diff.hunks_intersecting_range_rev(
excerpt.range.context.start..excerpt_end,
&excerpt.buffer,
) {
let hunk_end = hunk.buffer_range.end.to_offset(&excerpt.buffer);
if hunk_end >= current_position {
continue;
}
let start =
Anchor::in_buffer(excerpt.id, excerpt.buffer_id, hunk.buffer_range.start)
.to_point(&self);
return Some(MultiBufferRow(start.row));
}
}
// Find the corresponding buffer offset.
let overshoot = if region.is_main_buffer {
offset - region.range.start.key
} else {
0
};
let mut max_buffer_offset = region
.buffer
.clip_offset(region.buffer_range.start.key + overshoot, Bias::Right);
loop {
let excerpt = cursor.excerpt()?;
let excerpt_end = excerpt.range.context.end.to_offset(&excerpt.buffer);
let buffer_offset = excerpt_end.min(max_buffer_offset);
let buffer_end = excerpt.buffer.anchor_before(buffer_offset);
let buffer_end_row = buffer_end.to_point(&excerpt.buffer).row;
if let Some(diff) = self.diffs.get(&excerpt.buffer_id) {
for hunk in diff.hunks_intersecting_range_rev(
excerpt.range.context.start..buffer_end,
&excerpt.buffer,
) {
let hunk_range = hunk.buffer_range.to_offset(&excerpt.buffer);
if hunk.row_range.end >= buffer_end_row {
continue;
}
let hunk_start = Point::new(hunk.row_range.start, 0);
let hunk_end = Point::new(hunk.row_range.end, 0);
cursor.seek_to_buffer_position_in_current_excerpt(&DimensionPair {
key: hunk_range.start,
value: None,
});
let mut region = cursor.region()?;
while !region.is_main_buffer || region.buffer_range.start.key >= hunk_range.end
{
cursor.prev();
region = cursor.region()?;
}
let overshoot = if region.is_main_buffer {
hunk_start.saturating_sub(region.buffer_range.start.value.unwrap())
} else {
Point::zero()
};
let start = region.range.start.value.unwrap() + overshoot;
while let Some(region) = cursor.region() {
if !region.is_main_buffer
|| region.buffer_range.end.value.unwrap() <= hunk_end
{
cursor.next();
} else {
break;
}
}
let end = if let Some(region) = cursor.region() {
let overshoot = if region.is_main_buffer {
hunk_end.saturating_sub(region.buffer_range.start.value.unwrap())
} else {
Point::zero()
};
region.range.start.value.unwrap() + overshoot
} else {
self.max_point()
};
return Some(MultiBufferDiffHunk {
row_range: MultiBufferRow(start.row)..MultiBufferRow(end.row),
buffer_id: excerpt.buffer_id,
excerpt_id: excerpt.id,
buffer_range: hunk.buffer_range.clone(),
diff_base_byte_range: hunk.diff_base_byte_range.clone(),
secondary_status: hunk.secondary_status,
});
}
}
cursor.prev_excerpt();
max_buffer_offset = usize::MAX;
let excerpt = cursor.excerpt()?;
let Some(diff) = self.diffs.get(&excerpt.buffer_id) else {
continue;
};
let mut hunks =
diff.hunks_intersecting_range_rev(excerpt.range.context.clone(), &excerpt.buffer);
let Some(hunk) = hunks.next() else {
continue;
};
let start = Anchor::in_buffer(excerpt.id, excerpt.buffer_id, hunk.buffer_range.start)
.to_point(&self);
return Some(MultiBufferRow(start.row));
}
}
@@ -6090,21 +6058,6 @@ where
}
}
fn seek_to_buffer_position_in_current_excerpt(&mut self, position: &D) {
self.cached_region.take();
if let Some(excerpt) = self.excerpts.item() {
let excerpt_start = excerpt.range.context.start.summary::<D>(&excerpt.buffer);
let position_in_excerpt = *position - excerpt_start;
let mut excerpt_position = self.excerpts.start().0;
excerpt_position.add_assign(&position_in_excerpt);
self.diff_transforms
.seek(&ExcerptDimension(excerpt_position), Bias::Left, &());
if self.diff_transforms.item().is_none() {
self.diff_transforms.next(&());
}
}
}
fn next_excerpt(&mut self) {
self.excerpts.next(&());
self.seek_to_start_of_current_excerpt();

View File

@@ -440,23 +440,14 @@ fn test_diff_hunks_in_range(cx: &mut TestAppContext) {
vec![1..3, 4..6, 7..8]
);
assert_eq!(snapshot.diff_hunk_before(Point::new(1, 1)), None,);
assert_eq!(
snapshot
.diff_hunk_before(Point::new(1, 1))
.map(|hunk| hunk.row_range.start.0..hunk.row_range.end.0),
None,
snapshot.diff_hunk_before(Point::new(7, 0)),
Some(MultiBufferRow(4))
);
assert_eq!(
snapshot
.diff_hunk_before(Point::new(7, 0))
.map(|hunk| hunk.row_range.start.0..hunk.row_range.end.0),
Some(4..6)
);
assert_eq!(
snapshot
.diff_hunk_before(Point::new(4, 0))
.map(|hunk| hunk.row_range.start.0..hunk.row_range.end.0),
Some(1..3)
snapshot.diff_hunk_before(Point::new(4, 0)),
Some(MultiBufferRow(1))
);
multibuffer.update(cx, |multibuffer, cx| {
@@ -478,16 +469,12 @@ fn test_diff_hunks_in_range(cx: &mut TestAppContext) {
);
assert_eq!(
snapshot
.diff_hunk_before(Point::new(2, 0))
.map(|hunk| hunk.row_range.start.0..hunk.row_range.end.0),
Some(1..1),
snapshot.diff_hunk_before(Point::new(2, 0)),
Some(MultiBufferRow(1)),
);
assert_eq!(
snapshot
.diff_hunk_before(Point::new(4, 0))
.map(|hunk| hunk.row_range.start.0..hunk.row_range.end.0),
Some(2..2)
snapshot.diff_hunk_before(Point::new(4, 0)),
Some(MultiBufferRow(2))
);
}
@@ -2160,6 +2147,7 @@ impl ReferenceMultibuffer {
.unwrap();
let excerpt = self.excerpts.remove(ix);
let buffer = excerpt.buffer.read(cx);
let id = buffer.remote_id();
log::info!(
"Removing excerpt {}: {:?}",
ix,
@@ -2167,6 +2155,13 @@ impl ReferenceMultibuffer {
.text_for_range(excerpt.range.to_offset(buffer))
.collect::<String>(),
);
if !self
.excerpts
.iter()
.any(|excerpt| excerpt.buffer.read(cx).remote_id() == id)
{
self.diffs.remove(&id);
}
}
fn insert_excerpt_after(
@@ -2266,7 +2261,7 @@ impl ReferenceMultibuffer {
}
if !hunk.buffer_range.start.is_valid(&buffer) {
log::trace!("skipping hunk with deleted start: {:?}", hunk.row_range);
log::trace!("skipping hunk with deleted start: {:?}", hunk.range);
continue;
}
@@ -2415,6 +2410,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
.unwrap_or(10);
let mut buffers: Vec<Entity<Buffer>> = Vec::new();
let mut base_texts: HashMap<BufferId, String> = HashMap::default();
let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
let mut reference = ReferenceMultibuffer::default();
let mut anchors = Vec::new();
@@ -2522,9 +2518,10 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
..snapshot.anchor_in_excerpt(excerpt.id, end).unwrap();
log::info!(
"expanding diff hunks in range {:?} (excerpt id {:?}) index {excerpt_ix:?})",
"expanding diff hunks in range {:?} (excerpt id {:?}, index {excerpt_ix:?}, buffer id {:?})",
range.to_offset(&snapshot),
excerpt.id
excerpt.id,
excerpt.buffer.read(cx).remote_id(),
);
reference.expand_diff_hunks(excerpt.id, start..end, cx);
multibuffer.expand_diff_hunks(vec![range], cx);
@@ -2534,7 +2531,7 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
multibuffer.update(cx, |multibuffer, cx| {
for buffer in multibuffer.all_buffers() {
let snapshot = buffer.read(cx).snapshot();
let _ = multibuffer.diff_for(snapshot.remote_id()).unwrap().update(
multibuffer.diff_for(snapshot.remote_id()).unwrap().update(
cx,
|diff, cx| {
log::info!(
@@ -2551,17 +2548,16 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
}
_ => {
let buffer_handle = if buffers.is_empty() || rng.gen_bool(0.4) {
let base_text = util::RandomCharIter::new(&mut rng)
let mut base_text = util::RandomCharIter::new(&mut rng)
.take(256)
.collect::<String>();
let buffer = cx.new(|cx| Buffer::local(base_text.clone(), cx));
let diff = cx.new(|cx| BufferDiff::new_with_base_text(&base_text, &buffer, cx));
multibuffer.update(cx, |multibuffer, cx| {
reference.add_diff(diff.clone(), cx);
multibuffer.add_diff(diff, cx)
});
text::LineEnding::normalize(&mut base_text);
base_texts.insert(
buffer.read_with(cx, |buffer, _| buffer.remote_id()),
base_text,
);
buffers.push(buffer);
buffers.last().unwrap()
} else {
@@ -2595,6 +2591,18 @@ async fn test_random_multibuffer(cx: &mut TestAppContext, mut rng: StdRng) {
(start_ix..end_ix, anchor_range)
});
multibuffer.update(cx, |multibuffer, cx| {
let id = buffer_handle.read(cx).remote_id();
if multibuffer.diff_for(id).is_none() {
let base_text = base_texts.get(&id).unwrap();
let diff = cx.new(|cx| {
BufferDiff::new_with_base_text(base_text, &buffer_handle, cx)
});
reference.add_diff(diff.clone(), cx);
multibuffer.add_diff(diff, cx)
}
});
let excerpt_id = multibuffer.update(cx, |multibuffer, cx| {
multibuffer
.insert_excerpts_after(

View File

@@ -84,9 +84,10 @@ pub fn panel_editor_style(monospace: bool, window: &Window, cx: &App) -> EditorS
let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size());
let (font_family, font_features, font_weight, line_height) = if monospace {
let (font_family, font_fallbacks, font_features, font_weight, line_height) = if monospace {
(
settings.buffer_font.family.clone(),
settings.buffer_font.fallbacks.clone(),
settings.buffer_font.features.clone(),
settings.buffer_font.weight,
font_size * settings.buffer_line_height.value(),
@@ -94,6 +95,7 @@ pub fn panel_editor_style(monospace: bool, window: &Window, cx: &App) -> EditorS
} else {
(
settings.ui_font.family.clone(),
settings.ui_font.fallbacks.clone(),
settings.ui_font.features.clone(),
settings.ui_font.weight,
window.line_height(),
@@ -106,6 +108,7 @@ pub fn panel_editor_style(monospace: bool, window: &Window, cx: &App) -> EditorS
text: TextStyle {
color: cx.theme().colors().text,
font_family,
font_fallbacks,
font_features,
font_size: TextSize::Small.rems(cx).into(),
font_weight,

View File

@@ -48,6 +48,7 @@ pub struct Picker<D: PickerDelegate> {
pending_update_matches: Option<PendingUpdateMatches>,
confirm_on_update: Option<bool>,
width: Option<Length>,
widest_item: Option<usize>,
max_height: Option<Length>,
focus_handle: FocusHandle,
/// An external control to display a scrollbar in the `Picker`.
@@ -283,6 +284,7 @@ impl<D: PickerDelegate> Picker<D> {
pending_update_matches: None,
confirm_on_update: None,
width: None,
widest_item: None,
max_height: Some(rems(18.).into()),
focus_handle,
show_scrollbar: false,
@@ -332,6 +334,11 @@ impl<D: PickerDelegate> Picker<D> {
self
}
pub fn widest_item(mut self, ix: Option<usize>) -> Self {
self.widest_item = ix;
self
}
pub fn max_height(mut self, max_height: Option<gpui::Length>) -> Self {
self.max_height = max_height;
self
@@ -690,6 +697,9 @@ impl<D: PickerDelegate> Picker<D> {
},
)
.with_sizing_behavior(sizing_behavior)
.when_some(self.widest_item, |el, widest_item| {
el.with_width_from_item(Some(widest_item))
})
.flex_grow()
.py_1()
.track_scroll(scroll_handle.clone())

View File

@@ -27,11 +27,13 @@ test-support = [
[dependencies]
aho-corasick.workspace = true
anyhow.workspace = true
askpass.workspace = true
async-trait.workspace = true
buffer_diff.workspace = true
client.workspace = true
clock.workspace = true
collections.workspace = true
buffer_diff.workspace = true
fancy-regex.workspace = true
fs.workspace = true
futures.workspace = true
fuzzy.workspace = true
@@ -39,25 +41,22 @@ git.workspace = true
globset.workspace = true
gpui.workspace = true
http_client.workspace = true
image.workspace = true
itertools.workspace = true
language.workspace = true
log.workspace = true
lsp.workspace = true
node_runtime.workspace = true
image.workspace = true
parking_lot.workspace = true
pathdiff.workspace = true
paths.workspace = true
postage.workspace = true
prettier.workspace = true
worktree.workspace = true
rand.workspace = true
regex.workspace = true
remote.workspace = true
rpc.workspace = true
schemars.workspace = true
task.workspace = true
tempfile.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
@@ -67,13 +66,15 @@ shlex.workspace = true
smol.workspace = true
snippet.workspace = true
snippet_provider.workspace = true
task.workspace = true
tempfile.workspace = true
terminal.workspace = true
text.workspace = true
toml.workspace = true
util.workspace = true
url.workspace = true
util.workspace = true
which.workspace = true
fancy-regex.workspace = true
worktree.workspace = true
[dev-dependencies]
client = { workspace = true, features = ["test-support"] }

View File

@@ -136,6 +136,15 @@ impl BufferDiffState {
let _ = self.diff_bases_changed(buffer, diff_bases_change, cx);
}
pub fn wait_for_recalculation(&mut self) -> Option<oneshot::Receiver<()>> {
if self.diff_updated_futures.is_empty() {
return None;
}
let (tx, rx) = oneshot::channel();
self.diff_updated_futures.push(tx);
Some(rx)
}
fn diff_bases_changed(
&mut self,
buffer: text::BufferSnapshot,
@@ -330,6 +339,7 @@ enum OpenBuffer {
pub enum BufferStoreEvent {
BufferAdded(Entity<Buffer>),
BufferDiffAdded(Entity<BufferDiff>),
BufferDropped(BufferId),
BufferChangedFilePath {
buffer: Entity<Buffer>,
@@ -1362,8 +1372,23 @@ impl BufferStore {
cx: &mut Context<Self>,
) -> Task<Result<Entity<BufferDiff>>> {
let buffer_id = buffer.read(cx).remote_id();
if let Some(diff) = self.get_unstaged_diff(buffer_id, cx) {
return Task::ready(Ok(diff));
if let Some(OpenBuffer::Complete { diff_state, .. }) = self.opened_buffers.get(&buffer_id) {
if let Some(unstaged_diff) = diff_state
.read(cx)
.unstaged_diff
.as_ref()
.and_then(|weak| weak.upgrade())
{
if let Some(task) =
diff_state.update(cx, |diff_state, _| diff_state.wait_for_recalculation())
{
return cx.background_executor().spawn(async move {
task.await?;
Ok(unstaged_diff)
});
}
return Task::ready(Ok(unstaged_diff));
}
}
let task = match self.loading_diffs.entry((buffer_id, DiffKind::Unstaged)) {
@@ -1402,8 +1427,24 @@ impl BufferStore {
cx: &mut Context<Self>,
) -> Task<Result<Entity<BufferDiff>>> {
let buffer_id = buffer.read(cx).remote_id();
if let Some(diff) = self.get_uncommitted_diff(buffer_id, cx) {
return Task::ready(Ok(diff));
if let Some(OpenBuffer::Complete { diff_state, .. }) = self.opened_buffers.get(&buffer_id) {
if let Some(uncommitted_diff) = diff_state
.read(cx)
.uncommitted_diff
.as_ref()
.and_then(|weak| weak.upgrade())
{
if let Some(task) =
diff_state.update(cx, |diff_state, _| diff_state.wait_for_recalculation())
{
return cx.background_executor().spawn(async move {
task.await?;
Ok(uncommitted_diff)
});
}
return Task::ready(Ok(uncommitted_diff));
}
}
let task = match self.loading_diffs.entry((buffer_id, DiffKind::Uncommitted)) {
@@ -1482,11 +1523,12 @@ impl BufferStore {
if let Some(OpenBuffer::Complete { diff_state, .. }) =
this.opened_buffers.get_mut(&buffer_id)
{
let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
cx.emit(BufferStoreEvent::BufferDiffAdded(diff.clone()));
diff_state.update(cx, |diff_state, cx| {
diff_state.language = language;
diff_state.language_registry = language_registry;
let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx));
match kind {
DiffKind::Unstaged => diff_state.unstaged_diff = Some(diff.downgrade()),
DiffKind::Uncommitted => {

View File

@@ -1,39 +1,53 @@
use crate::buffer_store::BufferStore;
use crate::worktree_store::{WorktreeStore, WorktreeStoreEvent};
use crate::{Project, ProjectPath};
use crate::{
buffer_store::{BufferStore, BufferStoreEvent},
worktree_store::{WorktreeStore, WorktreeStoreEvent},
Project, ProjectItem, ProjectPath,
};
use anyhow::{Context as _, Result};
use askpass::{AskPassDelegate, AskPassSession};
use buffer_diff::BufferDiffEvent;
use client::ProjectId;
use futures::channel::{mpsc, oneshot};
use futures::StreamExt as _;
use git::repository::{Branch, CommitDetails, PushOptions, Remote, RemoteCommandOutput, ResetMode};
use collections::HashMap;
use futures::{
channel::{mpsc, oneshot},
StreamExt as _,
};
use git::{
repository::{GitRepository, RepoPath},
status::{GitSummary, TrackedSummary},
repository::{
Branch, CommitDetails, GitRepository, PushOptions, Remote, RemoteCommandOutput, RepoPath,
ResetMode,
},
status::FileStatus,
};
use gpui::{
App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task,
WeakEntity,
};
use language::{Buffer, LanguageRegistry};
use rpc::proto::{git_reset, ToProto};
use rpc::{proto, AnyProtoClient, TypedEnvelope};
use parking_lot::Mutex;
use rpc::{
proto::{self, git_reset, ToProto},
AnyProtoClient, TypedEnvelope,
};
use settings::WorktreeId;
use std::collections::VecDeque;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::{
collections::VecDeque,
future::Future,
path::{Path, PathBuf},
sync::Arc,
};
use text::BufferId;
use util::{maybe, ResultExt};
use worktree::{ProjectEntryId, RepositoryEntry, StatusEntry};
use util::{debug_panic, maybe, ResultExt};
use worktree::{ProjectEntryId, RepositoryEntry, StatusEntry, WorkDirectory};
pub struct GitStore {
buffer_store: Entity<BufferStore>,
pub(super) project_id: Option<ProjectId>,
pub(super) client: Option<AnyProtoClient>,
pub(super) client: AnyProtoClient,
repositories: Vec<Entity<Repository>>,
active_index: Option<usize>,
update_sender: mpsc::UnboundedSender<GitJob>,
_subscription: Subscription,
_subscriptions: [Subscription; 2],
}
pub struct Repository {
@@ -44,6 +58,8 @@ pub struct Repository {
pub git_repo: GitRepo,
pub merge_message: Option<String>,
job_sender: mpsc::UnboundedSender<GitJob>,
askpass_delegates: Arc<Mutex<HashMap<u64, AskPassDelegate>>>,
latest_askpass_id: u64,
}
#[derive(Clone)]
@@ -57,10 +73,12 @@ pub enum GitRepo {
},
}
#[derive(Debug)]
pub enum GitEvent {
ActiveRepositoryChanged,
FileSystemUpdated,
GitStateUpdated,
IndexWriteError(anyhow::Error),
}
struct GitJob {
@@ -79,12 +97,15 @@ impl GitStore {
pub fn new(
worktree_store: &Entity<WorktreeStore>,
buffer_store: Entity<BufferStore>,
client: Option<AnyProtoClient>,
client: AnyProtoClient,
project_id: Option<ProjectId>,
cx: &mut Context<'_, Self>,
) -> Self {
let update_sender = Self::spawn_git_worker(cx);
let _subscription = cx.subscribe(worktree_store, Self::on_worktree_store_event);
let _subscriptions = [
cx.subscribe(worktree_store, Self::on_worktree_store_event),
cx.subscribe(&buffer_store, Self::on_buffer_store_event),
];
GitStore {
project_id,
@@ -93,12 +114,15 @@ impl GitStore {
repositories: Vec::new(),
active_index: None,
update_sender,
_subscription,
_subscriptions,
}
}
pub fn init(client: &AnyProtoClient) {
client.add_entity_request_handler(Self::handle_get_remotes);
client.add_entity_request_handler(Self::handle_get_branches);
client.add_entity_request_handler(Self::handle_change_branch);
client.add_entity_request_handler(Self::handle_create_branch);
client.add_entity_request_handler(Self::handle_push);
client.add_entity_request_handler(Self::handle_pull);
client.add_entity_request_handler(Self::handle_fetch);
@@ -110,6 +134,8 @@ impl GitStore {
client.add_entity_request_handler(Self::handle_checkout_files);
client.add_entity_request_handler(Self::handle_open_commit_message_buffer);
client.add_entity_request_handler(Self::handle_set_index_text);
client.add_entity_request_handler(Self::handle_askpass);
client.add_entity_request_handler(Self::handle_check_for_pushed_commits);
}
pub fn active_repository(&self) -> Option<Entity<Repository>> {
@@ -144,7 +170,7 @@ impl GitStore {
)
})
.or_else(|| {
let client = client.clone()?;
let client = client.clone();
let project_id = project_id?;
Some((
GitRepo::Remote {
@@ -196,6 +222,8 @@ impl GitStore {
cx.new(|_| Repository {
git_store: this.clone(),
worktree_id,
askpass_delegates: Default::default(),
latest_askpass_id: 0,
repository_entry: repo.clone(),
git_repo,
job_sender: self.update_sender.clone(),
@@ -226,10 +254,82 @@ impl GitStore {
}
}
fn on_buffer_store_event(
&mut self,
_: Entity<BufferStore>,
event: &BufferStoreEvent,
cx: &mut Context<'_, Self>,
) {
if let BufferStoreEvent::BufferDiffAdded(diff) = event {
cx.subscribe(diff, Self::on_buffer_diff_event).detach();
}
}
fn on_buffer_diff_event(
this: &mut GitStore,
diff: Entity<buffer_diff::BufferDiff>,
event: &BufferDiffEvent,
cx: &mut Context<'_, GitStore>,
) {
if let BufferDiffEvent::HunksStagedOrUnstaged(new_index_text) = event {
let buffer_id = diff.read(cx).buffer_id;
if let Some((repo, path)) = this.repository_and_path_for_buffer_id(buffer_id, cx) {
let recv = repo
.read(cx)
.set_index_text(&path, new_index_text.as_ref().map(|rope| rope.to_string()));
let diff = diff.downgrade();
cx.spawn(|this, mut cx| async move {
if let Some(result) = cx.background_spawn(async move { recv.await.ok() }).await
{
if let Err(error) = result {
diff.update(&mut cx, |diff, cx| {
diff.clear_pending_hunks(cx);
})
.ok();
this.update(&mut cx, |_, cx| cx.emit(GitEvent::IndexWriteError(error)))
.ok();
}
}
})
.detach();
}
}
}
pub fn all_repositories(&self) -> Vec<Entity<Repository>> {
self.repositories.clone()
}
pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option<FileStatus> {
let (repo, path) = self.repository_and_path_for_buffer_id(buffer_id, cx)?;
let status = repo.read(cx).repository_entry.status_for_path(&path)?;
Some(status.status)
}
fn repository_and_path_for_buffer_id(
&self,
buffer_id: BufferId,
cx: &App,
) -> Option<(Entity<Repository>, RepoPath)> {
let buffer = self.buffer_store.read(cx).get(buffer_id)?;
let path = buffer.read(cx).project_path(cx)?;
let mut result: Option<(Entity<Repository>, RepoPath)> = None;
for repo_handle in &self.repositories {
let repo = repo_handle.read(cx);
if repo.worktree_id == path.worktree_id {
if let Ok(relative_path) = repo.repository_entry.relativize(&path.path) {
if result
.as_ref()
.is_none_or(|(result, _)| !repo.contains_sub_repo(result, cx))
{
result = Some((repo_handle.clone(), relative_path))
}
}
}
}
result
}
fn spawn_git_worker(cx: &mut Context<'_, GitStore>) -> mpsc::UnboundedSender<GitJob> {
let (job_tx, mut job_rx) = mpsc::unbounded::<GitJob>();
@@ -270,9 +370,21 @@ impl GitStore {
let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
let repository_handle =
Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
let askpass_id = envelope.payload.askpass_id;
let askpass = make_remote_delegate(
this,
envelope.payload.project_id,
worktree_id,
work_directory_id,
askpass_id,
&mut cx,
);
let remote_output = repository_handle
.update(&mut cx, |repository_handle, _cx| repository_handle.fetch())?
.update(&mut cx, |repository_handle, cx| {
repository_handle.fetch(askpass, cx)
})?
.await??;
Ok(proto::RemoteMessageResponse {
@@ -291,6 +403,16 @@ impl GitStore {
let repository_handle =
Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
let askpass_id = envelope.payload.askpass_id;
let askpass = make_remote_delegate(
this,
envelope.payload.project_id,
worktree_id,
work_directory_id,
askpass_id,
&mut cx,
);
let options = envelope
.payload
.options
@@ -304,8 +426,8 @@ impl GitStore {
let remote_name = envelope.payload.remote_name.into();
let remote_output = repository_handle
.update(&mut cx, |repository_handle, _cx| {
repository_handle.push(branch_name, remote_name, options)
.update(&mut cx, |repository_handle, cx| {
repository_handle.push(branch_name, remote_name, options, askpass, cx)
})?
.await??;
Ok(proto::RemoteMessageResponse {
@@ -323,15 +445,25 @@ impl GitStore {
let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
let repository_handle =
Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
let askpass_id = envelope.payload.askpass_id;
let askpass = make_remote_delegate(
this,
envelope.payload.project_id,
worktree_id,
work_directory_id,
askpass_id,
&mut cx,
);
let branch_name = envelope.payload.branch_name.into();
let remote_name = envelope.payload.remote_name.into();
let remote_message = repository_handle
.update(&mut cx, |repository_handle, _cx| {
repository_handle.pull(branch_name, remote_name)
.update(&mut cx, |repository_handle, cx| {
repository_handle.pull(branch_name, remote_name, askpass, cx)
})?
.await??;
Ok(proto::RemoteMessageResponse {
stdout: remote_message.stdout,
stderr: remote_message.stderr,
@@ -462,6 +594,67 @@ impl GitStore {
})
}
async fn handle_get_branches(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GitGetBranches>,
mut cx: AsyncApp,
) -> Result<proto::GitBranchesResponse> {
let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
let repository_handle =
Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
let branches = repository_handle
.update(&mut cx, |repository_handle, _| repository_handle.branches())?
.await??;
Ok(proto::GitBranchesResponse {
branches: branches
.into_iter()
.map(|branch| worktree::branch_to_proto(&branch))
.collect::<Vec<_>>(),
})
}
async fn handle_create_branch(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GitCreateBranch>,
mut cx: AsyncApp,
) -> Result<proto::Ack> {
let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
let repository_handle =
Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
let branch_name = envelope.payload.branch_name;
repository_handle
.update(&mut cx, |repository_handle, _| {
repository_handle.create_branch(branch_name)
})?
.await??;
Ok(proto::Ack {})
}
async fn handle_change_branch(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GitChangeBranch>,
mut cx: AsyncApp,
) -> Result<proto::Ack> {
let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
let repository_handle =
Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
let branch_name = envelope.payload.branch_name;
repository_handle
.update(&mut cx, |repository_handle, _| {
repository_handle.change_branch(branch_name)
})?
.await??;
Ok(proto::Ack {})
}
async fn handle_show(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GitShow>,
@@ -566,6 +759,54 @@ impl GitStore {
})
}
async fn handle_askpass(
this: Entity<Self>,
envelope: TypedEnvelope<proto::AskPassRequest>,
mut cx: AsyncApp,
) -> Result<proto::AskPassResponse> {
let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
let repository =
Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
let delegates = cx.update(|cx| repository.read(cx).askpass_delegates.clone())?;
let Some(mut askpass) = delegates.lock().remove(&envelope.payload.askpass_id) else {
debug_panic!("no askpass found");
return Err(anyhow::anyhow!("no askpass found"));
};
let response = askpass.ask_password(envelope.payload.prompt).await?;
delegates
.lock()
.insert(envelope.payload.askpass_id, askpass);
Ok(proto::AskPassResponse { response })
}
async fn handle_check_for_pushed_commits(
this: Entity<Self>,
envelope: TypedEnvelope<proto::CheckForPushedCommits>,
mut cx: AsyncApp,
) -> Result<proto::CheckForPushedCommitsResponse> {
let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
let work_directory_id = ProjectEntryId::from_proto(envelope.payload.work_directory_id);
let repository_handle =
Self::repository_for_request(&this, worktree_id, work_directory_id, &mut cx)?;
let branches = repository_handle
.update(&mut cx, |repository_handle, _| {
repository_handle.check_for_pushed_commits()
})?
.await??;
Ok(proto::CheckForPushedCommitsResponse {
pushed_to: branches
.into_iter()
.map(|commit| commit.to_string())
.collect(),
})
}
fn repository_for_request(
this: &Entity<Self>,
worktree_id: WorktreeId,
@@ -573,9 +814,8 @@ impl GitStore {
cx: &mut AsyncApp,
) -> Result<Entity<Repository>> {
this.update(cx, |this, cx| {
let repository_handle = this
.all_repositories()
.into_iter()
this.repositories
.iter()
.find(|repository_handle| {
repository_handle.read(cx).worktree_id == worktree_id
&& repository_handle
@@ -584,12 +824,39 @@ impl GitStore {
.work_directory_id()
== work_directory_id
})
.context("missing repository handle")?;
anyhow::Ok(repository_handle)
.context("missing repository handle")
.cloned()
})?
}
}
fn make_remote_delegate(
this: Entity<GitStore>,
project_id: u64,
worktree_id: WorktreeId,
work_directory_id: ProjectEntryId,
askpass_id: u64,
cx: &mut AsyncApp,
) -> AskPassDelegate {
AskPassDelegate::new(cx, move |prompt, tx, cx| {
this.update(cx, |this, cx| {
let response = this.client.request(proto::AskPassRequest {
project_id,
worktree_id: worktree_id.to_proto(),
work_directory_id: work_directory_id.to_proto(),
askpass_id,
prompt,
});
cx.spawn(|_, _| async move {
tx.send(response.await?.response).ok();
anyhow::Ok(())
})
.detach_and_log_err(cx);
})
.log_err();
})
}
impl GitRepo {}
impl Repository {
@@ -691,6 +958,33 @@ impl Repository {
self.worktree_id_path_to_repo_path(path.worktree_id, &path.path)
}
// note: callers must verify these come from the same worktree
pub fn contains_sub_repo(&self, other: &Entity<Self>, cx: &App) -> bool {
let other_work_dir = &other.read(cx).repository_entry.work_directory;
match (&self.repository_entry.work_directory, other_work_dir) {
(WorkDirectory::InProject { .. }, WorkDirectory::AboveProject { .. }) => false,
(WorkDirectory::AboveProject { .. }, WorkDirectory::InProject { .. }) => true,
(
WorkDirectory::InProject {
relative_path: this_path,
},
WorkDirectory::InProject {
relative_path: other_path,
},
) => other_path.starts_with(this_path),
(
WorkDirectory::AboveProject {
absolute_path: this_path,
..
},
WorkDirectory::AboveProject {
absolute_path: other_path,
..
},
) => other_path.starts_with(this_path),
}
}
pub fn worktree_id_path_to_repo_path(
&self,
worktree_id: WorktreeId,
@@ -1046,18 +1340,6 @@ impl Repository {
self.repository_entry.status_len()
}
fn have_changes(&self) -> bool {
self.repository_entry.status_summary() != GitSummary::UNCHANGED
}
fn have_staged_changes(&self) -> bool {
self.repository_entry.status_summary().index != TrackedSummary::UNCHANGED
}
pub fn can_commit(&self, commit_all: bool) -> bool {
return self.have_changes() && (commit_all || self.have_staged_changes());
}
pub fn commit(
&self,
message: SharedString,
@@ -1096,21 +1378,39 @@ impl Repository {
})
}
pub fn fetch(&self) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
self.send_job(|git_repo| async move {
pub fn fetch(
&mut self,
askpass: AskPassDelegate,
cx: &App,
) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
let executor = cx.background_executor().clone();
let askpass_delegates = self.askpass_delegates.clone();
let askpass_id = util::post_inc(&mut self.latest_askpass_id);
self.send_job(move |git_repo| async move {
match git_repo {
GitRepo::Local(git_repository) => git_repository.fetch(),
GitRepo::Local(git_repository) => {
let askpass = AskPassSession::new(&executor, askpass).await?;
git_repository.fetch(askpass)
}
GitRepo::Remote {
project_id,
client,
worktree_id,
work_directory_id,
} => {
askpass_delegates.lock().insert(askpass_id, askpass);
let _defer = util::defer(|| {
let askpass_delegate = askpass_delegates.lock().remove(&askpass_id);
debug_assert!(askpass_delegate.is_some());
});
let response = client
.request(proto::Fetch {
project_id: project_id.0,
worktree_id: worktree_id.to_proto(),
work_directory_id: work_directory_id.to_proto(),
askpass_id,
})
.await
.context("sending fetch request")?;
@@ -1125,25 +1425,40 @@ impl Repository {
}
pub fn push(
&self,
&mut self,
branch: SharedString,
remote: SharedString,
options: Option<PushOptions>,
askpass: AskPassDelegate,
cx: &App,
) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
let executor = cx.background_executor().clone();
let askpass_delegates = self.askpass_delegates.clone();
let askpass_id = util::post_inc(&mut self.latest_askpass_id);
self.send_job(move |git_repo| async move {
match git_repo {
GitRepo::Local(git_repository) => git_repository.push(&branch, &remote, options),
GitRepo::Local(git_repository) => {
let askpass = AskPassSession::new(&executor, askpass).await?;
git_repository.push(&branch, &remote, options, askpass)
}
GitRepo::Remote {
project_id,
client,
worktree_id,
work_directory_id,
} => {
askpass_delegates.lock().insert(askpass_id, askpass);
let _defer = util::defer(|| {
let askpass_delegate = askpass_delegates.lock().remove(&askpass_id);
debug_assert!(askpass_delegate.is_some());
});
let response = client
.request(proto::Push {
project_id: project_id.0,
worktree_id: worktree_id.to_proto(),
work_directory_id: work_directory_id.to_proto(),
askpass_id,
branch_name: branch.to_string(),
remote_name: remote.to_string(),
options: options.map(|options| match options {
@@ -1164,24 +1479,38 @@ impl Repository {
}
pub fn pull(
&self,
&mut self,
branch: SharedString,
remote: SharedString,
askpass: AskPassDelegate,
cx: &App,
) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
self.send_job(|git_repo| async move {
let executor = cx.background_executor().clone();
let askpass_delegates = self.askpass_delegates.clone();
let askpass_id = util::post_inc(&mut self.latest_askpass_id);
self.send_job(move |git_repo| async move {
match git_repo {
GitRepo::Local(git_repository) => git_repository.pull(&branch, &remote),
GitRepo::Local(git_repository) => {
let askpass = AskPassSession::new(&executor, askpass).await?;
git_repository.pull(&branch, &remote, askpass)
}
GitRepo::Remote {
project_id,
client,
worktree_id,
work_directory_id,
} => {
askpass_delegates.lock().insert(askpass_id, askpass);
let _defer = util::defer(|| {
let askpass_delegate = askpass_delegates.lock().remove(&askpass_id);
debug_assert!(askpass_delegate.is_some());
});
let response = client
.request(proto::Pull {
project_id: project_id.0,
worktree_id: worktree_id.to_proto(),
work_directory_id: work_directory_id.to_proto(),
askpass_id,
branch_name: branch.to_string(),
remote_name: remote.to_string(),
})
@@ -1197,7 +1526,7 @@ impl Repository {
})
}
pub fn set_index_text(
fn set_index_text(
&self,
path: &RepoPath,
content: Option<String>,
@@ -1267,4 +1596,110 @@ impl Repository {
}
})
}
pub fn branches(&self) -> oneshot::Receiver<Result<Vec<Branch>>> {
self.send_job(|repo| async move {
match repo {
GitRepo::Local(git_repository) => git_repository.branches(),
GitRepo::Remote {
project_id,
client,
worktree_id,
work_directory_id,
} => {
let response = client
.request(proto::GitGetBranches {
project_id: project_id.0,
worktree_id: worktree_id.to_proto(),
work_directory_id: work_directory_id.to_proto(),
})
.await?;
let branches = response
.branches
.into_iter()
.map(|branch| worktree::proto_to_branch(&branch))
.collect();
Ok(branches)
}
}
})
}
pub fn create_branch(&self, branch_name: String) -> oneshot::Receiver<Result<()>> {
self.send_job(|repo| async move {
match repo {
GitRepo::Local(git_repository) => git_repository.create_branch(&branch_name),
GitRepo::Remote {
project_id,
client,
worktree_id,
work_directory_id,
} => {
client
.request(proto::GitCreateBranch {
project_id: project_id.0,
worktree_id: worktree_id.to_proto(),
work_directory_id: work_directory_id.to_proto(),
branch_name,
})
.await?;
Ok(())
}
}
})
}
pub fn change_branch(&self, branch_name: String) -> oneshot::Receiver<Result<()>> {
self.send_job(|repo| async move {
match repo {
GitRepo::Local(git_repository) => git_repository.change_branch(&branch_name),
GitRepo::Remote {
project_id,
client,
worktree_id,
work_directory_id,
} => {
client
.request(proto::GitChangeBranch {
project_id: project_id.0,
worktree_id: worktree_id.to_proto(),
work_directory_id: work_directory_id.to_proto(),
branch_name,
})
.await?;
Ok(())
}
}
})
}
pub fn check_for_pushed_commits(&self) -> oneshot::Receiver<Result<Vec<SharedString>>> {
self.send_job(|repo| async move {
match repo {
GitRepo::Local(git_repository) => git_repository.check_for_pushed_commit(),
GitRepo::Remote {
project_id,
client,
worktree_id,
work_directory_id,
} => {
let response = client
.request(proto::CheckForPushedCommits {
project_id: project_id.0,
worktree_id: worktree_id.to_proto(),
work_directory_id: work_directory_id.to_proto(),
})
.await?;
let branches = response.pushed_to.into_iter().map(Into::into).collect();
Ok(branches)
}
}
})
}
}

View File

@@ -3184,7 +3184,7 @@ impl LspStore {
}
}
}
BufferStoreEvent::BufferDropped(_) => {}
_ => {}
}
}

View File

@@ -46,11 +46,7 @@ use futures::{
pub use image_store::{ImageItem, ImageStore};
use image_store::{ImageItemEvent, ImageStoreEvent};
use ::git::{
blame::Blame,
repository::{Branch, GitRepository, RepoPath},
status::FileStatus,
};
use ::git::{blame::Blame, repository::GitRepository, status::FileStatus};
use gpui::{
AnyEntity, App, AppContext as _, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter,
Hsla, SharedString, Task, WeakEntity, Window,
@@ -701,8 +697,15 @@ impl Project {
)
});
let git_store =
cx.new(|cx| GitStore::new(&worktree_store, buffer_store.clone(), None, None, cx));
let git_store = cx.new(|cx| {
GitStore::new(
&worktree_store,
buffer_store.clone(),
client.clone().into(),
None,
cx,
)
});
cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
@@ -826,7 +829,7 @@ impl Project {
GitStore::new(
&worktree_store,
buffer_store.clone(),
Some(ssh_proto.clone()),
ssh_proto.clone(),
Some(ProjectId(SSH_PROJECT_ID)),
cx,
)
@@ -1034,7 +1037,7 @@ impl Project {
GitStore::new(
&worktree_store,
buffer_store.clone(),
Some(client.clone().into()),
client.clone().into(),
Some(ProjectId(remote_id)),
cx,
)
@@ -2266,7 +2269,6 @@ impl Project {
BufferStoreEvent::BufferAdded(buffer) => {
self.register_buffer(buffer, cx).log_err();
}
BufferStoreEvent::BufferChangedFilePath { .. } => {}
BufferStoreEvent::BufferDropped(buffer_id) => {
if let Some(ref ssh_client) = self.ssh_client {
ssh_client
@@ -2279,6 +2281,7 @@ impl Project {
.log_err();
}
}
_ => {}
}
}
@@ -3655,21 +3658,6 @@ impl Project {
worktree.get_local_repo(&root_entry)?.repo().clone().into()
}
pub fn branches(&self, project_path: ProjectPath, cx: &App) -> Task<Result<Vec<Branch>>> {
self.worktree_store().read(cx).branches(project_path, cx)
}
pub fn update_or_create_branch(
&self,
repository: ProjectPath,
new_branch: String,
cx: &App,
) -> Task<Result<()>> {
self.worktree_store()
.read(cx)
.update_or_create_branch(repository, new_branch, cx)
}
pub fn blame_buffer(
&self,
buffer: &Entity<Buffer>,
@@ -4301,25 +4289,8 @@ impl Project {
self.git_store.read(cx).all_repositories()
}
pub fn repository_and_path_for_buffer_id(
&self,
buffer_id: BufferId,
cx: &App,
) -> Option<(Entity<Repository>, RepoPath)> {
let path = self
.buffer_for_id(buffer_id, cx)?
.read(cx)
.project_path(cx)?;
self.git_store
.read(cx)
.all_repositories()
.into_iter()
.find_map(|repo| {
Some((
repo.clone(),
repo.read(cx).repository_entry.relativize(&path.path).ok()?,
))
})
pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option<FileStatus> {
self.git_store.read(cx).status_for_buffer_id(buffer_id, cx)
}
}

View File

@@ -168,6 +168,10 @@ pub struct GitSettings {
///
/// Default: on
pub inline_blame: Option<InlineBlameSettings>,
/// How hunks are displayed visually in the editor.
///
/// Default: transparent
pub hunk_style: Option<GitHunkStyleSetting>,
}
impl GitSettings {
@@ -200,6 +204,16 @@ impl GitSettings {
}
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum GitHunkStyleSetting {
/// Show unstaged hunks with a transparent background
#[default]
Transparent,
/// Show unstaged hunks with a pattern background
Pattern,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum GitGutterSetting {

View File

@@ -1,5 +1,7 @@
use crate::{task_inventory::TaskContexts, Event, *};
use buffer_diff::{assert_hunks, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind};
use buffer_diff::{
assert_hunks, BufferDiffEvent, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind,
};
use fs::FakeFs;
use futures::{future, StreamExt};
use gpui::{App, SemanticVersion, UpdateGlobal};
@@ -5786,7 +5788,7 @@ async fn test_unstaged_diff_for_buffer(cx: &mut gpui::TestAppContext) {
unstaged_diff.update(cx, |unstaged_diff, cx| {
let snapshot = buffer.read(cx).snapshot();
assert_hunks(
unstaged_diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx),
unstaged_diff.hunks(&snapshot, cx),
&snapshot,
&unstaged_diff.base_text_string().unwrap(),
&[
@@ -6008,6 +6010,271 @@ async fn test_uncommitted_diff_for_buffer(cx: &mut gpui::TestAppContext) {
});
}
#[gpui::test]
async fn test_staging_hunks(cx: &mut gpui::TestAppContext) {
use DiffHunkSecondaryStatus::*;
init_test(cx);
let committed_contents = r#"
zero
one
two
three
four
five
"#
.unindent();
let file_contents = r#"
one
TWO
three
FOUR
five
"#
.unindent();
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
"/dir",
json!({
".git": {},
"file.txt": file_contents.clone()
}),
)
.await;
fs.set_head_for_repo(
"/dir/.git".as_ref(),
&[("file.txt".into(), committed_contents.clone())],
);
fs.set_index_for_repo(
"/dir/.git".as_ref(),
&[("file.txt".into(), committed_contents.clone())],
);
let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer("/dir/file.txt", cx)
})
.await
.unwrap();
let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
let uncommitted_diff = project
.update(cx, |project, cx| {
project.open_uncommitted_diff(buffer.clone(), cx)
})
.await
.unwrap();
let mut diff_events = cx.events(&uncommitted_diff);
// The hunks are initially unstaged.
uncommitted_diff.read_with(cx, |diff, cx| {
assert_hunks(
diff.hunks(&snapshot, cx),
&snapshot,
&diff.base_text_string().unwrap(),
&[
(
0..0,
"zero\n",
"",
DiffHunkStatus::deleted(HasSecondaryHunk),
),
(
1..2,
"two\n",
"TWO\n",
DiffHunkStatus::modified(HasSecondaryHunk),
),
(
3..4,
"four\n",
"FOUR\n",
DiffHunkStatus::modified(HasSecondaryHunk),
),
],
);
});
// Stage a hunk. It appears as optimistically staged.
uncommitted_diff.update(cx, |diff, cx| {
let range =
snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_before(Point::new(2, 0));
let hunks = diff
.hunks_intersecting_range(range, &snapshot, cx)
.collect::<Vec<_>>();
diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx);
assert_hunks(
diff.hunks(&snapshot, cx),
&snapshot,
&diff.base_text_string().unwrap(),
&[
(
0..0,
"zero\n",
"",
DiffHunkStatus::deleted(HasSecondaryHunk),
),
(
1..2,
"two\n",
"TWO\n",
DiffHunkStatus::modified(SecondaryHunkRemovalPending),
),
(
3..4,
"four\n",
"FOUR\n",
DiffHunkStatus::modified(HasSecondaryHunk),
),
],
);
});
// The diff emits a change event for the range of the staged hunk.
assert!(matches!(
diff_events.next().await.unwrap(),
BufferDiffEvent::HunksStagedOrUnstaged(_)
));
let event = diff_events.next().await.unwrap();
if let BufferDiffEvent::DiffChanged {
changed_range: Some(changed_range),
} = event
{
let changed_range = changed_range.to_point(&snapshot);
assert_eq!(changed_range, Point::new(1, 0)..Point::new(2, 0));
} else {
panic!("Unexpected event {event:?}");
}
// When the write to the index completes, it appears as staged.
cx.run_until_parked();
uncommitted_diff.update(cx, |diff, cx| {
assert_hunks(
diff.hunks(&snapshot, cx),
&snapshot,
&diff.base_text_string().unwrap(),
&[
(
0..0,
"zero\n",
"",
DiffHunkStatus::deleted(HasSecondaryHunk),
),
(1..2, "two\n", "TWO\n", DiffHunkStatus::modified(None)),
(
3..4,
"four\n",
"FOUR\n",
DiffHunkStatus::modified(HasSecondaryHunk),
),
],
);
});
// The diff emits a change event for the changed index text.
let event = diff_events.next().await.unwrap();
if let BufferDiffEvent::DiffChanged {
changed_range: Some(changed_range),
} = event
{
let changed_range = changed_range.to_point(&snapshot);
assert_eq!(changed_range, Point::new(0, 0)..Point::new(5, 0));
} else {
panic!("Unexpected event {event:?}");
}
// Simulate a problem writing to the git index.
fs.set_error_message_for_index_write(
"/dir/.git".as_ref(),
Some("failed to write git index".into()),
);
// Stage another hunk.
uncommitted_diff.update(cx, |diff, cx| {
let range =
snapshot.anchor_before(Point::new(3, 0))..snapshot.anchor_before(Point::new(4, 0));
let hunks = diff
.hunks_intersecting_range(range, &snapshot, cx)
.collect::<Vec<_>>();
diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx);
assert_hunks(
diff.hunks(&snapshot, cx),
&snapshot,
&diff.base_text_string().unwrap(),
&[
(
0..0,
"zero\n",
"",
DiffHunkStatus::deleted(HasSecondaryHunk),
),
(1..2, "two\n", "TWO\n", DiffHunkStatus::modified(None)),
(
3..4,
"four\n",
"FOUR\n",
DiffHunkStatus::modified(SecondaryHunkRemovalPending),
),
],
);
});
assert!(matches!(
diff_events.next().await.unwrap(),
BufferDiffEvent::HunksStagedOrUnstaged(_)
));
let event = diff_events.next().await.unwrap();
if let BufferDiffEvent::DiffChanged {
changed_range: Some(changed_range),
} = event
{
let changed_range = changed_range.to_point(&snapshot);
assert_eq!(changed_range, Point::new(3, 0)..Point::new(4, 0));
} else {
panic!("Unexpected event {event:?}");
}
// When the write fails, the hunk returns to being unstaged.
cx.run_until_parked();
uncommitted_diff.update(cx, |diff, cx| {
assert_hunks(
diff.hunks(&snapshot, cx),
&snapshot,
&diff.base_text_string().unwrap(),
&[
(
0..0,
"zero\n",
"",
DiffHunkStatus::deleted(HasSecondaryHunk),
),
(1..2, "two\n", "TWO\n", DiffHunkStatus::modified(None)),
(
3..4,
"four\n",
"FOUR\n",
DiffHunkStatus::modified(HasSecondaryHunk),
),
],
);
});
let event = diff_events.next().await.unwrap();
if let BufferDiffEvent::DiffChanged {
changed_range: Some(changed_range),
} = event
{
let changed_range = changed_range.to_point(&snapshot);
assert_eq!(changed_range, Point::new(0, 0)..Point::new(5, 0));
} else {
panic!("Unexpected event {event:?}");
}
}
#[gpui::test]
async fn test_single_file_diffs(cx: &mut gpui::TestAppContext) {
init_test(cx);
@@ -6065,7 +6332,7 @@ async fn test_single_file_diffs(cx: &mut gpui::TestAppContext) {
uncommitted_diff.update(cx, |uncommitted_diff, cx| {
let snapshot = buffer.read(cx).snapshot();
assert_hunks(
uncommitted_diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx),
uncommitted_diff.hunks(&snapshot, cx),
&snapshot,
&uncommitted_diff.base_text_string().unwrap(),
&[(

View File

@@ -27,10 +27,7 @@ use smol::{
};
use text::ReplicaId;
use util::{paths::SanitizedPath, ResultExt};
use worktree::{
branch_to_proto, Entry, ProjectEntryId, UpdatedEntriesSet, Worktree, WorktreeId,
WorktreeSettings,
};
use worktree::{Entry, ProjectEntryId, UpdatedEntriesSet, Worktree, WorktreeId, WorktreeSettings};
use crate::{search::SearchQuery, ProjectPath};
@@ -83,8 +80,6 @@ impl WorktreeStore {
client.add_entity_request_handler(Self::handle_delete_project_entry);
client.add_entity_request_handler(Self::handle_expand_project_entry);
client.add_entity_request_handler(Self::handle_expand_all_for_project_entry);
client.add_entity_request_handler(Self::handle_git_branches);
client.add_entity_request_handler(Self::handle_update_branch);
}
pub fn local(retain_worktrees: bool, fs: Arc<dyn Fs>) -> Self {
@@ -890,150 +885,6 @@ impl WorktreeStore {
Ok(())
}
pub fn branches(
&self,
project_path: ProjectPath,
cx: &App,
) -> Task<Result<Vec<git::repository::Branch>>> {
let Some(worktree) = self.worktree_for_id(project_path.worktree_id, cx) else {
return Task::ready(Err(anyhow!("No worktree found for ProjectPath")));
};
match worktree.read(cx) {
Worktree::Local(local_worktree) => {
let branches = util::maybe!({
let worktree_error = |error| {
format!(
"{} for worktree {}",
error,
local_worktree.abs_path().to_string_lossy()
)
};
let entry = local_worktree
.git_entry(project_path.path)
.with_context(|| worktree_error("No git entry found"))?;
let repo = local_worktree
.get_local_repo(&entry)
.with_context(|| worktree_error("No repository found"))?
.repo()
.clone();
repo.branches()
});
Task::ready(branches)
}
Worktree::Remote(remote_worktree) => {
let request = remote_worktree.client().request(proto::GitBranches {
project_id: remote_worktree.project_id(),
repository: Some(proto::ProjectPath {
worktree_id: project_path.worktree_id.to_proto(),
path: project_path.path.to_proto(), // Root path
}),
});
cx.background_spawn(async move {
let response = request.await?;
let branches = response
.branches
.into_iter()
.map(|proto_branch| git::repository::Branch {
is_head: proto_branch.is_head,
name: proto_branch.name.into(),
upstream: proto_branch.upstream.map(|upstream| {
git::repository::Upstream {
ref_name: upstream.ref_name.into(),
tracking: upstream
.tracking
.map(|tracking| {
git::repository::UpstreamTracking::Tracked(
git::repository::UpstreamTrackingStatus {
ahead: tracking.ahead as u32,
behind: tracking.behind as u32,
},
)
})
.unwrap_or(git::repository::UpstreamTracking::Gone),
}
}),
most_recent_commit: proto_branch.most_recent_commit.map(|commit| {
git::repository::CommitSummary {
sha: commit.sha.into(),
subject: commit.subject.into(),
commit_timestamp: commit.commit_timestamp,
}
}),
})
.collect();
Ok(branches)
})
}
}
}
pub fn update_or_create_branch(
&self,
repository: ProjectPath,
new_branch: String,
cx: &App,
) -> Task<Result<()>> {
let Some(worktree) = self.worktree_for_id(repository.worktree_id, cx) else {
return Task::ready(Err(anyhow!("No worktree found for ProjectPath")));
};
match worktree.read(cx) {
Worktree::Local(local_worktree) => {
let result = util::maybe!({
let worktree_error = |error| {
format!(
"{} for worktree {}",
error,
local_worktree.abs_path().to_string_lossy()
)
};
let entry = local_worktree
.git_entry(repository.path)
.with_context(|| worktree_error("No git entry found"))?;
let repo = local_worktree
.get_local_repo(&entry)
.with_context(|| worktree_error("No repository found"))?
.repo()
.clone();
if !repo.branch_exits(&new_branch)? {
repo.create_branch(&new_branch)?;
}
repo.change_branch(&new_branch)?;
Ok(())
});
Task::ready(result)
}
Worktree::Remote(remote_worktree) => {
let request = remote_worktree.client().request(proto::UpdateGitBranch {
project_id: remote_worktree.project_id(),
repository: Some(proto::ProjectPath {
worktree_id: repository.worktree_id.to_proto(),
path: repository.path.to_proto(), // Root path
}),
branch_name: new_branch,
});
cx.background_spawn(async move {
request.await?;
Ok(())
})
}
}
}
async fn filter_paths(
fs: &Arc<dyn Fs>,
mut input: Receiver<MatchingEntry>,
@@ -1130,54 +981,6 @@ impl WorktreeStore {
.ok_or_else(|| anyhow!("invalid request"))?;
Worktree::handle_expand_all_for_entry(worktree, envelope.payload, cx).await
}
pub async fn handle_git_branches(
this: Entity<Self>,
branches: TypedEnvelope<proto::GitBranches>,
cx: AsyncApp,
) -> Result<proto::GitBranchesResponse> {
let project_path = branches
.payload
.repository
.clone()
.context("Invalid GitBranches call")?;
let project_path = ProjectPath {
worktree_id: WorktreeId::from_proto(project_path.worktree_id),
path: Arc::<Path>::from_proto(project_path.path),
};
let branches = this
.read_with(&cx, |this, cx| this.branches(project_path, cx))?
.await?;
Ok(proto::GitBranchesResponse {
branches: branches.iter().map(branch_to_proto).collect(),
})
}
pub async fn handle_update_branch(
this: Entity<Self>,
update_branch: TypedEnvelope<proto::UpdateGitBranch>,
cx: AsyncApp,
) -> Result<proto::Ack> {
let project_path = update_branch
.payload
.repository
.clone()
.context("Invalid GitBranches call")?;
let project_path = ProjectPath {
worktree_id: WorktreeId::from_proto(project_path.worktree_id),
path: Arc::<Path>::from_proto(project_path.path),
};
let new_branch = update_branch.payload.branch_name;
this.read_with(&cx, |this, cx| {
this.update_or_create_branch(project_path, new_branch, cx)
})?
.await?;
Ok(proto::Ack {})
}
}
#[derive(Clone, Debug)]

View File

@@ -1,3 +1,4 @@
syntax = "proto3";
package zed.messages;
@@ -278,7 +279,6 @@ message Envelope {
LanguageServerPromptRequest language_server_prompt_request = 268;
LanguageServerPromptResponse language_server_prompt_response = 269;
GitBranches git_branches = 270;
GitBranchesResponse git_branches_response = 271;
UpdateGitBranch update_git_branch = 272;
@@ -332,7 +332,17 @@ message Envelope {
ApplyCodeActionKind apply_code_action_kind = 309;
ApplyCodeActionKindResponse apply_code_action_kind_response = 310;
RemoteMessageResponse remote_message_response = 311; // current max
RemoteMessageResponse remote_message_response = 311;
GitGetBranches git_get_branches = 312;
GitCreateBranch git_create_branch = 313;
GitChangeBranch git_change_branch = 314;
CheckForPushedCommits check_for_pushed_commits = 315;
CheckForPushedCommitsResponse check_for_pushed_commits_response = 316;
AskPassRequest ask_pass_request = 317;
AskPassResponse ask_pass_response = 318; // current max
}
reserved 87 to 88;
@@ -348,6 +358,7 @@ message Envelope {
reserved 221;
reserved 224 to 229;
reserved 246;
reserved 270;
reserved 247 to 254;
reserved 255 to 256;
}
@@ -2801,6 +2812,7 @@ message Push {
string remote_name = 4;
string branch_name = 5;
optional PushOptions options = 6;
uint64 askpass_id = 7;
enum PushOptions {
SET_UPSTREAM = 0;
@@ -2812,6 +2824,7 @@ message Fetch {
uint64 project_id = 1;
uint64 worktree_id = 2;
uint64 work_directory_id = 3;
uint64 askpass_id = 4;
}
message GetRemotes {
@@ -2835,9 +2848,52 @@ message Pull {
uint64 work_directory_id = 3;
string remote_name = 4;
string branch_name = 5;
uint64 askpass_id = 6;
}
message RemoteMessageResponse {
string stdout = 1;
string stderr = 2;
}
message AskPassRequest {
uint64 project_id = 1;
uint64 worktree_id = 2;
uint64 work_directory_id = 3;
uint64 askpass_id = 4;
string prompt = 5;
}
message AskPassResponse {
string response = 1;
}
message GitGetBranches {
uint64 project_id = 1;
uint64 worktree_id = 2;
uint64 work_directory_id = 3;
}
message GitCreateBranch {
uint64 project_id = 1;
uint64 worktree_id = 2;
uint64 work_directory_id = 3;
string branch_name = 4;
}
message GitChangeBranch {
uint64 project_id = 1;
uint64 worktree_id = 2;
uint64 work_directory_id = 3;
string branch_name = 4;
}
message CheckForPushedCommits {
uint64 project_id = 1;
uint64 worktree_id = 2;
uint64 work_directory_id = 3;
}
message CheckForPushedCommitsResponse {
repeated string pushed_to = 1;
}

View File

@@ -424,7 +424,7 @@ messages!(
(FlushBufferedMessages, Foreground),
(LanguageServerPromptRequest, Foreground),
(LanguageServerPromptResponse, Foreground),
(GitBranches, Background),
(GitGetBranches, Background),
(GitBranchesResponse, Background),
(UpdateGitBranch, Background),
(ListToolchains, Foreground),
@@ -452,6 +452,12 @@ messages!(
(GetRemotesResponse, Background),
(Pull, Background),
(RemoteMessageResponse, Background),
(AskPassRequest, Background),
(AskPassResponse, Background),
(GitCreateBranch, Background),
(GitChangeBranch, Background),
(CheckForPushedCommits, Background),
(CheckForPushedCommitsResponse, Background),
);
request_messages!(
@@ -575,7 +581,7 @@ request_messages!(
(GetPermalinkToLine, GetPermalinkToLineResponse),
(FlushBufferedMessages, Ack),
(LanguageServerPromptRequest, LanguageServerPromptResponse),
(GitBranches, GitBranchesResponse),
(GitGetBranches, GitBranchesResponse),
(UpdateGitBranch, Ack),
(ListToolchains, ListToolchainsResponse),
(ActivateToolchain, Ack),
@@ -594,6 +600,10 @@ request_messages!(
(Fetch, RemoteMessageResponse),
(GetRemotes, GetRemotesResponse),
(Pull, RemoteMessageResponse),
(AskPassRequest, AskPassResponse),
(GitCreateBranch, Ack),
(GitChangeBranch, Ack),
(CheckForPushedCommits, CheckForPushedCommitsResponse),
);
entity_messages!(
@@ -679,7 +689,7 @@ entity_messages!(
OpenServerSettings,
GetPermalinkToLine,
LanguageServerPromptRequest,
GitBranches,
GitGetBranches,
UpdateGitBranch,
ListToolchains,
ActivateToolchain,
@@ -695,6 +705,10 @@ entity_messages!(
Fetch,
GetRemotes,
Pull,
AskPassRequest,
GitChangeBranch,
GitCreateBranch,
CheckForPushedCommits,
);
entity_messages!(

View File

@@ -131,7 +131,7 @@ pub struct SshPrompt {
connection_string: SharedString,
nickname: Option<SharedString>,
status_message: Option<SharedString>,
prompt: Option<(Entity<Markdown>, oneshot::Sender<Result<String>>)>,
prompt: Option<(Entity<Markdown>, oneshot::Sender<String>)>,
cancellation: Option<oneshot::Sender<()>>,
editor: Entity<Editor>,
}
@@ -176,7 +176,7 @@ impl SshPrompt {
pub fn set_prompt(
&mut self,
prompt: String,
tx: oneshot::Sender<Result<String>>,
tx: oneshot::Sender<String>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -223,7 +223,7 @@ impl SshPrompt {
if let Some((_, tx)) = self.prompt.take() {
self.status_message = Some("Connecting".into());
self.editor.update(cx, |editor, cx| {
tx.send(Ok(editor.text(cx))).ok();
tx.send(editor.text(cx)).ok();
editor.clear(window, cx);
});
}
@@ -429,11 +429,10 @@ pub struct SshClientDelegate {
}
impl remote::SshClientDelegate for SshClientDelegate {
fn ask_password(&self, prompt: String, cx: &mut AsyncApp) -> oneshot::Receiver<Result<String>> {
let (tx, rx) = oneshot::channel();
fn ask_password(&self, prompt: String, tx: oneshot::Sender<String>, cx: &mut AsyncApp) {
let mut known_password = self.known_password.clone();
if let Some(password) = known_password.take() {
tx.send(Ok(password)).ok();
tx.send(password).ok();
} else {
self.window
.update(cx, |_, window, cx| {
@@ -443,7 +442,6 @@ impl remote::SshClientDelegate for SshClientDelegate {
})
.ok();
}
rx
}
fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {

View File

@@ -19,6 +19,7 @@ test-support = ["fs/test-support"]
[dependencies]
anyhow.workspace = true
askpass.workspace = true
async-trait.workspace = true
collections.workspace = true
fs.workspace = true
@@ -26,9 +27,10 @@ futures.workspace = true
gpui.workspace = true
itertools.workspace = true
log.workspace = true
paths.workspace = true
parking_lot.workspace = true
paths.workspace = true
prost.workspace = true
release_channel.workspace = true
rpc = { workspace = true, features = ["gpui"] }
schemars.workspace = true
serde.workspace = true
@@ -38,8 +40,6 @@ smol.workspace = true
tempfile.workspace = true
thiserror.workspace = true
util.workspace = true
release_channel.workspace = true
which.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }

View File

@@ -316,7 +316,7 @@ impl SshPlatform {
}
pub trait SshClientDelegate: Send + Sync {
fn ask_password(&self, prompt: String, cx: &mut AsyncApp) -> oneshot::Receiver<Result<String>>;
fn ask_password(&self, prompt: String, tx: oneshot::Sender<String>, cx: &mut AsyncApp);
fn get_download_params(
&self,
platform: SshPlatform,
@@ -1454,83 +1454,22 @@ impl SshRemoteConnection {
delegate: Arc<dyn SshClientDelegate>,
cx: &mut AsyncApp,
) -> Result<Self> {
use futures::AsyncWriteExt as _;
use futures::{io::BufReader, AsyncBufReadExt as _};
use smol::net::unix::UnixStream;
use smol::{fs::unix::PermissionsExt as _, net::unix::UnixListener};
use util::ResultExt as _;
use askpass::AskPassResult;
delegate.set_status(Some("Connecting"), cx);
let url = connection_options.ssh_url();
let temp_dir = tempfile::Builder::new()
.prefix("zed-ssh-session")
.tempdir()?;
// Create a domain socket listener to handle requests from the askpass program.
let askpass_socket = temp_dir.path().join("askpass.sock");
let (askpass_opened_tx, askpass_opened_rx) = oneshot::channel::<()>();
let listener =
UnixListener::bind(&askpass_socket).context("failed to create askpass socket")?;
let (askpass_kill_master_tx, askpass_kill_master_rx) = oneshot::channel::<UnixStream>();
let mut kill_tx = Some(askpass_kill_master_tx);
let askpass_task = cx.spawn({
let askpass_delegate = askpass::AskPassDelegate::new(cx, {
let delegate = delegate.clone();
|mut cx| async move {
let mut askpass_opened_tx = Some(askpass_opened_tx);
while let Ok((mut stream, _)) = listener.accept().await {
if let Some(askpass_opened_tx) = askpass_opened_tx.take() {
askpass_opened_tx.send(()).ok();
}
let mut buffer = Vec::new();
let mut reader = BufReader::new(&mut stream);
if reader.read_until(b'\0', &mut buffer).await.is_err() {
buffer.clear();
}
let password_prompt = String::from_utf8_lossy(&buffer);
if let Some(password) = delegate
.ask_password(password_prompt.to_string(), &mut cx)
.await
.context("failed to get ssh password")
.and_then(|p| p)
.log_err()
{
stream.write_all(password.as_bytes()).await.log_err();
} else {
if let Some(kill_tx) = kill_tx.take() {
kill_tx.send(stream).log_err();
break;
}
}
}
}
move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx)
});
anyhow::ensure!(
which::which("nc").is_ok(),
"Cannot find `nc` command (netcat), which is required to connect over SSH."
);
// Create an askpass script that communicates back to this process.
let askpass_script = format!(
"{shebang}\n{print_args} | {nc} -U {askpass_socket} 2> /dev/null \n",
// on macOS `brew install netcat` provides the GNU netcat implementation
// which does not support -U.
nc = if cfg!(target_os = "macos") {
"/usr/bin/nc"
} else {
"nc"
},
askpass_socket = askpass_socket.display(),
print_args = "printf '%s\\0' \"$@\"",
shebang = "#!/bin/sh",
);
let askpass_script_path = temp_dir.path().join("askpass.sh");
fs::write(&askpass_script_path, askpass_script).await?;
fs::set_permissions(&askpass_script_path, std::fs::Permissions::from_mode(0o755)).await?;
let mut askpass =
askpass::AskPassSession::new(cx.background_executor(), askpass_delegate).await?;
// Start the master SSH process, which does not do anything except for establish
// the connection and keep it open, allowing other ssh commands to reuse it
@@ -1542,7 +1481,7 @@ impl SshRemoteConnection {
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env("SSH_ASKPASS_REQUIRE", "force")
.env("SSH_ASKPASS", &askpass_script_path)
.env("SSH_ASKPASS", &askpass.script_path())
.args(connection_options.additional_args())
.args([
"-N",
@@ -1556,35 +1495,25 @@ impl SshRemoteConnection {
.arg(&url)
.kill_on_drop(true)
.spawn()?;
// Wait for this ssh process to close its stdout, indicating that authentication
// has completed.
let mut stdout = master_process.stdout.take().unwrap();
let mut output = Vec::new();
let connection_timeout = Duration::from_secs(10);
let result = select_biased! {
_ = askpass_opened_rx.fuse() => {
select_biased! {
stream = askpass_kill_master_rx.fuse() => {
result = askpass.run().fuse() => {
match result {
AskPassResult::CancelledByUser => {
master_process.kill().ok();
drop(stream);
Err(anyhow!("SSH connection canceled"))
Err(anyhow!("SSH connection canceled"))?
}
// If the askpass script has opened, that means the user is typing
// their password, in which case we don't want to timeout anymore,
// since we know a connection has been established.
result = stdout.read_to_end(&mut output).fuse() => {
result?;
Ok(())
AskPassResult::Timedout => {
Err(anyhow!("connecting to host timed out"))?
}
}
}
_ = stdout.read_to_end(&mut output).fuse() => {
Ok(())
}
_ = futures::FutureExt::fuse(smol::Timer::after(connection_timeout)) => {
Err(anyhow!("Exceeded {:?} timeout trying to connect to host", connection_timeout))
anyhow::Ok(())
}
};
@@ -1592,8 +1521,6 @@ impl SshRemoteConnection {
return Err(e.context("Failed to connect to host"));
}
drop(askpass_task);
if master_process.try_status()?.is_some() {
output.clear();
let mut stderr = master_process.stderr.take().unwrap();
@@ -1606,6 +1533,8 @@ impl SshRemoteConnection {
Err(anyhow!(error_message))?;
}
drop(askpass);
let socket = SshSocket {
connection_options,
socket_path,
@@ -2558,7 +2487,7 @@ mod fake {
pub(super) struct Delegate;
impl SshClientDelegate for Delegate {
fn ask_password(&self, _: String, _: &mut AsyncApp) -> oneshot::Receiver<Result<String>> {
fn ask_password(&self, _: String, _: oneshot::Sender<String>, _: &mut AsyncApp) {
unreachable!()
}

View File

@@ -87,8 +87,15 @@ impl HeadlessProject {
buffer_store
});
let git_store =
cx.new(|cx| GitStore::new(&worktree_store, buffer_store.clone(), None, None, cx));
let git_store = cx.new(|cx| {
GitStore::new(
&worktree_store,
buffer_store.clone(),
session.clone().into(),
None,
cx,
)
});
let prettier_store = cx.new(|cx| {
PrettierStore::new(
node_runtime.clone(),

View File

@@ -1328,9 +1328,12 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA
// Give the worktree a bit of time to index the file system
cx.run_until_parked();
let remote_branches = project
.update(cx, |project, cx| project.branches(root_path.clone(), cx))
let repository = project.update(cx, |project, cx| project.active_repository(cx).unwrap());
let remote_branches = repository
.update(cx, |repository, _| repository.branches())
.await
.unwrap()
.unwrap();
let new_branch = branches[2];
@@ -1342,13 +1345,10 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA
assert_eq!(&remote_branches, &branches_set);
cx.update(|cx| {
project.update(cx, |project, cx| {
project.update_or_create_branch(root_path.clone(), new_branch.to_string(), cx)
})
})
.await
.unwrap();
cx.update(|cx| repository.read(cx).change_branch(new_branch.to_string()))
.await
.unwrap()
.unwrap();
cx.run_until_parked();
@@ -1368,11 +1368,21 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA
// Also try creating a new branch
cx.update(|cx| {
project.update(cx, |project, cx| {
project.update_or_create_branch(root_path.clone(), "totally-new-branch".to_string(), cx)
})
repository
.read(cx)
.create_branch("totally-new-branch".to_string())
})
.await
.unwrap()
.unwrap();
cx.update(|cx| {
repository
.read(cx)
.change_branch("totally-new-branch".to_string())
})
.await
.unwrap()
.unwrap();
cx.run_until_parked();

View File

@@ -70,6 +70,10 @@ impl<K: Clone + Ord, V: Clone> TreeMap<K, V> {
self.0.insert_or_replace(MapEntry { key, value }, &());
}
pub fn clear(&mut self) {
self.0 = SumTree::default();
}
pub fn remove(&mut self, key: &K) -> Option<V> {
let mut removed = None;
let mut cursor = self.0.cursor::<MapKeyRef<'_, K>>(&());
@@ -157,6 +161,14 @@ impl<K: Clone + Ord, V: Clone> TreeMap<K, V> {
self.0.iter().map(|entry| &entry.value)
}
pub fn first(&self) -> Option<(&K, &V)> {
self.0.first().map(|entry| (&entry.key, &entry.value))
}
pub fn last(&self) -> Option<(&K, &V)> {
self.0.last().map(|entry| (&entry.key, &entry.value))
}
pub fn insert_tree(&mut self, other: TreeMap<K, V>) {
let edits = other
.iter()

View File

@@ -401,7 +401,7 @@ impl TitleBar {
.child(
Label::new(nickname.clone())
.size(LabelSize::Small)
.text_ellipsis(),
.truncate(),
),
)
.tooltip(move |window, cx| {

View File

@@ -19,7 +19,6 @@ mod modal;
mod navigable;
mod numeric_stepper;
mod popover;
mod popover_button;
mod popover_menu;
mod radio;
mod right_click_menu;
@@ -57,7 +56,6 @@ pub use modal::*;
pub use navigable::*;
pub use numeric_stepper::*;
pub use popover::*;
pub use popover_button::*;
pub use popover_menu::*;
pub use radio::*;
pub use right_click_menu::*;

View File

@@ -97,6 +97,7 @@ pub struct Button {
key_binding: Option<KeyBinding>,
key_binding_position: KeybindingPosition,
alpha: Option<f32>,
truncate: bool,
}
impl Button {
@@ -123,6 +124,7 @@ impl Button {
key_binding: None,
key_binding_position: KeybindingPosition::default(),
alpha: None,
truncate: false,
}
}
@@ -206,6 +208,15 @@ impl Button {
self.alpha = Some(alpha);
self
}
/// Truncates overflowing labels with an ellipsis (`…`) if needed.
///
/// Buttons with static labels should _never_ be truncated, ensure
/// this is only used when the label is dynamic and may overflow.
pub fn truncate(mut self, truncate: bool) -> Self {
self.truncate = truncate;
self
}
}
impl Toggleable for Button {
@@ -437,7 +448,8 @@ impl RenderOnce for Button {
.color(label_color)
.size(self.label_size.unwrap_or_default())
.when_some(self.alpha, |this, alpha| this.alpha(alpha))
.line_height_style(LineHeightStyle::UiLabel),
.line_height_style(LineHeightStyle::UiLabel)
.when(self.truncate, |this| this.truncate()),
)
.children(self.key_binding),
)

View File

@@ -64,8 +64,8 @@ impl LabelCommon for HighlightedLabel {
self
}
fn text_ellipsis(mut self) -> Self {
self.base = self.base.text_ellipsis();
fn truncate(mut self) -> Self {
self.base = self.base.truncate();
self
}

View File

@@ -171,8 +171,9 @@ impl LabelCommon for Label {
self
}
fn text_ellipsis(mut self) -> Self {
self.base = self.base.text_ellipsis();
/// Truncates overflowing text with an ellipsis (`…`) if needed.
fn truncate(mut self) -> Self {
self.base = self.base.truncate();
self
}
@@ -240,7 +241,7 @@ mod label_preview {
"Special Cases",
vec![
single_example("Single Line", Label::new("Line 1\nLine 2\nLine 3").single_line().into_any_element()),
single_example("Text Ellipsis", div().max_w_24().child(Label::new("This is a very long file name that should be truncated: very_long_file_name_with_many_words.rs").text_ellipsis()).into_any_element()),
single_example("Text Ellipsis", div().max_w_24().child(Label::new("This is a very long file name that should be truncated: very_long_file_name_with_many_words.rs").truncate()).into_any_element()),
],
),
])

View File

@@ -57,7 +57,7 @@ pub trait LabelCommon {
fn alpha(self, alpha: f32) -> Self;
/// Truncates overflowing text with an ellipsis (`…`) if needed.
fn text_ellipsis(self) -> Self;
fn truncate(self) -> Self;
/// Sets the label to render as a single line.
fn single_line(self) -> Self;
@@ -84,7 +84,7 @@ pub struct LabelLike {
alpha: Option<f32>,
underline: bool,
single_line: bool,
text_ellipsis: bool,
truncate: bool,
}
impl Default for LabelLike {
@@ -109,7 +109,7 @@ impl LabelLike {
alpha: None,
underline: false,
single_line: false,
text_ellipsis: false,
truncate: false,
}
}
}
@@ -166,8 +166,9 @@ impl LabelCommon for LabelLike {
self
}
fn text_ellipsis(mut self) -> Self {
self.text_ellipsis = true;
/// Truncates overflowing text with an ellipsis (`…`) if needed.
fn truncate(mut self) -> Self {
self.truncate = true;
self
}
@@ -220,7 +221,7 @@ impl RenderOnce for LabelLike {
})
.when(self.strikethrough, |this| this.line_through())
.when(self.single_line, |this| this.whitespace_nowrap())
.when(self.text_ellipsis, |this| {
.when(self.truncate, |this| {
this.overflow_x_hidden().text_ellipsis()
})
.text_color(color)

View File

@@ -1,57 +0,0 @@
use gpui::{AnyView, Corner, Entity, ManagedView};
use crate::{prelude::*, PopoverMenu, PopoverMenuHandle, PopoverTrigger};
pub trait TriggerablePopover: ManagedView {
fn menu_handle(
&mut self,
window: &mut Window,
cx: &mut gpui::Context<Self>,
) -> PopoverMenuHandle<Self>;
}
pub struct PopoverButton<T, B, F> {
selector: Entity<T>,
button: B,
tooltip: F,
corner: Corner,
}
impl<T, B, F> PopoverButton<T, B, F> {
pub fn new(selector: Entity<T>, corner: Corner, button: B, tooltip: F) -> Self
where
F: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
Self {
selector,
button,
tooltip,
corner,
}
}
}
impl<T: TriggerablePopover, B: PopoverTrigger + ButtonCommon, F> RenderOnce
for PopoverButton<T, B, F>
where
F: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let menu_handle = self
.selector
.update(cx, |selector, cx| selector.menu_handle(window, cx));
PopoverMenu::new("popover-button")
.menu({
let selector = self.selector.clone();
move |_window, _cx| Some(selector.clone())
})
.trigger_with_tooltip(self.button, self.tooltip)
.anchor(self.corner)
.with_handle(menu_handle)
.offset(gpui::Point {
x: px(0.0),
y: px(-2.0),
})
}
}

View File

@@ -55,6 +55,7 @@ git_ui.workspace = true
gpui = { workspace = true, features = ["test-support"] }
indoc.workspace = true
language = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
lsp = { workspace = true, features = ["test-support"] }
parking_lot.workspace = true
project_panel.workspace = true

View File

@@ -1329,12 +1329,25 @@ pub(crate) fn start_of_relative_buffer_row(
fn up_down_buffer_rows(
map: &DisplaySnapshot,
point: DisplayPoint,
mut point: DisplayPoint,
mut goal: SelectionGoal,
times: isize,
mut times: isize,
text_layout_details: &TextLayoutDetails,
) -> (DisplayPoint, SelectionGoal) {
let bias = if times < 0 { Bias::Left } else { Bias::Right };
while map.is_folded_buffer_header(point.row()) {
if times < 0 {
(point, _) = movement::up(map, point, goal, true, text_layout_details);
times += 1;
} else if times > 0 {
(point, _) = movement::down(map, point, goal, true, text_layout_details);
times -= 1;
} else {
break;
}
}
let start = map.display_point_to_fold_point(point, Bias::Left);
let begin_folded_line = map.fold_point_to_display_point(
map.fold_snapshot

View File

@@ -6,9 +6,13 @@ use std::time::Duration;
use collections::HashMap;
use command_palette::CommandPalette;
use editor::{actions::DeleteLine, display_map::DisplayRow, DisplayPoint};
use editor::{
actions::DeleteLine, display_map::DisplayRow, test::editor_test_context::EditorTestContext,
DisplayPoint, Editor, EditorMode, MultiBuffer,
};
use futures::StreamExt;
use gpui::{KeyBinding, Modifiers, MouseButton, TestAppContext};
use language::Point;
pub use neovim_backed_test_context::*;
use settings::SettingsStore;
pub use vim_test_context::*;
@@ -1707,3 +1711,202 @@ async fn test_ctrl_o_dot(cx: &mut gpui::TestAppContext) {
cx.simulate_shared_keystrokes("l l escape .").await;
cx.shared_state().await.assert_eq("hellˇllo world.");
}
#[gpui::test]
async fn test_folded_multibuffer_excerpts(cx: &mut gpui::TestAppContext) {
VimTestContext::init(cx);
cx.update(|cx| {
VimTestContext::init_keybindings(true, cx);
});
let (editor, cx) = cx.add_window_view(|window, cx| {
let multi_buffer = MultiBuffer::build_multi(
[
("111\n222\n333\n444\n", vec![Point::row_range(0..2)]),
("aaa\nbbb\nccc\nddd\n", vec![Point::row_range(0..2)]),
("AAA\nBBB\nCCC\nDDD\n", vec![Point::row_range(0..2)]),
("one\ntwo\nthr\nfou\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
});
let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await;
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
ˇ[FOLDED]
[EXCERPT]
aaa
bbb
[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
"
});
cx.simulate_keystroke("j");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
ˇaaa
bbb
[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
"
});
cx.simulate_keystroke("j");
cx.simulate_keystroke("j");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
aaa
bbb
ˇ[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
"
});
cx.simulate_keystroke("j");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
aaa
bbb
[EXCERPT]
ˇ[FOLDED]
[EXCERPT]
[FOLDED]
"
});
cx.simulate_keystroke("j");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
aaa
bbb
[EXCERPT]
[FOLDED]
[EXCERPT]
ˇ[FOLDED]
"
});
cx.simulate_keystroke("k");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
aaa
bbb
[EXCERPT]
ˇ[FOLDED]
[EXCERPT]
[FOLDED]
"
});
cx.simulate_keystroke("k");
cx.simulate_keystroke("k");
cx.simulate_keystroke("k");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
ˇaaa
bbb
[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
"
});
cx.simulate_keystroke("k");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
ˇ[FOLDED]
[EXCERPT]
aaa
bbb
[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
"
});
cx.simulate_keystroke("shift-g");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
aaa
bbb
[EXCERPT]
[FOLDED]
[EXCERPT]
ˇ[FOLDED]
"
});
cx.simulate_keystrokes("g g");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
ˇ[FOLDED]
[EXCERPT]
aaa
bbb
[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
"
});
cx.update_editor(|editor, _, cx| {
let buffer_ids = editor.buffer().read(cx).excerpt_buffer_ids();
editor.fold_buffer(buffer_ids[1], cx);
});
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
ˇ[FOLDED]
[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
"
});
cx.simulate_keystrokes("2 j");
cx.assert_excerpts_with_selections(indoc! {"
[EXCERPT]
[FOLDED]
[EXCERPT]
[FOLDED]
[EXCERPT]
ˇ[FOLDED]
[EXCERPT]
[FOLDED]
"
});
}

View File

@@ -24,6 +24,10 @@ impl VimTestContext {
git_ui::init(cx);
crate::init(cx);
search::init(cx);
language::init(cx);
editor::init_settings(cx);
project::Project::init_settings(cx);
theme::init(theme::LoadThemes::JustBase, cx);
});
}
@@ -56,22 +60,26 @@ impl VimTestContext {
)
}
pub fn init_keybindings(enabled: bool, cx: &mut App) {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings::<VimModeSetting>(cx, |s| *s = Some(enabled));
});
let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
"keymaps/default-macos.json",
cx,
)
.unwrap();
cx.bind_keys(default_key_bindings);
if enabled {
let vim_key_bindings =
settings::KeymapFile::load_asset("keymaps/vim.json", cx).unwrap();
cx.bind_keys(vim_key_bindings);
}
}
pub fn new_with_lsp(mut cx: EditorLspTestContext, enabled: bool) -> VimTestContext {
cx.update(|_, cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings::<VimModeSetting>(cx, |s| *s = Some(enabled));
});
let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
"keymaps/default-macos.json",
cx,
)
.unwrap();
cx.bind_keys(default_key_bindings);
if enabled {
let vim_key_bindings =
settings::KeymapFile::load_asset("keymaps/vim.json", cx).unwrap();
cx.bind_keys(vim_key_bindings);
}
Self::init_keybindings(enabled, cx);
});
// Setup search toolbars and keypress hook

View File

@@ -4292,7 +4292,11 @@ impl BackgroundScanner {
let mut containing_git_repository = None;
for (index, ancestor) in root_abs_path.as_path().ancestors().enumerate() {
if index != 0 {
if let Ok(ignore) =
if Some(ancestor) == self.fs.home_dir().as_deref() {
// Unless $HOME is itself the worktree root, don't consider it as a
// containing git repository---expensive and likely unwanted.
break;
} else if let Ok(ignore) =
build_gitignore(&ancestor.join(*GITIGNORE), self.fs.as_ref()).await
{
self.state
@@ -4304,6 +4308,7 @@ impl BackgroundScanner {
}
let ancestor_dot_git = ancestor.join(*DOT_GIT);
log::debug!("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
@@ -4312,21 +4317,26 @@ 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) =
self.fs.canonicalize(&ancestor_dot_git).await.log_err()
{
let location_in_repo = root_abs_path
.as_path()
.strip_prefix(ancestor)
.unwrap()
.into();
log::debug!(
"inserting parent git repo for this worktree: {location_in_repo:?}"
);
// We associate the external git repo with our root folder and
// also mark where in the git repo the root folder is located.
let local_repository = self.state.lock().insert_git_repository_for_path(
WorkDirectory::AboveProject {
absolute_path: ancestor.into(),
location_in_repo: root_abs_path
.as_path()
.strip_prefix(ancestor)
.unwrap()
.into(),
location_in_repo,
},
ancestor_dot_git.clone().into(),
self.fs.as_ref(),
@@ -4341,9 +4351,13 @@ impl BackgroundScanner {
// Reached root of git repository.
break;
} else {
log::debug!(".git path doesn't exist");
}
}
log::debug!("containing git repository: {containing_git_repository:?}");
let (scan_job_tx, scan_job_rx) = channel::unbounded();
{
let mut state = self.state.lock();

View File

@@ -5,6 +5,7 @@ use crate::{
use anyhow::Result;
use fs::{FakeFs, Fs, RealFs, RemoveOptions};
use git::{
repository::RepoPath,
status::{
FileStatus, GitSummary, StatusCode, TrackedStatus, TrackedSummary, UnmergedStatus,
UnmergedStatusCode,
@@ -2241,6 +2242,73 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) {
});
}
#[gpui::test]
async fn test_home_dir_as_git_repository(cx: &mut TestAppContext) {
init_test(cx);
cx.executor().allow_parking();
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
"/root",
json!({
"home": {
".git": {},
"project": {
"a.txt": "A"
},
},
}),
)
.await;
fs.set_home_dir(Path::new(path!("/root/home")).to_owned());
let tree = Worktree::local(
Path::new(path!("/root/home/project")),
true,
fs.clone(),
Default::default(),
&mut cx.to_async(),
)
.await
.unwrap();
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
.await;
tree.flush_fs_events(cx).await;
tree.read_with(cx, |tree, _cx| {
let tree = tree.as_local().unwrap();
let repo = tree.repository_for_path(path!("a.txt").as_ref());
assert!(repo.is_none());
});
let home_tree = Worktree::local(
Path::new(path!("/root/home")),
true,
fs.clone(),
Default::default(),
&mut cx.to_async(),
)
.await
.unwrap();
cx.read(|cx| home_tree.read(cx).as_local().unwrap().scan_complete())
.await;
home_tree.flush_fs_events(cx).await;
home_tree.read_with(cx, |home_tree, _cx| {
let home_tree = home_tree.as_local().unwrap();
let repo = home_tree.repository_for_path(path!("project/a.txt").as_ref());
assert_eq!(
repo.map(|repo| &repo.work_directory),
Some(&WorkDirectory::InProject {
relative_path: Path::new("").into()
})
);
})
}
#[gpui::test]
async fn test_git_repository_for_path(cx: &mut TestAppContext) {
init_test(cx);
@@ -3240,6 +3308,87 @@ async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) {
);
}
#[gpui::test]
async fn test_conflicted_cherry_pick(cx: &mut TestAppContext) {
init_test(cx);
cx.executor().allow_parking();
let root = TempTree::new(json!({
"project": {
"a.txt": "a",
},
}));
let root_path = root.path();
let tree = Worktree::local(
root_path,
true,
Arc::new(RealFs::default()),
Default::default(),
&mut cx.to_async(),
)
.await
.unwrap();
let repo = git_init(&root_path.join("project"));
git_add("a.txt", &repo);
git_commit("init", &repo);
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
.await;
tree.flush_fs_events(cx).await;
git_branch("other-branch", &repo);
git_checkout("refs/heads/other-branch", &repo);
std::fs::write(root_path.join("project/a.txt"), "A").unwrap();
git_add("a.txt", &repo);
git_commit("capitalize", &repo);
let commit = repo
.head()
.expect("Failed to get HEAD")
.peel_to_commit()
.expect("HEAD is not a commit");
git_checkout("refs/heads/master", &repo);
std::fs::write(root_path.join("project/a.txt"), "b").unwrap();
git_add("a.txt", &repo);
git_commit("improve letter", &repo);
git_cherry_pick(&commit, &repo);
std::fs::read_to_string(root_path.join("project/.git/CHERRY_PICK_HEAD"))
.expect("No CHERRY_PICK_HEAD");
pretty_assertions::assert_eq!(
git_status(&repo),
collections::HashMap::from_iter([("a.txt".to_owned(), git2::Status::CONFLICTED)])
);
tree.flush_fs_events(cx).await;
let conflicts = tree.update(cx, |tree, _| {
let entry = tree.git_entries().nth(0).expect("No git entry").clone();
entry
.current_merge_conflicts
.iter()
.cloned()
.collect::<Vec<_>>()
});
pretty_assertions::assert_eq!(conflicts, [RepoPath::from("a.txt")]);
git_add("a.txt", &repo);
// Attempt to manually simulate what `git cherry-pick --continue` would do.
git_commit("whatevs", &repo);
std::fs::remove_file(root.path().join("project/.git/CHERRY_PICK_HEAD"))
.expect("Failed to remove CHERRY_PICK_HEAD");
pretty_assertions::assert_eq!(git_status(&repo), collections::HashMap::default());
tree.flush_fs_events(cx).await;
let conflicts = tree.update(cx, |tree, _| {
let entry = tree.git_entries().nth(0).expect("No git entry").clone();
entry
.current_merge_conflicts
.iter()
.cloned()
.collect::<Vec<_>>()
});
pretty_assertions::assert_eq!(conflicts, []);
}
#[gpui::test]
async fn test_private_single_file_worktree(cx: &mut TestAppContext) {
init_test(cx);
@@ -3338,6 +3487,11 @@ fn git_commit(msg: &'static str, repo: &git2::Repository) {
}
}
#[track_caller]
fn git_cherry_pick(commit: &git2::Commit<'_>, repo: &git2::Repository) {
repo.cherrypick(commit, None).expect("Failed to cherrypick");
}
#[track_caller]
fn git_stash(repo: &mut git2::Repository) {
use git2::Signature;
@@ -3363,6 +3517,22 @@ fn git_reset(offset: usize, repo: &git2::Repository) {
.expect("Could not reset");
}
#[track_caller]
fn git_branch(name: &str, repo: &git2::Repository) {
let head = repo
.head()
.expect("Couldn't get repo head")
.peel_to_commit()
.expect("HEAD is not a commit");
repo.branch(name, &head, false).expect("Failed to commit");
}
#[track_caller]
fn git_checkout(name: &str, repo: &git2::Repository) {
repo.set_head(name).expect("Failed to set head");
repo.checkout_head(None).expect("Failed to check out head");
}
#[allow(dead_code)]
#[track_caller]
fn git_status(repo: &git2::Repository) -> collections::HashMap<String, git2::Status> {

View File

@@ -2,7 +2,7 @@
description = "The fast, collaborative code editor."
edition.workspace = true
name = "zed"
version = "0.177.0"
version = "0.177.4"
publish.workspace = true
license = "GPL-3.0-or-later"
authors = ["Zed Team <hi@zed.dev>"]

View File

@@ -1 +1 @@
dev
preview

View File

@@ -507,7 +507,6 @@ fn main() {
outline::init(cx);
project_symbols::init(cx);
project_panel::init(cx);
git_ui::git_panel::init(cx);
outline_panel::init(cx);
component_preview::init(cx);
tasks_ui::init(cx);
@@ -1020,7 +1019,7 @@ fn eager_load_active_theme_and_icon_theme(fs: Arc<dyn Fs>, cx: &App) {
let extension_store = ExtensionStore::global(cx);
let theme_registry = ThemeRegistry::global(cx);
let theme_settings = ThemeSettings::get_global(cx);
let appearance = cx.window_appearance().into();
let appearance = SystemAppearance::global(cx).0;
if let Some(theme_selection) = theme_settings.theme_selection.as_ref() {
let theme_name = theme_selection.theme(appearance);

View File

@@ -182,18 +182,8 @@ impl Render for QuickActionBar {
.action("Next Problem", Box::new(GoToDiagnostic))
.action("Previous Problem", Box::new(GoToPreviousDiagnostic))
.separator()
.action(
"Next Hunk",
Box::new(GoToHunk {
center_cursor: true,
}),
)
.action(
"Previous Hunk",
Box::new(GoToPreviousHunk {
center_cursor: true,
}),
)
.action("Next Hunk", Box::new(GoToHunk))
.action("Previous Hunk", Box::new(GoToPreviousHunk))
.separator()
.action("Move Line Up", Box::new(MoveLineUp))
.action("Move Line Down", Box::new(MoveLineDown))

View File

@@ -10,6 +10,12 @@ To preview the docs locally you will need to install [mdBook](https://rust-lang.
mdbook serve docs
```
Before committing, verify that the docs are formatted in the way prettier expects with:
```
cd docs && pnpm dlx prettier@3.5.0 . --write && cd ..
```
## Preprocessor
We have a custom mdbook preprocessor for interfacing with our crates (`crates/docs_preprocessor`).

View File

@@ -72,10 +72,15 @@ The following commands use the language server to help you navigate and refactor
### Git
| Command | Default Shortcut |
| ------------------------- | ---------------- |
| Go to next git change | `] c` |
| Go to previous git change | `[ c` |
| Command | Default Shortcut |
| ------------------------------- | ---------------- |
| Go to next git change | `] c` |
| Go to previous git change | `[ c` |
| Expand diff hunk | `d o` |
| Toggle staged | `d O` |
| Stage and next (in diff view) | `d u` |
| Unstage and next (in diff view) | `d U` |
| Restore change | `d p` |
### Treesitter