Compare commits

...

7 commits

Author SHA1 Message Date
Gabriel
5e2a56754c
Merge 602ae328fa into 09165c15dc 2026-05-31 01:34:12 -04:00
Nathan Sobo
09165c15dc
gpui: Support prompt_for_paths in TestPlatform (#58139)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Has been skipped
Implements the previously-`unimplemented!()`
`TestPlatform::prompt_for_paths` so tests can drive the platform Open
dialog deterministically.

Adds `TestAppContext::simulate_path_prompt_response` and
`did_prompt_for_paths`, mirroring the existing `prompt_for_new_path`
test helpers (`simulate_new_path_selection`). The simulated response
validates that callers don't return multiple paths when
`PathPromptOptions::multiple` is false.

Release Notes:

- N/A
2026-05-30 20:37:39 +00:00
renovate[bot]
e2e7a6769e
Update dependency requests to v2.33.0 [SECURITY] (#58093)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Has been skipped
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [requests](https://redirect.github.com/psf/requests)
([changelog](https://redirect.github.com/psf/requests/blob/master/HISTORY.md))
| `2.32.3` → `2.33.0` |
![age](https://developer.mend.io/api/mc/badges/age/pypi/requests/2.33.0?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/requests/2.32.3/2.33.0?slim=true)
|

---

> [!WARNING]
> Some dependencies could not be looked up. Check the [Dependency
Dashboard](../issues/15138) for more information.

---

### Requests vulnerable to .netrc credentials leak via malicious URLs
[CVE-2024-47081](https://nvd.nist.gov/vuln/detail/CVE-2024-47081) /
[GHSA-9hjg-9r4m-mvj7](https://redirect.github.com/advisories/GHSA-9hjg-9r4m-mvj7)

<details>
<summary>More information</summary>

#### Details
##### Impact

Due to a URL parsing issue, Requests releases prior to 2.32.4 may leak
.netrc credentials to third parties for specific maliciously-crafted
URLs.

##### Workarounds
For older versions of Requests, use of the .netrc file can be disabled
with `trust_env=False` on your Requests Session
([docs](https://requests.readthedocs.io/en/latest/api/#requests.Session.trust_env)).

##### References

[https://github.com/psf/requests/pull/6965](https://redirect.github.com/psf/requests/pull/6965)
https://seclists.org/fulldisclosure/2025/Jun/2

#### Severity
- CVSS Score: 5.3 / 10 (Medium)
- Vector String: `CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N`

#### References
-
[https://github.com/psf/requests/security/advisories/GHSA-9hjg-9r4m-mvj7](https://redirect.github.com/psf/requests/security/advisories/GHSA-9hjg-9r4m-mvj7)
-
[https://nvd.nist.gov/vuln/detail/CVE-2024-47081](https://nvd.nist.gov/vuln/detail/CVE-2024-47081)
-
[https://github.com/psf/requests/pull/6965](https://redirect.github.com/psf/requests/pull/6965)
-
[96ba401c12)
-
[https://requests.readthedocs.io/en/latest/api/#requests.Session.trust_env](https://requests.readthedocs.io/en/latest/api/#requests.Session.trust_env)
-
[https://seclists.org/fulldisclosure/2025/Jun/2](https://seclists.org/fulldisclosure/2025/Jun/2)
-
[http://seclists.org/fulldisclosure/2025/Jun/2](http://seclists.org/fulldisclosure/2025/Jun/2)
-
[http://www.openwall.com/lists/oss-security/2025/06/03/11](http://www.openwall.com/lists/oss-security/2025/06/03/11)
-
[http://www.openwall.com/lists/oss-security/2025/06/03/9](http://www.openwall.com/lists/oss-security/2025/06/03/9)
-
[http://www.openwall.com/lists/oss-security/2025/06/04/1](http://www.openwall.com/lists/oss-security/2025/06/04/1)
-
[http://www.openwall.com/lists/oss-security/2025/06/04/6](http://www.openwall.com/lists/oss-security/2025/06/04/6)
-
[https://github.com/advisories/GHSA-9hjg-9r4m-mvj7](https://redirect.github.com/advisories/GHSA-9hjg-9r4m-mvj7)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-9hjg-9r4m-mvj7)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Requests has Insecure Temp File Reuse in its extract_zipped_paths()
utility function
[CVE-2026-25645](https://nvd.nist.gov/vuln/detail/CVE-2026-25645) /
[GHSA-gc5v-m9x4-r6x2](https://redirect.github.com/advisories/GHSA-gc5v-m9x4-r6x2)

<details>
<summary>More information</summary>

#### Details
##### Impact
The `requests.utils.extract_zipped_paths()` utility function uses a
predictable filename when extracting files from zip archives into the
system temporary directory. If the target file already exists, it is
reused without validation. A local attacker with write access to the
temp directory could pre-create a malicious file that would be loaded in
place of the legitimate one.

##### Affected usages
**Standard usage of the Requests library is not affected by this
vulnerability.** Only applications that call `extract_zipped_paths()`
directly are impacted.

##### Remediation
Upgrade to at least Requests 2.33.0, where the library now extracts
files to a non-deterministic location.

If developers are unable to upgrade, they can set `TMPDIR` in their
environment to a directory with restricted write access.

#### Severity
- CVSS Score: 4.4 / 10 (Medium)
- Vector String: `CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:N/I:H/A:N`

#### References
-
[https://github.com/psf/requests/security/advisories/GHSA-gc5v-m9x4-r6x2](https://redirect.github.com/psf/requests/security/advisories/GHSA-gc5v-m9x4-r6x2)
-
[66d21cb07b)
-
[https://github.com/psf/requests/releases/tag/v2.33.0](https://redirect.github.com/psf/requests/releases/tag/v2.33.0)
-
[https://nvd.nist.gov/vuln/detail/CVE-2026-25645](https://nvd.nist.gov/vuln/detail/CVE-2026-25645)
-
[https://github.com/advisories/GHSA-gc5v-m9x4-r6x2](https://redirect.github.com/advisories/GHSA-gc5v-m9x4-r6x2)

This data is provided by the [GitHub Advisory
Database](https://redirect.github.com/advisories/GHSA-gc5v-m9x4-r6x2)
([CC-BY
4.0](https://redirect.github.com/github/advisory-database/blob/main/LICENSE.md)).
</details>

---

### Release Notes

<details>
<summary>psf/requests (requests)</summary>

###
[`v2.33.0`](https://redirect.github.com/psf/requests/blob/HEAD/HISTORY.md#2330-2026-03-25)

[Compare
Source](https://redirect.github.com/psf/requests/compare/v2.32.5...v2.33.0)

**Announcements**

- 📣 Requests is adding inline types. If you have a typed code base that
uses Requests, please take a look at
[#&#8203;7271](https://redirect.github.com/psf/requests/issues/7271).
Give it a try, and report
  any gaps or feedback you may have in the issue. 📣

**Security**

- CVE-2026-25645 `requests.utils.extract_zipped_paths` now extracts
  contents to a non-deterministic location to prevent malicious file
  replacement. This does not affect default usage of Requests, only
  applications calling the utility function directly.

**Improvements**

- Migrated to a PEP 517 build system using setuptools.
([#&#8203;7012](https://redirect.github.com/psf/requests/issues/7012))

**Bugfixes**

- Fixed an issue where an empty netrc entry could cause
  malformed authentication to be applied to Requests on
Python 3.11+.
([#&#8203;7205](https://redirect.github.com/psf/requests/issues/7205))

**Deprecations**

- Dropped support for Python 3.9 following its end of support.
([#&#8203;7196](https://redirect.github.com/psf/requests/issues/7196))

**Documentation**

- Various typo fixes and doc improvements.

###
[`v2.32.5`](https://redirect.github.com/psf/requests/blob/HEAD/HISTORY.md#2325-2025-08-18)

[Compare
Source](https://redirect.github.com/psf/requests/compare/v2.32.4...v2.32.5)

**Bugfixes**

- The SSLContext caching feature originally introduced in 2.32.0 has
created
a new class of issues in Requests that have had negative impact across a
number
of use cases. The Requests team has decided to revert this feature as
long term
maintenance of it is proving to be unsustainable in its current
iteration.

**Deprecations**

- Added support for Python 3.14.
- Dropped support for Python 3.8 following its end of support.

###
[`v2.32.4`](https://redirect.github.com/psf/requests/blob/HEAD/HISTORY.md#2324-2025-06-10)

[Compare
Source](https://redirect.github.com/psf/requests/compare/v2.32.3...v2.32.4)

**Security**

- CVE-2024-47081 Fixed an issue where a maliciously crafted URL and
trusted
environment will retrieve credentials for the wrong hostname/machine
from a
  netrc file.

**Improvements**

- Numerous documentation improvements

**Deprecations**

- Added support for pypy 3.11 for Linux and macOS.
- Dropped support for pypy 3.9 following its end of support.

</details>

---

### Configuration

📅 **Schedule**: (in timezone America/New_York)

- Branch creation
  - ""
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

Release Notes:

- N/A

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMDIuMSIsInVwZGF0ZWRJblZlciI6IjQzLjIwMi4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-29 20:29:38 +00:00
Sathwik Chirivelli
5d3b9e467e
git_ui: Open file diffs from git panel (#56152)
Self-Review Checklist:
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Addresses https://github.com/zed-industries/zed/discussions/33773.

This changes git panel file activation so double-clicking or
secondary-opening a changed file opens a dedicated full-file diff tab
backed by a `SplittableEditor`.

The per-file diff reuses the project diff staging and restore controls,
respects the configured diff view style, and focuses an existing
per-file diff tab when one is already open instead of creating
duplicates.

Verified with `cargo run`.

Release Notes:

- Improved git panel file diff opening.

---------

Co-authored-by: Christopher Biscardi <chris@christopherbiscardi.com>
2026-05-29 19:43:26 +00:00
Joseph T. Lyons
06826ef10f
Bump urllib3 to v2.7.0 (#58092)
Self-Review Checklist:

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

Release Notes:

- N/A
2026-05-29 17:55:04 +00:00
Anthony Eid
7f4a99aa95
Fix task modal fallback when LSP tasks are empty (#58090)
References
[FR-28](https://linear.app/zed-industries/issue/FR-28/task-modal-does-not-show-runnable-rust-test).

The bug this PR aims to fix is

> I can click the play button beside a rust test function, but it does
not show up in the task modal.

I wasn't able to reproduce it, but I suspect this was caused by the task
system preferring LSP code actions by default. It checked that an LSP
was queried for a task instead of checking if the queried LSP actually
returned any tasks. So the fix was just adding the below if statement as
a check.

```rust
if !new_lsp_tasks.is_empty() {
    lsp_tasks
        .entry(source_kind)
        .or_insert_with(Vec::new)
        .append(&mut new_lsp_tasks);
}
```

I also added a regression test for this

Self-Review Checklist:

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


Release Notes:

- Fixed task modal failing to show language tasks in some cases
2026-05-29 17:49:11 +00:00
Gabriel Minatel
602ae328fa
fix(helix): system clipboard clobbering 2026-05-01 12:17:55 -04:00
15 changed files with 1159 additions and 134 deletions

View file

@ -456,7 +456,7 @@
"alt-.": "vim::RepeatFind",
// Changes
"shift-r": "editor::Paste",
"shift-r": "vim::HelixReplaceWithYanked",
"`": "vim::ConvertToLowerCase",
"alt-`": "vim::ConvertToUpperCase",
"insert": "vim::InsertBefore", // not a helix default

View file

@ -164,12 +164,14 @@ pub fn lsp_tasks(
},
));
}
if !new_lsp_tasks.is_empty() {
lsp_tasks
.entry(source_kind)
.or_insert_with(Vec::new)
.append(&mut new_lsp_tasks);
}
}
}
lsp_tasks.into_iter().collect()
})
.with_timeout(Duration::from_millis(200), &cx.background_executor())

View file

@ -5,6 +5,7 @@ use crate::commit_view::CommitView;
use crate::git_panel_settings::GitPanelScrollbarAccessor;
use crate::project_diff::{self, BranchDiff, Diff, ProjectDiff};
use crate::remote_output::{self, RemoteAction, SuccessMessage};
use crate::solo_diff_view::SoloDiffView;
use crate::{branch_picker, picker_prompt, render_remote_button};
use crate::{
git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
@ -15,10 +16,7 @@ use anyhow::Context as _;
use askpass::AskPassDelegate;
use collections::{BTreeMap, HashMap, HashSet};
use db::kvp::KeyValueStore;
use editor::{
Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, SizingBehavior,
actions::ExpandAllDiffHunks,
};
use editor::{Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, SizingBehavior};
use editor::{EditorStyle, RewrapOptions};
use file_icons::FileIcons;
use futures::StreamExt as _;
@ -85,7 +83,7 @@ use workspace::SERIALIZATION_THROTTLE_TIME;
use workspace::{
Item, Workspace,
dock::{DockPosition, Panel, PanelEvent},
notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyResultExt},
notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyTaskExt},
};
use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize};
@ -1385,63 +1383,22 @@ impl GitPanel {
});
}
fn open_file(
fn open_solo_diff(
&mut self,
_: &menu::SecondaryConfirm,
window: &mut Window,
cx: &mut Context<Self>,
) {
maybe!({
let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
let active_repo = self.active_repository.as_ref()?;
let path = active_repo
.read(cx)
.repo_path_to_project_path(&entry.repo_path, cx)?;
if entry.status.is_deleted() {
return None;
}
let entry = self
.entries
.get(self.selected_entry?)?
.status_entry()?
.clone();
let repository = self.active_repository.clone()?;
let open_task = self
.workspace
.update(cx, |workspace, cx| {
workspace.open_path_preview(path, None, false, false, true, window, cx)
})
.ok()?;
let workspace = self.workspace.clone();
cx.spawn_in(window, async move |_, mut cx| {
let item = open_task
.await
.notify_workspace_async_err(workspace, &mut cx)
.ok_or_else(|| anyhow::anyhow!("Failed to open file"))?;
if let Some(active_editor) = item.downcast::<Editor>() {
if let Some(diff_task) =
active_editor.update(cx, |editor, _cx| editor.wait_for_diff_to_load())
{
diff_task.await;
}
cx.update(|window, cx| {
active_editor.update(cx, |editor, cx| {
editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx);
let snapshot = editor.snapshot(window, cx);
editor.go_to_hunk_before_or_after_position(
&snapshot,
language::Point::new(0, 0),
Direction::Next,
true,
window,
cx,
);
})
})
.log_err();
}
anyhow::Ok(())
})
.detach();
SoloDiffView::open_or_focus(entry, repository, self.workspace.clone(), window, cx)
.detach_and_notify_err(self.workspace.clone(), window, cx);
Some(())
});
@ -5970,7 +5927,7 @@ impl GitPanel {
)
.separator()
.action("Open Diff", menu::Confirm.boxed_clone())
.action("Open File", menu::SecondaryConfirm.boxed_clone())
.action("Open Diff (File)", menu::SecondaryConfirm.boxed_clone())
.when(!is_created, |context_menu| {
context_menu
.separator()
@ -6249,7 +6206,7 @@ impl GitPanel {
this.selected_entry = Some(ix);
cx.notify();
if event.click_count() > 1 || event.modifiers().secondary() {
this.open_file(&Default::default(), window, cx)
this.open_solo_diff(&Default::default(), window, cx)
} else {
this.open_diff(&Default::default(), window, cx);
this.focus_handle.focus(window, cx);
@ -6699,7 +6656,7 @@ impl Render for GitPanel {
.on_action(cx.listener(Self::last_entry))
.on_action(cx.listener(Self::close_panel))
.on_action(cx.listener(Self::open_diff))
.on_action(cx.listener(Self::open_file))
.on_action(cx.listener(Self::open_solo_diff))
.on_action(cx.listener(Self::focus_changes_list))
.on_action(cx.listener(Self::focus_editor))
.on_action(cx.listener(Self::expand_commit_editor))

View file

@ -45,6 +45,7 @@ pub mod picker_prompt;
pub mod project_diff;
pub(crate) mod remote_output;
pub mod repository_selector;
pub mod solo_diff_view;
pub mod stash_picker;
pub mod text_diff_view;
pub mod worktree_names;

View file

@ -0,0 +1,787 @@
use crate::{git_panel::GitStatusEntry, git_status_icon};
use anyhow::{Context as _, Result};
use buffer_diff::DiffHunkSecondaryStatus;
use editor::{
Direction, Editor, EditorEvent, EditorSettings, SplittableEditor, ToggleSplitDiff,
actions::{GoToHunk, GoToPreviousHunk},
};
use fs::Fs;
use git::{
Commit, Restore, StageAndNext, StageFile, ToggleStaged, UnstageAndNext, UnstageFile,
repository::RepoPath, status::StageStatus,
};
use gpui::{
Action, AnyElement, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, Render, Subscription, Task, WeakEntity, Window,
};
use language::{Buffer, HighlightedText};
use multi_buffer::MultiBuffer;
use project::{
Project,
git_store::{Repository, RepositoryId},
};
use settings::{DiffViewStyle, Settings, SettingsStore, update_settings_file};
use std::{
any::{Any, TypeId},
sync::Arc,
};
use ui::{
Color, DiffStat, Divider, Icon, IconButton, IconButtonShape, IconName, Label, LabelCommon as _,
SharedString, Tooltip, prelude::*, vertical_divider,
};
use util::paths::{PathExt as _, PathStyle};
use workspace::{
Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
Workspace,
item::{ItemEvent, SaveOptions, TabContentParams},
notifications::NotifyTaskExt,
searchable::SearchableItemHandle,
};
pub struct SoloDiffView {
repository: Entity<Repository>,
repository_id: RepositoryId,
repo_path: RepoPath,
buffer: Entity<Buffer>,
editor: Entity<SplittableEditor>,
workspace: WeakEntity<Workspace>,
_settings_subscription: Subscription,
}
impl SoloDiffView {
pub fn open_or_focus(
entry: GitStatusEntry,
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
let Some(workspace_entity) = workspace.upgrade() else {
return Task::ready(Err(anyhow::anyhow!("workspace was dropped")));
};
let existing = workspace_entity
.read(cx)
.items_of_type::<SoloDiffView>(cx)
.find(|item| item.read(cx).matches(&repository, &entry.repo_path, cx));
if let Some(existing) = existing {
workspace_entity.update(cx, |workspace, cx| {
workspace.activate_item(&existing, true, true, window, cx);
});
existing.focus_handle(cx).focus(window, cx);
return Task::ready(Ok(existing));
}
let Some(project_path) = repository
.read(cx)
.repo_path_to_project_path(&entry.repo_path, cx)
else {
return Task::ready(Err(anyhow::anyhow!(
"could not resolve repository path {:?}",
entry.repo_path
)));
};
let project = workspace_entity.read(cx).project().clone();
let repo_path = entry.repo_path;
window.spawn(cx, async move |cx| {
let buffer = project
.update(cx, |project, cx| {
project.open_buffer(project_path.clone(), cx)
})
.await?;
let diff = project
.update(cx, |project, cx| {
project.open_uncommitted_diff(buffer.clone(), cx)
})
.await?;
workspace_entity.update_in(cx, |workspace, window, cx| {
let workspace_handle = cx.entity();
let view = cx.new(|cx| {
Self::new(
project,
repository,
repo_path,
buffer,
diff,
workspace_handle,
window,
cx,
)
});
workspace.add_item_to_active_pane(Box::new(view.clone()), None, true, window, cx);
view
})
})
}
fn new(
project: Entity<Project>,
repository: Entity<Repository>,
repo_path: RepoPath,
buffer: Entity<Buffer>,
diff: Entity<buffer_diff::BufferDiff>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let repository_id = repository.read(cx).id;
let multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::singleton(buffer.clone(), cx);
multibuffer.add_diff(diff, cx);
multibuffer.set_all_diff_hunks_expanded(cx);
multibuffer
});
let editor = cx.new(|cx| {
let editor = SplittableEditor::new(
EditorSettings::get_global(cx).diff_view_style,
multibuffer,
project.clone(),
workspace.clone(),
window,
cx,
);
editor.rhs_editor().update(cx, |editor, cx| {
editor.set_should_serialize(false, cx);
let snapshot = editor.snapshot(window, cx);
editor.go_to_hunk_before_or_after_position(
&snapshot,
language::Point::new(0, 0),
Direction::Next,
true,
window,
cx,
);
});
editor
});
let mut previous_diff_view_style = EditorSettings::get_global(cx).diff_view_style;
let settings_subscription =
cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
let diff_view_style = EditorSettings::get_global(cx).diff_view_style;
if diff_view_style != previous_diff_view_style {
this.editor.update(cx, |editor, cx| {
if editor.diff_view_style() != diff_view_style {
editor.toggle_split(&ToggleSplitDiff, window, cx);
}
});
previous_diff_view_style = diff_view_style;
cx.notify();
}
});
Self {
repository,
repository_id,
repo_path,
buffer,
editor,
workspace: workspace.downgrade(),
_settings_subscription: settings_subscription,
}
}
fn matches(&self, repository: &Entity<Repository>, repo_path: &RepoPath, cx: &App) -> bool {
self.repository_id == repository.read(cx).id && &self.repo_path == repo_path
}
fn button_states(&self, cx: &App) -> SoloDiffButtonStates {
let editor = self.editor.read(cx).rhs_editor().read(cx);
let multibuffer = editor.buffer().read(cx);
let snapshot = multibuffer.snapshot(cx);
let prev_next = snapshot.diff_hunks().nth(1).is_some();
let mut selection = true;
let mut ranges = editor
.selections
.disjoint_anchor_ranges()
.collect::<Vec<_>>();
if !ranges.iter().any(|range| range.start != range.end) {
selection = false;
let anchor = editor.selections.newest_anchor().head();
if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor)
&& let Some(range) = snapshot
.anchor_in_buffer(excerpt_range.context.start)
.zip(snapshot.anchor_in_buffer(excerpt_range.context.end))
.map(|(start, end)| start..end)
{
ranges = vec![range];
} else {
ranges = Vec::new();
}
}
let mut stage = false;
let mut unstage = false;
for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
match hunk.status.secondary {
DiffHunkSecondaryStatus::HasSecondaryHunk
| DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => {
stage = true;
}
DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
stage = true;
unstage = true;
}
DiffHunkSecondaryStatus::NoSecondaryHunk
| DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
unstage = true;
}
}
}
let stage_status = self
.repository
.read(cx)
.status_for_path(&self.repo_path)
.map(|entry| entry.status.staging())
.unwrap_or(StageStatus::Unstaged);
SoloDiffButtonStates {
stage,
unstage,
restore: stage || unstage,
prev_next,
selection,
stage_file: stage_status.has_unstaged(),
unstage_file: stage_status.has_staged(),
}
}
fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut App) {
self.focus_handle(cx).focus(window, cx);
let action = action.boxed_clone();
cx.defer(move |cx| {
cx.dispatch_action(action.as_ref());
});
}
fn change_file_stage(&self, stage: bool, window: &mut Window, cx: &mut Context<Self>) {
let repository = self.repository.clone();
let repo_path = self.repo_path.clone();
let workspace = self.workspace.clone();
let task = cx.spawn(async move |_, cx| {
repository
.update(cx, |repository, cx| {
if stage {
repository.stage_entries(vec![repo_path], cx)
} else {
repository.unstage_entries(vec![repo_path], cx)
}
})
.await
.with_context(|| {
if stage {
"failed to stage file"
} else {
"failed to unstage file"
}
})
});
task.detach_and_notify_err(workspace, window, cx);
}
}
impl EventEmitter<EditorEvent> for SoloDiffView {}
impl Focusable for SoloDiffView {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.editor.focus_handle(cx)
}
}
impl Item for SoloDiffView {
type Event = EditorEvent;
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
Some(Icon::new(IconName::Diff).color(Color::Muted))
}
fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
.color(if params.selected {
Color::Default
} else {
Color::Muted
})
.into_any_element()
}
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
self.buffer
.read(cx)
.file()
.and_then(|file| {
Some(
file.full_path(cx)
.file_name()?
.to_string_lossy()
.to_string(),
)
})
.unwrap_or_else(|| {
self.repo_path
.as_ref()
.display(PathStyle::local())
.into_owned()
})
.into()
}
fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
Some(
self.buffer
.read(cx)
.file()
.map(|file| file.full_path(cx).compact().to_string_lossy().into_owned())
.unwrap_or_else(|| {
self.repo_path
.as_ref()
.display(PathStyle::local())
.into_owned()
})
.into(),
)
}
fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
Editor::to_item_events(event, f)
}
fn telemetry_event_text(&self) -> Option<&'static str> {
Some("Solo Diff View Opened")
}
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.editor.deactivated(window, cx);
}
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &'a Entity<Self>,
cx: &'a App,
) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.clone().into())
} else {
self.editor.act_as_type(type_id, cx)
}
}
fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone()))
}
fn for_each_project_item(
&self,
cx: &App,
f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
) {
self.editor.for_each_project_item(cx, f)
}
fn set_nav_history(
&mut self,
nav_history: ItemNavHistory,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |editor, _| {
editor.set_nav_history(Some(nav_history));
})
});
}
fn navigate(
&mut self,
data: Arc<dyn Any + Send>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
self.editor.update(cx, |editor, cx| {
editor
.rhs_editor()
.update(cx, |editor, cx| editor.navigate(data, window, cx))
})
}
fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
ToolbarItemLocation::PrimaryLeft
}
fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<gpui::Font>)> {
self.editor.breadcrumbs(cx)
}
fn added_to_workspace(
&mut self,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |editor, cx| {
editor.added_to_workspace(workspace, window, cx)
})
});
}
fn can_save(&self, cx: &App) -> bool {
self.editor.read(cx).rhs_editor().read(cx).can_save(cx)
}
fn save(
&mut self,
options: SaveOptions,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.editor.save(options, project, window, cx)
}
}
impl Render for SoloDiffView {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
self.editor.clone()
}
}
pub struct SoloDiffStyleToolbar {
solo_diff: Option<WeakEntity<SoloDiffView>>,
}
pub struct SoloDiffGitToolbar {
solo_diff: Option<WeakEntity<SoloDiffView>>,
}
impl SoloDiffStyleToolbar {
pub fn new(_: &mut Context<Self>) -> Self {
Self { solo_diff: None }
}
fn solo_diff(&self) -> Option<Entity<SoloDiffView>> {
self.solo_diff.as_ref()?.upgrade()
}
fn set_diff_view_style(
&mut self,
diff_view_style: DiffViewStyle,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(solo_diff) = self.solo_diff() else {
return;
};
let workspace = solo_diff.read(cx).workspace.clone();
update_settings_file(<dyn Fs>::global(cx), cx, move |settings, _| {
settings.editor.diff_view_style = Some(diff_view_style);
});
if let Some(workspace) = workspace.upgrade() {
let splittable_editors = {
workspace
.read(cx)
.items(cx)
.filter_map(|item| item.act_as_type(TypeId::of::<SplittableEditor>(), cx))
.filter_map(|item| item.downcast::<SplittableEditor>().ok())
.collect::<Vec<_>>()
};
for editor in splittable_editors {
editor.update(cx, |editor, cx| {
if editor.diff_view_style() != diff_view_style {
editor.toggle_split(&ToggleSplitDiff, window, cx);
}
});
}
}
cx.notify();
}
}
impl EventEmitter<ToolbarItemEvent> for SoloDiffStyleToolbar {}
impl ToolbarItemView for SoloDiffStyleToolbar {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn ItemHandle>,
_: &mut Window,
cx: &mut Context<Self>,
) -> ToolbarItemLocation {
self.solo_diff = active_pane_item
.and_then(|item| item.act_as::<SoloDiffView>(cx))
.map(|entity| entity.downgrade());
if self.solo_diff.is_some() {
ToolbarItemLocation::PrimaryLeft
} else {
ToolbarItemLocation::Hidden
}
}
}
impl Render for SoloDiffStyleToolbar {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(solo_diff) = self.solo_diff() else {
return div();
};
let editor_entity = solo_diff.read(cx).editor.clone();
let editor = editor_entity.read(cx);
let diff_view_style = editor.diff_view_style();
let is_split_set = diff_view_style == DiffViewStyle::Split;
let split_icon = if is_split_set && !editor.is_split() {
IconName::DiffSplitAuto
} else {
IconName::DiffSplit
};
h_flex()
.h_8()
.items_center()
.gap_1()
.child(
IconButton::new("solo-diff-unified", IconName::DiffUnified)
.icon_size(IconSize::Small)
.toggle_state(diff_view_style == DiffViewStyle::Unified)
.tooltip(Tooltip::text("Unified"))
.on_click(cx.listener(|this, _, window, cx| {
this.set_diff_view_style(DiffViewStyle::Unified, window, cx);
})),
)
.child(
IconButton::new("solo-diff-split", split_icon)
.icon_size(IconSize::Small)
.toggle_state(diff_view_style == DiffViewStyle::Split)
.tooltip(Tooltip::text("Split"))
.on_click(cx.listener(|this, _, window, cx| {
this.set_diff_view_style(DiffViewStyle::Split, window, cx);
})),
)
.child(vertical_divider())
.child(div().w_1())
}
}
impl SoloDiffGitToolbar {
pub fn new(_: &mut Context<Self>) -> Self {
Self { solo_diff: None }
}
fn solo_diff(&self) -> Option<Entity<SoloDiffView>> {
self.solo_diff.as_ref()?.upgrade()
}
fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
if let Some(solo_diff) = self.solo_diff() {
solo_diff.update(cx, |solo_diff, cx| {
solo_diff.dispatch_action(action, window, cx);
});
}
}
fn stage_file(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(solo_diff) = self.solo_diff() {
solo_diff.update(cx, |solo_diff, cx| {
solo_diff.change_file_stage(true, window, cx);
});
}
}
fn unstage_file(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(solo_diff) = self.solo_diff() {
solo_diff.update(cx, |solo_diff, cx| {
solo_diff.change_file_stage(false, window, cx);
});
}
}
}
impl EventEmitter<ToolbarItemEvent> for SoloDiffGitToolbar {}
impl ToolbarItemView for SoloDiffGitToolbar {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn ItemHandle>,
_: &mut Window,
cx: &mut Context<Self>,
) -> ToolbarItemLocation {
self.solo_diff = active_pane_item
.and_then(|item| item.act_as::<SoloDiffView>(cx))
.map(|entity| entity.downgrade());
if self.solo_diff.is_some() {
ToolbarItemLocation::PrimaryRight
} else {
ToolbarItemLocation::Hidden
}
}
}
struct SoloDiffButtonStates {
stage: bool,
unstage: bool,
restore: bool,
prev_next: bool,
selection: bool,
stage_file: bool,
unstage_file: bool,
}
impl Render for SoloDiffGitToolbar {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(solo_diff) = self.solo_diff() else {
return div();
};
let focus_handle = solo_diff.focus_handle(cx);
let solo_diff = solo_diff.read(cx);
let button_states = solo_diff.button_states(cx);
let status_entry = solo_diff
.repository
.read(cx)
.status_for_path(&solo_diff.repo_path);
let status = status_entry.as_ref().map(|entry| entry.status);
let diff_stat = status_entry.and_then(|entry| entry.diff_stat);
h_group_xl()
.my_neg_1()
.py_1()
.items_center()
.flex_wrap()
.justify_between()
.children(status.map(|status| git_status_icon(status).into_any_element()))
.children(diff_stat.map(|stat| {
DiffStat::new("solo-diff-stat", stat.added as usize, stat.deleted as usize)
.into_any_element()
}))
.child(
h_group_sm()
.when(button_states.selection, |el| {
el.child(
Button::new("stage", "Toggle Staged")
.tooltip(Tooltip::for_action_title_in(
"Toggle Staged",
&ToggleStaged,
&focus_handle,
))
.disabled(!button_states.stage && !button_states.unstage)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&ToggleStaged, window, cx)
})),
)
})
.when(!button_states.selection, |el| {
el.child(
Button::new("stage", "Stage")
.tooltip(Tooltip::for_action_title_in(
"Stage and go to next hunk",
&StageAndNext,
&focus_handle,
))
.disabled(!button_states.stage)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&StageAndNext, window, cx)
})),
)
.child(
Button::new("unstage", "Unstage")
.tooltip(Tooltip::for_action_title_in(
"Unstage and go to next hunk",
&UnstageAndNext,
&focus_handle,
))
.disabled(!button_states.unstage)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&UnstageAndNext, window, cx)
})),
)
})
.child(
Button::new("restore", "Restore")
.tooltip(Tooltip::for_action_title_in(
"Restore selected hunk",
&Restore,
&focus_handle,
))
.disabled(!button_states.restore)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&Restore, window, cx)
})),
),
)
.child(
h_group_sm()
.child(
IconButton::new("up", IconName::ArrowUp)
.shape(IconButtonShape::Square)
.tooltip(Tooltip::for_action_title_in(
"Go to previous hunk",
&GoToPreviousHunk,
&focus_handle,
))
.disabled(!button_states.prev_next)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&GoToPreviousHunk, window, cx)
})),
)
.child(
IconButton::new("down", IconName::ArrowDown)
.shape(IconButtonShape::Square)
.tooltip(Tooltip::for_action_title_in(
"Go to next hunk",
&GoToHunk,
&focus_handle,
))
.disabled(!button_states.prev_next)
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&GoToHunk, window, cx)
})),
),
)
.child(vertical_divider())
.child(
h_group_sm()
.child(
Button::new("stage-file", "Stage File")
.tooltip(Tooltip::for_action_title_in(
"Stage file",
&StageFile,
&focus_handle,
))
.disabled(!button_states.stage_file)
.on_click(
cx.listener(|this, _, window, cx| this.stage_file(window, cx)),
),
)
.child(
Button::new("unstage-file", "Unstage File")
.tooltip(Tooltip::for_action_title_in(
"Unstage file",
&UnstageFile,
&focus_handle,
))
.disabled(!button_states.unstage_file)
.on_click(
cx.listener(|this, _, window, cx| this.unstage_file(window, cx)),
),
)
.child(Divider::vertical())
.child(
Button::new("commit", "Commit")
.tooltip(Tooltip::for_action_title_in(
"Commit",
&Commit,
&focus_handle,
))
.on_click(cx.listener(|this, _, window, cx| {
this.dispatch_action(&Commit, window, cx);
})),
),
)
}
}

