Compare commits

...
Sign in to create a new pull request.

33 commits

Author SHA1 Message Date
gcp-cherry-pick-bot[bot]
b4b57b586a
Fix racy leaked extension server adapters handling (cherry-pick #35319) (#35320)
Cherry-picked Kb/wasm panics (#35319)

Follow-up of https://github.com/zed-industries/zed/pull/34208
Closes https://github.com/zed-industries/zed/issues/35185

Previous code assumed that extensions' language server wrappers may leak
only in static data (e.g. fields that were not cleared on deinit), but
we seem to have a race that breaks this assumption.

1. We do clean `all_lsp_adapters` field after
https://github.com/zed-industries/zed/pull/34334 and it's called for
every extension that is unregistered.
2. `LspStore::maintain_workspace_config` ->
`LspStore::refresh_workspace_configurations` chain is triggered
independently, apparently on `ToolchainStoreEvent::ToolchainActivated`
event which means somewhere behind there's potentially a Python code
that gets executed to activate the toolchian, making
`refresh_workspace_configurations` start timings unpredictable.
3. Seems that toolchain activation overlaps with plugin reload, as 
`2025-07-28T12:16:19+03:00 INFO [extension_host] extensions updated.
loading 0, reloading 1, unloading 0` suggests in the issue logs.

The plugin reload seem to happen faster than workspace configuration
refresh in



c65da547c9/crates/project/src/lsp_store.rs (L7426-L7456)

as the language servers are just starting and take extra time to respond
to the notification.

At least one of the `.clone()`d `adapter`s there is the adapter that got
removed during plugin reload and has its channel closed, which causes a
panic later.

----------------------------

A good fix would be to re-architect the workspace refresh approach, same
as other accesses to the language server collections.
One way could be to use `Weak`-based structures instead, as definitely
the extension server data belongs to extension, not the `LspStore`.
This is quite a large undertaking near the extension core though, so is
not done yet.

Currently, to stop the excessive panics, no more `.expect` is done on
the channel result, as indeed, it now can be closed very dynamically.
This will result in more errors (and backtraces, presumably) printed in
the logs and no panics.

More logging and comments are added, and workspace querying is replaced
to the concurrent one: no need to wait until a previous server had
processed the notification to send the same to the next one.

Release Notes:

- Fixed warm-related panic happening during startup

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
2025-07-30 15:06:06 +03:00
gcp-cherry-pick-bot[bot]
bfb8f24acd
project_panel: Fix autoscroll to treat entries behind sticky items as out of viewport (cherry-pick #35067) (#35315)
Cherry-picked project_panel: Fix autoscroll to treat entries behind
sticky items as out of viewport (#35067)

Closes #34831

Autoscroll centers items only if they’re out of viewport. Before this
PR, entry behind sticky items was not considered out of viewport, and
hence actions like `reveal in project panel` or focusing buffer would
not autoscroll that entry into the view in that case.

This PR fixes that by using recently added `scroll_to_item_with_offset`
in https://github.com/zed-industries/zed/pull/35064.

Release Notes:

- Fixed issue where `pane: reveal in project panel` action was not
working if the entry was behind sticky items.

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-07-30 13:03:43 +05:30
gcp-cherry-pick-bot[bot]
4fd3e220db
gpui: Add scroll_to_item_with_offset to UniformListScrollState (cherry-pick #35064) (#35313)
Cherry-picked gpui: Add `scroll_to_item_with_offset` to
`UniformListScrollState` (#35064)

Previously we had `ScrollStrategy::ToPosition(usize)` which lets you
define the offset where you want to scroll that item to. This is the
same as `ScrollStrategy::Top` but imagine some space reserved at the
top.

This PR removes `ScrollStrategy::ToPosition` in favor of
`scroll_to_item_with_offset` which is the method to do the same. The
reason to add this method is that now not just `ScrollStrategy::Top` but
`ScrollStrategy::Center` can also uses this offset to center the item in
the remaining unreserved space.

```rs
// Before
scroll_handle.scroll_to_item(index, ScrollStrategy::ToPosition(offset));

// After
scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Top, offset);

// New! Centers item skipping first x items
scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Center, offset);
```

This will be useful for follow up PR.

Release Notes:

- N/A

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-07-30 12:42:16 +05:30
gcp-cherry-pick-bot[bot]
dd60cc285b
client: Send User-Agent header on WebSocket connection requests (cherry-pick #35280) (#35283)
Cherry-picked client: Send `User-Agent` header on WebSocket connection
requests (#35280)

This PR makes it so we send the `User-Agent` header on the WebSocket
connection requests when connecting to Collab.

We use the user agent set on the parent HTTP client.

Release Notes:

- N/A

Co-authored-by: Marshall Bowers <git@maxdeviant.com>
2025-07-29 13:35:35 -04:00
gcp-cherry-pick-bot[bot]
bd1cc7fa50
Add more data to see which extension got leaked (cherry-pick #35272) (#35278)
Cherry-picked Add more data to see which extension got leaked (#35272)

Part of https://github.com/zed-industries/zed/issues/35185

Release Notes:

- N/A

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
2025-07-29 13:02:15 -04:00
Finn Evers
e85c466632 Ensure context servers are spawned in the workspace directory (#35271)
This fixes an issue where we were not setting the context server working
directory at all.

Release Notes:

- Context servers will now be spawned in the currently active project
root.

---------

Co-authored-by: Danilo Leal <danilo@zed.dev>
2025-07-29 18:11:37 +02:00
gcp-cherry-pick-bot[bot]
da887b0cae
Cache LSP code lens requests (cherry-pick #35207) (#35257) 2025-07-29 10:21:40 +03:00
gcp-cherry-pick-bot[bot]
151f330dc5
Fix tasks leaked despite workspace window close (cherry-pick #35246) (#35252) 2025-07-29 10:21:05 +03:00
Zed Bot
f7aa90b2ec Bump to 0.197.3 for @probably-neb 2025-07-28 22:20:50 +00:00
gcp-cherry-pick-bot[bot]
4031bedee5
keymap_ui: Fix bug introduced in #35208 (cherry-pick #35237) (#35240)
Cherry-picked keymap_ui: Fix bug introduced in #35208 (#35237)

Closes #ISSUE

Fixes a bug that was cherry picked onto stable and preview branches
introduced in #35208 whereby modifier keys would show up and not be
removable when editing a keybind

Release Notes:

- (preview only) Keymap Editor: Fixed an issue introduced in v0.197.2
whereby modifier keys would show up and not be removable while recording
keystrokes in the keybind edit modal

Co-authored-by: Ben Kunkle <ben@zed.dev>
2025-07-28 18:02:00 -04:00
Peter Tripp
99debc2504
Fix environment loading with tcsh (#35054)
Closes https://github.com/zed-industries/zed/issues/34973

Fixes an issue where environment variables were not loaded when the
user's shell was tcsh and instead a file named `0` was dumped in the
current working directory with a copy of your environment variables as
json.

Follow-up to: 
- https://github.com/zed-industries/zed/pull/35002
- https://github.com/zed-industries/zed/pull/33599

Release Notes:

- Fixed a regression with loading environment variables in nushell
2025-07-28 16:57:01 -04:00
Peter Tripp
0a9e3c4185
zed 0.197.2 2025-07-28 15:49:12 -04:00
Richard Feldman
2ee15a75db
Use zed settings to detect .zed folders (#35224)
Behind-the-scenes enhancement of
https://github.com/zed-industries/zed/pull/35221

Release Notes:

- N/A
2025-07-28 15:48:34 -04:00
Richard Feldman
e2b863116d
Allow edit tool to access files outside project (with confirmation) (#35221)
Now the edit tool can access files outside the current project (just
like the terminal tool can), but it's behind a prompt (unlike other edit
tool actions).

Release Notes:

- The edit tool can now access files outside the current project, but
only if the user grants it permission to.
2025-07-28 14:02:54 -04:00
gcp-cherry-pick-bot[bot]
916eb996bc
keymap_ui: Additional keystroke input polish (cherry-pick #35208) (#35218)
Cherry-picked keymap_ui: Additional keystroke input polish (#35208)

Closes #ISSUE

Fixed various issues and improved UX around the keystroke input
primarily when used for keystroke search.

Release Notes:

- Keymap Editor: FIxed an issue where the modifiers used to activate
keystroke search would appear in the keystroke search
- Keymap Editor: Made it possible to search for repeat modifiers, such
as a binding with `cmd-shift cmd`
- Keymap Editor: Made keystroke search matches match based on ordered
(not necessarily contiguous) runs. For example, searching for `cmd
shift-j` will match `cmd-k cmd-shift-j alt-q` and `cmd-i g shift-j` but
not `alt-k shift-j` or `cmd-k alt-j`
- Keymap Editor: Fixed the clear keystrokes binding (`delete` by
default) not working in the keystroke input

Co-authored-by: Ben Kunkle <ben@zed.dev>
2025-07-28 13:37:39 -04:00
gcp-cherry-pick-bot[bot]
185122d74e
git: Touch up amend UX (cherry-pick #35114) (#35213)
Cherry-picked git: Touch up amend UX (#35114)

Follow-up to #26114

- Ensure that the previous commit message is filled in when toggling on
amend mode from the context menu
- Fix keybinding flicker in context menu

Release Notes:

- N/A

Co-authored-by: Cole Miller <cole@zed.dev>
2025-07-28 13:33:37 -04:00
Todd L Smith
44c6382e13
Fix Nushell environment variables (#35166)
- Fixes environment variable ingestion for Nushell.

Closes #35056

Release Notes:

- N/A
2025-07-28 13:01:09 -04:00
Peter Tripp
b06f843efd
Fix environment loading with nushell (#35002)
Closes https://github.com/zed-industries/zed/issues/34739

I believe this is a regression introduced here:
- https://github.com/zed-industries/zed/pull/33599

Release Notes:

- Fixed a regression with loading environment variables in nushell
2025-07-28 11:52:18 -04:00
Umesh Yadav
39a4409597
lmstudio: Propagate actual error message from server (#34538)
Discovered in this issue: #34513

Previously, we were propagating deserialization errors to users when
using LMStudio, instead of the actual error message sent from the
LMStudio server. This change will help users understand why their
request failed while streaming responses.

Release Notes:

- lmsudio: Display specific backend error messaging on failure rather
than generic ones

---------

Signed-off-by: Umesh Yadav <git@umesh.dev>
Co-authored-by: Peter Tripp <peter@zed.dev>
2025-07-25 09:37:17 -04:00
Smit Barmase
c86f82ba72 project_panel: Automatically open project panel when Rename or Duplicate is triggered from workspace (#34988)
In project panel, `rename` and `duplicate` action further needs user
input for editing, so if panel is closed we should open it.

Release Notes:

- Fixed project panel not opening when `project panel: rename` and
`project panel: duplicate` actions are triggered from workspace.
2025-07-24 13:18:16 -04:00
Richard Feldman
ceab8c17f4 Don't auto-retry in certain circumstances (#35037)
Someone encountered this in production, which should not happen:

<img width="1266" height="623" alt="Screenshot 2025-07-24 at 10 38
40 AM"
src="https://github.com/user-attachments/assets/40f3f977-5110-4808-a456-7e708d953b3b"
/>

This moves certain errors into the category of "never retry" and reduces
the number of retries for some others. Also it adds some diagnostic
logging for retry policy.

It's not a complete fix for the above, because the underlying issues is
that the server is sending a HTTP 403 response and although we were
already treating 403s as "do not retry" it was deciding to retry with 2
attempts anyway. So further debugging is needed to figure out why it
wasn't going down the 403 branch by the time the request got here.

Release Notes:

- N/A
2025-07-24 11:50:04 -04:00
Danilo Leal
f6f7762f32 ai onboarding: Add overall fixes to the whole flow (#34996)
Closes https://github.com/zed-industries/zed/issues/34979

Release Notes:

- N/A

---------

Co-authored-by: Agus Zubiaga <hi@aguz.me>
Co-authored-by: Ben Kunkle <Ben.kunkle@gmail.com>
2025-07-24 11:20:26 -04:00
Oleksiy Syvokon
c015ef64dc
linux: Fix ctrl-0..9, ctrl-[, ctrl-^ (#35028)
There were two different underlying reasons for the issues with
ctrl-number and ctrl-punctuation:

1. Some keys in the ctrl-0..9 range send codes in the `\1b`..`\1f`
range. For example, `ctrl-2` sends keycode for `ctrl-[` (0x1b), but we
want to map it to `2`, not to `[`.

2. `ctrl-[` and four other ctrl-punctuation were incorrectly mapped,
since the expected conversion is by adding 0x40

Closes #35012

Release Notes:

- N/A
2025-07-24 09:45:57 -04:00
Joseph T. Lyons
d3b2f604a9 Differentiate between file and selection diff events (#35014)
Release Notes:

- N/A
2025-07-24 04:43:56 -04:00
Joseph T. Lyons
b8849d83e6 Fix some bugs with editor: diff clipboard with selection (#34999)
Improves testing around `editor: diff clipboard with selection` as well.

Release Notes:

- Fixed some bugs with `editor: diff clipboard with selection`
2025-07-24 02:53:01 -04:00
versecafe
77dda2eca8
ollama: Add Magistral to Ollama (#35000)
See also: #34983

Release Notes:

- Added magistral support to ollama
2025-07-24 00:19:11 -04:00
Peter Tripp
ece9dd2c43
mistral: Add support for magistral-small and magistral-medium (#34983)
Release Notes:

- mistral: Added support for magistral-small and magistral-medium
2025-07-23 23:14:37 -04:00
Renato Lochetti
c60f37a044
mistral: Add support for Mistral Devstral Medium (#34888)
Mistral released their new DevstralMedium model to be used via API:
https://mistral.ai/news/devstral-2507

Release Notes:

- Add support for Mistral Devstral Medium
2025-07-23 23:13:16 -04:00
Joseph T. Lyons
ca646e2951 zed 0.197.1 2025-07-23 17:26:52 -04:00
Umesh Yadav
b5433a9a54 agent_ui: Show keybindings for NewThread and NewTextThread in new thread button (#34967)
I believe in this PR: #34829 we moved to context menu entry from action
but the side effect of that was we also removed the Keybindings from
showing it in the new thread button dropdown. This PR fixes that. cc
@danilo-leal

| Before | After |
|--------|--------|
| <img width="900" height="1962" alt="CleanShot 2025-07-23 at 23 36
28@2x"
src="https://github.com/user-attachments/assets/760cbe75-09b9-404b-9d33-1db73785234f"
/> | <img width="850" height="1964" alt="CleanShot 2025-07-23 at 23 37
17@2x"
src="https://github.com/user-attachments/assets/24a7e871-aebc-475c-845f-b76f02527b8f"
/> |

Release Notes:

- N/A
2025-07-23 17:19:04 -04:00
gcp-cherry-pick-bot[bot]
4727ae35d2
Fix telemetry event type names (cherry-pick #34974) (#34975)
Cherry-picked Fix telemetry event type names (#34974)

Release Notes:

- N/A

Co-authored-by: Joseph T. Lyons <JosephTLyons@gmail.com>
2025-07-23 17:09:42 -04:00
Danilo Leal
d61db1fae7 agent: Fix follow button disabled state (#34978)
Release Notes:

- N/A
2025-07-23 17:09:15 -04:00
Joseph T. Lyons
45d211a555 v0.197.x preview 2025-07-23 13:48:22 -04:00
68 changed files with 2112 additions and 834 deletions

2
Cargo.lock generated
View file

@ -20170,7 +20170,7 @@ dependencies = [
[[package]]
name = "zed"
version = "0.197.0"
version = "0.197.3"
dependencies = [
"activity_indicator",
"agent",

View file

@ -872,8 +872,6 @@
"tab": "git_panel::FocusEditor",
"shift-tab": "git_panel::FocusEditor",
"escape": "git_panel::ToggleFocus",
"ctrl-enter": "git::Commit",
"ctrl-shift-enter": "git::Amend",
"alt-enter": "menu::SecondaryConfirm",
"delete": ["git::RestoreFile", { "skip_prompt": false }],
"backspace": ["git::RestoreFile", { "skip_prompt": false }],
@ -910,7 +908,9 @@
"ctrl-g backspace": "git::RestoreTrackedFiles",
"ctrl-g shift-backspace": "git::TrashUntrackedFiles",
"ctrl-space": "git::StageAll",
"ctrl-shift-space": "git::UnstageAll"
"ctrl-shift-space": "git::UnstageAll",
"ctrl-enter": "git::Commit",
"ctrl-shift-enter": "git::Amend"
}
},
{

View file

@ -950,8 +950,6 @@
"tab": "git_panel::FocusEditor",
"shift-tab": "git_panel::FocusEditor",
"escape": "git_panel::ToggleFocus",
"cmd-enter": "git::Commit",
"cmd-shift-enter": "git::Amend",
"backspace": ["git::RestoreFile", { "skip_prompt": false }],
"delete": ["git::RestoreFile", { "skip_prompt": false }],
"cmd-backspace": ["git::RestoreFile", { "skip_prompt": true }],
@ -1001,7 +999,9 @@
"ctrl-g backspace": "git::RestoreTrackedFiles",
"ctrl-g shift-backspace": "git::TrashUntrackedFiles",
"cmd-ctrl-y": "git::StageAll",
"cmd-ctrl-shift-y": "git::UnstageAll"
"cmd-ctrl-shift-y": "git::UnstageAll",
"cmd-enter": "git::Commit",
"cmd-shift-enter": "git::Amend"
}
},
{

View file

@ -308,7 +308,12 @@ mod tests {
unimplemented!()
}
fn needs_confirmation(&self, _input: &serde_json::Value, _cx: &App) -> bool {
fn needs_confirmation(
&self,
_input: &serde_json::Value,
_project: &Entity<Project>,
_cx: &App,
) -> bool {
unimplemented!()
}

View file

@ -47,7 +47,7 @@ impl Tool for ContextServerTool {
}
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
true
}

View file

@ -942,7 +942,7 @@ impl Thread {
}
pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec<ToolUse> {
self.tool_use.tool_uses_for_message(id, cx)
self.tool_use.tool_uses_for_message(id, &self.project, cx)
}
pub fn tool_results_for_message(
@ -2037,6 +2037,12 @@ impl Thread {
if let Some(retry_strategy) =
Thread::get_retry_strategy(completion_error)
{
log::info!(
"Retrying with {:?} for language model completion error {:?}",
retry_strategy,
completion_error
);
retry_scheduled = thread
.handle_retryable_error_with_delay(
&completion_error,
@ -2246,15 +2252,14 @@ impl Thread {
..
}
| AuthenticationError { .. }
| PermissionError { .. } => None,
// These errors might be transient, so retry them
SerializeRequest { .. }
| BuildRequestBody { .. }
| PromptTooLarge { .. }
| PermissionError { .. }
| NoApiKey { .. }
| ApiEndpointNotFound { .. }
| NoApiKey { .. } => Some(RetryStrategy::Fixed {
| PromptTooLarge { .. } => None,
// These errors might be transient, so retry them
SerializeRequest { .. } | BuildRequestBody { .. } => Some(RetryStrategy::Fixed {
delay: BASE_RETRY_DELAY,
max_attempts: 2,
max_attempts: 1,
}),
// Retry all other 4xx and 5xx errors once.
HttpResponseError { status_code, .. }
@ -2552,7 +2557,7 @@ impl Thread {
return self.handle_hallucinated_tool_use(tool_use.id, tool_use.name, window, cx);
}
if tool.needs_confirmation(&tool_use.input, cx)
if tool.needs_confirmation(&tool_use.input, &self.project, cx)
&& !AgentSettings::get_global(cx).always_allow_tool_actions
{
self.tool_use.confirm_tool_use(

View file

@ -41,6 +41,9 @@ use std::{
};
use util::ResultExt as _;
pub static ZED_STATELESS: std::sync::LazyLock<bool> =
std::sync::LazyLock::new(|| std::env::var("ZED_STATELESS").map_or(false, |v| !v.is_empty()));
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DataType {
#[serde(rename = "json")]
@ -874,7 +877,11 @@ impl ThreadsDatabase {
let needs_migration_from_heed = mdb_path.exists();
let connection = Connection::open_file(&sqlite_path.to_string_lossy());
let connection = if *ZED_STATELESS {
Connection::open_memory(Some("THREAD_FALLBACK_DB"))
} else {
Connection::open_file(&sqlite_path.to_string_lossy())
};
connection.exec(indoc! {"
CREATE TABLE IF NOT EXISTS threads (

View file

@ -165,7 +165,12 @@ impl ToolUseState {
self.pending_tool_uses_by_id.values().collect()
}
pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec<ToolUse> {
pub fn tool_uses_for_message(
&self,
id: MessageId,
project: &Entity<Project>,
cx: &App,
) -> Vec<ToolUse> {
let Some(tool_uses_for_message) = &self.tool_uses_by_assistant_message.get(&id) else {
return Vec::new();
};
@ -211,7 +216,10 @@ impl ToolUseState {
let (icon, needs_confirmation) =
if let Some(tool) = self.tools.read(cx).tool(&tool_use.name, cx) {
(tool.icon(), tool.needs_confirmation(&tool_use.input, cx))
(
tool.icon(),
tool.needs_confirmation(&tool_use.input, project, cx),
)
} else {
(IconName::Cog, false)
};

View file

@ -185,6 +185,13 @@ impl AgentConfiguration {
None
};
let is_signed_in = self
.workspace
.read_with(cx, |workspace, _| {
workspace.client().status().borrow().is_connected()
})
.unwrap_or(false);
v_flex()
.when(is_expanded, |this| this.mb_2())
.child(
@ -230,8 +237,8 @@ impl AgentConfiguration {
.size(LabelSize::Large),
)
.map(|this| {
if is_zed_provider {
this.gap_2().child(
if is_zed_provider && is_signed_in {
this.child(
self.render_zed_plan_info(current_plan, cx),
)
} else {

View file

@ -564,6 +564,17 @@ impl AgentPanel {
let inline_assist_context_store =
cx.new(|_cx| ContextStore::new(project.downgrade(), Some(thread_store.downgrade())));
let thread_id = thread.read(cx).id().clone();
let history_store = cx.new(|cx| {
HistoryStore::new(
thread_store.clone(),
context_store.clone(),
[HistoryEntryId::Thread(thread_id)],
cx,
)
});
let message_editor = cx.new(|cx| {
MessageEditor::new(
fs.clone(),
@ -573,22 +584,13 @@ impl AgentPanel {
prompt_store.clone(),
thread_store.downgrade(),
context_store.downgrade(),
Some(history_store.downgrade()),
thread.clone(),
window,
cx,
)
});
let thread_id = thread.read(cx).id().clone();
let history_store = cx.new(|cx| {
HistoryStore::new(
thread_store.clone(),
context_store.clone(),
[HistoryEntryId::Thread(thread_id)],
cx,
)
});
cx.observe(&history_store, |_, _, cx| cx.notify()).detach();
let active_thread = cx.new(|cx| {
@ -851,6 +853,7 @@ impl AgentPanel {
self.prompt_store.clone(),
self.thread_store.downgrade(),
self.context_store.downgrade(),
Some(self.history_store.downgrade()),
thread.clone(),
window,
cx,
@ -1124,6 +1127,7 @@ impl AgentPanel {
self.prompt_store.clone(),
self.thread_store.downgrade(),
self.context_store.downgrade(),
Some(self.history_store.downgrade()),
thread.clone(),
window,
cx,
@ -1901,85 +1905,96 @@ impl AgentPanel {
)
.anchor(Corner::TopRight)
.with_handle(self.new_thread_menu_handle.clone())
.menu(move |window, cx| {
let active_thread = active_thread.clone();
Some(ContextMenu::build(window, cx, |mut menu, _window, cx| {
menu = menu
.when(cx.has_flag::<feature_flags::AcpFeatureFlag>(), |this| {
this.header("Zed Agent")
})
.item(
ContextMenuEntry::new("New Thread")
.icon(IconName::NewThread)
.icon_color(Color::Muted)
.handler(move |window, cx| {
window.dispatch_action(NewThread::default().boxed_clone(), cx);
}),
)
.item(
ContextMenuEntry::new("New Text Thread")
.icon(IconName::NewTextThread)
.icon_color(Color::Muted)
.handler(move |window, cx| {
window.dispatch_action(NewTextThread.boxed_clone(), cx);
}),
)
.when_some(active_thread, |this, active_thread| {
let thread = active_thread.read(cx);
.menu({
let focus_handle = focus_handle.clone();
move |window, cx| {
let active_thread = active_thread.clone();
Some(ContextMenu::build(window, cx, |mut menu, _window, cx| {
menu = menu
.context(focus_handle.clone())
.when(cx.has_flag::<feature_flags::AcpFeatureFlag>(), |this| {
this.header("Zed Agent")
})
.item(
ContextMenuEntry::new("New Thread")
.icon(IconName::NewThread)
.icon_color(Color::Muted)
.action(NewThread::default().boxed_clone())
.handler(move |window, cx| {
window.dispatch_action(
NewThread::default().boxed_clone(),
cx,
);
}),
)
.item(
ContextMenuEntry::new("New Text Thread")
.icon(IconName::NewTextThread)
.icon_color(Color::Muted)
.action(NewTextThread.boxed_clone())
.handler(move |window, cx| {
window.dispatch_action(NewTextThread.boxed_clone(), cx);
}),
)
.when_some(active_thread, |this, active_thread| {
let thread = active_thread.read(cx);
if !thread.is_empty() {
let thread_id = thread.id().clone();
this.item(
ContextMenuEntry::new("New From Summary")
.icon(IconName::NewFromSummary)
.icon_color(Color::Muted)
.handler(move |window, cx| {
window.dispatch_action(
Box::new(NewThread {
from_thread_id: Some(thread_id.clone()),
}),
cx,
);
}),
)
} else {
this
}
})
.when(cx.has_flag::<feature_flags::AcpFeatureFlag>(), |this| {
this.separator()
.header("External Agents")
.item(
ContextMenuEntry::new("New Gemini Thread")
.icon(IconName::AiGemini)
.icon_color(Color::Muted)
.handler(move |window, cx| {
window.dispatch_action(
NewExternalAgentThread {
agent: Some(crate::ExternalAgent::Gemini),
}
.boxed_clone(),
cx,
);
}),
)
.item(
ContextMenuEntry::new("New Claude Code Thread")
.icon(IconName::AiClaude)
.icon_color(Color::Muted)
.handler(move |window, cx| {
window.dispatch_action(
NewExternalAgentThread {
agent: Some(crate::ExternalAgent::ClaudeCode),
}
.boxed_clone(),
cx,
);
}),
)
});
menu
}))
if !thread.is_empty() {
let thread_id = thread.id().clone();
this.item(
ContextMenuEntry::new("New From Summary")
.icon(IconName::NewFromSummary)
.icon_color(Color::Muted)
.handler(move |window, cx| {
window.dispatch_action(
Box::new(NewThread {
from_thread_id: Some(thread_id.clone()),
}),
cx,
);
}),
)
} else {
this
}
})
.when(cx.has_flag::<feature_flags::AcpFeatureFlag>(), |this| {
this.separator()
.header("External Agents")
.item(
ContextMenuEntry::new("New Gemini Thread")
.icon(IconName::AiGemini)
.icon_color(Color::Muted)
.handler(move |window, cx| {
window.dispatch_action(
NewExternalAgentThread {
agent: Some(crate::ExternalAgent::Gemini),
}
.boxed_clone(),
cx,
);
}),
)
.item(
ContextMenuEntry::new("New Claude Code Thread")
.icon(IconName::AiClaude)
.icon_color(Color::Muted)
.handler(move |window, cx| {
window.dispatch_action(
NewExternalAgentThread {
agent: Some(
crate::ExternalAgent::ClaudeCode,
),
}
.boxed_clone(),
cx,
);
}),
)
});
menu
}))
}
});
let agent_panel_menu = PopoverMenu::new("agent-options-menu")
@ -2272,20 +2287,21 @@ impl AgentPanel {
}
match &self.active_view {
ActiveView::Thread { thread, .. } => thread
.read(cx)
.thread()
.read(cx)
.configured_model()
.map_or(true, |model| {
model.provider.id() == language_model::ZED_CLOUD_PROVIDER_ID
}),
ActiveView::TextThread { .. } => LanguageModelRegistry::global(cx)
.read(cx)
.default_model()
.map_or(true, |model| {
model.provider.id() == language_model::ZED_CLOUD_PROVIDER_ID
}),
ActiveView::Thread { .. } | ActiveView::TextThread { .. } => {
let history_is_empty = self
.history_store
.update(cx, |store, cx| store.recent_entries(1, cx).is_empty());
let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
.providers()
.iter()
.any(|provider| {
provider.is_authenticated(cx)
&& provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
});
history_is_empty || !has_configured_non_zed_providers
}
ActiveView::ExternalAgentThread { .. }
| ActiveView::History
| ActiveView::Configuration => false,
@ -2306,9 +2322,8 @@ impl AgentPanel {
Some(
div()
.size_full()
.when(thread_view, |this| {
this.bg(cx.theme().colors().panel_background)
this.size_full().bg(cx.theme().colors().panel_background)
})
.when(text_thread_view, |this| {
this.bg(cx.theme().colors().editor_background)

View file

@ -9,6 +9,7 @@ use crate::ui::{
MaxModeTooltip,
preview::{AgentPreview, UsageCallout},
};
use agent::history_store::HistoryStore;
use agent::{
context::{AgentContextKey, ContextLoadResult, load_context},
context_store::ContextStoreEvent,
@ -29,8 +30,9 @@ use fs::Fs;
use futures::future::Shared;
use futures::{FutureExt as _, future};
use gpui::{
Animation, AnimationExt, App, Entity, EventEmitter, Focusable, KeyContext, Subscription, Task,
TextStyle, WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
Animation, AnimationExt, App, Entity, EventEmitter, Focusable, IntoElement, KeyContext,
Subscription, Task, TextStyle, WeakEntity, linear_color_stop, linear_gradient, point,
pulsating_between,
};
use language::{Buffer, Language, Point};
use language_model::{
@ -80,6 +82,7 @@ pub struct MessageEditor {
user_store: Entity<UserStore>,
context_store: Entity<ContextStore>,
prompt_store: Option<Entity<PromptStore>>,
history_store: Option<WeakEntity<HistoryStore>>,
context_strip: Entity<ContextStrip>,
context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
model_selector: Entity<AgentModelSelector>,
@ -161,6 +164,7 @@ impl MessageEditor {
prompt_store: Option<Entity<PromptStore>>,
thread_store: WeakEntity<ThreadStore>,
text_thread_store: WeakEntity<TextThreadStore>,
history_store: Option<WeakEntity<HistoryStore>>,
thread: Entity<Thread>,
window: &mut Window,
cx: &mut Context<Self>,
@ -233,6 +237,7 @@ impl MessageEditor {
workspace,
context_store,
prompt_store,
history_store,
context_strip,
context_picker_menu_handle,
load_context_task: None,
@ -625,7 +630,7 @@ impl MessageEditor {
.unwrap_or(false);
IconButton::new("follow-agent", IconName::Crosshair)
.disabled(is_model_selected)
.disabled(!is_model_selected)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.toggle_state(following)
@ -1661,32 +1666,36 @@ impl Render for MessageEditor {
let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5;
let in_pro_trial = matches!(
self.user_store.read(cx).current_plan(),
Some(proto::Plan::ZedProTrial)
);
let has_configured_providers = LanguageModelRegistry::read_global(cx)
.providers()
.iter()
.filter(|provider| {
provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
})
.count()
> 0;
let pro_user = matches!(
self.user_store.read(cx).current_plan(),
Some(proto::Plan::ZedPro)
);
let is_signed_out = self
.workspace
.read_with(cx, |workspace, _| {
workspace.client().status().borrow().is_signed_out()
})
.unwrap_or(true);
let configured_providers: Vec<(IconName, SharedString)> =
LanguageModelRegistry::read_global(cx)
.providers()
.iter()
.filter(|provider| {
provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
})
.map(|provider| (provider.icon(), provider.name().0.clone()))
.collect();
let has_existing_providers = configured_providers.len() > 0;
let has_history = self
.history_store
.as_ref()
.and_then(|hs| hs.update(cx, |hs, cx| hs.entries(cx).len() > 0).ok())
.unwrap_or(false)
|| self
.thread
.read_with(cx, |thread, _| thread.messages().len() > 0);
v_flex()
.size_full()
.bg(cx.theme().colors().panel_background)
.when(
has_existing_providers && !in_pro_trial && !pro_user,
!has_history && is_signed_out && has_configured_providers,
|this| this.child(cx.new(ApiKeysWithProviders::new)),
)
.when(changed_buffers.len() > 0, |parent| {
@ -1778,6 +1787,7 @@ impl AgentPreview for MessageEditor {
None,
thread_store.downgrade(),
text_thread_store.downgrade(),
None,
thread,
window,
cx,

View file

@ -5,7 +5,6 @@ mod end_trial_upsell;
mod new_thread_button;
mod onboarding_modal;
pub mod preview;
mod upsell;
pub use agent_notification::*;
pub use burn_mode_tooltip::*;

View file

@ -3,7 +3,7 @@ use std::sync::Arc;
use ai_onboarding::{AgentPanelOnboardingCard, BulletItem};
use client::zed_urls;
use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
use ui::{Divider, List, prelude::*};
use ui::{Divider, List, Tooltip, prelude::*};
#[derive(IntoElement, RegisterComponent)]
pub struct EndTrialUpsell {
@ -33,14 +33,19 @@ impl RenderOnce for EndTrialUpsell {
)
.child(
List::new()
.child(BulletItem::new("500 prompts per month with Claude models"))
.child(BulletItem::new("Unlimited edit predictions")),
.child(BulletItem::new("500 prompts with Claude models"))
.child(BulletItem::new(
"Unlimited edit predictions with Zeta, our open-source model",
)),
)
.child(
Button::new("cta-button", "Upgrade to Zed Pro")
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))),
.on_click(move |_, _window, cx| {
telemetry::event!("Upgrade To Pro Clicked", state = "end-of-trial");
cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))
}),
);
let free_section = v_flex()
@ -55,37 +60,43 @@ impl RenderOnce for EndTrialUpsell {
.color(Color::Muted)
.buffer_font(cx),
)
.child(
Label::new("(Current Plan)")
.size(LabelSize::Small)
.color(Color::Custom(cx.theme().colors().text_muted.opacity(0.6)))
.buffer_font(cx),
)
.child(Divider::horizontal()),
)
.child(
List::new()
.child(BulletItem::new(
"50 prompts per month with the Claude models",
))
.child(BulletItem::new(
"2000 accepted edit predictions using our open-source Zeta model",
)),
)
.child(
Button::new("dismiss-button", "Stay on Free")
.full_width()
.style(ButtonStyle::Outlined)
.on_click({
let callback = self.dismiss_upsell.clone();
move |_, window, cx| callback(window, cx)
}),
.child(BulletItem::new("50 prompts with the Claude models"))
.child(BulletItem::new("2,000 accepted edit predictions")),
);
AgentPanelOnboardingCard::new()
.child(Headline::new("Your Zed Pro trial has expired."))
.child(Headline::new("Your Zed Pro Trial has expired"))
.child(
Label::new("You've been automatically reset to the Free plan.")
.size(LabelSize::Small)
.color(Color::Muted)
.mb_1(),
.mb_2(),
)
.child(pro_section)
.child(free_section)
.child(
h_flex().absolute().top_4().right_4().child(
IconButton::new("dismiss_onboarding", IconName::Close)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Dismiss"))
.on_click({
let callback = self.dismiss_upsell.clone();
move |_, window, cx| {
telemetry::event!("Banner Dismissed", source = "AI Onboarding");
callback(window, cx)
}
}),
),
)
}
}

View file

@ -1,163 +0,0 @@
use component::{Component, ComponentScope, single_example};
use gpui::{
AnyElement, App, ClickEvent, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Window,
};
use theme::ActiveTheme;
use ui::{
Button, ButtonCommon, ButtonStyle, Checkbox, Clickable, Color, Label, LabelCommon,
RegisterComponent, ToggleState, h_flex, v_flex,
};
/// A component that displays an upsell message with a call-to-action button
///
/// # Example
/// ```
/// let upsell = Upsell::new(
/// "Upgrade to Zed Pro",
/// "Get access to advanced AI features and more",
/// "Upgrade Now",
/// Box::new(|_, _window, cx| {
/// cx.open_url("https://zed.dev/pricing");
/// }),
/// Box::new(|_, _window, cx| {
/// // Handle dismiss
/// }),
/// Box::new(|checked, window, cx| {
/// // Handle don't show again
/// }),
/// );
/// ```
#[derive(IntoElement, RegisterComponent)]
pub struct Upsell {
title: SharedString,
message: SharedString,
cta_text: SharedString,
on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
on_dismiss: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
on_dont_show_again: Box<dyn Fn(bool, &mut Window, &mut App) + 'static>,
}
impl Upsell {
/// Create a new upsell component
pub fn new(
title: impl Into<SharedString>,
message: impl Into<SharedString>,
cta_text: impl Into<SharedString>,
on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
on_dismiss: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
on_dont_show_again: Box<dyn Fn(bool, &mut Window, &mut App) + 'static>,
) -> Self {
Self {
title: title.into(),
message: message.into(),
cta_text: cta_text.into(),
on_click,
on_dismiss,
on_dont_show_again,
}
}
}
impl RenderOnce for Upsell {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
v_flex()
.w_full()
.p_4()
.gap_3()
.bg(cx.theme().colors().surface_background)
.rounded_md()
.border_1()
.border_color(cx.theme().colors().border)
.child(
v_flex()
.gap_1()
.child(
Label::new(self.title)
.size(ui::LabelSize::Large)
.weight(gpui::FontWeight::BOLD),
)
.child(Label::new(self.message).color(Color::Muted)),
)
.child(
h_flex()
.w_full()
.justify_between()
.items_center()
.child(
h_flex()
.items_center()
.gap_1()
.child(
Checkbox::new("dont-show-again", ToggleState::Unselected).on_click(
move |_, window, cx| {
(self.on_dont_show_again)(true, window, cx);
},
),
)
.child(
Label::new("Don't show again")
.color(Color::Muted)
.size(ui::LabelSize::Small),
),
)
.child(
h_flex()
.gap_2()
.child(
Button::new("dismiss-button", "No Thanks")
.style(ButtonStyle::Subtle)
.on_click(self.on_dismiss),
)
.child(
Button::new("cta-button", self.cta_text)
.style(ButtonStyle::Filled)
.on_click(self.on_click),
),
),
)
}
}
impl Component for Upsell {
fn scope() -> ComponentScope {
ComponentScope::Agent
}
fn name() -> &'static str {
"Upsell"
}
fn description() -> Option<&'static str> {
Some("A promotional component that displays a message with a call-to-action.")
}
fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let examples = vec![
single_example(
"Default",
Upsell::new(
"Upgrade to Zed Pro",
"Get unlimited access to AI features and more with Zed Pro. Unlock advanced AI capabilities and other premium features.",
"Upgrade Now",
Box::new(|_, _, _| {}),
Box::new(|_, _, _| {}),
Box::new(|_, _, _| {}),
).render(window, cx).into_any_element(),
),
single_example(
"Short Message",
Upsell::new(
"Try Zed Pro for free",
"Start your 7-day trial today.",
"Start Trial",
Box::new(|_, _, _| {}),
Box::new(|_, _, _| {}),
Box::new(|_, _, _| {}),
).render(window, cx).into_any_element(),
),
];
Some(v_flex().gap_4().children(examples).into_any_element())
}
}

View file

@ -61,6 +61,11 @@ impl Render for AgentPanelOnboarding {
Some(proto::Plan::ZedProTrial)
);
let is_pro_user = matches!(
self.user_store.read(cx).current_plan(),
Some(proto::Plan::ZedPro)
);
AgentPanelOnboardingCard::new()
.child(
ZedAiOnboarding::new(
@ -75,7 +80,7 @@ impl Render for AgentPanelOnboarding {
}),
)
.map(|this| {
if enrolled_in_trial || self.configured_providers.len() >= 1 {
if enrolled_in_trial || is_pro_user || self.configured_providers.len() >= 1 {
this
} else {
this.child(ApiKeysWithoutProviders::new())

View file

@ -16,6 +16,7 @@ use client::{Client, UserStore, zed_urls};
use gpui::{AnyElement, Entity, IntoElement, ParentElement, SharedString};
use ui::{Divider, List, ListItem, RegisterComponent, TintColor, Tooltip, prelude::*};
#[derive(IntoElement)]
pub struct BulletItem {
label: SharedString,
}
@ -28,18 +29,27 @@ impl BulletItem {
}
}
impl IntoElement for BulletItem {
type Element = AnyElement;
impl RenderOnce for BulletItem {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let line_height = 0.85 * window.line_height();
fn into_element(self) -> Self::Element {
ListItem::new("list-item")
.selectable(false)
.start_slot(
Icon::new(IconName::Dash)
.size(IconSize::XSmall)
.color(Color::Hidden),
.child(
h_flex()
.w_full()
.min_w_0()
.gap_1()
.items_start()
.child(
h_flex().h(line_height).justify_center().child(
Icon::new(IconName::Dash)
.size(IconSize::XSmall)
.color(Color::Hidden),
),
)
.child(div().w_full().min_w_0().child(Label::new(self.label))),
)
.child(div().w_full().child(Label::new(self.label)))
.into_any_element()
}
}
@ -237,7 +247,7 @@ impl ZedAiOnboarding {
.icon_color(Color::Muted)
.icon_size(IconSize::XSmall)
.on_click(move |_, _window, cx| {
telemetry::event!("Review Terms of Service Click");
telemetry::event!("Review Terms of Service Clicked");
cx.open_url(&zed_urls::terms_of_service(cx))
}),
)
@ -248,7 +258,7 @@ impl ZedAiOnboarding {
.on_click({
let callback = self.accept_terms_of_service.clone();
move |_, window, cx| {
telemetry::event!("Accepted Terms of Service");
telemetry::event!("Terms of Service Accepted");
(callback)(window, cx)}
}),
)
@ -373,7 +383,9 @@ impl ZedAiOnboarding {
.child(
List::new()
.child(BulletItem::new("500 prompts with Claude models"))
.child(BulletItem::new("Unlimited edit predictions")),
.child(BulletItem::new(
"Unlimited edit predictions with Zeta, our open-source model",
)),
)
.child(
Button::new("pro", "Continue with Zed Pro")

View file

@ -767,6 +767,11 @@ impl ContextStore {
fn reload(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
let fs = self.fs.clone();
cx.spawn(async move |this, cx| {
pub static ZED_STATELESS: LazyLock<bool> =
LazyLock::new(|| std::env::var("ZED_STATELESS").map_or(false, |v| !v.is_empty()));
if *ZED_STATELESS {
return Ok(());
}
fs.create_dir(contexts_dir()).await?;
let mut paths = fs.read_dir(contexts_dir()).await?;

View file

@ -216,7 +216,12 @@ pub trait Tool: 'static + Send + Sync {
/// Returns true if the tool needs the users's confirmation
/// before having permission to run.
fn needs_confirmation(&self, input: &serde_json::Value, cx: &App) -> bool;
fn needs_confirmation(
&self,
input: &serde_json::Value,
project: &Entity<Project>,
cx: &App,
) -> bool;
/// Returns true if the tool may perform edits.
fn may_perform_edits(&self) -> bool;

View file

@ -375,7 +375,12 @@ mod tests {
false
}
fn needs_confirmation(&self, _input: &serde_json::Value, _cx: &App) -> bool {
fn needs_confirmation(
&self,
_input: &serde_json::Value,
_project: &Entity<Project>,
_cx: &App,
) -> bool {
true
}

View file

@ -44,7 +44,7 @@ impl Tool for CopyPathTool {
"copy_path".into()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}

View file

@ -37,7 +37,7 @@ impl Tool for CreateDirectoryTool {
include_str!("./create_directory_tool/description.md").into()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}

View file

@ -33,7 +33,7 @@ impl Tool for DeletePathTool {
"delete_path".into()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}

View file

@ -46,7 +46,7 @@ impl Tool for DiagnosticsTool {
"diagnostics".into()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}

View file

@ -25,6 +25,7 @@ use language::{
};
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use paths;
use project::{
Project, ProjectPath,
lsp_store::{FormatTrigger, LspFormatTarget},
@ -126,8 +127,47 @@ impl Tool for EditFileTool {
"edit_file".into()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
false
fn needs_confirmation(
&self,
input: &serde_json::Value,
project: &Entity<Project>,
cx: &App,
) -> bool {
if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
return false;
}
let Ok(input) = serde_json::from_value::<EditFileToolInput>(input.clone()) else {
// If it's not valid JSON, it's going to error and confirming won't do anything.
return false;
};
// If any path component matches the local settings folder, then this could affect
// the editor in ways beyond the project source, so prompt.
let local_settings_folder = paths::local_settings_folder_relative_path();
let path = Path::new(&input.path);
if path
.components()
.any(|component| component.as_os_str() == local_settings_folder.as_os_str())
{
return true;
}
// It's also possible that the global config dir is configured to be inside the project,
// so check for that edge case too.
if let Ok(canonical_path) = std::fs::canonicalize(&input.path) {
if canonical_path.starts_with(paths::config_dir()) {
return true;
}
}
// Check if path is inside the global config directory
// First check if it's already inside project - if not, try to canonicalize
let project_path = project.read(cx).find_project_path(&input.path, cx);
// If the path is inside the project, and it's not one of the above edge cases,
// then no confirmation is necessary. Otherwise, confirmation is necessary.
project_path.is_none()
}
fn may_perform_edits(&self) -> bool {
@ -148,7 +188,25 @@ impl Tool for EditFileTool {
fn ui_text(&self, input: &serde_json::Value) -> String {
match serde_json::from_value::<EditFileToolInput>(input.clone()) {
Ok(input) => input.display_description,
Ok(input) => {
let path = Path::new(&input.path);
let mut description = input.display_description.clone();
// Add context about why confirmation may be needed
let local_settings_folder = paths::local_settings_folder_relative_path();
if path
.components()
.any(|c| c.as_os_str() == local_settings_folder.as_os_str())
{
description.push_str(" (local settings)");
} else if let Ok(canonical_path) = std::fs::canonicalize(&input.path) {
if canonical_path.starts_with(paths::config_dir()) {
description.push_str(" (global settings)");
}
}
description
}
Err(_) => "Editing file".to_string(),
}
}
@ -1175,19 +1233,20 @@ async fn build_buffer_diff(
#[cfg(test)]
mod tests {
use super::*;
use ::fs::Fs;
use client::TelemetrySettings;
use fs::{FakeFs, Fs};
use gpui::{TestAppContext, UpdateGlobal};
use language_model::fake_provider::FakeLanguageModel;
use serde_json::json;
use settings::SettingsStore;
use std::fs;
use util::path;
#[gpui::test]
async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let fs = project::FakeFs::new(cx.executor());
fs.insert_tree("/root", json!({})).await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
@ -1277,7 +1336,7 @@ mod tests {
) -> anyhow::Result<ProjectPath> {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let fs = project::FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
@ -1384,6 +1443,21 @@ mod tests {
cx.set_global(settings_store);
language::init(cx);
TelemetrySettings::register(cx);
agent_settings::AgentSettings::register(cx);
Project::init_settings(cx);
});
}
fn init_test_with_config(cx: &mut TestAppContext, data_dir: &Path) {
cx.update(|cx| {
// Set custom data directory (config will be under data_dir/config)
paths::set_custom_data_dir(data_dir.to_str().unwrap());
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
language::init(cx);
TelemetrySettings::register(cx);
agent_settings::AgentSettings::register(cx);
Project::init_settings(cx);
});
}
@ -1392,7 +1466,7 @@ mod tests {
async fn test_format_on_save(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let fs = project::FakeFs::new(cx.executor());
fs.insert_tree("/root", json!({"src": {}})).await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
@ -1591,7 +1665,7 @@ mod tests {
async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let fs = project::FakeFs::new(cx.executor());
fs.insert_tree("/root", json!({"src": {}})).await;
// Create a simple file with trailing whitespace
@ -1723,4 +1797,641 @@ mod tests {
"Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
);
}
#[gpui::test]
async fn test_needs_confirmation(cx: &mut TestAppContext) {
init_test(cx);
let tool = Arc::new(EditFileTool);
let fs = project::FakeFs::new(cx.executor());
fs.insert_tree("/root", json!({})).await;
// Test 1: Path with .zed component should require confirmation
let input_with_zed = json!({
"display_description": "Edit settings",
"path": ".zed/settings.json",
"mode": "edit"
});
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
cx.update(|cx| {
assert!(
tool.needs_confirmation(&input_with_zed, &project, cx),
"Path with .zed component should require confirmation"
);
});
// Test 2: Absolute path should require confirmation
let input_absolute = json!({
"display_description": "Edit file",
"path": "/etc/hosts",
"mode": "edit"
});
cx.update(|cx| {
assert!(
tool.needs_confirmation(&input_absolute, &project, cx),
"Absolute path should require confirmation"
);
});
// Test 3: Relative path without .zed should not require confirmation
let input_relative = json!({
"display_description": "Edit file",
"path": "root/src/main.rs",
"mode": "edit"
});
cx.update(|cx| {
assert!(
!tool.needs_confirmation(&input_relative, &project, cx),
"Relative path without .zed should not require confirmation"
);
});
// Test 4: Path with .zed in the middle should require confirmation
let input_zed_middle = json!({
"display_description": "Edit settings",
"path": "root/.zed/tasks.json",
"mode": "edit"
});
cx.update(|cx| {
assert!(
tool.needs_confirmation(&input_zed_middle, &project, cx),
"Path with .zed in any component should require confirmation"
);
});
// Test 5: When always_allow_tool_actions is enabled, no confirmation needed
cx.update(|cx| {
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
settings.always_allow_tool_actions = true;
agent_settings::AgentSettings::override_global(settings, cx);
assert!(
!tool.needs_confirmation(&input_with_zed, &project, cx),
"When always_allow_tool_actions is true, no confirmation should be needed"
);
assert!(
!tool.needs_confirmation(&input_absolute, &project, cx),
"When always_allow_tool_actions is true, no confirmation should be needed for absolute paths"
);
});
}
#[gpui::test]
async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) {
// Set up a custom config directory for testing
let temp_dir = tempfile::tempdir().unwrap();
init_test_with_config(cx, temp_dir.path());
let tool = Arc::new(EditFileTool);
// Test ui_text shows context for various paths
let test_cases = vec![
(
json!({
"display_description": "Update config",
"path": ".zed/settings.json",
"mode": "edit"
}),
"Update config (local settings)",
".zed path should show local settings context",
),
(
json!({
"display_description": "Fix bug",
"path": "src/.zed/local.json",
"mode": "edit"
}),
"Fix bug (local settings)",
"Nested .zed path should show local settings context",
),
(
json!({
"display_description": "Update readme",
"path": "README.md",
"mode": "edit"
}),
"Update readme",
"Normal path should not show additional context",
),
(
json!({
"display_description": "Edit config",
"path": "config.zed",
"mode": "edit"
}),
"Edit config",
".zed as extension should not show context",
),
];
for (input, expected_text, description) in test_cases {
cx.update(|_cx| {
let ui_text = tool.ui_text(&input);
assert_eq!(ui_text, expected_text, "Failed for case: {}", description);
});
}
}
#[gpui::test]
async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) {
init_test(cx);
let tool = Arc::new(EditFileTool);
let fs = project::FakeFs::new(cx.executor());
// Create a project in /project directory
fs.insert_tree("/project", json!({})).await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
// Test file outside project requires confirmation
let input_outside = json!({
"display_description": "Edit file",
"path": "/outside/file.txt",
"mode": "edit"
});
cx.update(|cx| {
assert!(
tool.needs_confirmation(&input_outside, &project, cx),
"File outside project should require confirmation"
);
});
// Test file inside project doesn't require confirmation
let input_inside = json!({
"display_description": "Edit file",
"path": "project/file.txt",
"mode": "edit"
});
cx.update(|cx| {
assert!(
!tool.needs_confirmation(&input_inside, &project, cx),
"File inside project should not require confirmation"
);
});
}
#[gpui::test]
async fn test_needs_confirmation_config_paths(cx: &mut TestAppContext) {
// Set up a custom data directory for testing
let temp_dir = tempfile::tempdir().unwrap();
init_test_with_config(cx, temp_dir.path());
let tool = Arc::new(EditFileTool);
let fs = project::FakeFs::new(cx.executor());
fs.insert_tree("/home/user/myproject", json!({})).await;
let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await;
// Get the actual local settings folder name
let local_settings_folder = paths::local_settings_folder_relative_path();
// Test various config path patterns
let test_cases = vec![
(
format!("{}/settings.json", local_settings_folder.display()),
true,
"Top-level local settings file".to_string(),
),
(
format!(
"myproject/{}/settings.json",
local_settings_folder.display()
),
true,
"Local settings in project path".to_string(),
),
(
format!("src/{}/config.toml", local_settings_folder.display()),
true,
"Local settings in subdirectory".to_string(),
),
(
".zed.backup/file.txt".to_string(),
true,
".zed.backup is outside project".to_string(),
),
(
"my.zed/file.txt".to_string(),
true,
"my.zed is outside project".to_string(),
),
(
"myproject/src/file.zed".to_string(),
false,
".zed as file extension".to_string(),
),
(
"myproject/normal/path/file.rs".to_string(),
false,
"Normal file without config paths".to_string(),
),
];
for (path, should_confirm, description) in test_cases {
let input = json!({
"display_description": "Edit file",
"path": path,
"mode": "edit"
});
cx.update(|cx| {
assert_eq!(
tool.needs_confirmation(&input, &project, cx),
should_confirm,
"Failed for case: {} - path: {}",
description,
path
);
});
}
}
#[gpui::test]
async fn test_needs_confirmation_global_config(cx: &mut TestAppContext) {
// Set up a custom data directory for testing
let temp_dir = tempfile::tempdir().unwrap();
init_test_with_config(cx, temp_dir.path());
let tool = Arc::new(EditFileTool);
let fs = project::FakeFs::new(cx.executor());
// Create test files in the global config directory
let global_config_dir = paths::config_dir();
fs::create_dir_all(&global_config_dir).unwrap();
let global_settings_path = global_config_dir.join("settings.json");
fs::write(&global_settings_path, "{}").unwrap();
fs.insert_tree("/project", json!({})).await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
// Test global config paths
let test_cases = vec![
(
global_settings_path.to_str().unwrap().to_string(),
true,
"Global settings file should require confirmation",
),
(
global_config_dir
.join("keymap.json")
.to_str()
.unwrap()
.to_string(),
true,
"Global keymap file should require confirmation",
),
(
"project/normal_file.rs".to_string(),
false,
"Normal project file should not require confirmation",
),
];
for (path, should_confirm, description) in test_cases {
let input = json!({
"display_description": "Edit file",
"path": path,
"mode": "edit"
});
cx.update(|cx| {
assert_eq!(
tool.needs_confirmation(&input, &project, cx),
should_confirm,
"Failed for case: {}",
description
);
});
}
}
#[gpui::test]
async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
init_test(cx);
let tool = Arc::new(EditFileTool);
let fs = project::FakeFs::new(cx.executor());
// Create multiple worktree directories
fs.insert_tree(
"/workspace/frontend",
json!({
"src": {
"main.js": "console.log('frontend');"
}
}),
)
.await;
fs.insert_tree(
"/workspace/backend",
json!({
"src": {
"main.rs": "fn main() {}"
}
}),
)
.await;
fs.insert_tree(
"/workspace/shared",
json!({
".zed": {
"settings.json": "{}"
}
}),
)
.await;
// Create project with multiple worktrees
let project = Project::test(
fs.clone(),
[
path!("/workspace/frontend").as_ref(),
path!("/workspace/backend").as_ref(),
path!("/workspace/shared").as_ref(),
],
cx,
)
.await;
// Test files in different worktrees
let test_cases = vec![
("frontend/src/main.js", false, "File in first worktree"),
("backend/src/main.rs", false, "File in second worktree"),
(
"shared/.zed/settings.json",
true,
".zed file in third worktree",
),
("/etc/hosts", true, "Absolute path outside all worktrees"),
(
"../outside/file.txt",
true,
"Relative path outside worktrees",
),
];
for (path, should_confirm, description) in test_cases {
let input = json!({
"display_description": "Edit file",
"path": path,
"mode": "edit"
});
cx.update(|cx| {
assert_eq!(
tool.needs_confirmation(&input, &project, cx),
should_confirm,
"Failed for case: {} - path: {}",
description,
path
);
});
}
}
#[gpui::test]
async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
init_test(cx);
let tool = Arc::new(EditFileTool);
let fs = project::FakeFs::new(cx.executor());
fs.insert_tree(
"/project",
json!({
".zed": {
"settings.json": "{}"
},
"src": {
".zed": {
"local.json": "{}"
}
}
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
// Test edge cases
let test_cases = vec![
// Empty path - find_project_path returns Some for empty paths
("", false, "Empty path is treated as project root"),
// Root directory
("/", true, "Root directory should be outside project"),
// Parent directory references - find_project_path resolves these
(
"project/../other",
false,
"Path with .. is resolved by find_project_path",
),
(
"project/./src/file.rs",
false,
"Path with . should work normally",
),
// Windows-style paths (if on Windows)
#[cfg(target_os = "windows")]
("C:\\Windows\\System32\\hosts", true, "Windows system path"),
#[cfg(target_os = "windows")]
("project\\src\\main.rs", false, "Windows-style project path"),
];
for (path, should_confirm, description) in test_cases {
let input = json!({
"display_description": "Edit file",
"path": path,
"mode": "edit"
});
cx.update(|cx| {
assert_eq!(
tool.needs_confirmation(&input, &project, cx),
should_confirm,
"Failed for case: {} - path: {}",
description,
path
);
});
}
}
#[gpui::test]
async fn test_ui_text_with_all_path_types(cx: &mut TestAppContext) {
init_test(cx);
let tool = Arc::new(EditFileTool);
// Test UI text for various scenarios
let test_cases = vec![
(
json!({
"display_description": "Update config",
"path": ".zed/settings.json",
"mode": "edit"
}),
"Update config (local settings)",
".zed path should show local settings context",
),
(
json!({
"display_description": "Fix bug",
"path": "src/.zed/local.json",
"mode": "edit"
}),
"Fix bug (local settings)",
"Nested .zed path should show local settings context",
),
(
json!({
"display_description": "Update readme",
"path": "README.md",
"mode": "edit"
}),
"Update readme",
"Normal path should not show additional context",
),
(
json!({
"display_description": "Edit config",
"path": "config.zed",
"mode": "edit"
}),
"Edit config",
".zed as extension should not show context",
),
];
for (input, expected_text, description) in test_cases {
cx.update(|_cx| {
let ui_text = tool.ui_text(&input);
assert_eq!(ui_text, expected_text, "Failed for case: {}", description);
});
}
}
#[gpui::test]
async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
init_test(cx);
let tool = Arc::new(EditFileTool);
let fs = project::FakeFs::new(cx.executor());
fs.insert_tree(
"/project",
json!({
"existing.txt": "content",
".zed": {
"settings.json": "{}"
}
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
// Test different EditFileMode values
let modes = vec![
EditFileMode::Edit,
EditFileMode::Create,
EditFileMode::Overwrite,
];
for mode in modes {
// Test .zed path with different modes
let input_zed = json!({
"display_description": "Edit settings",
"path": "project/.zed/settings.json",
"mode": mode
});
cx.update(|cx| {
assert!(
tool.needs_confirmation(&input_zed, &project, cx),
".zed path should require confirmation regardless of mode: {:?}",
mode
);
});
// Test outside path with different modes
let input_outside = json!({
"display_description": "Edit file",
"path": "/outside/file.txt",
"mode": mode
});
cx.update(|cx| {
assert!(
tool.needs_confirmation(&input_outside, &project, cx),
"Outside path should require confirmation regardless of mode: {:?}",
mode
);
});
// Test normal path with different modes
let input_normal = json!({
"display_description": "Edit file",
"path": "project/normal.txt",
"mode": mode
});
cx.update(|cx| {
assert!(
!tool.needs_confirmation(&input_normal, &project, cx),
"Normal path should not require confirmation regardless of mode: {:?}",
mode
);
});
}
}
#[gpui::test]
async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) {
// Set up with custom directories for deterministic testing
let temp_dir = tempfile::tempdir().unwrap();
init_test_with_config(cx, temp_dir.path());
let tool = Arc::new(EditFileTool);
let fs = project::FakeFs::new(cx.executor());
fs.insert_tree("/project", json!({})).await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
// Enable always_allow_tool_actions
cx.update(|cx| {
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
settings.always_allow_tool_actions = true;
agent_settings::AgentSettings::override_global(settings, cx);
});
// Test that all paths that normally require confirmation are bypassed
let global_settings_path = paths::config_dir().join("settings.json");
fs::create_dir_all(paths::config_dir()).unwrap();
fs::write(&global_settings_path, "{}").unwrap();
let test_cases = vec![
".zed/settings.json",
"project/.zed/config.toml",
global_settings_path.to_str().unwrap(),
"/etc/hosts",
"/absolute/path/file.txt",
"../outside/project.txt",
];
for path in test_cases {
let input = json!({
"display_description": "Edit file",
"path": path,
"mode": "edit"
});
cx.update(|cx| {
assert!(
!tool.needs_confirmation(&input, &project, cx),
"Path {} should not require confirmation when always_allow_tool_actions is true",
path
);
});
}
// Disable always_allow_tool_actions and verify confirmation is required again
cx.update(|cx| {
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
settings.always_allow_tool_actions = false;
agent_settings::AgentSettings::override_global(settings, cx);
});
// Verify .zed path requires confirmation again
let input = json!({
"display_description": "Edit file",
"path": ".zed/settings.json",
"mode": "edit"
});
cx.update(|cx| {
assert!(
tool.needs_confirmation(&input, &project, cx),
".zed path should require confirmation when always_allow_tool_actions is false"
);
});
}
}

View file

@ -116,7 +116,7 @@ impl Tool for FetchTool {
"fetch".to_string()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}

View file

@ -55,7 +55,7 @@ impl Tool for FindPathTool {
"find_path".into()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}

View file

@ -57,7 +57,7 @@ impl Tool for GrepTool {
"grep".into()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}

View file

@ -45,7 +45,7 @@ impl Tool for ListDirectoryTool {
"list_directory".into()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}

View file

@ -42,7 +42,7 @@ impl Tool for MovePathTool {
"move_path".into()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}

View file

@ -33,7 +33,7 @@ impl Tool for NowTool {
"now".into()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}

View file

@ -23,7 +23,7 @@ impl Tool for OpenTool {
"open".to_string()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
true
}
fn may_perform_edits(&self) -> bool {

View file

@ -19,7 +19,7 @@ impl Tool for ProjectNotificationsTool {
"project_notifications".to_string()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}
fn may_perform_edits(&self) -> bool {

View file

@ -54,7 +54,7 @@ impl Tool for ReadFileTool {
"read_file".into()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}

View file

@ -77,7 +77,7 @@ impl Tool for TerminalTool {
Self::NAME.to_string()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
true
}

View file

@ -24,7 +24,7 @@ impl Tool for ThinkingTool {
"thinking".to_string()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}

View file

@ -32,7 +32,7 @@ impl Tool for WebSearchTool {
"web_search".into()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
false
}

View file

@ -21,7 +21,7 @@ use futures::{
channel::oneshot, future::BoxFuture,
};
use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions};
use http_client::{AsyncBody, HttpClient, HttpClientWithUrl};
use http_client::{AsyncBody, HttpClient, HttpClientWithUrl, http};
use parking_lot::RwLock;
use postage::watch;
use proxy::connect_proxy_stream;
@ -1158,6 +1158,7 @@ impl Client {
let http = self.http.clone();
let proxy = http.proxy().cloned();
let user_agent = http.user_agent().cloned();
let credentials = credentials.clone();
let rpc_url = self.rpc_url(http, release_channel);
let system_id = self.telemetry.system_id();
@ -1209,7 +1210,7 @@ impl Client {
// We then modify the request to add our desired headers.
let request_headers = request.headers_mut();
request_headers.insert(
"Authorization",
http::header::AUTHORIZATION,
HeaderValue::from_str(&credentials.authorization_header())?,
);
request_headers.insert(
@ -1221,6 +1222,9 @@ impl Client {
"x-zed-release-channel",
HeaderValue::from_str(release_channel.map(|r| r.dev_name()).unwrap_or("unknown"))?,
);
if let Some(user_agent) = user_agent {
request_headers.insert(http::header::USER_AGENT, user_agent);
}
if let Some(system_id) = system_id {
request_headers.insert("x-zed-system-id", HeaderValue::from_str(&system_id)?);
}

View file

@ -765,12 +765,14 @@ impl UserStore {
pub fn current_plan(&self) -> Option<proto::Plan> {
#[cfg(debug_assertions)]
if let Ok(plan) = std::env::var("ZED_SIMULATE_ZED_PRO_PLAN").as_ref() {
if let Ok(plan) = std::env::var("ZED_SIMULATE_PLAN").as_ref() {
return match plan.as_str() {
"free" => Some(proto::Plan::Free),
"trial" => Some(proto::Plan::ZedProTrial),
"pro" => Some(proto::Plan::ZedPro),
_ => None,
_ => {
panic!("ZED_SIMULATE_PLAN must be one of 'free', 'trial', or 'pro'");
}
};
}

View file

@ -144,6 +144,7 @@ impl Client {
pub fn stdio(
server_id: ContextServerId,
binary: ModelContextServerBinary,
working_directory: &Option<PathBuf>,
cx: AsyncApp,
) -> Result<Self> {
log::info!(
@ -158,7 +159,7 @@ impl Client {
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(String::new);
let transport = Arc::new(StdioTransport::new(binary, &cx)?);
let transport = Arc::new(StdioTransport::new(binary, working_directory, &cx)?);
Self::new(server_id, server_name.into(), transport, cx)
}

View file

@ -53,7 +53,7 @@ impl std::fmt::Debug for ContextServerCommand {
}
enum ContextServerTransport {
Stdio(ContextServerCommand),
Stdio(ContextServerCommand, Option<PathBuf>),
Custom(Arc<dyn crate::transport::Transport>),
}
@ -64,11 +64,18 @@ pub struct ContextServer {
}
impl ContextServer {
pub fn stdio(id: ContextServerId, command: ContextServerCommand) -> Self {
pub fn stdio(
id: ContextServerId,
command: ContextServerCommand,
working_directory: Option<Arc<Path>>,
) -> Self {
Self {
id,
client: RwLock::new(None),
configuration: ContextServerTransport::Stdio(command),
configuration: ContextServerTransport::Stdio(
command,
working_directory.map(|directory| directory.to_path_buf()),
),
}
}
@ -90,13 +97,14 @@ impl ContextServer {
pub async fn start(self: Arc<Self>, cx: &AsyncApp) -> Result<()> {
let client = match &self.configuration {
ContextServerTransport::Stdio(command) => Client::stdio(
ContextServerTransport::Stdio(command, working_directory) => Client::stdio(
client::ContextServerId(self.id.0.clone()),
client::ModelContextServerBinary {
executable: Path::new(&command.path).to_path_buf(),
args: command.args.clone(),
env: command.env.clone(),
},
working_directory,
cx.clone(),
)?,
ContextServerTransport::Custom(transport) => Client::new(

View file

@ -1,3 +1,4 @@
use std::path::PathBuf;
use std::pin::Pin;
use anyhow::{Context as _, Result};
@ -22,7 +23,11 @@ pub struct StdioTransport {
}
impl StdioTransport {
pub fn new(binary: ModelContextServerBinary, cx: &AsyncApp) -> Result<Self> {
pub fn new(
binary: ModelContextServerBinary,
working_directory: &Option<PathBuf>,
cx: &AsyncApp,
) -> Result<Self> {
let mut command = util::command::new_smol_command(&binary.executable);
command
.args(&binary.args)
@ -32,6 +37,10 @@ impl StdioTransport {
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
if let Some(working_directory) = working_directory {
command.current_dir(working_directory);
}
let mut server = command.spawn().with_context(|| {
format!(
"failed to spawn command. (path={:?}, args={:?})",

View file

@ -1774,7 +1774,7 @@ impl Editor {
) -> Self {
debug_assert!(
display_map.is_none() || mode.is_minimap(),
"Providing a display map for a new editor is only intended for the minimap and might have unindended side effects otherwise!"
"Providing a display map for a new editor is only intended for the minimap and might have unintended side effects otherwise!"
);
let full_mode = mode.is_full();
@ -8235,8 +8235,7 @@ impl Editor {
return;
};
// Try to find a closest, enclosing node using tree-sitter that has a
// task
// Try to find a closest, enclosing node using tree-sitter that has a task
let Some((buffer, buffer_row, tasks)) = self
.find_enclosing_node_task(cx)
// Or find the task that's closest in row-distance.
@ -21812,11 +21811,11 @@ impl CodeActionProvider for Entity<Project> {
cx: &mut App,
) -> Task<Result<Vec<CodeAction>>> {
self.update(cx, |project, cx| {
let code_lens = project.code_lens(buffer, range.clone(), cx);
let code_lens_actions = project.code_lens_actions(buffer, range.clone(), cx);
let code_actions = project.code_actions(buffer, range, None, cx);
cx.background_spawn(async move {
let (code_lens, code_actions) = join(code_lens, code_actions).await;
Ok(code_lens
let (code_lens_actions, code_actions) = join(code_lens_actions, code_actions).await;
Ok(code_lens_actions
.context("code lens fetch")?
.into_iter()
.chain(code_actions.context("code action fetch")?)

View file

@ -10028,8 +10028,14 @@ async fn test_autosave_with_dirty_buffers(cx: &mut TestAppContext) {
);
}
#[gpui::test]
async fn test_range_format_during_save(cx: &mut TestAppContext) {
async fn setup_range_format_test(
cx: &mut TestAppContext,
) -> (
Entity<Project>,
Entity<Editor>,
&mut gpui::VisualTestContext,
lsp::FakeLanguageServer,
) {
init_test(cx, |_| {});
let fs = FakeFs::new(cx.executor());
@ -10044,9 +10050,9 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) {
FakeLspAdapter {
capabilities: lsp::ServerCapabilities {
document_range_formatting_provider: Some(lsp::OneOf::Left(true)),
..Default::default()
..lsp::ServerCapabilities::default()
},
..Default::default()
..FakeLspAdapter::default()
},
);
@ -10061,14 +10067,22 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) {
let (editor, cx) = cx.add_window_view(|window, cx| {
build_editor_with_project(project.clone(), buffer, window, cx)
});
cx.executor().start_waiting();
let fake_server = fake_servers.next().await.unwrap();
(project, editor, cx, fake_server)
}
#[gpui::test]
async fn test_range_format_on_save_success(cx: &mut TestAppContext) {
let (project, editor, cx, fake_server) = setup_range_format_test(cx).await;
editor.update_in(cx, |editor, window, cx| {
editor.set_text("one\ntwo\nthree\n", window, cx)
});
assert!(cx.read(|cx| editor.is_dirty(cx)));
cx.executor().start_waiting();
let fake_server = fake_servers.next().await.unwrap();
let save = editor
.update_in(cx, |editor, window, cx| {
editor.save(
@ -10103,13 +10117,18 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) {
"one, two\nthree\n"
);
assert!(!cx.read(|cx| editor.is_dirty(cx)));
}
#[gpui::test]
async fn test_range_format_on_save_timeout(cx: &mut TestAppContext) {
let (project, editor, cx, fake_server) = setup_range_format_test(cx).await;
editor.update_in(cx, |editor, window, cx| {
editor.set_text("one\ntwo\nthree\n", window, cx)
});
assert!(cx.read(|cx| editor.is_dirty(cx)));
// Ensure we can still save even if formatting hangs.
// Test that save still works when formatting hangs
fake_server.set_request_handler::<lsp::request::RangeFormatting, _, _>(
move |params, _| async move {
assert_eq!(
@ -10141,8 +10160,13 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) {
"one\ntwo\nthree\n"
);
assert!(!cx.read(|cx| editor.is_dirty(cx)));
}
// For non-dirty buffer, no formatting request should be sent
#[gpui::test]
async fn test_range_format_not_called_for_clean_buffer(cx: &mut TestAppContext) {
let (project, editor, cx, fake_server) = setup_range_format_test(cx).await;
// Buffer starts clean, no formatting should be requested
let save = editor
.update_in(cx, |editor, window, cx| {
editor.save(
@ -10163,6 +10187,12 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) {
.next();
cx.executor().start_waiting();
save.await;
cx.run_until_parked();
}
#[gpui::test]
async fn test_range_format_respects_language_tab_size_override(cx: &mut TestAppContext) {
let (project, editor, cx, fake_server) = setup_range_format_test(cx).await;
// Set Rust language override and assert overridden tabsize is sent to language server
update_test_language_settings(cx, |settings| {
@ -10176,7 +10206,7 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) {
});
editor.update_in(cx, |editor, window, cx| {
editor.set_text("somehting_new\n", window, cx)
editor.set_text("something_new\n", window, cx)
});
assert!(cx.read(|cx| editor.is_dirty(cx)));
let save = editor
@ -16837,7 +16867,7 @@ async fn test_multibuffer_reverts(cx: &mut TestAppContext) {
}
#[gpui::test]
async fn test_mutlibuffer_in_navigation_history(cx: &mut TestAppContext) {
async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let cols = 4;
@ -21266,16 +21296,32 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex
},
);
let (buffer, _handle) = project
.update(cx, |p, cx| {
p.open_local_buffer_with_lsp(path!("/dir/a.ts"), cx)
let editor = workspace
.update(cx, |workspace, window, cx| {
workspace.open_abs_path(
PathBuf::from(path!("/dir/a.ts")),
OpenOptions::default(),
window,
cx,
)
})
.unwrap()
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
cx.executor().run_until_parked();
let fake_server = fake_language_servers.next().await.unwrap();
let buffer = editor.update(cx, |editor, cx| {
editor
.buffer()
.read(cx)
.as_singleton()
.expect("have opened a single file by path")
});
let buffer_snapshot = buffer.update(cx, |buffer, _| buffer.snapshot());
let anchor = buffer_snapshot.anchor_at(0, text::Bias::Left);
drop(buffer_snapshot);
@ -21333,7 +21379,7 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex
assert_eq!(
actions.len(),
1,
"Should have only one valid action for the 0..0 range"
"Should have only one valid action for the 0..0 range, got: {actions:#?}"
);
let action = actions[0].clone();
let apply = project.update(cx, |project, cx| {
@ -21379,7 +21425,7 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex
.into_iter()
.collect(),
),
..Default::default()
..lsp::WorkspaceEdit::default()
},
},
)
@ -21402,6 +21448,38 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex
buffer.undo(cx);
assert_eq!(buffer.text(), "a");
});
let actions_after_edits = cx
.update_window(*workspace, |_, window, cx| {
project.code_actions(&buffer, anchor..anchor, window, cx)
})
.unwrap()
.await
.unwrap();
assert_eq!(
actions, actions_after_edits,
"For the same selection, same code lens actions should be returned"
);
let _responses =
fake_server.set_request_handler::<lsp::request::CodeLensRequest, _, _>(|_, _| async move {
panic!("No more code lens requests are expected");
});
editor.update_in(cx, |editor, window, cx| {
editor.select_all(&SelectAll, window, cx);
});
cx.executor().run_until_parked();
let new_actions = cx
.update_window(*workspace, |_, window, cx| {
project.code_actions(&buffer, anchor..anchor, window, cx)
})
.unwrap()
.await
.unwrap();
assert_eq!(
actions, new_actions,
"Code lens are queried for the same range and should get the same set back, but without additional LSP queries now"
);
}
#[gpui::test]

View file

@ -6,7 +6,7 @@ use gpui::{Hsla, Rgba};
use itertools::Itertools;
use language::point_from_lsp;
use multi_buffer::Anchor;
use project::{DocumentColor, lsp_store::ColorFetchStrategy};
use project::{DocumentColor, lsp_store::LspFetchStrategy};
use settings::Settings as _;
use text::{Bias, BufferId, OffsetRangeExt as _};
use ui::{App, Context, Window};
@ -180,9 +180,9 @@ impl Editor {
.filter_map(|buffer| {
let buffer_id = buffer.read(cx).remote_id();
let fetch_strategy = if ignore_cache {
ColorFetchStrategy::IgnoreCache
LspFetchStrategy::IgnoreCache
} else {
ColorFetchStrategy::UseCache {
LspFetchStrategy::UseCache {
known_cache_version: self.colors.as_ref().and_then(|colors| {
Some(colors.buffer_colors.get(&buffer_id)?.cache_version_used)
}),

View file

@ -102,7 +102,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn language_server_initialization_options(
@ -127,7 +127,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn language_server_workspace_configuration(
@ -150,7 +150,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn language_server_additional_initialization_options(
@ -175,7 +175,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn language_server_additional_workspace_configuration(
@ -200,7 +200,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn labels_for_completions(
@ -226,7 +226,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn labels_for_symbols(
@ -252,7 +252,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn complete_slash_command_argument(
@ -271,7 +271,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn run_slash_command(
@ -297,7 +297,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn context_server_command(
@ -316,7 +316,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn context_server_configuration(
@ -343,7 +343,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn suggest_docs_packages(&self, provider: Arc<str>) -> Result<Vec<String>> {
@ -358,7 +358,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn index_docs(
@ -384,7 +384,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn get_dap_binary(
@ -406,7 +406,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn dap_request_kind(
&self,
@ -423,7 +423,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn dap_config_to_scenario(&self, config: ZedDebugConfig) -> Result<DebugScenario> {
@ -437,7 +437,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn dap_locator_create_scenario(
@ -461,7 +461,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
async fn run_dap_locator(
&self,
@ -477,7 +477,7 @@ impl extension::Extension for WasmExtension {
}
.boxed()
})
.await
.await?
}
}
@ -739,7 +739,7 @@ impl WasmExtension {
.with_context(|| format!("failed to load wasm extension {}", manifest.id))
}
pub async fn call<T, Fn>(&self, f: Fn) -> T
pub async fn call<T, Fn>(&self, f: Fn) -> Result<T>
where
T: 'static + Send,
Fn: 'static
@ -755,8 +755,19 @@ impl WasmExtension {
}
.boxed()
}))
.expect("wasm extension channel should not be closed yet");
return_rx.await.expect("wasm extension channel")
.map_err(|_| {
anyhow!(
"wasm extension channel should not be closed yet, extension {} (id {})",
self.manifest.name,
self.manifest.id,
)
})?;
return_rx.await.with_context(|| {
format!(
"wasm extension channel, extension {} (id {})",
self.manifest.name, self.manifest.id,
)
})
}
}
@ -777,8 +788,19 @@ impl WasmState {
}
.boxed_local()
}))
.expect("main thread message channel should not be closed yet");
async move { return_rx.await.expect("main thread message channel") }
.unwrap_or_else(|_| {
panic!(
"main thread message channel should not be closed yet, extension {} (id {})",
self.manifest.name, self.manifest.id,
)
});
let name = self.manifest.name.clone();
let id = self.manifest.id.clone();
async move {
return_rx.await.unwrap_or_else(|_| {
panic!("main thread message channel, extension {name} (id {id})")
})
}
}
fn work_dir(&self) -> PathBuf {

View file

@ -295,11 +295,13 @@ impl CommitModal {
IconPosition::Start,
Some(Box::new(Amend)),
{
let git_panel = git_panel_entity.clone();
move |window, cx| {
git_panel.update(cx, |git_panel, cx| {
git_panel.toggle_amend_pending(&Amend, window, cx);
})
let git_panel = git_panel_entity.downgrade();
move |_, cx| {
git_panel
.update(cx, |git_panel, cx| {
git_panel.toggle_amend_pending(cx);
})
.ok();
}
},
)

View file

@ -3054,6 +3054,7 @@ impl GitPanel {
),
)
.menu({
let git_panel = cx.entity();
let has_previous_commit = self.head_commit(cx).is_some();
let amend = self.amend_pending();
let signoff = self.signoff_enabled;
@ -3070,7 +3071,16 @@ impl GitPanel {
amend,
IconPosition::Start,
Some(Box::new(Amend)),
move |window, cx| window.dispatch_action(Box::new(Amend), cx),
{
let git_panel = git_panel.downgrade();
move |_, cx| {
git_panel
.update(cx, |git_panel, cx| {
git_panel.toggle_amend_pending(cx);
})
.ok();
}
},
)
})
.toggleable_entry(
@ -3441,9 +3451,11 @@ impl GitPanel {
.truncate(),
),
)
.child(panel_button("Cancel").size(ButtonSize::Default).on_click(
cx.listener(|this, _, window, cx| this.toggle_amend_pending(&Amend, window, cx)),
))
.child(
panel_button("Cancel")
.size(ButtonSize::Default)
.on_click(cx.listener(|this, _, _, cx| this.set_amend_pending(false, cx))),
)
}
fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
@ -4204,17 +4216,8 @@ impl GitPanel {
pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context<Self>) {
self.amend_pending = value;
cx.notify();
}
pub fn toggle_amend_pending(
&mut self,
_: &Amend,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.set_amend_pending(!self.amend_pending, cx);
self.serialize(cx);
cx.notify();
}
pub fn signoff_enabled(&self) -> bool {
@ -4308,6 +4311,13 @@ impl GitPanel {
anchor: path,
});
}
pub(crate) fn toggle_amend_pending(&mut self, cx: &mut Context<Self>) {
self.set_amend_pending(!self.amend_pending, cx);
if self.amend_pending {
self.load_last_commit_message_if_empty(cx);
}
}
}
fn current_language_model(cx: &Context<'_, GitPanel>) -> Option<Arc<dyn LanguageModel>> {
@ -4352,7 +4362,6 @@ impl Render for GitPanel {
.on_action(cx.listener(Self::stage_range))
.on_action(cx.listener(GitPanel::commit))
.on_action(cx.listener(GitPanel::amend))
.on_action(cx.listener(GitPanel::toggle_amend_pending))
.on_action(cx.listener(GitPanel::toggle_signoff_enabled))
.on_action(cx.listener(Self::stage_all))
.on_action(cx.listener(Self::unstage_all))

View file

@ -12,6 +12,7 @@ use language::{self, Buffer, Point};
use project::Project;
use std::{
any::{Any, TypeId},
cmp,
ops::Range,
pin::pin,
sync::Arc,
@ -45,38 +46,60 @@ impl TextDiffView {
) -> Option<Task<Result<Entity<Self>>>> {
let source_editor = diff_data.editor.clone();
let source_editor_buffer_and_range = source_editor.update(cx, |editor, cx| {
let selection_data = source_editor.update(cx, |editor, cx| {
let multibuffer = editor.buffer().read(cx);
let source_buffer = multibuffer.as_singleton()?.clone();
let selections = editor.selections.all::<Point>(cx);
let buffer_snapshot = source_buffer.read(cx);
let first_selection = selections.first()?;
let selection_range = if first_selection.is_empty() {
Point::new(0, 0)..buffer_snapshot.max_point()
} else {
first_selection.start..first_selection.end
};
let max_point = buffer_snapshot.max_point();
Some((source_buffer, selection_range))
if first_selection.is_empty() {
let full_range = Point::new(0, 0)..max_point;
return Some((source_buffer, full_range));
}
let start = first_selection.start;
let end = first_selection.end;
let expanded_start = Point::new(start.row, 0);
let expanded_end = if end.column > 0 {
let next_row = end.row + 1;
cmp::min(max_point, Point::new(next_row, 0))
} else {
end
};
Some((source_buffer, expanded_start..expanded_end))
});
let Some((source_buffer, selected_range)) = source_editor_buffer_and_range else {
let Some((source_buffer, expanded_selection_range)) = selection_data else {
log::warn!("There should always be at least one selection in Zed. This is a bug.");
return None;
};
let clipboard_text = diff_data.clipboard_text.clone();
let workspace = workspace.weak_handle();
let diff_buffer = cx.new(|cx| {
let source_buffer_snapshot = source_buffer.read(cx).snapshot();
let diff = BufferDiff::new(&source_buffer_snapshot.text, cx);
diff
source_editor.update(cx, |source_editor, cx| {
source_editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges(vec![
expanded_selection_range.start..expanded_selection_range.end,
]);
})
});
let clipboard_buffer =
build_clipboard_buffer(clipboard_text, &source_buffer, selected_range.clone(), cx);
let source_buffer_snapshot = source_buffer.read(cx).snapshot();
let mut clipboard_text = diff_data.clipboard_text.clone();
if !clipboard_text.ends_with("\n") {
clipboard_text.push_str("\n");
}
let workspace = workspace.weak_handle();
let diff_buffer = cx.new(|cx| BufferDiff::new(&source_buffer_snapshot.text, cx));
let clipboard_buffer = build_clipboard_buffer(
clipboard_text,
&source_buffer,
expanded_selection_range.clone(),
cx,
);
let task = window.spawn(cx, async move |cx| {
let project = workspace.update(cx, |workspace, _| workspace.project().clone())?;
@ -89,7 +112,7 @@ impl TextDiffView {
clipboard_buffer,
source_editor,
source_buffer,
selected_range,
expanded_selection_range,
diff_buffer,
project,
window,
@ -208,9 +231,9 @@ impl TextDiffView {
}
fn build_clipboard_buffer(
clipboard_text: String,
text: String,
source_buffer: &Entity<Buffer>,
selected_range: Range<Point>,
replacement_range: Range<Point>,
cx: &mut App,
) -> Entity<Buffer> {
let source_buffer_snapshot = source_buffer.read(cx).snapshot();
@ -219,9 +242,9 @@ fn build_clipboard_buffer(
let language = source_buffer.read(cx).language().cloned();
buffer.set_language(language, cx);
let range_start = source_buffer_snapshot.point_to_offset(selected_range.start);
let range_end = source_buffer_snapshot.point_to_offset(selected_range.end);
buffer.edit([(range_start..range_end, clipboard_text)], None, cx);
let range_start = source_buffer_snapshot.point_to_offset(replacement_range.start);
let range_end = source_buffer_snapshot.point_to_offset(replacement_range.end);
buffer.edit([(range_start..range_end, text)], None, cx);
buffer
})
@ -293,7 +316,7 @@ impl Item for TextDiffView {
}
fn telemetry_event_text(&self) -> Option<&'static str> {
Some("Diff View Opened")
Some("Selection Diff View Opened")
}
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@ -395,21 +418,13 @@ pub fn selection_location_text(editor: &Editor, cx: &App) -> Option<String> {
let buffer_snapshot = buffer.snapshot(cx);
let first_selection = editor.selections.disjoint.first()?;
let (start_row, start_column, end_row, end_column) =
if first_selection.start == first_selection.end {
let max_point = buffer_snapshot.max_point();
(0, 0, max_point.row, max_point.column)
} else {
let selection_start = first_selection.start.to_point(&buffer_snapshot);
let selection_end = first_selection.end.to_point(&buffer_snapshot);
let selection_start = first_selection.start.to_point(&buffer_snapshot);
let selection_end = first_selection.end.to_point(&buffer_snapshot);
(
selection_start.row,
selection_start.column,
selection_end.row,
selection_end.column,
)
};
let start_row = selection_start.row;
let start_column = selection_start.column;
let end_row = selection_end.row;
let end_column = selection_end.column;
let range_text = if start_row == end_row {
format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1)
@ -435,14 +450,13 @@ impl Render for TextDiffView {
#[cfg(test)]
mod tests {
use super::*;
use editor::{actions, test::editor_test_context::assert_state_with_diff};
use editor::test::editor_test_context::assert_state_with_diff;
use gpui::{TestAppContext, VisualContext};
use project::{FakeFs, Project};
use serde_json::json;
use settings::{Settings, SettingsStore};
use unindent::unindent;
use util::path;
use util::{path, test::marked_text_ranges};
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
@ -457,52 +471,236 @@ mod tests {
}
#[gpui::test]
async fn test_diffing_clipboard_against_specific_selection(cx: &mut TestAppContext) {
base_test(true, cx).await;
async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer_selection(
cx: &mut TestAppContext,
) {
base_test(
path!("/test"),
path!("/test/text.txt"),
"def process_incoming_inventory(items, warehouse_id):\n pass\n",
"def process_outgoing_inventory(items, warehouse_id):\n passˇ\n",
&unindent(
"
- def process_incoming_inventory(items, warehouse_id):
+ ˇdef process_outgoing_inventory(items, warehouse_id):
pass
",
),
"Clipboard ↔ text.txt @ L1:1-L3:1",
&format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer(
async fn test_diffing_clipboard_against_multiline_selection_expands_to_full_lines(
cx: &mut TestAppContext,
) {
base_test(false, cx).await;
base_test(
path!("/test"),
path!("/test/text.txt"),
"def process_incoming_inventory(items, warehouse_id):\n pass\n",
"«def process_outgoing_inventory(items, warehouse_id):\n passˇ»\n",
&unindent(
"
- def process_incoming_inventory(items, warehouse_id):
+ ˇdef process_outgoing_inventory(items, warehouse_id):
pass
",
),
"Clipboard ↔ text.txt @ L1:1-L3:1",
&format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
cx,
)
.await;
}
async fn base_test(select_all_text: bool, cx: &mut TestAppContext) {
#[gpui::test]
async fn test_diffing_clipboard_against_single_line_selection(cx: &mut TestAppContext) {
base_test(
path!("/test"),
path!("/test/text.txt"),
"a",
"«bbˇ»",
&unindent(
"
- a
+ ˇbb",
),
"Clipboard ↔ text.txt @ L1:1-3",
&format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_with_leading_whitespace_against_line(cx: &mut TestAppContext) {
base_test(
path!("/test"),
path!("/test/text.txt"),
" a",
"«bbˇ»",
&unindent(
"
- a
+ ˇbb",
),
"Clipboard ↔ text.txt @ L1:1-3",
&format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_against_line_with_leading_whitespace(cx: &mut TestAppContext) {
base_test(
path!("/test"),
path!("/test/text.txt"),
"a",
" «bbˇ»",
&unindent(
"
- a
+ ˇ bb",
),
"Clipboard ↔ text.txt @ L1:1-7",
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_against_line_with_leading_whitespace_included_in_selection(
cx: &mut TestAppContext,
) {
base_test(
path!("/test"),
path!("/test/text.txt"),
"a",
"« bbˇ»",
&unindent(
"
- a
+ ˇ bb",
),
"Clipboard ↔ text.txt @ L1:1-7",
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace(
cx: &mut TestAppContext,
) {
base_test(
path!("/test"),
path!("/test/text.txt"),
" a",
" «bbˇ»",
&unindent(
"
- a
+ ˇ bb",
),
"Clipboard ↔ text.txt @ L1:1-7",
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace_included_in_selection(
cx: &mut TestAppContext,
) {
base_test(
path!("/test"),
path!("/test/text.txt"),
" a",
"« bbˇ»",
&unindent(
"
- a
+ ˇ bb",
),
"Clipboard ↔ text.txt @ L1:1-7",
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_against_partial_selection_expands_to_include_trailing_characters(
cx: &mut TestAppContext,
) {
base_test(
path!("/test"),
path!("/test/text.txt"),
"a",
"«bˇ»b",
&unindent(
"
- a
+ ˇbb",
),
"Clipboard ↔ text.txt @ L1:1-3",
&format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
cx,
)
.await;
}
async fn base_test(
project_root: &str,
file_path: &str,
clipboard_text: &str,
editor_text: &str,
expected_diff: &str,
expected_tab_title: &str,
expected_tab_tooltip: &str,
cx: &mut TestAppContext,
) {
init_test(cx);
let file_name = std::path::Path::new(file_path)
.file_name()
.unwrap()
.to_str()
.unwrap();
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/test"),
project_root,
json!({
"a": {
"b": {
"text.txt": "new line 1\nline 2\nnew line 3\nline 4"
}
}
file_name: editor_text
}),
)
.await;
let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
let project = Project::test(fs, [project_root.as_ref()], cx).await;
let (workspace, mut 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!("/test/a/b/text.txt"), cx)
})
.update(cx, |project, cx| project.open_local_buffer(file_path, cx))
.await
.unwrap();
let editor = cx.new_window_entity(|window, cx| {
let mut editor = Editor::for_buffer(buffer, None, window, cx);
editor.set_text("new line 1\nline 2\nnew line 3\nline 4\n", window, cx);
if select_all_text {
editor.select_all(&actions::SelectAll, window, cx);
}
let (unmarked_text, selection_ranges) = marked_text_ranges(editor_text, false);
editor.set_text(unmarked_text, window, cx);
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges(selection_ranges)
});
editor
});
@ -511,7 +709,7 @@ mod tests {
.update_in(cx, |workspace, window, cx| {
TextDiffView::open(
&DiffClipboardWithSelectionData {
clipboard_text: "old line 1\nline 2\nold line 3\nline 4\n".to_string(),
clipboard_text: clipboard_text.to_string(),
editor,
},
workspace,
@ -528,26 +726,14 @@ mod tests {
assert_state_with_diff(
&diff_view.read_with(cx, |diff_view, _| diff_view.diff_editor.clone()),
&mut cx,
&unindent(
"
- old line 1
+ ˇnew line 1
line 2
- old line 3
+ new line 3
line 4
",
),
expected_diff,
);
diff_view.read_with(cx, |diff_view, cx| {
assert_eq!(
diff_view.tab_content_text(0, cx),
"Clipboard ↔ text.txt @ L1:1-L5:1"
);
assert_eq!(diff_view.tab_content_text(0, cx), expected_tab_title);
assert_eq!(
diff_view.tab_tooltip_text(cx).unwrap(),
format!("Clipboard ↔ {}", path!("test/a/b/text.txt @ L1:1-L5:1"))
expected_tab_tooltip
);
});
}

View file

@ -2023,6 +2023,10 @@ impl HttpClient for NullHttpClient {
.boxed()
}
fn user_agent(&self) -> Option<&http_client::http::HeaderValue> {
None
}
fn proxy(&self) -> Option<&Url> {
None
}

View file

@ -88,15 +88,24 @@ pub enum ScrollStrategy {
/// May not be possible if there's not enough list items above the item scrolled to:
/// in this case, the element will be placed at the closest possible position.
Center,
/// Scrolls the element to be at the given item index from the top of the viewport.
ToPosition(usize),
}
#[derive(Clone, Copy, Debug)]
#[allow(missing_docs)]
pub struct DeferredScrollToItem {
/// The item index to scroll to
pub item_index: usize,
/// The scroll strategy to use
pub strategy: ScrollStrategy,
/// The offset in number of items
pub offset: usize,
}
#[derive(Clone, Debug, Default)]
#[allow(missing_docs)]
pub struct UniformListScrollState {
pub base_handle: ScrollHandle,
pub deferred_scroll_to_item: Option<(usize, ScrollStrategy)>,
pub deferred_scroll_to_item: Option<DeferredScrollToItem>,
/// Size of the item, captured during last layout.
pub last_item_size: Option<ItemSize>,
/// Whether the list was vertically flipped during last layout.
@ -126,7 +135,24 @@ impl UniformListScrollHandle {
/// Scroll the list to the given item index.
pub fn scroll_to_item(&self, ix: usize, strategy: ScrollStrategy) {
self.0.borrow_mut().deferred_scroll_to_item = Some((ix, strategy));
self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem {
item_index: ix,
strategy,
offset: 0,
});
}
/// Scroll the list to the given item index with an offset.
///
/// For ScrollStrategy::Top, the item will be placed at the offset position from the top.
///
/// For ScrollStrategy::Center, the item will be centered between offset and the last visible item.
pub fn scroll_to_item_with_offset(&self, ix: usize, strategy: ScrollStrategy, offset: usize) {
self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem {
item_index: ix,
strategy,
offset,
});
}
/// Check if the list is flipped vertically.
@ -139,7 +165,8 @@ impl UniformListScrollHandle {
pub fn logical_scroll_top_index(&self) -> usize {
let this = self.0.borrow();
this.deferred_scroll_to_item
.map(|(ix, _)| ix)
.as_ref()
.map(|deferred| deferred.item_index)
.unwrap_or_else(|| this.base_handle.logical_scroll_top().0)
}
@ -321,7 +348,8 @@ impl Element for UniformList {
scroll_offset.x = Pixels::ZERO;
}
if let Some((mut ix, scroll_strategy)) = shared_scroll_to_item {
if let Some(deferred_scroll) = shared_scroll_to_item {
let mut ix = deferred_scroll.item_index;
if y_flipped {
ix = self.item_count.saturating_sub(ix + 1);
}
@ -330,23 +358,28 @@ impl Element for UniformList {
let item_top = item_height * ix + padding.top;
let item_bottom = item_top + item_height;
let scroll_top = -updated_scroll_offset.y;
let offset_pixels = item_height * deferred_scroll.offset;
let mut scrolled_to_top = false;
if item_top < scroll_top + padding.top {
if item_top < scroll_top + padding.top + offset_pixels {
scrolled_to_top = true;
updated_scroll_offset.y = -(item_top) + padding.top;
updated_scroll_offset.y = -(item_top) + padding.top + offset_pixels;
} else if item_bottom > scroll_top + list_height - padding.bottom {
scrolled_to_top = true;
updated_scroll_offset.y = -(item_bottom - list_height) - padding.bottom;
}
match scroll_strategy {
match deferred_scroll.strategy {
ScrollStrategy::Top => {}
ScrollStrategy::Center => {
if scrolled_to_top {
let item_center = item_top + item_height / 2.0;
let target_scroll_top = item_center - list_height / 2.0;
if item_top < scroll_top
let viewport_height = list_height - offset_pixels;
let viewport_center = offset_pixels + viewport_height / 2.0;
let target_scroll_top = item_center - viewport_center;
if item_top < scroll_top + offset_pixels
|| item_bottom > scroll_top + list_height
{
updated_scroll_offset.y = -target_scroll_top
@ -356,15 +389,6 @@ impl Element for UniformList {
}
}
}
ScrollStrategy::ToPosition(sticky_index) => {
let target_y_in_viewport = item_height * sticky_index;
let target_scroll_top = item_top - target_y_in_viewport;
let max_scroll_top =
(content_height - list_height).max(Pixels::ZERO);
let new_scroll_top =
target_scroll_top.clamp(Pixels::ZERO, max_scroll_top);
updated_scroll_offset.y = -new_scroll_top;
}
}
scroll_offset = *updated_scroll_offset
}

View file

@ -417,17 +417,6 @@ impl Modifiers {
self.control || self.alt || self.shift || self.platform || self.function
}
/// Returns the XOR of two modifier sets
pub fn xor(&self, other: &Modifiers) -> Modifiers {
Modifiers {
control: self.control ^ other.control,
alt: self.alt ^ other.alt,
shift: self.shift ^ other.shift,
platform: self.platform ^ other.platform,
function: self.function ^ other.function,
}
}
/// Whether the semantically 'secondary' modifier key is pressed.
///
/// On macOS, this is the command key.
@ -545,11 +534,62 @@ impl Modifiers {
/// Checks if this [`Modifiers`] is a subset of another [`Modifiers`].
pub fn is_subset_of(&self, other: &Modifiers) -> bool {
(other.control || !self.control)
&& (other.alt || !self.alt)
&& (other.shift || !self.shift)
&& (other.platform || !self.platform)
&& (other.function || !self.function)
(*other & *self) == *self
}
}
impl std::ops::BitOr for Modifiers {
type Output = Self;
fn bitor(mut self, other: Self) -> Self::Output {
self |= other;
self
}
}
impl std::ops::BitOrAssign for Modifiers {
fn bitor_assign(&mut self, other: Self) {
self.control |= other.control;
self.alt |= other.alt;
self.shift |= other.shift;
self.platform |= other.platform;
self.function |= other.function;
}
}
impl std::ops::BitXor for Modifiers {
type Output = Self;
fn bitxor(mut self, rhs: Self) -> Self::Output {
self ^= rhs;
self
}
}
impl std::ops::BitXorAssign for Modifiers {
fn bitxor_assign(&mut self, other: Self) {
self.control ^= other.control;
self.alt ^= other.alt;
self.shift ^= other.shift;
self.platform ^= other.platform;
self.function ^= other.function;
}
}
impl std::ops::BitAnd for Modifiers {
type Output = Self;
fn bitand(mut self, rhs: Self) -> Self::Output {
self &= rhs;
self
}
}
impl std::ops::BitAndAssign for Modifiers {
fn bitand_assign(&mut self, other: Self) {
self.control &= other.control;
self.alt &= other.alt;
self.shift &= other.shift;
self.platform &= other.platform;
self.function &= other.function;
}
}

View file

@ -845,9 +845,15 @@ impl crate::Keystroke {
{
if key.is_ascii_graphic() {
key_utf8.to_lowercase()
// map ctrl-a to a
} else if key_utf32 <= 0x1f {
((key_utf32 as u8 + 0x60) as char).to_string()
// map ctrl-a to `a`
// ctrl-0..9 may emit control codes like ctrl-[, but
// we don't want to map them to `[`
} else if key_utf32 <= 0x1f
&& !name.chars().next().is_some_and(|c| c.is_ascii_digit())
{
((key_utf32 as u8 + 0x40) as char)
.to_ascii_lowercase()
.to_string()
} else {
name
}

View file

@ -4,6 +4,7 @@ pub mod github;
pub use anyhow::{Result, anyhow};
pub use async_body::{AsyncBody, Inner};
use derive_more::Deref;
use http::HeaderValue;
pub use http::{self, Method, Request, Response, StatusCode, Uri};
use futures::future::BoxFuture;
@ -39,6 +40,8 @@ impl HttpRequestExt for http::request::Builder {
pub trait HttpClient: 'static + Send + Sync {
fn type_name(&self) -> &'static str;
fn user_agent(&self) -> Option<&HeaderValue>;
fn send(
&self,
req: http::Request<AsyncBody>,
@ -118,6 +121,10 @@ impl HttpClient for HttpClientWithProxy {
self.client.send(req)
}
fn user_agent(&self) -> Option<&HeaderValue> {
self.client.user_agent()
}
fn proxy(&self) -> Option<&Url> {
self.proxy.as_ref()
}
@ -135,6 +142,10 @@ impl HttpClient for Arc<HttpClientWithProxy> {
self.client.send(req)
}
fn user_agent(&self) -> Option<&HeaderValue> {
self.client.user_agent()
}
fn proxy(&self) -> Option<&Url> {
self.proxy.as_ref()
}
@ -250,6 +261,10 @@ impl HttpClient for Arc<HttpClientWithUrl> {
self.client.send(req)
}
fn user_agent(&self) -> Option<&HeaderValue> {
self.client.user_agent()
}
fn proxy(&self) -> Option<&Url> {
self.client.proxy.as_ref()
}
@ -267,6 +282,10 @@ impl HttpClient for HttpClientWithUrl {
self.client.send(req)
}
fn user_agent(&self) -> Option<&HeaderValue> {
self.client.user_agent()
}
fn proxy(&self) -> Option<&Url> {
self.client.proxy.as_ref()
}
@ -314,6 +333,10 @@ impl HttpClient for BlockedHttpClient {
})
}
fn user_agent(&self) -> Option<&HeaderValue> {
None
}
fn proxy(&self) -> Option<&Url> {
None
}
@ -334,6 +357,7 @@ type FakeHttpHandler = Box<
#[cfg(feature = "test-support")]
pub struct FakeHttpClient {
handler: FakeHttpHandler,
user_agent: HeaderValue,
}
#[cfg(feature = "test-support")]
@ -348,6 +372,7 @@ impl FakeHttpClient {
client: HttpClientWithProxy {
client: Arc::new(Self {
handler: Box::new(move |req| Box::pin(handler(req))),
user_agent: HeaderValue::from_static(type_name::<Self>()),
}),
proxy: None,
},
@ -390,6 +415,10 @@ impl HttpClient for FakeHttpClient {
future
}
fn user_agent(&self) -> Option<&HeaderValue> {
Some(&self.user_agent)
}
fn proxy(&self) -> Option<&Url> {
None
}

View file

@ -1159,19 +1159,20 @@ impl RenderOnce for ZedAiConfiguration {
let manage_subscription_buttons = if is_pro {
Button::new("manage_settings", "Manage Subscription")
.full_width()
.style(ButtonStyle::Tinted(TintColor::Accent))
.on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx)))
.into_any_element()
} else if self.plan.is_none() || self.eligible_for_trial {
Button::new("start_trial", "Start 14-day Free Pro Trial")
.style(ui::ButtonStyle::Tinted(ui::TintColor::Accent))
.full_width()
.style(ui::ButtonStyle::Tinted(ui::TintColor::Accent))
.on_click(|_, _, cx| cx.open_url(&zed_urls::start_trial_url(cx)))
.into_any_element()
} else {
Button::new("upgrade", "Upgrade to Pro")
.style(ui::ButtonStyle::Tinted(ui::TintColor::Accent))
.full_width()
.style(ui::ButtonStyle::Tinted(ui::TintColor::Accent))
.on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)))
.into_any_element()
};

View file

@ -1,4 +1,4 @@
use anyhow::{Context as _, Result};
use anyhow::{Context as _, Result, anyhow};
use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream};
use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, http};
use serde::{Deserialize, Serialize};
@ -275,11 +275,16 @@ impl Capabilities {
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct LmStudioError {
pub message: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(untagged)]
pub enum ResponseStreamResult {
Ok(ResponseStreamEvent),
Err { error: String },
Err { error: LmStudioError },
}
#[derive(Serialize, Deserialize, Debug)]
@ -392,7 +397,6 @@ pub async fn stream_chat_completion(
let mut response = client.send(request).await?;
if response.status().is_success() {
let reader = BufReader::new(response.into_body());
Ok(reader
.lines()
.filter_map(|line| async move {
@ -402,18 +406,16 @@ pub async fn stream_chat_completion(
if line == "[DONE]" {
None
} else {
let result = serde_json::from_str(&line)
.context("Unable to parse chat completions response");
if let Err(ref e) = result {
eprintln!("Error parsing line: {e}\nLine content: '{line}'");
match serde_json::from_str(line) {
Ok(ResponseStreamResult::Ok(response)) => Some(Ok(response)),
Ok(ResponseStreamResult::Err { error, .. }) => {
Some(Err(anyhow!(error.message)))
}
Err(error) => Some(Err(anyhow!(error))),
}
Some(result)
}
}
Err(e) => {
eprintln!("Error reading line: {e}");
Some(Err(e.into()))
}
Err(error) => Some(Err(anyhow!(error))),
}
})
.boxed())

View file

@ -48,18 +48,29 @@ pub enum Model {
#[serde(rename = "codestral-latest", alias = "codestral-latest")]
#[default]
CodestralLatest,
#[serde(rename = "mistral-large-latest", alias = "mistral-large-latest")]
MistralLargeLatest,
#[serde(rename = "mistral-medium-latest", alias = "mistral-medium-latest")]
MistralMediumLatest,
#[serde(rename = "mistral-small-latest", alias = "mistral-small-latest")]
MistralSmallLatest,
#[serde(rename = "magistral-medium-latest", alias = "magistral-medium-latest")]
MagistralMediumLatest,
#[serde(rename = "magistral-small-latest", alias = "magistral-small-latest")]
MagistralSmallLatest,
#[serde(rename = "open-mistral-nemo", alias = "open-mistral-nemo")]
OpenMistralNemo,
#[serde(rename = "open-codestral-mamba", alias = "open-codestral-mamba")]
OpenCodestralMamba,
#[serde(rename = "devstral-medium-latest", alias = "devstral-medium-latest")]
DevstralMediumLatest,
#[serde(rename = "devstral-small-latest", alias = "devstral-small-latest")]
DevstralSmallLatest,
#[serde(rename = "pixtral-12b-latest", alias = "pixtral-12b-latest")]
Pixtral12BLatest,
#[serde(rename = "pixtral-large-latest", alias = "pixtral-large-latest")]
@ -89,8 +100,11 @@ impl Model {
"mistral-large-latest" => Ok(Self::MistralLargeLatest),
"mistral-medium-latest" => Ok(Self::MistralMediumLatest),
"mistral-small-latest" => Ok(Self::MistralSmallLatest),
"magistral-medium-latest" => Ok(Self::MagistralMediumLatest),
"magistral-small-latest" => Ok(Self::MagistralSmallLatest),
"open-mistral-nemo" => Ok(Self::OpenMistralNemo),
"open-codestral-mamba" => Ok(Self::OpenCodestralMamba),
"devstral-medium-latest" => Ok(Self::DevstralMediumLatest),
"devstral-small-latest" => Ok(Self::DevstralSmallLatest),
"pixtral-12b-latest" => Ok(Self::Pixtral12BLatest),
"pixtral-large-latest" => Ok(Self::PixtralLargeLatest),
@ -104,8 +118,11 @@ impl Model {
Self::MistralLargeLatest => "mistral-large-latest",
Self::MistralMediumLatest => "mistral-medium-latest",
Self::MistralSmallLatest => "mistral-small-latest",
Self::MagistralMediumLatest => "magistral-medium-latest",
Self::MagistralSmallLatest => "magistral-small-latest",
Self::OpenMistralNemo => "open-mistral-nemo",
Self::OpenCodestralMamba => "open-codestral-mamba",
Self::DevstralMediumLatest => "devstral-medium-latest",
Self::DevstralSmallLatest => "devstral-small-latest",
Self::Pixtral12BLatest => "pixtral-12b-latest",
Self::PixtralLargeLatest => "pixtral-large-latest",
@ -119,8 +136,11 @@ impl Model {
Self::MistralLargeLatest => "mistral-large-latest",
Self::MistralMediumLatest => "mistral-medium-latest",
Self::MistralSmallLatest => "mistral-small-latest",
Self::MagistralMediumLatest => "magistral-medium-latest",
Self::MagistralSmallLatest => "magistral-small-latest",
Self::OpenMistralNemo => "open-mistral-nemo",
Self::OpenCodestralMamba => "open-codestral-mamba",
Self::DevstralMediumLatest => "devstral-medium-latest",
Self::DevstralSmallLatest => "devstral-small-latest",
Self::Pixtral12BLatest => "pixtral-12b-latest",
Self::PixtralLargeLatest => "pixtral-large-latest",
@ -136,8 +156,11 @@ impl Model {
Self::MistralLargeLatest => 131000,
Self::MistralMediumLatest => 128000,
Self::MistralSmallLatest => 32000,
Self::MagistralMediumLatest => 40000,
Self::MagistralSmallLatest => 40000,
Self::OpenMistralNemo => 131000,
Self::OpenCodestralMamba => 256000,
Self::DevstralMediumLatest => 128000,
Self::DevstralSmallLatest => 262144,
Self::Pixtral12BLatest => 128000,
Self::PixtralLargeLatest => 128000,
@ -160,8 +183,11 @@ impl Model {
| Self::MistralLargeLatest
| Self::MistralMediumLatest
| Self::MistralSmallLatest
| Self::MagistralMediumLatest
| Self::MagistralSmallLatest
| Self::OpenMistralNemo
| Self::OpenCodestralMamba
| Self::DevstralMediumLatest
| Self::DevstralSmallLatest
| Self::Pixtral12BLatest
| Self::PixtralLargeLatest => true,
@ -177,8 +203,11 @@ impl Model {
| Self::MistralSmallLatest => true,
Self::CodestralLatest
| Self::MistralLargeLatest
| Self::MagistralMediumLatest
| Self::MagistralSmallLatest
| Self::OpenMistralNemo
| Self::OpenCodestralMamba
| Self::DevstralMediumLatest
| Self::DevstralSmallLatest => false,
Self::Custom {
supports_images, ..

View file

@ -55,6 +55,7 @@ fn get_max_tokens(name: &str) -> u64 {
"codellama" | "starcoder2" => 16384,
"mistral" | "codestral" | "mixstral" | "llava" | "qwen2" | "qwen2.5-coder"
| "dolphin-mixtral" => 32768,
"magistral" => 40000,
"llama3.1" | "llama3.2" | "llama3.3" | "phi3" | "phi3.5" | "phi4" | "command-r"
| "qwen3" | "gemma3" | "deepseek-coder-v2" | "deepseek-v3" | "deepseek-r1" | "yi-coder"
| "devstral" => 128000,

View file

@ -13,6 +13,7 @@ use settings::{Settings as _, SettingsStore};
use util::ResultExt as _;
use crate::{
Project,
project_settings::{ContextServerSettings, ProjectSettings},
worktree_store::WorktreeStore,
};
@ -144,6 +145,7 @@ pub struct ContextServerStore {
context_server_settings: HashMap<Arc<str>, ContextServerSettings>,
servers: HashMap<ContextServerId, ContextServerState>,
worktree_store: Entity<WorktreeStore>,
project: WeakEntity<Project>,
registry: Entity<ContextServerDescriptorRegistry>,
update_servers_task: Option<Task<Result<()>>>,
context_server_factory: Option<ContextServerFactory>,
@ -161,12 +163,17 @@ pub enum Event {
impl EventEmitter<Event> for ContextServerStore {}
impl ContextServerStore {
pub fn new(worktree_store: Entity<WorktreeStore>, cx: &mut Context<Self>) -> Self {
pub fn new(
worktree_store: Entity<WorktreeStore>,
weak_project: WeakEntity<Project>,
cx: &mut Context<Self>,
) -> Self {
Self::new_internal(
true,
None,
ContextServerDescriptorRegistry::default_global(cx),
worktree_store,
weak_project,
cx,
)
}
@ -184,9 +191,10 @@ impl ContextServerStore {
pub fn test(
registry: Entity<ContextServerDescriptorRegistry>,
worktree_store: Entity<WorktreeStore>,
weak_project: WeakEntity<Project>,
cx: &mut Context<Self>,
) -> Self {
Self::new_internal(false, None, registry, worktree_store, cx)
Self::new_internal(false, None, registry, worktree_store, weak_project, cx)
}
#[cfg(any(test, feature = "test-support"))]
@ -194,6 +202,7 @@ impl ContextServerStore {
context_server_factory: ContextServerFactory,
registry: Entity<ContextServerDescriptorRegistry>,
worktree_store: Entity<WorktreeStore>,
weak_project: WeakEntity<Project>,
cx: &mut Context<Self>,
) -> Self {
Self::new_internal(
@ -201,6 +210,7 @@ impl ContextServerStore {
Some(context_server_factory),
registry,
worktree_store,
weak_project,
cx,
)
}
@ -210,6 +220,7 @@ impl ContextServerStore {
context_server_factory: Option<ContextServerFactory>,
registry: Entity<ContextServerDescriptorRegistry>,
worktree_store: Entity<WorktreeStore>,
weak_project: WeakEntity<Project>,
cx: &mut Context<Self>,
) -> Self {
let subscriptions = if maintain_server_loop {
@ -235,6 +246,7 @@ impl ContextServerStore {
context_server_settings: Self::resolve_context_server_settings(&worktree_store, cx)
.clone(),
worktree_store,
project: weak_project,
registry,
needs_server_update: false,
servers: HashMap::default(),
@ -360,7 +372,7 @@ impl ContextServerStore {
let configuration = state.configuration();
self.stop_server(&state.server().id(), cx)?;
let new_server = self.create_context_server(id.clone(), configuration.clone())?;
let new_server = self.create_context_server(id.clone(), configuration.clone(), cx);
self.run_server(new_server, configuration, cx);
}
Ok(())
@ -449,14 +461,33 @@ impl ContextServerStore {
&self,
id: ContextServerId,
configuration: Arc<ContextServerConfiguration>,
) -> Result<Arc<ContextServer>> {
cx: &mut Context<Self>,
) -> Arc<ContextServer> {
let root_path = self
.project
.read_with(cx, |project, cx| project.active_project_directory(cx))
.ok()
.flatten()
.or_else(|| {
self.worktree_store.read_with(cx, |store, cx| {
store.visible_worktrees(cx).fold(None, |acc, item| {
if acc.is_none() {
item.read(cx).root_dir()
} else {
acc
}
})
})
});
if let Some(factory) = self.context_server_factory.as_ref() {
Ok(factory(id, configuration))
factory(id, configuration)
} else {
Ok(Arc::new(ContextServer::stdio(
Arc::new(ContextServer::stdio(
id,
configuration.command().clone(),
)))
root_path,
))
}
}
@ -553,7 +584,7 @@ impl ContextServerStore {
let mut servers_to_remove = HashSet::default();
let mut servers_to_stop = HashSet::default();
this.update(cx, |this, _cx| {
this.update(cx, |this, cx| {
for server_id in this.servers.keys() {
// All servers that are not in desired_servers should be removed from the store.
// This can happen if the user removed a server from the context server settings.
@ -572,14 +603,10 @@ impl ContextServerStore {
let existing_config = state.as_ref().map(|state| state.configuration());
if existing_config.as_deref() != Some(&config) || is_stopped {
let config = Arc::new(config);
if let Some(server) = this
.create_context_server(id.clone(), config.clone())
.log_err()
{
servers_to_start.push((server, config));
if this.servers.contains_key(&id) {
servers_to_stop.insert(id);
}
let server = this.create_context_server(id.clone(), config.clone(), cx);
servers_to_start.push((server, config));
if this.servers.contains_key(&id) {
servers_to_stop.insert(id);
}
}
}
@ -630,7 +657,12 @@ mod tests {
let registry = cx.new(|_| ContextServerDescriptorRegistry::new());
let store = cx.new(|cx| {
ContextServerStore::test(registry.clone(), project.read(cx).worktree_store(), cx)
ContextServerStore::test(
registry.clone(),
project.read(cx).worktree_store(),
project.downgrade(),
cx,
)
});
let server_1_id = ContextServerId(SERVER_1_ID.into());
@ -705,7 +737,12 @@ mod tests {
let registry = cx.new(|_| ContextServerDescriptorRegistry::new());
let store = cx.new(|cx| {
ContextServerStore::test(registry.clone(), project.read(cx).worktree_store(), cx)
ContextServerStore::test(
registry.clone(),
project.read(cx).worktree_store(),
project.downgrade(),
cx,
)
});
let server_1_id = ContextServerId(SERVER_1_ID.into());
@ -758,7 +795,12 @@ mod tests {
let registry = cx.new(|_| ContextServerDescriptorRegistry::new());
let store = cx.new(|cx| {
ContextServerStore::test(registry.clone(), project.read(cx).worktree_store(), cx)
ContextServerStore::test(
registry.clone(),
project.read(cx).worktree_store(),
project.downgrade(),
cx,
)
});
let server_id = ContextServerId(SERVER_1_ID.into());
@ -842,6 +884,7 @@ mod tests {
}),
registry.clone(),
project.read(cx).worktree_store(),
project.downgrade(),
cx,
)
});
@ -1074,6 +1117,7 @@ mod tests {
}),
registry.clone(),
project.read(cx).worktree_store(),
project.downgrade(),
cx,
)
});

View file

@ -3551,7 +3551,8 @@ pub struct LspStore {
_maintain_buffer_languages: Task<()>,
diagnostic_summaries:
HashMap<WorktreeId, HashMap<Arc<Path>, HashMap<LanguageServerId, DiagnosticSummary>>>,
lsp_data: HashMap<BufferId, DocumentColorData>,
lsp_document_colors: HashMap<BufferId, DocumentColorData>,
lsp_code_lens: HashMap<BufferId, CodeLensData>,
}
#[derive(Debug, Default, Clone)]
@ -3561,6 +3562,7 @@ pub struct DocumentColors {
}
type DocumentColorTask = Shared<Task<std::result::Result<DocumentColors, Arc<anyhow::Error>>>>;
type CodeLensTask = Shared<Task<std::result::Result<Vec<CodeAction>, Arc<anyhow::Error>>>>;
#[derive(Debug, Default)]
struct DocumentColorData {
@ -3570,8 +3572,15 @@ struct DocumentColorData {
colors_update: Option<(Global, DocumentColorTask)>,
}
#[derive(Debug, Default)]
struct CodeLensData {
lens_for_version: Global,
lens: HashMap<LanguageServerId, Vec<CodeAction>>,
update: Option<(Global, CodeLensTask)>,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum ColorFetchStrategy {
pub enum LspFetchStrategy {
IgnoreCache,
UseCache { known_cache_version: Option<usize> },
}
@ -3804,7 +3813,8 @@ impl LspStore {
language_server_statuses: Default::default(),
nonce: StdRng::from_entropy().r#gen(),
diagnostic_summaries: HashMap::default(),
lsp_data: HashMap::default(),
lsp_document_colors: HashMap::default(),
lsp_code_lens: HashMap::default(),
active_entry: None,
_maintain_workspace_config,
_maintain_buffer_languages: Self::maintain_buffer_languages(languages, cx),
@ -3861,7 +3871,8 @@ impl LspStore {
language_server_statuses: Default::default(),
nonce: StdRng::from_entropy().r#gen(),
diagnostic_summaries: HashMap::default(),
lsp_data: HashMap::default(),
lsp_document_colors: HashMap::default(),
lsp_code_lens: HashMap::default(),
active_entry: None,
toolchain_store,
_maintain_workspace_config,
@ -4162,7 +4173,8 @@ impl LspStore {
*refcount
};
if refcount == 0 {
lsp_store.lsp_data.remove(&buffer_id);
lsp_store.lsp_document_colors.remove(&buffer_id);
lsp_store.lsp_code_lens.remove(&buffer_id);
let local = lsp_store.as_local_mut().unwrap();
local.registered_buffers.remove(&buffer_id);
local.buffers_opened_in_servers.remove(&buffer_id);
@ -5702,69 +5714,168 @@ impl LspStore {
}
}
pub fn code_lens(
pub fn code_lens_actions(
&mut self,
buffer_handle: &Entity<Buffer>,
buffer: &Entity<Buffer>,
cx: &mut Context<Self>,
) -> Task<Result<Vec<CodeAction>>> {
) -> CodeLensTask {
let version_queried_for = buffer.read(cx).version();
let buffer_id = buffer.read(cx).remote_id();
if let Some(cached_data) = self.lsp_code_lens.get(&buffer_id) {
if !version_queried_for.changed_since(&cached_data.lens_for_version) {
let has_different_servers = self.as_local().is_some_and(|local| {
local
.buffers_opened_in_servers
.get(&buffer_id)
.cloned()
.unwrap_or_default()
!= cached_data.lens.keys().copied().collect()
});
if !has_different_servers {
return Task::ready(Ok(cached_data.lens.values().flatten().cloned().collect()))
.shared();
}
}
}
let lsp_data = self.lsp_code_lens.entry(buffer_id).or_default();
if let Some((updating_for, running_update)) = &lsp_data.update {
if !version_queried_for.changed_since(&updating_for) {
return running_update.clone();
}
}
let buffer = buffer.clone();
let query_version_queried_for = version_queried_for.clone();
let new_task = cx
.spawn(async move |lsp_store, cx| {
cx.background_executor()
.timer(Duration::from_millis(30))
.await;
let fetched_lens = lsp_store
.update(cx, |lsp_store, cx| lsp_store.fetch_code_lens(&buffer, cx))
.map_err(Arc::new)?
.await
.context("fetching code lens")
.map_err(Arc::new);
let fetched_lens = match fetched_lens {
Ok(fetched_lens) => fetched_lens,
Err(e) => {
lsp_store
.update(cx, |lsp_store, _| {
lsp_store.lsp_code_lens.entry(buffer_id).or_default().update = None;
})
.ok();
return Err(e);
}
};
lsp_store
.update(cx, |lsp_store, _| {
let lsp_data = lsp_store.lsp_code_lens.entry(buffer_id).or_default();
if lsp_data.lens_for_version == query_version_queried_for {
lsp_data.lens.extend(fetched_lens.clone());
} else if !lsp_data
.lens_for_version
.changed_since(&query_version_queried_for)
{
lsp_data.lens_for_version = query_version_queried_for;
lsp_data.lens = fetched_lens.clone();
}
lsp_data.update = None;
lsp_data.lens.values().flatten().cloned().collect()
})
.map_err(Arc::new)
})
.shared();
lsp_data.update = Some((version_queried_for, new_task.clone()));
new_task
}
fn fetch_code_lens(
&mut self,
buffer: &Entity<Buffer>,
cx: &mut Context<Self>,
) -> Task<Result<HashMap<LanguageServerId, Vec<CodeAction>>>> {
if let Some((upstream_client, project_id)) = self.upstream_client() {
let request_task = upstream_client.request(proto::MultiLspQuery {
buffer_id: buffer_handle.read(cx).remote_id().into(),
version: serialize_version(&buffer_handle.read(cx).version()),
buffer_id: buffer.read(cx).remote_id().into(),
version: serialize_version(&buffer.read(cx).version()),
project_id,
strategy: Some(proto::multi_lsp_query::Strategy::All(
proto::AllLanguageServers {},
)),
request: Some(proto::multi_lsp_query::Request::GetCodeLens(
GetCodeLens.to_proto(project_id, buffer_handle.read(cx)),
GetCodeLens.to_proto(project_id, buffer.read(cx)),
)),
});
let buffer = buffer_handle.clone();
cx.spawn(async move |weak_project, cx| {
let Some(project) = weak_project.upgrade() else {
return Ok(Vec::new());
let buffer = buffer.clone();
cx.spawn(async move |weak_lsp_store, cx| {
let Some(lsp_store) = weak_lsp_store.upgrade() else {
return Ok(HashMap::default());
};
let responses = request_task.await?.responses;
let code_lens = join_all(
let code_lens_actions = join_all(
responses
.into_iter()
.filter_map(|lsp_response| match lsp_response.response? {
proto::lsp_response::Response::GetCodeLensResponse(response) => {
Some(response)
}
unexpected => {
debug_panic!("Unexpected response: {unexpected:?}");
None
}
.filter_map(|lsp_response| {
let response = match lsp_response.response? {
proto::lsp_response::Response::GetCodeLensResponse(response) => {
Some(response)
}
unexpected => {
debug_panic!("Unexpected response: {unexpected:?}");
None
}
}?;
let server_id = LanguageServerId::from_proto(lsp_response.server_id);
Some((server_id, response))
})
.map(|code_lens_response| {
GetCodeLens.response_from_proto(
code_lens_response,
project.clone(),
buffer.clone(),
cx.clone(),
)
.map(|(server_id, code_lens_response)| {
let lsp_store = lsp_store.clone();
let buffer = buffer.clone();
let cx = cx.clone();
async move {
(
server_id,
GetCodeLens
.response_from_proto(
code_lens_response,
lsp_store,
buffer,
cx,
)
.await,
)
}
}),
)
.await;
Ok(code_lens
let mut has_errors = false;
let code_lens_actions = code_lens_actions
.into_iter()
.collect::<Result<Vec<Vec<_>>>>()?
.into_iter()
.flatten()
.collect())
.filter_map(|(server_id, code_lens)| match code_lens {
Ok(code_lens) => Some((server_id, code_lens)),
Err(e) => {
has_errors = true;
log::error!("{e:#}");
None
}
})
.collect::<HashMap<_, _>>();
anyhow::ensure!(
!has_errors || !code_lens_actions.is_empty(),
"Failed to fetch code lens"
);
Ok(code_lens_actions)
})
} else {
let code_lens_task =
self.request_multiple_lsp_locally(buffer_handle, None::<usize>, GetCodeLens, cx);
cx.spawn(async move |_, _| {
Ok(code_lens_task
.await
.into_iter()
.flat_map(|(_, code_lens)| code_lens)
.collect())
})
let code_lens_actions_task =
self.request_multiple_lsp_locally(buffer, None::<usize>, GetCodeLens, cx);
cx.background_spawn(
async move { Ok(code_lens_actions_task.await.into_iter().collect()) },
)
}
}
@ -6597,7 +6708,7 @@ impl LspStore {
pub fn document_colors(
&mut self,
fetch_strategy: ColorFetchStrategy,
fetch_strategy: LspFetchStrategy,
buffer: Entity<Buffer>,
cx: &mut Context<Self>,
) -> Option<DocumentColorTask> {
@ -6605,11 +6716,11 @@ impl LspStore {
let buffer_id = buffer.read(cx).remote_id();
match fetch_strategy {
ColorFetchStrategy::IgnoreCache => {}
ColorFetchStrategy::UseCache {
LspFetchStrategy::IgnoreCache => {}
LspFetchStrategy::UseCache {
known_cache_version,
} => {
if let Some(cached_data) = self.lsp_data.get(&buffer_id) {
if let Some(cached_data) = self.lsp_document_colors.get(&buffer_id) {
if !version_queried_for.changed_since(&cached_data.colors_for_version) {
let has_different_servers = self.as_local().is_some_and(|local| {
local
@ -6642,7 +6753,7 @@ impl LspStore {
}
}
let lsp_data = self.lsp_data.entry(buffer_id).or_default();
let lsp_data = self.lsp_document_colors.entry(buffer_id).or_default();
if let Some((updating_for, running_update)) = &lsp_data.colors_update {
if !version_queried_for.changed_since(&updating_for) {
return Some(running_update.clone());
@ -6656,14 +6767,14 @@ impl LspStore {
.await;
let fetched_colors = lsp_store
.update(cx, |lsp_store, cx| {
lsp_store.fetch_document_colors_for_buffer(buffer.clone(), cx)
lsp_store.fetch_document_colors_for_buffer(&buffer, cx)
})?
.await
.context("fetching document colors")
.map_err(Arc::new);
let fetched_colors = match fetched_colors {
Ok(fetched_colors) => {
if fetch_strategy != ColorFetchStrategy::IgnoreCache
if fetch_strategy != LspFetchStrategy::IgnoreCache
&& Some(true)
== buffer
.update(cx, |buffer, _| {
@ -6679,7 +6790,7 @@ impl LspStore {
lsp_store
.update(cx, |lsp_store, _| {
lsp_store
.lsp_data
.lsp_document_colors
.entry(buffer_id)
.or_default()
.colors_update = None;
@ -6691,7 +6802,7 @@ impl LspStore {
lsp_store
.update(cx, |lsp_store, _| {
let lsp_data = lsp_store.lsp_data.entry(buffer_id).or_default();
let lsp_data = lsp_store.lsp_document_colors.entry(buffer_id).or_default();
if lsp_data.colors_for_version == query_version_queried_for {
lsp_data.colors.extend(fetched_colors.clone());
@ -6725,7 +6836,7 @@ impl LspStore {
fn fetch_document_colors_for_buffer(
&mut self,
buffer: Entity<Buffer>,
buffer: &Entity<Buffer>,
cx: &mut Context<Self>,
) -> Task<anyhow::Result<HashMap<LanguageServerId, HashSet<DocumentColor>>>> {
if let Some((client, project_id)) = self.upstream_client() {
@ -6740,6 +6851,7 @@ impl LspStore {
GetDocumentColor {}.to_proto(project_id, buffer.read(cx)),
)),
});
let buffer = buffer.clone();
cx.spawn(async move |project, cx| {
let Some(project) = project.upgrade() else {
return Ok(HashMap::default());
@ -6785,7 +6897,7 @@ impl LspStore {
})
} else {
let document_colors_task =
self.request_multiple_lsp_locally(&buffer, None::<usize>, GetDocumentColor, cx);
self.request_multiple_lsp_locally(buffer, None::<usize>, GetDocumentColor, cx);
cx.spawn(async move |_, _| {
Ok(document_colors_task
.await
@ -7325,21 +7437,23 @@ impl LspStore {
}
pub(crate) async fn refresh_workspace_configurations(
this: &WeakEntity<Self>,
lsp_store: &WeakEntity<Self>,
fs: Arc<dyn Fs>,
cx: &mut AsyncApp,
) {
maybe!(async move {
let servers = this
.update(cx, |this, cx| {
let Some(local) = this.as_local() else {
let mut refreshed_servers = HashSet::default();
let servers = lsp_store
.update(cx, |lsp_store, cx| {
let toolchain_store = lsp_store.toolchain_store(cx);
let Some(local) = lsp_store.as_local() else {
return Vec::default();
};
local
.language_server_ids
.iter()
.flat_map(|((worktree_id, _), server_ids)| {
let worktree = this
let worktree = lsp_store
.worktree_store
.read(cx)
.worktree_for_id(*worktree_id, cx);
@ -7355,43 +7469,54 @@ impl LspStore {
)
});
server_ids.iter().filter_map(move |server_id| {
let fs = fs.clone();
let toolchain_store = toolchain_store.clone();
server_ids.iter().filter_map(|server_id| {
let delegate = delegate.clone()? as Arc<dyn LspAdapterDelegate>;
let states = local.language_servers.get(server_id)?;
match states {
LanguageServerState::Starting { .. } => None,
LanguageServerState::Running {
adapter, server, ..
} => Some((
adapter.adapter.clone(),
server.clone(),
delegate.clone()? as Arc<dyn LspAdapterDelegate>,
)),
} => {
let fs = fs.clone();
let toolchain_store = toolchain_store.clone();
let adapter = adapter.clone();
let server = server.clone();
refreshed_servers.insert(server.name());
Some(cx.spawn(async move |_, cx| {
let settings =
LocalLspStore::workspace_configuration_for_adapter(
adapter.adapter.clone(),
fs.as_ref(),
&delegate,
toolchain_store,
cx,
)
.await
.ok()?;
server
.notify::<lsp::notification::DidChangeConfiguration>(
&lsp::DidChangeConfigurationParams { settings },
)
.ok()?;
Some(())
}))
}
}
})
}).collect::<Vec<_>>()
})
.collect::<Vec<_>>()
})
.ok()?;
let toolchain_store = this.update(cx, |this, cx| this.toolchain_store(cx)).ok()?;
for (adapter, server, delegate) in servers {
let settings = LocalLspStore::workspace_configuration_for_adapter(
adapter,
fs.as_ref(),
&delegate,
toolchain_store.clone(),
cx,
)
.await
.ok()?;
server
.notify::<lsp::notification::DidChangeConfiguration>(
&lsp::DidChangeConfigurationParams { settings },
)
.ok();
}
log::info!("Refreshing workspace configurations for servers {refreshed_servers:?}");
// TODO this asynchronous job runs concurrently with extension (de)registration and may take enough time for a certain extension
// to stop and unregister its language server wrapper.
// This is racy : an extension might have already removed all `local.language_servers` state, but here we `.clone()` and hold onto it anyway.
// This now causes errors in the logs, we should find a way to remove such servers from the processing everywhere.
let _: Vec<Option<()>> = join_all(servers).await;
Some(())
})
.await;
@ -11278,9 +11403,12 @@ impl LspStore {
}
fn cleanup_lsp_data(&mut self, for_server: LanguageServerId) {
for buffer_lsp_data in self.lsp_data.values_mut() {
buffer_lsp_data.colors.remove(&for_server);
buffer_lsp_data.cache_version += 1;
for buffer_colors in self.lsp_document_colors.values_mut() {
buffer_colors.colors.remove(&for_server);
buffer_colors.cache_version += 1;
}
for buffer_lens in self.lsp_code_lens.values_mut() {
buffer_lens.lens.remove(&for_server);
}
if let Some(local) = self.as_local_mut() {
local.buffer_pull_diagnostics_result_ids.remove(&for_server);

View file

@ -113,7 +113,7 @@ use std::{
use task_store::TaskStore;
use terminals::Terminals;
use text::{Anchor, BufferId, Point};
use text::{Anchor, BufferId, OffsetRangeExt, Point};
use toolchain_store::EmptyToolchainStore;
use util::{
ResultExt as _,
@ -590,7 +590,7 @@ pub(crate) struct CoreCompletion {
}
/// A code action provided by a language server.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub struct CodeAction {
/// The id of the language server that produced this code action.
pub server_id: LanguageServerId,
@ -604,7 +604,7 @@ pub struct CodeAction {
}
/// An action sent back by a language server.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub enum LspAction {
/// An action with the full data, may have a command or may not.
/// May require resolving.
@ -998,8 +998,9 @@ impl Project {
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
.detach();
let weak_self = cx.weak_entity();
let context_server_store =
cx.new(|cx| ContextServerStore::new(worktree_store.clone(), cx));
cx.new(|cx| ContextServerStore::new(worktree_store.clone(), weak_self, cx));
let environment = cx.new(|_| ProjectEnvironment::new(env));
let manifest_tree = ManifestTree::new(worktree_store.clone(), cx);
@ -1167,8 +1168,9 @@ impl Project {
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
.detach();
let weak_self = cx.weak_entity();
let context_server_store =
cx.new(|cx| ContextServerStore::new(worktree_store.clone(), cx));
cx.new(|cx| ContextServerStore::new(worktree_store.clone(), weak_self, cx));
let buffer_store = cx.new(|cx| {
BufferStore::remote(
@ -1428,8 +1430,6 @@ impl Project {
let image_store = cx.new(|cx| {
ImageStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx)
})?;
let context_server_store =
cx.new(|cx| ContextServerStore::new(worktree_store.clone(), cx))?;
let environment = cx.new(|_| ProjectEnvironment::new(None))?;
@ -1496,6 +1496,10 @@ impl Project {
let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([]), cx);
let weak_self = cx.weak_entity();
let context_server_store =
cx.new(|cx| ContextServerStore::new(worktree_store.clone(), weak_self, cx));
let mut worktrees = Vec::new();
for worktree in response.payload.worktrees {
let worktree =
@ -3607,20 +3611,29 @@ impl Project {
})
}
pub fn code_lens<T: Clone + ToOffset>(
pub fn code_lens_actions<T: Clone + ToOffset>(
&mut self,
buffer_handle: &Entity<Buffer>,
buffer: &Entity<Buffer>,
range: Range<T>,
cx: &mut Context<Self>,
) -> Task<Result<Vec<CodeAction>>> {
let snapshot = buffer_handle.read(cx).snapshot();
let range = snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end);
let snapshot = buffer.read(cx).snapshot();
let range = range.clone().to_owned().to_point(&snapshot);
let range_start = snapshot.anchor_before(range.start);
let range_end = if range.start == range.end {
range_start
} else {
snapshot.anchor_after(range.end)
};
let range = range_start..range_end;
let code_lens_actions = self
.lsp_store
.update(cx, |lsp_store, cx| lsp_store.code_lens(buffer_handle, cx));
.update(cx, |lsp_store, cx| lsp_store.code_lens_actions(buffer, cx));
cx.background_spawn(async move {
let mut code_lens_actions = code_lens_actions.await?;
let mut code_lens_actions = code_lens_actions
.await
.map_err(|e| anyhow!("code lens fetch failed: {e:#}"))?;
code_lens_actions.retain(|code_lens_action| {
range
.start

View file

@ -114,6 +114,7 @@ pub struct ProjectPanel {
mouse_down: bool,
hover_expand_task: Option<Task<()>>,
previous_drag_position: Option<Point<Pixels>>,
sticky_items_count: usize,
}
struct DragTargetEntry {
@ -322,6 +323,7 @@ pub fn init(cx: &mut App) {
});
workspace.register_action(|workspace, action: &Rename, window, cx| {
workspace.open_panel::<ProjectPanel>(window, cx);
if let Some(panel) = workspace.panel::<ProjectPanel>(cx) {
panel.update(cx, |panel, cx| {
if let Some(first_marked) = panel.marked_entries.first() {
@ -335,6 +337,7 @@ pub fn init(cx: &mut App) {
});
workspace.register_action(|workspace, action: &Duplicate, window, cx| {
workspace.open_panel::<ProjectPanel>(window, cx);
if let Some(panel) = workspace.panel::<ProjectPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.duplicate(action, window, cx);
@ -570,6 +573,9 @@ impl ProjectPanel {
if project_panel_settings.hide_root != new_settings.hide_root {
this.update_visible_entries(None, cx);
}
if project_panel_settings.sticky_scroll && !new_settings.sticky_scroll {
this.sticky_items_count = 0;
}
project_panel_settings = new_settings;
this.update_diagnostics(cx);
cx.notify();
@ -613,6 +619,7 @@ impl ProjectPanel {
mouse_down: false,
hover_expand_task: None,
previous_drag_position: None,
sticky_items_count: 0,
};
this.update_visible_entries(None, cx);
@ -2265,8 +2272,11 @@ impl ProjectPanel {
fn autoscroll(&mut self, cx: &mut Context<Self>) {
if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
self.scroll_handle
.scroll_to_item(index, ScrollStrategy::Center);
self.scroll_handle.scroll_to_item_with_offset(
index,
ScrollStrategy::Center,
self.sticky_items_count,
);
cx.notify();
}
}
@ -4224,10 +4234,7 @@ impl ProjectPanel {
this.marked_entries.clear();
if is_sticky {
if let Some((_, _, index)) = this.index_for_entry(entry_id, worktree_id) {
let strategy = sticky_index
.map(ScrollStrategy::ToPosition)
.unwrap_or(ScrollStrategy::Top);
this.scroll_handle.scroll_to_item(index, strategy);
this.scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Top, sticky_index.unwrap_or(0));
cx.notify();
// move down by 1px so that clicked item
// don't count as sticky anymore
@ -5364,7 +5371,10 @@ impl Render for ProjectPanel {
items
},
|this, marker_entry, window, cx| {
this.render_sticky_entries(marker_entry, window, cx)
let sticky_entries =
this.render_sticky_entries(marker_entry, window, cx);
this.sticky_items_count = sticky_entries.len();
sticky_entries
},
);
list.with_decoration(if show_indent_guides {

View file

@ -20,6 +20,7 @@ static REDACT_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"key=[^&]+")
pub struct ReqwestClient {
client: reqwest::Client,
proxy: Option<Url>,
user_agent: Option<HeaderValue>,
handle: tokio::runtime::Handle,
}
@ -44,9 +45,11 @@ impl ReqwestClient {
Ok(client.into())
}
pub fn proxy_and_user_agent(proxy: Option<Url>, agent: &str) -> anyhow::Result<Self> {
pub fn proxy_and_user_agent(proxy: Option<Url>, user_agent: &str) -> anyhow::Result<Self> {
let user_agent = HeaderValue::from_str(user_agent)?;
let mut map = HeaderMap::new();
map.insert(http::header::USER_AGENT, HeaderValue::from_str(agent)?);
map.insert(http::header::USER_AGENT, user_agent.clone());
let mut client = Self::builder().default_headers(map);
let client_has_proxy;
@ -73,6 +76,7 @@ impl ReqwestClient {
.build()?;
let mut client: ReqwestClient = client.into();
client.proxy = client_has_proxy.then_some(proxy).flatten();
client.user_agent = Some(user_agent);
Ok(client)
}
}
@ -96,6 +100,7 @@ impl From<reqwest::Client> for ReqwestClient {
client,
handle,
proxy: None,
user_agent: None,
}
}
}
@ -216,6 +221,10 @@ impl http_client::HttpClient for ReqwestClient {
type_name::<Self>()
}
fn user_agent(&self) -> Option<&HeaderValue> {
self.user_agent.as_ref()
}
fn send(
&self,
req: http::Request<http_client::AsyncBody>,

View file

@ -566,24 +566,40 @@ impl KeymapEditor {
&& query.modifiers == keystroke.modifiers
},
)
} else if keystroke_query.len() > keystrokes.len() {
return false;
} else {
let key_press_query =
KeyPressIterator::new(keystroke_query.as_slice());
let mut last_match_idx = 0;
for keystroke_offset in 0..keystrokes.len() {
let mut found_count = 0;
let mut query_cursor = 0;
let mut keystroke_cursor = keystroke_offset;
while query_cursor < keystroke_query.len()
&& keystroke_cursor < keystrokes.len()
{
let query = &keystroke_query[query_cursor];
let keystroke = &keystrokes[keystroke_cursor];
let matches =
query.modifiers.is_subset_of(&keystroke.modifiers)
&& ((query.key.is_empty()
|| query.key == keystroke.key)
&& query
.key_char
.as_ref()
.map_or(true, |q_kc| {
q_kc == &keystroke.key
}));
if matches {
found_count += 1;
query_cursor += 1;
}
keystroke_cursor += 1;
}
key_press_query.into_iter().all(|key| {
let key_presses = KeyPressIterator::new(keystrokes);
key_presses.into_iter().enumerate().any(
|(index, keystroke)| {
if last_match_idx > index || keystroke != key {
return false;
}
last_match_idx = index;
true
},
)
})
if found_count == keystroke_query.len() {
return true;
}
}
return false;
}
})
});
@ -1232,11 +1248,14 @@ impl KeymapEditor {
match self.search_mode {
SearchMode::KeyStroke { .. } => {
window.focus(&self.keystroke_editor.read(cx).recording_focus_handle(cx));
self.keystroke_editor.update(cx, |editor, cx| {
editor.start_recording(&StartRecording, window, cx);
});
}
SearchMode::Normal => {
self.keystroke_editor.update(cx, |editor, cx| {
editor.clear_keystrokes(&ClearKeystrokes, window, cx)
editor.stop_recording(&StopRecording, window, cx);
editor.clear_keystrokes(&ClearKeystrokes, window, cx);
});
window.focus(&self.filter_editor.focus_handle(cx));
}
@ -2962,16 +2981,6 @@ enum CloseKeystrokeResult {
None,
}
#[derive(PartialEq, Eq, Debug, Clone)]
enum KeyPress<'a> {
Alt,
Control,
Function,
Shift,
Platform,
Key(&'a String),
}
struct KeystrokeInput {
keystrokes: Vec<Keystroke>,
placeholder_keystrokes: Option<Vec<Keystroke>>,
@ -2983,6 +2992,7 @@ struct KeystrokeInput {
/// Handles tripe escape to stop recording
close_keystrokes: Option<Vec<Keystroke>>,
close_keystrokes_start: Option<usize>,
previous_modifiers: Modifiers,
}
impl KeystrokeInput {
@ -3009,6 +3019,7 @@ impl KeystrokeInput {
search: false,
close_keystrokes: None,
close_keystrokes_start: None,
previous_modifiers: Modifiers::default(),
}
}
@ -3031,7 +3042,7 @@ impl KeystrokeInput {
}
fn key_context() -> KeyContext {
let mut key_context = KeyContext::new_with_defaults();
let mut key_context = KeyContext::default();
key_context.add("KeystrokeInput");
key_context
}
@ -3098,12 +3109,26 @@ impl KeystrokeInput {
) {
let keystrokes_len = self.keystrokes.len();
if self.previous_modifiers.modified()
&& event.modifiers.is_subset_of(&self.previous_modifiers)
{
self.previous_modifiers &= event.modifiers;
cx.stop_propagation();
return;
}
if let Some(last) = self.keystrokes.last_mut()
&& last.key.is_empty()
&& keystrokes_len <= Self::KEYSTROKE_COUNT_MAX
{
if self.search {
last.modifiers = last.modifiers.xor(&event.modifiers);
if self.previous_modifiers.modified() {
last.modifiers |= event.modifiers;
self.previous_modifiers |= event.modifiers;
} else {
self.keystrokes.push(Self::dummy(event.modifiers));
self.previous_modifiers |= event.modifiers;
}
} else if !event.modifiers.modified() {
self.keystrokes.pop();
} else {
@ -3113,6 +3138,9 @@ impl KeystrokeInput {
self.keystrokes_changed(cx);
} else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX {
self.keystrokes.push(Self::dummy(event.modifiers));
if self.search {
self.previous_modifiers |= event.modifiers;
}
self.keystrokes_changed(cx);
}
cx.stop_propagation();
@ -3138,6 +3166,9 @@ impl KeystrokeInput {
{
self.close_keystrokes_start = Some(self.keystrokes.len() - 1);
}
if self.search {
self.previous_modifiers = keystroke.modifiers;
}
self.keystrokes_changed(cx);
cx.stop_propagation();
return;
@ -3152,7 +3183,9 @@ impl KeystrokeInput {
self.close_keystrokes_start = Some(self.keystrokes.len());
}
self.keystrokes.push(keystroke.clone());
if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX {
if self.search {
self.previous_modifiers = keystroke.modifiers;
} else if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX {
self.keystrokes.push(Self::dummy(keystroke.modifiers));
}
} else if close_keystroke_result != CloseKeystrokeResult::Partial {
@ -3222,17 +3255,11 @@ impl KeystrokeInput {
})
}
fn recording_focus_handle(&self, _cx: &App) -> FocusHandle {
self.inner_focus_handle.clone()
}
fn start_recording(&mut self, _: &StartRecording, window: &mut Window, cx: &mut Context<Self>) {
if !self.outer_focus_handle.is_focused(window) {
return;
}
self.clear_keystrokes(&ClearKeystrokes, window, cx);
window.focus(&self.inner_focus_handle);
cx.notify();
self.clear_keystrokes(&ClearKeystrokes, window, cx);
self.previous_modifiers = window.modifiers();
cx.stop_propagation();
}
fn stop_recording(&mut self, _: &StopRecording, window: &mut Window, cx: &mut Context<Self>) {
@ -3364,7 +3391,7 @@ impl Render for KeystrokeInput {
})
.key_context(Self::key_context())
.on_action(cx.listener(Self::start_recording))
.on_action(cx.listener(Self::stop_recording))
.on_action(cx.listener(Self::clear_keystrokes))
.child(
h_flex()
.w(horizontal_padding)
@ -3633,72 +3660,3 @@ mod persistence {
}
}
}
/// Iterator that yields KeyPress values from a slice of Keystrokes
struct KeyPressIterator<'a> {
keystrokes: &'a [Keystroke],
current_keystroke_index: usize,
current_key_press_index: usize,
}
impl<'a> KeyPressIterator<'a> {
fn new(keystrokes: &'a [Keystroke]) -> Self {
Self {
keystrokes,
current_keystroke_index: 0,
current_key_press_index: 0,
}
}
}
impl<'a> Iterator for KeyPressIterator<'a> {
type Item = KeyPress<'a>;
fn next(&mut self) -> Option<Self::Item> {
loop {
let keystroke = self.keystrokes.get(self.current_keystroke_index)?;
match self.current_key_press_index {
0 => {
self.current_key_press_index = 1;
if keystroke.modifiers.platform {
return Some(KeyPress::Platform);
}
}
1 => {
self.current_key_press_index = 2;
if keystroke.modifiers.alt {
return Some(KeyPress::Alt);
}
}
2 => {
self.current_key_press_index = 3;
if keystroke.modifiers.control {
return Some(KeyPress::Control);
}
}
3 => {
self.current_key_press_index = 4;
if keystroke.modifiers.shift {
return Some(KeyPress::Shift);
}
}
4 => {
self.current_key_press_index = 5;
if keystroke.modifiers.function {
return Some(KeyPress::Function);
}
}
_ => {
self.current_keystroke_index += 1;
self.current_key_press_index = 0;
if keystroke.key.is_empty() {
continue;
}
return Some(KeyPress::Key(&keystroke.key));
}
}
}
}
}

View file

@ -18,15 +18,19 @@ pub fn capture(directory: &std::path::Path) -> Result<collections::HashMap<Strin
// In some shells, file descriptors greater than 2 cannot be used in interactive mode,
// so file descriptor 0 (stdin) is used instead. This impacts zsh, old bash; perhaps others.
// See: https://github.com/zed-industries/zed/pull/32136#issuecomment-2999645482
const ENV_OUTPUT_FD: std::os::fd::RawFd = 0;
let redir = match shell_name {
Some("rc") => format!(">[1={}]", ENV_OUTPUT_FD), // `[1=0]`
_ => format!(">&{}", ENV_OUTPUT_FD), // `>&0`
const FD_STDIN: std::os::fd::RawFd = 0;
const FD_STDOUT: std::os::fd::RawFd = 1;
let (fd_num, redir) = match shell_name {
Some("rc") => (FD_STDIN, format!(">[1={}]", FD_STDIN)), // `[1=0]`
Some("nu") | Some("tcsh") => (FD_STDOUT, "".to_string()),
_ => (FD_STDIN, format!(">&{}", FD_STDIN)), // `>&0`
};
command.stdin(Stdio::null());
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
let mut command_prefix = String::new();
match shell_name {
Some("tcsh" | "csh") => {
// For csh/tcsh, login shell requires passing `-` as 0th argument (instead of `-l`)
@ -37,18 +41,25 @@ pub fn capture(directory: &std::path::Path) -> Result<collections::HashMap<Strin
command_string.push_str("emit fish_prompt;");
command.arg("-l");
}
Some("nu") => {
// nu needs special handling for -- options.
command_prefix = String::from("^");
}
_ => {
command.arg("-l");
}
}
// cd into the directory, triggering directory specific side-effects (asdf, direnv, etc)
command_string.push_str(&format!("cd '{}';", directory.display()));
command_string.push_str(&format!("{} --printenv {}", zed_path, redir));
command_string.push_str(&format!(
"{}{} --printenv {}",
command_prefix, zed_path, redir
));
command.args(["-i", "-c", &command_string]);
super::set_pre_exec_to_start_new_session(&mut command);
let (env_output, process_output) = spawn_and_read_fd(command, ENV_OUTPUT_FD)?;
let (env_output, process_output) = spawn_and_read_fd(command, fd_num)?;
let env_output = String::from_utf8_lossy(&env_output);
anyhow::ensure!(

View file

@ -73,7 +73,7 @@ impl Workspace {
if let Some(terminal_provider) = self.terminal_provider.as_ref() {
let task_status = terminal_provider.spawn(spawn_in_terminal, window, cx);
cx.background_spawn(async move {
let task = cx.background_spawn(async move {
match task_status.await {
Some(Ok(status)) => {
if status.success() {
@ -82,11 +82,11 @@ impl Workspace {
log::debug!("Task spawn failed, code: {:?}", status.code());
}
}
Some(Err(e)) => log::error!("Task spawn failed: {e}"),
Some(Err(e)) => log::error!("Task spawn failed: {e:#}"),
None => log::debug!("Task spawn got cancelled"),
}
})
.detach();
});
self.scheduled_tasks.push(task);
}
}

View file

@ -1097,6 +1097,7 @@ pub struct Workspace {
serialized_ssh_project: Option<SerializedSshProject>,
_items_serializer: Task<Result<()>>,
session_id: Option<String>,
scheduled_tasks: Vec<Task<()>>,
}
impl EventEmitter<Event> for Workspace {}
@ -1428,6 +1429,7 @@ impl Workspace {
_items_serializer,
session_id: Some(session_id),
serialized_ssh_project: None,
scheduled_tasks: Vec::new(),
}
}

View file

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

View file

@ -1 +1 @@
dev
preview