View file

@ -336,6 +336,20 @@ impl TestAppContext {
self.test_platform.simulate_new_path_selection(select_path);
}
/// Simulates responding to a `prompt_for_paths` ("Open") dialog.
pub fn simulate_path_prompt_response(
&self,
select_paths: impl FnOnce(&crate::PathPromptOptions) -> Option<Vec<std::path::PathBuf>>,
) {
self.test_platform
.simulate_path_prompt_response(select_paths);
}
/// Returns true if there's a path selection dialog pending.
pub fn did_prompt_for_paths(&self) -> bool {
self.test_platform.did_prompt_for_paths()
}
/// Simulates clicking a button in an platform-level alert dialog.
#[track_caller]
pub fn simulate_prompt_answer(&self, button: &str) {
@ -1098,3 +1112,54 @@ impl AnyWindowHandle {
.unwrap()
}
}
#[cfg(test)]
mod tests {
use crate::{PathPromptOptions, TestAppContext};
use std::path::PathBuf;
#[gpui::test]
async fn test_simulate_path_prompt_response(cx: &mut TestAppContext) {
assert!(!cx.did_prompt_for_paths());
let receiver = cx.update(|cx| {
cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: true,
prompt: None,
})
});
assert!(cx.did_prompt_for_paths());
let selected = vec![PathBuf::from("/a"), PathBuf::from("/b")];
cx.simulate_path_prompt_response({
let selected = selected.clone();
move |options| {
assert!(options.multiple);
Some(selected)
}
});
assert!(!cx.did_prompt_for_paths());
let response = receiver.await.unwrap().unwrap();
assert_eq!(response, Some(selected));
}
#[gpui::test]
async fn test_simulate_path_prompt_cancellation(cx: &mut TestAppContext) {
let receiver = cx.update(|cx| {
cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
})
});
cx.simulate_path_prompt_response(|_options| None);
let response = receiver.await.unwrap().unwrap();
assert_eq!(response, None);
}
}

View file

@ -1,9 +1,10 @@
use crate::{
AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels,
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay,
PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata,
Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size,
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, PathPromptOptions, Platform,
PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper,
PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream,
SourceMetadata, Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams,
size,
};
use anyhow::Result;
use collections::VecDeque;
@ -85,6 +86,10 @@ struct TestPrompt {
pub(crate) struct TestPrompts {
multiple_choice: VecDeque<TestPrompt>,
new_path: VecDeque<(PathBuf, oneshot::Sender<Result<Option<PathBuf>>>)>,
paths: VecDeque<(
PathPromptOptions,
oneshot::Sender<Result<Option<Vec<PathBuf>>>>,
)>,
}
impl TestPlatform {
@ -147,6 +152,33 @@ impl TestPlatform {
tx.send(Ok(select_path(&path))).ok();
}
pub(crate) fn simulate_path_prompt_response(
&self,
select_paths: impl FnOnce(&PathPromptOptions) -> Option<Vec<std::path::PathBuf>>,
) {
let (options, tx) = self
.prompts
.borrow_mut()
.paths
.pop_front()
.expect("no pending paths prompt");
let selection = select_paths(&options);
if let Some(paths) = &selection
&& !options.multiple
&& paths.len() > 1
{
panic!(
"selected {} paths for a prompt that does not allow multiple selection",
paths.len()
);
}
tx.send(Ok(selection)).ok();
}
pub(crate) fn did_prompt_for_paths(&self) -> bool {
!self.prompts.borrow().paths.is_empty()
}
#[track_caller]
pub(crate) fn simulate_prompt_answer(&self, response: &str) {
let prompt = self
@ -348,9 +380,11 @@ impl Platform for TestPlatform {
fn prompt_for_paths(
&self,
_options: crate::PathPromptOptions,
options: crate::PathPromptOptions,
) -> oneshot::Receiver<Result<Option<Vec<std::path::PathBuf>>>> {
unimplemented!()
let (tx, rx) = oneshot::channel();
self.prompts.borrow_mut().paths.push_back((options, tx));
rx
}
fn prompt_for_new_path(

View file

@ -734,11 +734,14 @@ mod tests {
use std::{path::PathBuf, sync::Arc};
use editor::{Editor, SelectionEffects};
use gpui::{TestAppContext, VisualTestContext};
use language::{Language, LanguageConfig, LanguageMatcher, Point};
use gpui::{App, Entity, Task, TestAppContext, VisualTestContext};
use language::{
Buffer, ContextProvider, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher,
LanguageServerName, Point,
};
use project::{ContextProviderWithTasks, FakeFs, Project};
use serde_json::json;
use task::TaskTemplates;
use task::{TaskTemplate, TaskTemplates};
use util::path;
use workspace::{CloseInactiveTabsAndPanes, MultiWorkspace, OpenOptions, OpenVisible};
@ -1033,6 +1036,80 @@ mod tests {
cx.executor().run_until_parked();
}
#[gpui::test]
async fn test_empty_lsp_task_response_keeps_language_tasks_in_modal(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/dir"), json!({ "main.test": "test" }))
.await;
let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(Arc::new(
Language::new(
LanguageConfig {
name: "Test".into(),
matcher: LanguageMatcher {
path_suffixes: vec!["test".to_string()],
..LanguageMatcher::default()
},
..LanguageConfig::default()
},
None,
)
.with_context_provider(Some(Arc::new(
ContextProviderWithLspTaskSource::new(ContextProviderWithTasks::new(
TaskTemplates(vec![TaskTemplate {
label: "Run language task".to_string(),
command: "echo".to_string(),
args: vec!["language task".to_string()],
..TaskTemplate::default()
}]),
)),
))),
));
let mut fake_servers = language_registry.register_fake_lsp(
"Test",
FakeLspAdapter {
name: TEST_LSP_NAME,
..FakeLspAdapter::default()
},
);
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let workspace =
multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
let _item = workspace
.update_in(cx, |workspace, window, cx| {
workspace.open_abs_path(
PathBuf::from(path!("/dir/main.test")),
OpenOptions {
visible: Some(OpenVisible::All),
..Default::default()
},
window,
cx,
)
})
.await
.unwrap();
cx.executor().run_until_parked();
let fake_server = fake_servers
.try_recv()
.expect("fake LSP server should have started");
use project::lsp_store::lsp_ext_command::Runnables;
fake_server
.set_request_handler::<Runnables, _, _>(move |_, _| async move { Ok(Vec::new()) });
let tasks_picker = open_spawn_tasks(&workspace, cx);
assert_eq!(
task_names(&tasks_picker, cx),
vec!["Run language task"],
"An empty LSP task response should not suppress language tasks in the modal"
);
}
#[gpui::test]
async fn test_language_task_filtering(cx: &mut TestAppContext) {
init_test(cx);
@ -1238,6 +1315,32 @@ mod tests {
);
}
const TEST_LSP_NAME: &str = "test-lsp";
struct ContextProviderWithLspTaskSource {
tasks: ContextProviderWithTasks,
}
impl ContextProviderWithLspTaskSource {
fn new(tasks: ContextProviderWithTasks) -> Self {
Self { tasks }
}
}
impl ContextProvider for ContextProviderWithLspTaskSource {
fn associated_tasks(
&self,
buffer: Option<Entity<Buffer>>,
cx: &App,
) -> Task<Option<TaskTemplates>> {
self.tasks.associated_tasks(buffer, cx)
}
fn lsp_task_source(&self) -> Option<LanguageServerName> {
Some(LanguageServerName::new_static(TEST_LSP_NAME))
}
}
fn emulate_task_schedule(
tasks_picker: Entity<Picker<TasksModalDelegate>>,
project: &Entity<Project>,

View file

@ -5,7 +5,7 @@ mod paste;
mod select;
mod surround;
use editor::display_map::{DisplayRow, DisplaySnapshot};
use editor::display_map::{DisplayRow, DisplaySnapshot, ToDisplayPoint};
use editor::{
DisplayPoint, Editor, EditorSettings, MultiBufferOffset, NavigationOverlayLabel,
NavigationTargetOverlay, SelectionEffects, ToOffset, ToPoint, movement,
@ -57,6 +57,8 @@ actions!(
HelixSubstitute,
/// Delete the selection and enter edit mode, without yanking the selection.
HelixSubstituteNoYank,
/// Replace the selection with the contents of the default register (`R`).
HelixReplaceWithYanked,
/// Activate Helix-style word jump labels.
HelixJumpToWord,
/// Select the next match for the current search query.
@ -74,6 +76,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
Vim::action(editor, cx, Vim::helix_yank);
Vim::action(editor, cx, Vim::helix_goto_last_modification);
Vim::action(editor, cx, Vim::helix_paste);
Vim::action(editor, cx, Vim::helix_replace_with_yanked);
Vim::action(editor, cx, Vim::helix_select_regex);
Vim::action(editor, cx, Vim::helix_keep_newest_selection);
Vim::action(editor, cx, |vim, _: &HelixDuplicateBelow, window, cx| {
@ -571,6 +574,11 @@ impl Vim {
.iter()
.any(|selection| !selection.is_empty());
// Plain `y` writes to register `"` only, never the system clipboard
if vim.selected_register.is_none() {
vim.selected_register = Some('"');
}
if !has_selection {
// If no selection, expand to current character (like 'v' does)
editor.change_selections(Default::default(), window, cx, |s| {
@ -727,6 +735,62 @@ impl Vim {
});
}
/// Replace each selection with the contents of the default
/// register (or an explicitly selected register)
pub fn helix_replace_with_yanked(
&mut self,
_: &HelixReplaceWithYanked,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.record_current_action(cx);
self.update_editor(cx, |vim, editor, cx| {
let selected_register = vim.selected_register.take();
let Some(register) = Vim::update_globals(cx, |globals, cx| {
if selected_register.is_some() {
globals.read_register(selected_register, Some(editor), cx)
} else {
globals.read_default_register()
}
})
.filter(|reg| !reg.text.is_empty()) else {
return;
};
let text = register.text;
editor.transact(window, cx, |editor, window, cx| {
let display_map = editor.display_snapshot(cx);
let selections = editor.selections.all_adjusted(&display_map);
let snapshot = display_map.buffer_snapshot();
let mut edits = Vec::with_capacity(selections.len());
let mut new_anchors = Vec::with_capacity(selections.len());
for selection in &selections {
let mut range = selection.start..selection.end;
if range.is_empty() {
range.end = movement::saturating_right(
&display_map,
range.end.to_display_point(&display_map),
)
.to_point(&display_map);
}
new_anchors.push(snapshot.anchor_before(range.start));
edits.push((range, text.to_string()));
}
editor.edit(edits, cx);
let snapshot = editor.buffer().read(cx).snapshot(cx);
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges(new_anchors.into_iter().map(|anchor| {
let start = anchor.to_offset(&snapshot);
start..start + text.len()
}));
});
});
});
self.switch_mode(Mode::HelixNormal, true, window, cx);
}
pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
self.update_editor(cx, |_, editor, cx| {
editor.transact(window, cx, |editor, window, cx| {
@ -877,6 +941,9 @@ impl Vim {
})
});
if yank {
if vim.selected_register.is_none() {
vim.selected_register = Some('"');
}
vim.copy_selections_content(editor, MotionKind::Exclusive, window, cx);
}
let selections = editor
@ -2495,54 +2562,66 @@ mod test {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// Test yanking current character with no selection
cx.write_to_clipboard(gpui::ClipboardItem::new_string("external".into()));
// Test yanking a single character (no explicit selection) should not clobber the system clipboard
cx.set_state("hello ˇworld", Mode::HelixNormal);
cx.simulate_keystrokes("y");
// Test cursor remains at the same position after yanking single character
cx.assert_state("hello ˇworld", Mode::HelixNormal);
cx.shared_clipboard().assert_eq("w");
cx.shared_clipboard().assert_eq("external");
cx.simulate_keystrokes("l p");
cx.assert_state("hello wo«wˇ»rld", Mode::HelixNormal);
// Move cursor and yank another character
cx.simulate_keystrokes("l");
cx.simulate_keystrokes("y");
cx.shared_clipboard().assert_eq("o");
// Test yanking with existing selection
// Test yanking a multi-char selection
cx.set_state("hello «worlˇ»d", Mode::HelixNormal);
cx.simulate_keystrokes("y");
cx.shared_clipboard().assert_eq("worl");
cx.shared_clipboard().assert_eq("external");
cx.assert_state("hello «worlˇ»d", Mode::HelixNormal);
// Test yanking in select mode character by character
cx.set_state("hello ˇworld", Mode::HelixNormal);
cx.simulate_keystroke("v");
cx.assert_state("hello «wˇ»orld", Mode::HelixSelect);
cx.simulate_keystroke("y");
cx.assert_state("hello «wˇ»orld", Mode::HelixNormal);
cx.shared_clipboard().assert_eq("w");
}
#[gpui::test]
async fn test_shift_r_paste(cx: &mut gpui::TestAppContext) {
async fn test_helix_delete_preserves_system_clipboard(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// First copy some text to clipboard
cx.write_to_clipboard(gpui::ClipboardItem::new_string("external".into()));
cx.shared_clipboard().assert_eq("external");
cx.set_state("hello «worlˇ»d", Mode::HelixNormal);
cx.simulate_keystrokes("d");
cx.shared_clipboard().assert_eq("external");
cx.set_state(
indoc! {"
ˇ
second line"},
Mode::HelixNormal,
);
cx.simulate_keystrokes("d");
cx.shared_clipboard().assert_eq("external");
}
#[gpui::test]
#[gpui::test]
async fn test_shift_r_replace_with_yanked(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
// External clipboard content is irrelevant
cx.write_to_clipboard(gpui::ClipboardItem::new_string("external".into()));
cx.set_state("«hello worldˇ»", Mode::HelixNormal);
cx.simulate_keystrokes("y");
// Test paste with shift-r on single cursor
// Test bare cursor
cx.set_state("foo ˇbar", Mode::HelixNormal);
cx.simulate_keystrokes("shift-r");
cx.assert_state("foo «hello worldˇ»ar", Mode::HelixNormal);
cx.assert_state("foo hello worldˇbar", Mode::HelixNormal);
// Test paste with shift-r on selection
// Test multi-char selection
cx.set_state("foo «barˇ» baz", Mode::HelixNormal);
cx.simulate_keystrokes("shift-r");
cx.assert_state("foo hello worldˇ baz", Mode::HelixNormal);
cx.assert_state("foo «hello worldˇ» baz", Mode::HelixNormal);
}
#[gpui::test]

View file

@ -37,8 +37,13 @@ impl Vim {
let selected_register = vim.selected_register.take();
// With an explicit register, defer to read_register so `+`/`*` still hit the clipboard
let Some(register) = Vim::update_globals(cx, |globals, cx| {
if selected_register.is_some() {
globals.read_register(selected_register, Some(editor), cx)
} else {
globals.read_default_register()
}
})
.filter(|reg| !reg.text.is_empty()) else {
return;
@ -158,7 +163,7 @@ mod test {
use crate::{state::Mode, test::VimTestContext};
#[gpui::test]
async fn test_system_clipboard_paste(cx: &mut gpui::TestAppContext) {
async fn test_helix_paste_ignores_system_clipboard(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.enable_helix();
cx.set_state(
@ -173,36 +178,11 @@ mod test {
cx.simulate_keystrokes("p");
cx.assert_state(
indoc! {"
The quic«clipboardˇ»k brown
The quiˇck brown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
// Multiple cursors with system clipboard (no metadata) pastes
// the same text at each cursor.
cx.set_state(
indoc! {"
ˇThe quick brown
fox ˇjumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.write_to_clipboard(ClipboardItem::new_string("hi".to_string()));
cx.simulate_keystrokes("p");
cx.assert_state(
indoc! {"
T«hiˇ»he quick brown
fox j«hiˇ»umps over
the lazy dog."},
Mode::HelixNormal,
);
// Multiple cursors on empty lines should paste on those same lines.
cx.set_state("ˇ\nˇ\nˇ\nend", Mode::HelixNormal);
cx.write_to_clipboard(ClipboardItem::new_string("X".to_string()));
cx.simulate_keystrokes("p");
cx.assert_state("«Xˇ»\n«Xˇ»\n«Xˇ»\nend", Mode::HelixNormal);
}
#[gpui::test]

View file

@ -155,6 +155,10 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
})
})
});
// Plain `d` yanks to register `"` only, never the system clipboard
if vim.selected_register.is_none() {
vim.selected_register = Some('"');
}
vim.visual_delete(false, window, cx);
vim.switch_mode(Mode::HelixNormal, true, window, cx);
});

View file

@ -905,6 +905,11 @@ impl VimGlobals {
}
}
/// Reads the default register `"` literally, ignoring `use_system_clipboard`
pub(crate) fn read_default_register(&self) -> Option<Register> {
self.registers.get(&'"').cloned()
}
pub(crate) fn read_register(
&self,
register: Option<char>,

View file

@ -34,6 +34,7 @@ use futures::{StreamExt, channel::mpsc, select_biased};
use git_ui::commit_view::CommitViewToolbar;
use git_ui::git_panel::GitPanel;
use git_ui::project_diff::{BranchDiffToolbar, ProjectDiffToolbar};
use git_ui::solo_diff_view::{SoloDiffGitToolbar, SoloDiffStyleToolbar};
use gpui::{
Action, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent,
Element, Entity, FocusHandle, Focusable, Image, ImageFormat, KeyBinding, ParentElement,
@ -1305,6 +1306,8 @@ fn initialize_pane(
pane.toolbar().update(cx, |toolbar, cx| {
let multibuffer_hint = cx.new(|_| MultibufferHint::new());
toolbar.add_item(multibuffer_hint, window, cx);
let solo_diff_style_toolbar = cx.new(SoloDiffStyleToolbar::new);
toolbar.add_item(solo_diff_style_toolbar, window, cx);
let breadcrumbs = cx.new(|_| Breadcrumbs::new());
toolbar.add_item(breadcrumbs, window, cx);
let buffer_search_bar = cx.new(|cx| {
@ -1343,6 +1346,8 @@ fn initialize_pane(
toolbar.add_item(project_diff_toolbar, window, cx);
let branch_diff_toolbar = cx.new(BranchDiffToolbar::new);
toolbar.add_item(branch_diff_toolbar, window, cx);
let solo_diff_git_toolbar = cx.new(SoloDiffGitToolbar::new);
toolbar.add_item(solo_diff_git_toolbar, window, cx);
let commit_view_toolbar = cx.new(|_| CommitViewToolbar::new());
toolbar.add_item(commit_view_toolbar, window, cx);
let agent_diff_toolbar = cx.new(AgentDiffToolbar::new);

View file

@ -11,4 +11,5 @@ dependencies = [
"typer>=0.15.1",
"types-pytz>=2025.1.0.20250204",
"types-requests>=2.32.0",
"urllib3>=2.7.0",
]

View file

@ -134,7 +134,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.32.3"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@ -142,9 +142,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
@ -252,6 +252,7 @@ dependencies = [
{ name = "typer" },
{ name = "types-pytz" },
{ name = "types-requests" },
{ name = "urllib3" },
]
[package.metadata]
@ -263,13 +264,14 @@ requires-dist = [
{ name = "typer", specifier = ">=0.15.1" },
{ name = "types-pytz", specifier = ">=2025.1.0.20250204" },
{ name = "types-requests", specifier = ">=2.32.0" },
{ name = "urllib3", specifier = ">=2.7.0" },
]
[[package]]
name = "urllib3"
version = "2.2.3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677, upload-time = "2024-09-12T10:52:18.401Z" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338, upload-time = "2024-09-12T10:52:16.589Z" },
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]