Compare commits

...

47 Commits

Author SHA1 Message Date
Richard Feldman
8ffdc2c326 wip 2025-04-10 11:48:53 -04:00
Richard Feldman
c3eacb8c83 wip 2025-04-10 11:33:12 -04:00
Richard Feldman
c97f067fd3 Make diagnostics support multiple paths 2025-04-10 11:20:08 -04:00
Richard Feldman
a8c295e844 Allow configurable diagnostic levels 2025-04-10 10:50:23 -04:00
Richard Feldman
fd863ac9e9 wip 2025-04-10 09:06:01 -04:00
Richard Feldman
6f2ad775e5 Add quickfix tool 2025-04-09 22:37:38 -04:00
Richard Feldman
3340abd127 Fix typo in comment 2025-04-09 19:57:14 -04:00
Richard Feldman
b3911355b8 Split out rename_tool 2025-04-09 15:31:43 -04:00
Richard Feldman
9a5633b8e2 Cleanup 2025-04-09 14:37:10 -04:00
Richard Feldman
9cf5f85c8b Use snake_case for action name 2025-04-09 10:47:16 -04:00
Richard Feldman
a6c4f46bef Merge remote-tracking branch 'origin/main' into add-code-action-tool 2025-04-09 10:36:28 -04:00
Richard Feldman
b298ae47f6 Revert "Try replacing find-replace tool with code action tool"
This reverts commit 1927dc039e.
2025-04-09 10:33:08 -04:00
Agus Zubiaga
1cb4f8288d Fix bash tool output (#28391) 2025-04-09 08:20:24 -06:00
Richard Feldman
3a8fe4d973 Add reminder message about system prompt (#28344)
Trying out sending the model a reminder message about code blocks in the
system prompt. If this seems to work well, we can include more specific
reminder messages, e.g. tool-specific ones.

Release Notes:

- N/A
2025-04-09 10:09:48 -04:00
Joseph T. Lyons
9d6d152918 Bump Zed to v0.183 (#28419)
Release Notes:

-N/A
2025-04-09 09:11:25 -04:00
Joseph T. Lyons
31034f8296 Add toggle case command (#28415)
A small addition for those coming from JetBrain's IDEs. A behavioral
detail: when any upper case character is detected, the command defaults
to toggling to lower case.

> Note that when you apply the toggle case action to the CamelCase name
format, IntelliJ IDEA converts the name to the lower case.


https://www.jetbrains.com/help/idea/working-with-source-code.html#edit_code_fragments

Release Notes:

- Added an `editor: toggle case` command. Use `cmd-shift-u` for macOS
and `ctrl-shift-u` for Linux, when using the `JetBrains` keymap.
2025-04-09 08:44:53 -04:00
Piotr Osiewicz
c441b651fa debugger: Add support for CodeLLDB (#28376)
Closes #ISSUE

Release Notes:

- N/A
2025-04-09 12:57:24 +02:00
Piotr Osiewicz
61ddcd516f chore: Add workspace-hack dependency to agent_rules (#28412)
Closes #ISSUE

Release Notes:

- N/A
2025-04-09 10:19:54 +00:00
Michael Sloan
f12a554f86 Use Project instead of Workspace in ContextStore (#28402)
Release Notes:

- N/A
2025-04-09 05:05:24 +00:00
Cole Miller
9dae4d8c59 Remove references to SSH remoting beta (#28399)
Release Notes:

- N/A
2025-04-09 03:26:22 +00:00
Cole Miller
f0b7f355a2 Clean up environment loading a bit (#28356)
Closes #ISSUE

Release Notes:

- N/A
2025-04-08 22:16:35 -04:00
Cole Miller
b687a5e56d git: Always reload current branch after pushing (#28327)
Closes #27347 

Release Notes:

- Fixed a bug causing the git panel to not update after pushing to a
remote
2025-04-08 22:16:03 -04:00
Ben Kunkle
e66a24edcf format: Re-implement support for formatting with code actions that contain commands (#28392)
Closes #27692
Closes #27935

Release Notes:

- Fixed a regression where code-actions used when formatting on save
were rejected if they contained commands
2025-04-09 01:53:54 +00:00
Michael Sloan
301fc7cd7b Pull out plain rules file loading code into a new agent_rules crate (#28383)
Also renames for rules file templated into the system prompt

Release Notes:

- N/A
2025-04-09 01:31:56 +00:00
Mikayla Maki
020a1071d5 Add the project search as an item in the status bar (#28388)
Was chatting with @wilhelmklopp, he pointed out that our current
UI-accessible way to access the project search was pretty obscure.


<img width="393" alt="Screenshot 2025-04-08 at 6 57 51 PM"
src="https://github.com/user-attachments/assets/636053cd-5a88-4a5e-8155-6d41d189b7db"
/>

Release Notes:

- Added a button to open the project search to the status bar
2025-04-09 01:13:48 +00:00
Bennet Bo Fenner
38d2487630 agent: Polish Generating... animation (#28379)
https://github.com/user-attachments/assets/9e798a50-9403-4e1c-a3df-2931e748b77d



Release Notes:

- N/A
2025-04-08 18:14:30 -06:00
0x2CA
79c9f2bbd9 editor: Fix invalid read-only with split pane (#28012)
Closes #28004

Release Notes:

- Fixed invalid read-only with split pane
2025-04-08 18:09:34 -06:00
Danilo Leal
c8caae03df agent: Change the reject changes keybinding (#28381)
This PR makes the reject keybinding, in the Review Changes mutlbuffer,
`cmd-n`.

Release Notes:

- N/A
2025-04-08 21:09:05 -03:00
Danilo Leal
dabc4d8ff5 agent: Remove type of item in the panel history view (#28382)
This PR removes the labels displaying whether a certain item in the
Agent Panel's history is a thread or prompt editor.

Release Notes:

- N/A
2025-04-08 21:08:56 -03:00
Antonio Scandurra
c0ad3e8183 Introduce a telemetry event for when a tool finishes (#28380)
This should help us understand which tools fail the most.

Release Notes:

- N/A
2025-04-09 00:07:06 +00:00
Kirill Bulatov
afde25a5cb Fix a docs typo (#28384)
Closes https://github.com/zed-industries/zed/pull/28053

Release Notes:

- N/A
2025-04-09 00:05:58 +00:00
Michael Sloan
9f708ee789 Fix refactoring bug in dashes around rounded corners (#28378)
Accidentally introduced in #28341

Release Notes:

- N/A
2025-04-09 00:00:30 +00:00
Michael Sloan
58731e2fd1 Remove log when pulldown_cmark produces long substituted text (#28375)
Turns out that consecutive dashes are substituted with half the number
of input dashes. Extended the test with this case as well

Release Notes:

- N/A
2025-04-08 23:45:49 +00:00
Antonio Scandurra
d0632a5332 Fix truncation of bash output (#28374)
Release Notes:

- Fixed a regression that caused the bash tool to not include all of the
output.

---------

Co-authored-by: Agus Zubiaga <hi@aguz.me>
2025-04-08 23:41:20 +00:00
Danilo Leal
64cea2f1f1 agent: Refine toolbar spacing (#28373)
Release Notes:

- N/A
2025-04-08 23:28:25 +00:00
Antonio Scandurra
ac958d4a2d Encourage agent to edit files it just created (#28372)
Release Notes:

- Fixed a problem that would cause the agent to keep recreating a file
instead of editing it.
2025-04-08 23:18:34 +00:00
Danilo Leal
2df06cd2e4 agent: Improve thinking design display (#28186)
Release Notes:

- N/A
2025-04-08 20:13:49 -03:00
Danilo Leal
0d4ca71e68 agent: Change "prompt editor" to "text thread" (#28370)
Release Notes:

- N/A
2025-04-08 19:56:01 -03:00
Danilo Leal
e2d6505d12 agent: Make the copy button in the codeblock visible on hover (#28371)
This simplifies the UI a little bit.

Release Notes:

- N/A
2025-04-08 19:55:53 -03:00
Richard Feldman
1927dc039e Try replacing find-replace tool with code action tool 2025-04-04 10:17:40 -05:00
Richard Feldman
af0e2068cd wip 2025-03-31 16:35:17 -05:00
Richard Feldman
44e6701ccc Save buffers after running tool actions 2025-03-31 15:53:00 -05:00
Richard Feldman
47948f8309 Get basic code actions working 2025-03-31 15:46:25 -05:00
Richard Feldman
84a67d82cb Let the code action actually do edits 2025-03-31 13:34:13 -05:00
Richard Feldman
d478a709ed Make code-actions a default action 2025-03-31 13:34:13 -05:00
Richard Feldman
07f7a391c9 Add ListActions to tool 2025-03-31 13:34:13 -05:00
Richard Feldman
09af38a144 Add Code Action Tool 2025-03-31 13:34:13 -05:00
71 changed files with 2818 additions and 937 deletions

20
Cargo.lock generated
View File

@@ -52,6 +52,7 @@ dependencies = [
name = "agent"
version = "0.1.0"
dependencies = [
"agent_rules",
"anyhow",
"assistant_context_editor",
"assistant_settings",
@@ -161,6 +162,20 @@ dependencies = [
"workspace-hack",
]
[[package]]
name = "agent_rules"
version = "0.1.0"
dependencies = [
"anyhow",
"fs",
"gpui",
"indoc",
"prompt_store",
"util",
"workspace-hack",
"worktree",
]
[[package]]
name = "ahash"
version = "0.7.8"
@@ -746,6 +761,8 @@ dependencies = [
"itertools 0.14.0",
"language",
"language_model",
"log",
"lsp-types",
"open",
"project",
"rand 0.8.5",
@@ -753,6 +770,7 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"strum",
"ui",
"unindent",
"util",
@@ -18090,7 +18108,7 @@ dependencies = [
[[package]]
name = "zed"
version = "0.182.0"
version = "0.183.0"
dependencies = [
"activity_indicator",
"agent",

View File

@@ -3,6 +3,7 @@ resolver = "2"
members = [
"crates/activity_indicator",
"crates/agent",
"crates/agent_rules",
"crates/anthropic",
"crates/askpass",
"crates/assets",
@@ -209,6 +210,7 @@ edition = "2024"
activity_indicator = { path = "crates/activity_indicator" }
agent = { path = "crates/agent" }
agent_rules = { path = "crates/agent_rules" }
ai = { path = "crates/ai" }
anthropic = { path = "crates/anthropic" }
askpass = { path = "crates/askpass" }
@@ -294,6 +296,7 @@ livekit_api = { path = "crates/livekit_api" }
livekit_client = { path = "crates/livekit_client" }
lmstudio = { path = "crates/lmstudio" }
lsp = { path = "crates/lsp" }
lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "1fff0dd12e2071c5667327394cfec163d2a466ab" }
markdown = { path = "crates/markdown" }
markdown_preview = { path = "crates/markdown_preview" }
media = { path = "crates/media" }

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.1331 11.3776C10.2754 10.6665 10.1331 9.78593 11.1998 8.53327C11.82 7.80489 12.2664 6.96894 12.2664 6.04456C12.2664 4.91305 11.8169 3.82788 11.0168 3.02778C10.2167 2.22769 9.13152 1.7782 8.00001 1.7782C6.8685 1.7782 5.78334 2.22769 4.98324 3.02778C4.18314 3.82788 3.73364 4.91305 3.73364 6.04456C3.73364 6.75562 3.87586 7.6089 4.80024 8.53327C5.86683 9.80679 5.72462 10.6665 5.86683 11.3776M10.1331 11.3776V12.8821C10.1331 13.622 9.53341 14.2218 8.79353 14.2218H7.2065C6.46662 14.2218 5.86683 13.622 5.86683 12.8821V11.3776M10.1331 11.3776H5.86683" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 751 B

View File

@@ -150,7 +150,7 @@
"context": "AgentDiff",
"bindings": {
"ctrl-y": "agent::Keep",
"ctrl-k ctrl-r": "agent::Reject"
"ctrl-n": "agent::Reject"
}
},
{

View File

@@ -242,7 +242,7 @@
"use_key_equivalents": true,
"bindings": {
"cmd-y": "agent::Keep",
"cmd-alt-z": "agent::Reject"
"cmd-n": "agent::Reject"
}
},
{

View File

@@ -58,7 +58,8 @@
"ctrl-shift-home": "editor::SelectToBeginning",
"ctrl-shift-end": "editor::SelectToEnd",
"ctrl-f8": "editor::ToggleBreakpoint",
"ctrl-shift-f8": "editor::EditLogBreakpoint"
"ctrl-shift-f8": "editor::EditLogBreakpoint",
"ctrl-shift-u": "editor::ToggleCase"
}
},
{

View File

@@ -55,7 +55,8 @@
"cmd-shift-home": "editor::SelectToBeginning",
"cmd-shift-end": "editor::SelectToEnd",
"ctrl-f8": "editor::ToggleBreakpoint",
"ctrl-shift-f8": "editor::EditLogBreakpoint"
"ctrl-shift-f8": "editor::EditLogBreakpoint",
"cmd-shift-u": "editor::ToggleCase"
}
},
{

View File

@@ -155,7 +155,7 @@ There are rules that apply to these root directories:
{{#each worktrees}}
{{#if rules_file}}
`{{root_name}}/{{rules_file.rel_path}}`:
`{{root_name}}/{{rules_file.path_in_worktree}}`:
``````
{{{rules_file.text}}}

View File

@@ -0,0 +1 @@
In your response, and also when thinking, make sure to remember and follow my instructions about how to format code blocks (and don't ever mention that you are remembering it, just follow the instructions).

View File

@@ -658,6 +658,7 @@
"tools": {
"bash": true,
"batch_tool": true,
"code_actions": true,
"code_symbols": true,
"copy_path": false,
"create_file": true,
@@ -671,6 +672,7 @@
"path_search": true,
"read_file": true,
"regex_search": true,
"rename": true,
"symbol_info": true,
"thinking": true
}

View File

@@ -19,6 +19,7 @@ test-support = [
]
[dependencies]
agent_rules.workspace = true
anyhow.workspace = true
assistant_context_editor.workspace = true
assistant_settings.workspace = true

View File

@@ -376,7 +376,7 @@ fn render_markdown_code_block(
.cursor_pointer()
.rounded_sm()
.hover(|item| item.bg(cx.theme().colors().element_hover.opacity(0.5)))
.tooltip(Tooltip::text("Jump to file"))
.tooltip(Tooltip::text("Jump to File"))
.children(
file_icons::FileIcons::get_icon(&path_range.path, cx)
.map(Icon::from_path)
@@ -456,6 +456,7 @@ fn render_markdown_code_block(
.contains(&(message_id, ix));
let codeblock_header = h_flex()
.group("codeblock_header")
.p_1()
.gap_1()
.justify_between()
@@ -465,45 +466,47 @@ fn render_markdown_code_block(
.rounded_t_md()
.children(label)
.child(
IconButton::new(
("copy-markdown-code", ix),
if codeblock_was_copied {
IconName::Check
} else {
IconName::Copy
},
)
.icon_color(Color::Muted)
.shape(ui::IconButtonShape::Square)
.tooltip(Tooltip::text("Copy Code"))
.on_click({
let active_thread = active_thread.clone();
let parsed_markdown = parsed_markdown.clone();
move |_event, _window, cx| {
active_thread.update(cx, |this, cx| {
this.copied_code_block_ids.insert((message_id, ix));
div().visible_on_hover("codeblock_header").child(
IconButton::new(
("copy-markdown-code", ix),
if codeblock_was_copied {
IconName::Check
} else {
IconName::Copy
},
)
.icon_color(Color::Muted)
.shape(ui::IconButtonShape::Square)
.tooltip(Tooltip::text("Copy Code"))
.on_click({
let active_thread = active_thread.clone();
let parsed_markdown = parsed_markdown.clone();
move |_event, _window, cx| {
active_thread.update(cx, |this, cx| {
this.copied_code_block_ids.insert((message_id, ix));
let code =
without_fences(&parsed_markdown.source()[codeblock_range.clone()])
.to_string();
let code =
without_fences(&parsed_markdown.source()[codeblock_range.clone()])
.to_string();
cx.write_to_clipboard(ClipboardItem::new_string(code.clone()));
cx.write_to_clipboard(ClipboardItem::new_string(code.clone()));
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.spawn(async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|cx| {
this.update(cx, |this, cx| {
this.copied_code_block_ids.remove(&(message_id, ix));
cx.notify();
cx.update(|cx| {
this.update(cx, |this, cx| {
this.copied_code_block_ids.remove(&(message_id, ix));
cx.notify();
})
})
.ok();
})
.ok();
})
.detach();
});
}
}),
.detach();
});
}
}),
),
);
v_flex()
@@ -1219,17 +1222,30 @@ impl ActiveThread {
Label::new("Generating")
.color(Color::Muted)
.size(LabelSize::Small)
.with_animation(
.with_animations(
"generating-label",
Animation::new(Duration::from_secs(1)).repeat(),
|mut label, delta| {
let text = match delta {
d if d < 0.25 => "Generating",
d if d < 0.5 => "Generating.",
d if d < 0.75 => "Generating..",
_ => "Generating...",
};
label.set_text(text);
vec![
Animation::new(Duration::from_secs(1)),
Animation::new(Duration::from_secs(1)).repeat(),
],
|mut label, animation_ix, delta| {
match animation_ix {
0 => {
let chars_to_show = (delta * 10.).ceil() as usize;
let text = &"Generating"[0..chars_to_show];
label.set_text(text);
}
1 => {
let text = match delta {
d if d < 0.25 => "Generating",
d if d < 0.5 => "Generating.",
d if d < 0.75 => "Generating..",
_ => "Generating...",
};
label.set_text(text);
}
_ => {}
}
label
},
)
@@ -1753,7 +1769,7 @@ impl ActiveThread {
None
};
div()
v_flex()
.text_ui(cx)
.gap_2()
.children(
@@ -1838,177 +1854,225 @@ impl ActiveThread {
.copied()
.unwrap_or_default();
let editor_bg = cx.theme().colors().editor_background;
let editor_bg = cx.theme().colors().panel_background;
div().pt_0p5().pb_2().child(
v_flex()
.rounded_lg()
.border_1()
.border_color(self.tool_card_border_color(cx))
.child(
h_flex()
.group("disclosure-header")
.justify_between()
.py_1()
.px_2()
.bg(self.tool_card_header_bg(cx))
.map(|this| {
if pending || is_open {
this.rounded_t_md()
.border_b_1()
.border_color(self.tool_card_border_color(cx))
} else {
this.rounded_md()
}
})
.child(
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Brain)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child({
if pending {
Label::new("Thinking…")
div().map(|this| {
if pending {
this.v_flex()
.mt_neg_2()
.mb_1p5()
.child(
h_flex()
.group("disclosure-header")
.justify_between()
.child(
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::LightBulb)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child({
Label::new("Thinking")
.color(Color::Muted)
.size(LabelSize::Small)
.buffer_font(cx)
.with_animation(
"generating-label",
Animation::new(Duration::from_secs(1)).repeat(),
|mut label, delta| {
let text = match delta {
d if d < 0.25 => "Thinking",
d if d < 0.5 => "Thinking.",
d if d < 0.75 => "Thinking..",
_ => "Thinking...",
};
label.set_text(text);
label
},
)
.with_animation(
"pulsating-label",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.4, 0.8)),
|label, delta| label.alpha(delta),
.with_easing(pulsating_between(0.6, 1.)),
|label, delta| {
label.map_element(|label| label.alpha(delta))
},
)
.into_any_element()
} else {
Label::new("Thought Process")
.size(LabelSize::Small)
.buffer_font(cx)
.into_any_element()
}
}),
)
.child(
h_flex()
.gap_1()
.child(
div().visible_on_hover("disclosure-header").child(
Disclosure::new("thinking-disclosure", is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown)
.on_click(cx.listener({
move |this, _event, _window, _cx| {
let is_open = this
.expanded_thinking_segments
.entry((message_id, ix))
.or_insert(false);
*is_open = !*is_open;
}
})),
),
)
.child({
let (icon_name, color, animated) = if pending {
(IconName::ArrowCircle, Color::Accent, true)
} else {
(IconName::Check, Color::Success, false)
};
let icon =
Icon::new(icon_name).color(color).size(IconSize::Small);
if animated {
icon.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| {
icon.transform(Transformation::rotate(percentage(
delta,
)))
},
)
.into_any_element()
} else {
icon.into_any_element()
}
}),
),
)
.when(pending && !is_open, |this| {
let gradient_overlay = div()
.rounded_b_lg()
.h_20()
.absolute()
.w_full()
.bottom_0()
.left_0()
.bg(linear_gradient(
180.,
linear_color_stop(editor_bg, 1.),
linear_color_stop(editor_bg.opacity(0.2), 0.),
));
this.child(
div()
.relative()
.bg(editor_bg)
.rounded_b_lg()
.child(
div()
.id(("thinking-content", ix))
.p_2()
.h_20()
.track_scroll(scroll_handle)
.text_ui_sm(cx)
.child(
MarkdownElement::new(
markdown.clone(),
default_markdown_style(window, cx),
)
.on_url_click({
let workspace = self.workspace.clone();
move |text, window, cx| {
open_markdown_link(
text,
workspace.clone(),
window,
cx,
);
}
}),
)
.overflow_hidden(),
}),
)
.child(gradient_overlay),
)
})
.when(is_open, |this| {
this.child(
div()
.id(("thinking-content", ix))
.h_full()
.p_2()
.rounded_b_lg()
.bg(editor_bg)
.text_ui_sm(cx)
.child(
MarkdownElement::new(
markdown.clone(),
default_markdown_style(window, cx),
)
.on_url_click({
let workspace = self.workspace.clone();
move |text, window, cx| {
open_markdown_link(text, workspace.clone(), window, cx);
}
}),
h_flex()
.gap_1()
.child(
div().visible_on_hover("disclosure-header").child(
Disclosure::new("thinking-disclosure", is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown)
.on_click(cx.listener({
move |this, _event, _window, _cx| {
let is_open = this
.expanded_thinking_segments
.entry((message_id, ix))
.or_insert(false);
*is_open = !*is_open;
}
})),
),
)
.child({
Icon::new(IconName::ArrowCircle)
.color(Color::Accent)
.size(IconSize::Small)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| {
icon.transform(Transformation::rotate(
percentage(delta),
))
},
)
}),
),
)
}),
)
.when(!is_open, |this| {
let gradient_overlay = div()
.rounded_b_lg()
.h_full()
.absolute()
.w_full()
.bottom_0()
.left_0()
.bg(linear_gradient(
180.,
linear_color_stop(editor_bg, 1.),
linear_color_stop(editor_bg.opacity(0.2), 0.),
));
this.child(
div()
.relative()
.bg(editor_bg)
.rounded_b_lg()
.mt_2()
.pl_4()
.child(
div()
.id(("thinking-content", ix))
.max_h_20()
.track_scroll(scroll_handle)
.text_ui_sm(cx)
.overflow_hidden()
.child(
MarkdownElement::new(
markdown.clone(),
default_markdown_style(window, cx),
)
.on_url_click({
let workspace = self.workspace.clone();
move |text, window, cx| {
open_markdown_link(
text,
workspace.clone(),
window,
cx,
);
}
}),
),
)
.child(gradient_overlay),
)
})
.when(is_open, |this| {
this.child(
div()
.id(("thinking-content", ix))
.h_full()
.bg(editor_bg)
.text_ui_sm(cx)
.child(
MarkdownElement::new(
markdown.clone(),
default_markdown_style(window, cx),
)
.on_url_click({
let workspace = self.workspace.clone();
move |text, window, cx| {
open_markdown_link(text, workspace.clone(), window, cx);
}
}),
),
)
})
} else {
this.v_flex()
.mt_neg_2()
.child(
h_flex()
.group("disclosure-header")
.pr_1()
.justify_between()
.opacity(0.8)
.hover(|style| style.opacity(1.))
.child(
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::LightBulb)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child(Label::new("Thought Process").size(LabelSize::Small)),
)
.child(
div().visible_on_hover("disclosure-header").child(
Disclosure::new("thinking-disclosure", is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown)
.on_click(cx.listener({
move |this, _event, _window, _cx| {
let is_open = this
.expanded_thinking_segments
.entry((message_id, ix))
.or_insert(false);
*is_open = !*is_open;
}
})),
),
),
)
.child(
div()
.id(("thinking-content", ix))
.relative()
.mt_1p5()
.ml_1p5()
.pl_2p5()
.border_l_1()
.border_color(cx.theme().colors().border_variant)
.text_ui_sm(cx)
.when(is_open, |this| {
this.child(
MarkdownElement::new(
markdown.clone(),
default_markdown_style(window, cx),
)
.on_url_click({
let workspace = self.workspace.clone();
move |text, window, cx| {
open_markdown_link(text, workspace.clone(), window, cx);
}
}),
)
}),
)
}
})
}
fn render_tool_use(
@@ -2030,6 +2094,7 @@ impl ActiveThread {
.upgrade()
.map(|workspace| workspace.read(cx).app_state().fs.clone());
let needs_confirmation = matches!(&tool_use.status, ToolUseStatus::NeedsConfirmation);
let edit_tools = tool_use.needs_confirmation;
let status_icons = div().child(match &tool_use.status {
ToolUseStatus::Pending | ToolUseStatus::NeedsConfirmation => {
@@ -2206,10 +2271,10 @@ impl ActiveThread {
};
div().map(|element| {
if !tool_use.needs_confirmation {
if !edit_tools {
element.child(
v_flex()
.my_1p5()
.my_2()
.child(
h_flex()
.group("disclosure-header")
@@ -2516,7 +2581,7 @@ impl ActiveThread {
let label_text = match rules_files.as_slice() {
&[] => return div().into_any(),
&[rules_file] => {
format!("Using {:?} file", rules_file.rel_path)
format!("Using {:?} file", rules_file.path_in_worktree)
}
rules_files => {
format!("Using {} rules files", rules_files.len())

View File

@@ -227,14 +227,14 @@ impl AssistantPanel {
) -> Self {
let thread = thread_store.update(cx, |this, cx| this.create_thread(cx));
let fs = workspace.app_state().fs.clone();
let project = workspace.project().clone();
let project = workspace.project();
let language_registry = project.read(cx).languages().clone();
let workspace = workspace.weak_handle();
let weak_self = cx.entity().downgrade();
let message_editor_context_store = cx.new(|_cx| {
crate::context_store::ContextStore::new(
workspace.clone(),
project.downgrade(),
Some(thread_store.downgrade()),
)
});
@@ -344,7 +344,7 @@ impl AssistantPanel {
let message_editor_context_store = cx.new(|_cx| {
crate::context_store::ContextStore::new(
self.workspace.clone(),
self.project.downgrade(),
Some(self.thread_store.downgrade()),
)
});
@@ -521,7 +521,7 @@ impl AssistantPanel {
this.set_active_view(thread_view, window, cx);
let message_editor_context_store = cx.new(|_cx| {
crate::context_store::ContextStore::new(
this.workspace.clone(),
this.project.downgrade(),
Some(this.thread_store.downgrade()),
)
});
@@ -855,9 +855,11 @@ impl AssistantPanel {
if is_empty {
Label::new(Thread::DEFAULT_SUMMARY.clone())
.truncate()
.ml_2()
.into_any_element()
} else if summary.is_none() {
Label::new(LOADING_SUMMARY_PLACEHOLDER)
.ml_2()
.truncate()
.into_any_element()
} else {
@@ -873,7 +875,7 @@ impl AssistantPanel {
})
.unwrap_or_else(|| SharedString::from(LOADING_SUMMARY_PLACEHOLDER));
Label::new(title).truncate().into_any_element()
Label::new(title).ml_2().truncate().into_any_element()
}
ActiveView::History => Label::new("History").truncate().into_any_element(),
ActiveView::Configuration => Label::new("Settings").truncate().into_any_element(),
@@ -910,23 +912,25 @@ impl AssistantPanel {
let go_back_button = match &self.active_view {
ActiveView::History | ActiveView::Configuration => Some(
IconButton::new("go-back", IconName::ArrowLeft)
.icon_size(IconSize::Small)
.on_click(cx.listener(|this, _, window, cx| {
this.go_back(&workspace::GoBack, window, cx);
}))
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Go Back",
&workspace::GoBack,
&focus_handle,
window,
cx,
)
}
}),
div().pl_1().child(
IconButton::new("go-back", IconName::ArrowLeft)
.icon_size(IconSize::Small)
.on_click(cx.listener(|this, _, window, cx| {
this.go_back(&workspace::GoBack, window, cx);
}))
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Go Back",
&workspace::GoBack,
&focus_handle,
window,
cx,
)
}
}),
),
),
_ => None,
};
@@ -944,8 +948,7 @@ impl AssistantPanel {
.child(
h_flex()
.w_full()
.pl_2()
.gap_2()
.gap_1()
.children(go_back_button)
.child(self.render_title_view(window, cx)),
)
@@ -1080,7 +1083,7 @@ impl AssistantPanel {
cx,
|menu, _window, _cx| {
menu.action(
"New Prompt Editor",
"New Text Thread",
NewPromptEditor.boxed_clone(),
)
.when(!is_empty, |menu| {

View File

@@ -867,7 +867,7 @@ mod tests {
.expect("Opened test file wasn't an editor")
});
let context_store = cx.new(|_| ContextStore::new(workspace.downgrade(), None));
let context_store = cx.new(|_| ContextStore::new(project.downgrade(), None));
let editor_entity = editor.downgrade();
editor.update_in(&mut cx, |editor, window, cx| {

View File

@@ -8,11 +8,10 @@ use futures::future::join_all;
use futures::{self, Future, FutureExt, future};
use gpui::{App, AppContext as _, Context, Entity, SharedString, Task, WeakEntity};
use language::{Buffer, File};
use project::{ProjectItem, ProjectPath, Worktree};
use project::{Project, ProjectItem, ProjectPath, Worktree};
use rope::Rope;
use text::{Anchor, BufferId, OffsetRangeExt};
use util::{ResultExt as _, maybe};
use workspace::Workspace;
use crate::ThreadStore;
use crate::context::{
@@ -23,7 +22,7 @@ use crate::context_strip::SuggestedContext;
use crate::thread::{Thread, ThreadId};
pub struct ContextStore {
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
context: Vec<AssistantContext>,
thread_store: Option<WeakEntity<ThreadStore>>,
// TODO: If an EntityId is used for all context types (like BufferId), can remove ContextId.
@@ -40,11 +39,11 @@ pub struct ContextStore {
impl ContextStore {
pub fn new(
workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
thread_store: Option<WeakEntity<ThreadStore>>,
) -> Self {
Self {
workspace,
project,
thread_store,
context: Vec::new(),
next_context_id: ContextId(0),
@@ -81,12 +80,7 @@ impl ContextStore {
remove_if_exists: bool,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let workspace = self.workspace.clone();
let Some(project) = workspace
.upgrade()
.map(|workspace| workspace.read(cx).project().clone())
else {
let Some(project) = self.project.upgrade() else {
return Task::ready(Err(anyhow!("failed to read project")));
};
@@ -161,11 +155,7 @@ impl ContextStore {
remove_if_exists: bool,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let workspace = self.workspace.clone();
let Some(project) = workspace
.upgrade()
.map(|workspace| workspace.read(cx).project().clone())
else {
let Some(project) = self.project.upgrade() else {
return Task::ready(Err(anyhow!("failed to read project")));
};

View File

@@ -262,13 +262,7 @@ impl InlineAssistant {
}
InlineAssistTarget::Terminal(active_terminal) => {
TerminalInlineAssistant::update_global(cx, |assistant, cx| {
assistant.assist(
&active_terminal,
cx.entity().downgrade(),
thread_store,
window,
cx,
)
assistant.assist(&active_terminal, cx.entity(), thread_store, window, cx)
})
}
};
@@ -322,6 +316,13 @@ impl InlineAssistant {
window: &mut Window,
cx: &mut App,
) {
let Some(project) = workspace
.upgrade()
.map(|workspace| workspace.read(cx).project().downgrade())
else {
return;
};
let (snapshot, initial_selections) = editor.update(cx, |editor, cx| {
(
editor.snapshot(window, cx),
@@ -425,7 +426,7 @@ impl InlineAssistant {
for range in codegen_ranges {
let assist_id = self.next_assist_id.post_inc();
let context_store =
cx.new(|_cx| ContextStore::new(workspace.clone(), thread_store.clone()));
cx.new(|_cx| ContextStore::new(project.clone(), thread_store.clone()));
let codegen = cx.new(|cx| {
BufferCodegen::new(
editor.read(cx).buffer().clone(),
@@ -519,7 +520,7 @@ impl InlineAssistant {
initial_prompt: String,
initial_transaction_id: Option<TransactionId>,
focus: bool,
workspace: WeakEntity<Workspace>,
workspace: Entity<Workspace>,
thread_store: Option<WeakEntity<ThreadStore>>,
window: &mut Window,
cx: &mut App,
@@ -537,8 +538,8 @@ impl InlineAssistant {
range.end = range.end.bias_right(&snapshot);
}
let context_store =
cx.new(|_cx| ContextStore::new(workspace.clone(), thread_store.clone()));
let project = workspace.read(cx).project().downgrade();
let context_store = cx.new(|_cx| ContextStore::new(project, thread_store.clone()));
let codegen = cx.new(|cx| {
BufferCodegen::new(
@@ -562,7 +563,7 @@ impl InlineAssistant {
codegen.clone(),
self.fs.clone(),
context_store,
workspace.clone(),
workspace.downgrade(),
thread_store,
window,
cx,
@@ -589,7 +590,7 @@ impl InlineAssistant {
end_block_id,
range,
codegen.clone(),
workspace.clone(),
workspace.downgrade(),
window,
cx,
),
@@ -1779,6 +1780,7 @@ impl CodeActionProvider for AssistantCodeActionProvider {
let workspace = self.workspace.clone();
let thread_store = self.thread_store.clone();
window.spawn(cx, async move |cx| {
let workspace = workspace.upgrade().context("workspace was released")?;
let editor = editor.upgrade().context("editor was released")?;
let range = editor
.update(cx, |editor, cx| {

View File

@@ -66,7 +66,7 @@ impl TerminalInlineAssistant {
pub fn assist(
&mut self,
terminal_view: &Entity<TerminalView>,
workspace: WeakEntity<Workspace>,
workspace: Entity<Workspace>,
thread_store: Option<WeakEntity<ThreadStore>>,
window: &mut Window,
cx: &mut App,
@@ -75,8 +75,8 @@ impl TerminalInlineAssistant {
let assist_id = self.next_assist_id.post_inc();
let prompt_buffer =
cx.new(|cx| MultiBuffer::singleton(cx.new(|cx| Buffer::local(String::new(), cx)), cx));
let context_store =
cx.new(|_cx| ContextStore::new(workspace.clone(), thread_store.clone()));
let project = workspace.read(cx).project().downgrade();
let context_store = cx.new(|_cx| ContextStore::new(project, thread_store.clone()));
let codegen = cx.new(|_| TerminalCodegen::new(terminal, self.telemetry.clone()));
let prompt_editor = cx.new(|cx| {
@@ -87,7 +87,7 @@ impl TerminalInlineAssistant {
codegen,
self.fs.clone(),
context_store.clone(),
workspace.clone(),
workspace.downgrade(),
thread_store.clone(),
window,
cx,
@@ -106,7 +106,7 @@ impl TerminalInlineAssistant {
assist_id,
terminal_view,
prompt_editor,
workspace.clone(),
workspace.downgrade(),
context_store,
window,
cx,

View File

@@ -3,6 +3,7 @@ use std::io::Write;
use std::ops::Range;
use std::sync::Arc;
use agent_rules::load_worktree_rules_file;
use anyhow::{Context as _, Result, anyhow};
use assistant_settings::AssistantSettings;
use assistant_tool::{ActionLog, Tool, ToolWorkingSet};
@@ -21,13 +22,11 @@ use language_model::{
};
use project::git_store::{GitStore, GitStoreCheckpoint, RepositoryState};
use project::{Project, Worktree};
use prompt_store::{
AssistantSystemPromptContext, PromptBuilder, RulesFile, WorktreeInfoForSystemPrompt,
};
use prompt_store::{AssistantSystemPromptContext, PromptBuilder, WorktreeInfoForSystemPrompt};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
use util::{ResultExt as _, TryFutureExt as _, maybe, post_inc};
use util::{ResultExt as _, TryFutureExt as _, post_inc};
use uuid::Uuid;
use crate::context::{AssistantContext, ContextId, format_context_as_string};
@@ -854,67 +853,36 @@ impl Thread {
let root_name = worktree.root_name().into();
let abs_path = worktree.abs_path();
// Note that Cline supports `.clinerules` being a directory, but that is not currently
// supported. This doesn't seem to occur often in GitHub repositories.
const RULES_FILE_NAMES: [&'static str; 6] = [
".rules",
".cursorrules",
".windsurfrules",
".clinerules",
".github/copilot-instructions.md",
"CLAUDE.md",
];
let selected_rules_file = RULES_FILE_NAMES
.into_iter()
.filter_map(|name| {
worktree
.entry_for_path(name)
.filter(|entry| entry.is_file())
.map(|entry| (entry.path.clone(), worktree.absolutize(&entry.path)))
})
.next();
if let Some((rel_rules_path, abs_rules_path)) = selected_rules_file {
cx.spawn(async move |_| {
let rules_file_result = maybe!(async move {
let abs_rules_path = abs_rules_path?;
let text = fs.load(&abs_rules_path).await.with_context(|| {
format!("Failed to load assistant rules file {:?}", abs_rules_path)
})?;
anyhow::Ok(RulesFile {
rel_path: rel_rules_path,
abs_path: abs_rules_path.into(),
text: text.trim().to_string(),
})
})
.await;
let (rules_file, rules_file_error) = match rules_file_result {
Ok(rules_file) => (Some(rules_file), None),
Err(err) => (
None,
Some(ThreadError::Message {
header: "Error loading rules file".into(),
message: format!("{err}").into(),
}),
),
};
let worktree_info = WorktreeInfoForSystemPrompt {
root_name,
abs_path,
rules_file,
};
(worktree_info, rules_file_error)
})
} else {
Task::ready((
let rules_task = load_worktree_rules_file(fs, worktree, cx);
let Some(rules_task) = rules_task else {
return Task::ready((
WorktreeInfoForSystemPrompt {
root_name,
abs_path,
rules_file: None,
},
None,
))
}
));
};
cx.spawn(async move |_| {
let (rules_file, rules_file_error) = match rules_task.await {
Ok(rules_file) => (Some(rules_file), None),
Err(err) => (
None,
Some(ThreadError::Message {
header: "Error loading rules file".into(),
message: format!("{err}").into(),
}),
),
};
let worktree_info = WorktreeInfoForSystemPrompt {
root_name,
abs_path,
rules_file,
};
(worktree_info, rules_file_error)
})
}
pub fn send_to_model(
@@ -1029,6 +997,21 @@ impl Thread {
self.attached_tracked_files_state(&mut request.messages, cx);
// Add reminder to the last user message about
// easily-forgotten aspects of the system prompt.
if let Some(last_user_message) = request
.messages
.iter_mut()
.rev()
.find(|msg| msg.role == Role::User)
{
last_user_message
.content
.push(MessageContent::Text(system_prompt_reminder(
&self.prompt_builder,
)));
}
request
}
@@ -1842,6 +1825,12 @@ impl Thread {
}
}
pub fn system_prompt_reminder(prompt_builder: &prompt_store::PromptBuilder) -> String {
prompt_builder
.generate_assistant_system_prompt_reminder()
.unwrap_or_default()
}
#[derive(Debug, Clone)]
pub enum ThreadError {
PaymentRequired,
@@ -1911,7 +1900,7 @@ mod tests {
)
.await;
let (_workspace, _thread_store, thread, context_store) =
let (_workspace, _thread_store, thread, context_store, prompt_builder) =
setup_test_environment(cx, project.clone()).await;
add_file_to_context(&project, &context_store, "test/code.rs", cx)
@@ -1965,8 +1954,14 @@ fn main() {{
});
assert_eq!(request.messages.len(), 1);
let expected_full_message = format!("{}Please explain this code", expected_context);
assert_eq!(request.messages[0].string_contents(), expected_full_message);
let actual_message = request.messages[0].string_contents();
let expected_content = format!(
"{}Please explain this code{}",
expected_context,
system_prompt_reminder(&prompt_builder)
);
assert_eq!(actual_message, expected_content);
}
#[gpui::test]
@@ -1983,7 +1978,7 @@ fn main() {{
)
.await;
let (_, _thread_store, thread, context_store) =
let (_, _thread_store, thread, context_store, _prompt_builder) =
setup_test_environment(cx, project.clone()).await;
// Open files individually
@@ -2083,7 +2078,7 @@ fn main() {{
)
.await;
let (_, _thread_store, thread, _context_store) =
let (_, _thread_store, thread, _context_store, prompt_builder) =
setup_test_environment(cx, project.clone()).await;
// Insert user message without any context (empty context vector)
@@ -2109,11 +2104,14 @@ fn main() {{
});
assert_eq!(request.messages.len(), 1);
assert_eq!(
request.messages[0].string_contents(),
"What is the best way to learn Rust?"
let actual_message = request.messages[0].string_contents();
let expected_content = format!(
"What is the best way to learn Rust?{}",
system_prompt_reminder(&prompt_builder)
);
assert_eq!(actual_message, expected_content);
// Add second message, also without context
let message2_id = thread.update(cx, |thread, cx| {
thread.insert_user_message("Are there any good books?", vec![], None, cx)
@@ -2129,14 +2127,17 @@ fn main() {{
});
assert_eq!(request.messages.len(), 2);
assert_eq!(
request.messages[0].string_contents(),
"What is the best way to learn Rust?"
);
assert_eq!(
request.messages[1].string_contents(),
"Are there any good books?"
// First message should be the system prompt
assert_eq!(request.messages[0].role, Role::User);
// Second message should be the user message with prompt reminder
let actual_message = request.messages[1].string_contents();
let expected_content = format!(
"Are there any good books?{}",
system_prompt_reminder(&prompt_builder)
);
assert_eq!(actual_message, expected_content);
}
#[gpui::test]
@@ -2149,7 +2150,7 @@ fn main() {{
)
.await;
let (_workspace, _thread_store, thread, context_store) =
let (_workspace, _thread_store, thread, context_store, prompt_builder) =
setup_test_environment(cx, project.clone()).await;
// Open buffer and add it to context
@@ -2209,11 +2210,14 @@ fn main() {{
// The last message should be the stale buffer notification
assert_eq!(last_message.role, Role::User);
// Check the exact content of the message
let expected_content = "These files changed since last read:\n- code.rs\n";
let actual_message = last_message.string_contents();
let expected_content = format!(
"These files changed since last read:\n- code.rs\n{}",
system_prompt_reminder(&prompt_builder)
);
assert_eq!(
last_message.string_contents(),
expected_content,
actual_message, expected_content,
"Last message should be exactly the stale buffer notification"
);
}
@@ -2251,24 +2255,27 @@ fn main() {{
Entity<ThreadStore>,
Entity<Thread>,
Entity<ContextStore>,
Arc<PromptBuilder>,
) {
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap());
let thread_store = cx.update(|_, cx| {
ThreadStore::new(
project.clone(),
Arc::default(),
Arc::new(PromptBuilder::new(None).unwrap()),
cx,
)
.unwrap()
ThreadStore::new(project.clone(), Arc::default(), prompt_builder.clone(), cx).unwrap()
});
let thread = thread_store.update(cx, |store, cx| store.create_thread(cx));
let context_store = cx.new(|_cx| ContextStore::new(workspace.downgrade(), None));
let context_store = cx.new(|_cx| ContextStore::new(project.downgrade(), None));
(workspace, thread_store, thread, context_store)
(
workspace,
thread_store,
thread,
context_store,
prompt_builder,
)
}
async fn add_file_to_context(

View File

@@ -431,17 +431,6 @@ impl RenderOnce for PastThread {
.end_slot(
h_flex()
.gap_1p5()
.child(
Label::new("Thread")
.color(Color::Muted)
.size(LabelSize::XSmall),
)
.child(
div()
.size(px(3.))
.rounded_full()
.bg(cx.theme().colors().text_disabled),
)
.child(
Label::new(thread_timestamp)
.color(Color::Muted)
@@ -452,12 +441,7 @@ impl RenderOnce for PastThread {
.shape(IconButtonShape::Square)
.icon_size(IconSize::XSmall)
.tooltip(move |window, cx| {
Tooltip::for_action(
"Delete Thread",
&RemoveSelectedThread,
window,
cx,
)
Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
})
.on_click({
let assistant_panel = self.assistant_panel.clone();
@@ -538,17 +522,6 @@ impl RenderOnce for PastContext {
.end_slot(
h_flex()
.gap_1p5()
.child(
Label::new("Prompt Editor")
.color(Color::Muted)
.size(LabelSize::XSmall),
)
.child(
div()
.size(px(3.))
.rounded_full()
.bg(cx.theme().colors().text_disabled),
)
.child(
Label::new(context_timestamp)
.color(Color::Muted)
@@ -559,12 +532,7 @@ impl RenderOnce for PastContext {
.shape(IconButtonShape::Square)
.icon_size(IconSize::XSmall)
.tooltip(move |window, cx| {
Tooltip::for_action(
"Delete Prompt Editor",
&RemoveSelectedThread,
window,
cx,
)
Tooltip::for_action("Delete", &RemoveSelectedThread, window, cx)
})
.on_click({
let assistant_panel = self.assistant_panel.clone();

View File

@@ -334,6 +334,8 @@ impl ToolUseState {
output: Result<String>,
cx: &App,
) -> Option<PendingToolUse> {
telemetry::event!("Agent Tool Finished", tool_name, success = output.is_ok());
match output {
Ok(tool_result) => {
let model_registry = LanguageModelRegistry::read_global(cx);

View File

@@ -0,0 +1,25 @@
[package]
name = "agent_rules"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/agent_rules.rs"
doctest = false
[dependencies]
anyhow.workspace = true
fs.workspace = true
gpui.workspace = true
prompt_store.workspace = true
util.workspace = true
worktree.workspace = true
workspace-hack = { version = "0.1", path = "../../tooling/workspace-hack" }
[dev-dependencies]
indoc.workspace = true

View File

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

View File

@@ -0,0 +1,51 @@
use std::sync::Arc;
use anyhow::{Context as _, Result};
use fs::Fs;
use gpui::{App, AppContext, Task};
use prompt_store::SystemPromptRulesFile;
use util::maybe;
use worktree::Worktree;
const RULES_FILE_NAMES: [&'static str; 6] = [
".rules",
".cursorrules",
".windsurfrules",
".clinerules",
".github/copilot-instructions.md",
"CLAUDE.md",
];
pub fn load_worktree_rules_file(
fs: Arc<dyn Fs>,
worktree: &Worktree,
cx: &App,
) -> Option<Task<Result<SystemPromptRulesFile>>> {
let selected_rules_file = RULES_FILE_NAMES
.into_iter()
.filter_map(|name| {
worktree
.entry_for_path(name)
.filter(|entry| entry.is_file())
.map(|entry| (entry.path.clone(), worktree.absolutize(&entry.path)))
})
.next();
// Note that Cline supports `.clinerules` being a directory, but that is not currently
// supported. This doesn't seem to occur often in GitHub repositories.
selected_rules_file.map(|(path_in_worktree, abs_path)| {
let fs = fs.clone();
cx.background_spawn(maybe!(async move {
let abs_path = abs_path?;
let text = fs
.load(&abs_path)
.await
.with_context(|| format!("Failed to load assistant rules file {:?}", abs_path))?;
anyhow::Ok(SystemPromptRulesFile {
path_in_worktree,
abs_path: abs_path.into(),
text: text.trim().to_string(),
})
}))
})
}

View File

@@ -2793,7 +2793,7 @@ fn render_thought_process_fold_icon_button(
let button = match status {
ThoughtProcessStatus::Pending => button
.child(
Icon::new(IconName::Brain)
Icon::new(IconName::LightBulb)
.size(IconSize::Small)
.color(Color::Muted),
)
@@ -2808,7 +2808,7 @@ fn render_thought_process_fold_icon_button(
),
ThoughtProcessStatus::Completed => button
.style(ButtonStyle::Filled)
.child(Icon::new(IconName::Brain).size(IconSize::Small))
.child(Icon::new(IconName::LightBulb).size(IconSize::Small))
.child(Label::new("Thought Process").single_line()),
};

View File

@@ -23,15 +23,18 @@ http_client.workspace = true
itertools.workspace = true
language.workspace = true
language_model.workspace = true
log.workspace = true
lsp-types.workspace = true
open = { workspace = true }
project.workspace = true
regex.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
strum.workspace = true
ui.workspace = true
util.workspace = true
worktree.workspace = true
open = { workspace = true }
workspace-hack.workspace = true
[dev-dependencies]

View File

@@ -1,5 +1,6 @@
mod bash_tool;
mod batch_tool;
mod code_action_tool;
mod code_symbols_tool;
mod copy_path_tool;
mod create_directory_tool;
@@ -13,8 +14,10 @@ mod move_path_tool;
mod now_tool;
mod open_tool;
mod path_search_tool;
mod quickfix_tool;
mod read_file_tool;
mod regex_search_tool;
mod rename_tool;
mod replace;
mod schema;
mod symbol_info_tool;
@@ -30,6 +33,7 @@ use move_path_tool::MovePathTool;
use crate::bash_tool::BashTool;
use crate::batch_tool::BatchTool;
use crate::code_action_tool::CodeActionTool;
use crate::code_symbols_tool::CodeSymbolsTool;
use crate::create_directory_tool::CreateDirectoryTool;
use crate::create_file_tool::CreateFileTool;
@@ -41,8 +45,10 @@ use crate::list_directory_tool::ListDirectoryTool;
use crate::now_tool::NowTool;
use crate::open_tool::OpenTool;
use crate::path_search_tool::PathSearchTool;
use crate::quickfix_tool::QuickfixTool;
use crate::read_file_tool::ReadFileTool;
use crate::regex_search_tool::RegexSearchTool;
use crate::rename_tool::RenameTool;
use crate::symbol_info_tool::SymbolInfoTool;
use crate::thinking_tool::ThinkingTool;
@@ -58,6 +64,7 @@ pub fn init(http_client: Arc<HttpClientWithUrl>, cx: &mut App) {
registry.register_tool(DeletePathTool);
registry.register_tool(FindReplaceFileTool);
registry.register_tool(SymbolInfoTool);
registry.register_tool(CodeActionTool);
registry.register_tool(MovePathTool);
registry.register_tool(DiagnosticsTool);
registry.register_tool(ListDirectoryTool);
@@ -65,8 +72,10 @@ pub fn init(http_client: Arc<HttpClientWithUrl>, cx: &mut App) {
registry.register_tool(OpenTool);
registry.register_tool(CodeSymbolsTool);
registry.register_tool(PathSearchTool);
registry.register_tool(QuickfixTool);
registry.register_tool(ReadFileTool);
registry.register_tool(RegexSearchTool);
registry.register_tool(RenameTool);
registry.register_tool(ThinkingTool);
registry.register_tool(FetchTool::new(http_client));
}

View File

@@ -3,7 +3,7 @@ use anyhow::{Context as _, Result, anyhow};
use assistant_tool::{ActionLog, Tool};
use futures::io::BufReader;
use futures::{AsyncBufReadExt, AsyncReadExt};
use gpui::{App, Entity, Task};
use gpui::{App, AppContext, Entity, Task};
use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat};
use project::Project;
use schemars::JsonSchema;
@@ -123,94 +123,184 @@ impl Tool for BashTool {
worktree.read(cx).abs_path()
};
cx.spawn(async move |_| {
// Add 2>&1 to merge stderr into stdout for proper interleaving.
let command = format!("({}) 2>&1", input.command);
let mut cmd = new_smol_command("bash")
.arg("-c")
.arg(&command)
.current_dir(working_dir)
.stdout(std::process::Stdio::piped())
.spawn()
.context("Failed to execute bash command")?;
// Capture stdout with a limit
let stdout = cmd.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
const MESSAGE_1: &str = "Command output too long. The first ";
const MESSAGE_2: &str = " bytes:\n\n";
const ERR_MESSAGE_1: &str = "Command failed with exit code ";
const ERR_MESSAGE_2: &str = "\n\n";
const STDOUT_LIMIT: usize = 8192;
const LIMIT: usize = STDOUT_LIMIT
- (MESSAGE_1.len()
+ (STDOUT_LIMIT.ilog10() as usize + 1) // byte count
+ MESSAGE_2.len()
+ ERR_MESSAGE_1.len()
+ 3 // status code
+ ERR_MESSAGE_2.len());
// Read one more byte to determine whether the output was truncated
let mut buffer = vec![0; LIMIT + 1];
let bytes_read = reader.read(&mut buffer).await?;
// Repeatedly fill the output reader's buffer without copying it.
loop {
let skipped_bytes = reader.fill_buf().await?;
if skipped_bytes.is_empty() {
break;
}
let skipped_bytes_len = skipped_bytes.len();
reader.consume_unpin(skipped_bytes_len);
}
let output_bytes = &buffer[..bytes_read];
// Let the process continue running
let status = cmd.status().await.context("Failed to get command status")?;
let output_string = if bytes_read > LIMIT {
// Valid to find `\n` in UTF-8 since 0-127 ASCII characters are not used in
// multi-byte characters.
let last_line_ix = output_bytes.iter().rposition(|b| *b == b'\n');
let output_string = String::from_utf8_lossy(
&output_bytes[..last_line_ix.unwrap_or(output_bytes.len())],
);
format!(
"{}{}{}{}",
MESSAGE_1,
output_string.len(),
MESSAGE_2,
output_string
)
} else {
String::from_utf8_lossy(&output_bytes).into()
};
let output_with_status = if status.success() {
if output_string.is_empty() {
"Command executed successfully.".to_string()
} else {
output_string.to_string()
}
} else {
format!(
"{}{}{}{}",
ERR_MESSAGE_1,
status.code().unwrap_or(-1),
ERR_MESSAGE_2,
output_string,
)
};
debug_assert!(output_with_status.len() <= STDOUT_LIMIT);
Ok(output_with_status)
})
cx.background_spawn(run_command_limited(working_dir, input.command))
}
}
const LIMIT: usize = 16 * 1024;
async fn run_command_limited(working_dir: Arc<Path>, command: String) -> Result<String> {
// Add 2>&1 to merge stderr into stdout for proper interleaving.
let command = format!("({}) 2>&1", command);
let mut cmd = new_smol_command("bash")
.arg("-c")
.arg(&command)
.current_dir(working_dir)
.stdout(std::process::Stdio::piped())
.spawn()
.context("Failed to execute bash command")?;
// Capture stdout with a limit
let stdout = cmd.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
// Read one more byte to determine whether the output was truncated
let mut buffer = vec![0; LIMIT + 1];
let mut bytes_read = 0;
// Read until we reach the limit
loop {
let read = reader.read(&mut buffer[bytes_read..]).await?;
if read == 0 {
break;
}
bytes_read += read;
if bytes_read > LIMIT {
bytes_read = LIMIT + 1;
break;
}
}
// Repeatedly fill the output reader's buffer without copying it.
loop {
let skipped_bytes = reader.fill_buf().await?;
if skipped_bytes.is_empty() {
break;
}
let skipped_bytes_len = skipped_bytes.len();
reader.consume_unpin(skipped_bytes_len);
}
let output_bytes = &buffer[..bytes_read.min(LIMIT)];
let status = cmd.status().await.context("Failed to get command status")?;
let output_string = if bytes_read > LIMIT {
// Valid to find `\n` in UTF-8 since 0-127 ASCII characters are not used in
// multi-byte characters.
let last_line_ix = output_bytes.iter().rposition(|b| *b == b'\n');
let until_last_line = &output_bytes[..last_line_ix.unwrap_or(output_bytes.len())];
let output_string = String::from_utf8_lossy(until_last_line);
format!(
"Command output too long. The first {} bytes:\n\n{}",
output_string.len(),
output_block(&output_string),
)
} else {
output_block(&String::from_utf8_lossy(&output_bytes))
};
let output_with_status = if status.success() {
if output_string.is_empty() {
"Command executed successfully.".to_string()
} else {
output_string.to_string()
}
} else {
format!(
"Command failed with exit code {}\n\n{}",
status.code().unwrap_or(-1),
output_string,
)
};
Ok(output_with_status)
}
fn output_block(output: &str) -> String {
format!(
"```\n{}{}```",
output,
if output.ends_with('\n') { "" } else { "\n" }
)
}
#[cfg(test)]
#[cfg(not(windows))]
mod tests {
use gpui::TestAppContext;
use super::*;
#[gpui::test]
async fn test_run_command_simple(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let result =
run_command_limited(Path::new(".").into(), "echo 'Hello, World!'".to_string()).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "```\nHello, World!\n```");
}
#[gpui::test]
async fn test_interleaved_stdout_stderr(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let command =
"echo 'stdout 1' && echo 'stderr 1' >&2 && echo 'stdout 2' && echo 'stderr 2' >&2";
let result = run_command_limited(Path::new(".").into(), command.to_string()).await;
assert!(result.is_ok());
assert_eq!(
result.unwrap(),
"```\nstdout 1\nstderr 1\nstdout 2\nstderr 2\n```"
);
}
#[gpui::test]
async fn test_multiple_output_reads(cx: &mut TestAppContext) {
cx.executor().allow_parking();
// Command with multiple outputs that might require multiple reads
let result = run_command_limited(
Path::new(".").into(),
"echo '1'; sleep 0.01; echo '2'; sleep 0.01; echo '3'".to_string(),
)
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "```\n1\n2\n3\n```");
}
#[gpui::test]
async fn test_output_truncation_single_line(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let cmd = format!("echo '{}';", "X".repeat(LIMIT * 2));
let result = run_command_limited(Path::new(".").into(), cmd).await;
assert!(result.is_ok());
let output = result.unwrap();
let content_start = output.find("```\n").map(|i| i + 4).unwrap_or(0);
let content_end = output.rfind("\n```").unwrap_or(output.len());
let content_length = content_end - content_start;
// Output should be exactly the limit
assert_eq!(content_length, LIMIT);
}
#[gpui::test]
async fn test_output_truncation_multiline(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let cmd = format!("echo '{}'; ", "X".repeat(120)).repeat(160);
let result = run_command_limited(Path::new(".").into(), cmd).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.starts_with("Command output too long. The first 16334 bytes:\n\n"));
let content_start = output.find("```\n").map(|i| i + 4).unwrap_or(0);
let content_end = output.rfind("\n```").unwrap_or(output.len());
let content_length = content_end - content_start;
assert!(content_length <= LIMIT);
}
}

View File

@@ -0,0 +1,389 @@
use anyhow::{Context as _, Result, anyhow};
use assistant_tool::{ActionLog, Tool};
use gpui::{App, Entity, Task};
use language::{self, Anchor, Buffer, ToPointUtf16};
use language_model::LanguageModelRequestMessage;
use project::{self, LspAction, Project};
use regex::Regex;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{ops::Range, sync::Arc};
use ui::IconName;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct CodeActionToolInput {
/// The relative path to the file containing the text range.
///
/// WARNING: you MUST start this path with one of the project's root directories.
pub path: String,
/// The specific code action to execute.
///
/// If this field is provided, the tool will execute the specified action.
/// If omitted, the tool will list all available code actions for the text range.
///
/// Here are some actions that are commonly supported (but may not be for this particular
/// text range; you can omit this field to list all the actions, if you want to know
/// what your options are, or you can just try an action and if it fails I'll tell you
/// what the available actions were instead):
/// - "quickfix.all" - applies all available quick fixes in the range
/// - "source.organizeImports" - sorts and cleans up import statements
/// - "source.fixAll" - applies all available auto fixes
/// - "refactor.extract" - extracts selected code into a new function or variable
/// - "refactor.inline" - inlines a variable by replacing references with its value
/// - "refactor.rewrite" - general code rewriting operations
/// - "source.addMissingImports" - adds imports for references that lack them
/// - "source.removeUnusedImports" - removes imports that aren't being used
/// - "source.implementInterface" - generates methods required by an interface/trait
/// - "source.generateAccessors" - creates getter/setter methods
/// - "source.convertToAsyncFunction" - converts callback-style code to async/await
///
/// Also, there is a special case: if you specify exactly "textDocument/rename" as the action,
/// then this will rename the symbol to whatever string you specified for the `arguments` field.
pub action: Option<String>,
/// Optional arguments to pass to the code action.
///
/// For rename operations (when action="textDocument/rename"), this should contain the new name.
/// For other code actions, these arguments may be passed to the language server.
pub arguments: Option<serde_json::Value>,
/// The text that comes immediately before the text range in the file.
pub context_before_range: String,
/// The text range. This text must appear in the file right between `context_before_range`
/// and `context_after_range`.
///
/// The file must contain exactly one occurrence of `context_before_range` followed by
/// `text_range` followed by `context_after_range`. If the file contains zero occurrences,
/// or if it contains more than one occurrence, the tool will fail, so it is absolutely
/// critical that you verify ahead of time that the string is unique. You can search
/// the file's contents to verify this ahead of time.
///
/// To make the string more likely to be unique, include a minimum of 1 line of context
/// before the text range, as well as a minimum of 1 line of context after the text range.
/// If these lines of context are not enough to obtain a string that appears only once
/// in the file, then double the number of context lines until the string becomes unique.
/// (Start with 1 line before and 1 line after though, because too much context is
/// needlessly costly.)
///
/// Do not alter the context lines of code in any way, and make sure to preserve all
/// whitespace and indentation for all lines of code. The combined string must be exactly
/// as it appears in the file, or else this tool call will fail.
pub text_range: String,
/// The text that comes immediately after the text range in the file.
pub context_after_range: String,
}
pub struct CodeActionTool;
impl Tool for CodeActionTool {
fn name(&self) -> String {
"code_actions".into()
}
fn needs_confirmation(&self, _input: &serde_json::Value, _cx: &App) -> bool {
false
}
fn description(&self) -> String {
include_str!("./code_action_tool/description.md").into()
}
fn icon(&self) -> IconName {
IconName::Wand
}
fn input_schema(
&self,
_format: language_model::LanguageModelToolSchemaFormat,
) -> serde_json::Value {
let schema = schemars::schema_for!(CodeActionToolInput);
serde_json::to_value(&schema).unwrap()
}
fn ui_text(&self, input: &serde_json::Value) -> String {
match serde_json::from_value::<CodeActionToolInput>(input.clone()) {
Ok(input) => {
if let Some(action) = &input.action {
if action == "textDocument/rename" {
let new_name = match &input.arguments {
Some(serde_json::Value::String(new_name)) => new_name.clone(),
Some(value) => {
if let Ok(new_name) =
serde_json::from_value::<String>(value.clone())
{
new_name
} else {
"invalid name".to_string()
}
}
None => "missing name".to_string(),
};
format!("Rename '{}' to '{}'", input.text_range, new_name)
} else {
format!(
"Execute code action '{}' for '{}'",
action, input.text_range
)
}
} else {
format!("List available code actions for '{}'", input.text_range)
}
}
Err(_) => "Perform code action".to_string(),
}
}
fn run(
self: Arc<Self>,
input: serde_json::Value,
_messages: &[LanguageModelRequestMessage],
project: Entity<Project>,
action_log: Entity<ActionLog>,
cx: &mut App,
) -> Task<Result<String>> {
let input = match serde_json::from_value::<CodeActionToolInput>(input) {
Ok(input) => input,
Err(err) => return Task::ready(Err(anyhow!(err))),
};
cx.spawn(async move |cx| {
let buffer = {
let project_path = project.read_with(cx, |project, cx| {
project
.find_project_path(&input.path, cx)
.context("Path not found in project")
})??;
project.update(cx, |project, cx| project.open_buffer(project_path, cx))?.await?
};
action_log.update(cx, |action_log, cx| {
action_log.buffer_read(buffer.clone(), cx);
})?;
let range = {
let Some(range) = buffer.read_with(cx, |buffer, _cx| {
find_text_range(&buffer, &input.context_before_range, &input.text_range, &input.context_after_range)
})? else {
return Err(anyhow!(
"Failed to locate the text specified by context_before_range, text_range, and context_after_range. Make sure context_before_range and context_after_range each match exactly once in the file."
));
};
range
};
if let Some(action_type) = &input.action {
// Special-case the `rename` operation
let response = if action_type == "textDocument/rename" {
let Some(new_name) = input.arguments.and_then(|args| serde_json::from_value::<String>(args).ok()) else {
return Err(anyhow!("For rename operations, 'arguments' must be a string containing the new name"));
};
let position = buffer.read_with(cx, |buffer, _| {
range.start.to_point_utf16(&buffer.snapshot())
})?;
project
.update(cx, |project, cx| {
project.perform_rename(buffer.clone(), position, new_name.clone(), cx)
})?
.await?;
format!("Renamed '{}' to '{}'", input.text_range, new_name)
} else {
// Get code actions for the range
let actions = project
.update(cx, |project, cx| {
project.code_actions(&buffer, range.clone(), None, cx)
})?
.await?;
if actions.is_empty() {
return Err(anyhow!("No code actions available for this range"));
}
// Find all matching actions
let regex = match Regex::new(action_type) {
Ok(regex) => regex,
Err(err) => return Err(anyhow!("Invalid regex pattern: {}", err)),
};
let mut matching_actions = actions
.into_iter()
.filter(|action| { regex.is_match(action.lsp_action.title()) });
let Some(action) = matching_actions.next() else {
return Err(anyhow!("No code actions match the pattern: {}", action_type));
};
// There should have been exactly one matching action.
if let Some(second) = matching_actions.next() {
let mut all_matches = vec![action, second];
all_matches.extend(matching_actions);
return Err(anyhow!(
"Pattern '{}' matches multiple code actions: {}",
action_type,
all_matches.into_iter().map(|action| action.lsp_action.title().to_string()).collect::<Vec<_>>().join(", ")
));
}
let title = action.lsp_action.title().to_string();
project
.update(cx, |project, cx| {
project.apply_code_action(buffer.clone(), action, true, cx)
})?
.await?;
format!("Completed code action: {}", title)
};
project
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
.await?;
action_log.update(cx, |log, cx| {
log.buffer_edited(buffer.clone(), cx)
})?;
Ok(response)
} else {
// No action specified, so list the available ones.
let (position_start, position_end) = buffer.read_with(cx, |buffer, _| {
let snapshot = buffer.snapshot();
(
range.start.to_point_utf16(&snapshot),
range.end.to_point_utf16(&snapshot)
)
})?;
// Convert position to display coordinates (1-based)
let position_start_display = language::Point {
row: position_start.row + 1,
column: position_start.column + 1,
};
let position_end_display = language::Point {
row: position_end.row + 1,
column: position_end.column + 1,
};
// Get code actions for the range
let actions = project
.update(cx, |project, cx| {
project.code_actions(&buffer, range.clone(), None, cx)
})?
.await?;
let mut response = format!(
"Available code actions for text range '{}' at position {}:{} to {}:{} (UTF-16 coordinates):\n\n",
input.text_range,
position_start_display.row, position_start_display.column,
position_end_display.row, position_end_display.column
);
if actions.is_empty() {
response.push_str("No code actions available for this range.");
} else {
for (i, action) in actions.iter().enumerate() {
let title = match &action.lsp_action {
LspAction::Action(code_action) => code_action.title.as_str(),
LspAction::Command(command) => command.title.as_str(),
LspAction::CodeLens(code_lens) => {
if let Some(cmd) = &code_lens.command {
cmd.title.as_str()
} else {
"Unknown code lens"
}
},
};
let kind = match &action.lsp_action {
LspAction::Action(code_action) => {
if let Some(kind) = &code_action.kind {
kind.as_str()
} else {
"unknown"
}
},
LspAction::Command(_) => "command",
LspAction::CodeLens(_) => "code_lens",
};
response.push_str(&format!("{}. {title} ({kind})\n", i + 1));
}
}
Ok(response)
}
})
}
}
/// Finds the range of the text in the buffer, if it appears between context_before_range
/// and context_after_range, and if that combined string has one unique result in the buffer.
///
/// If an exact match fails, it tries adding a newline to the end of context_before_range and
/// to the beginning of context_after_range to accommodate line-based context matching.
fn find_text_range(
buffer: &Buffer,
context_before_range: &str,
text_range: &str,
context_after_range: &str,
) -> Option<Range<Anchor>> {
let snapshot = buffer.snapshot();
let text = snapshot.text();
// First try with exact match
let search_string = format!("{context_before_range}{text_range}{context_after_range}");
let mut positions = text.match_indices(&search_string);
let position_result = positions.next();
if let Some(position) = position_result {
// Check if the matched string is unique
if positions.next().is_none() {
let range_start = position.0 + context_before_range.len();
let range_end = range_start + text_range.len();
let range_start_anchor = snapshot.anchor_before(snapshot.offset_to_point(range_start));
let range_end_anchor = snapshot.anchor_before(snapshot.offset_to_point(range_end));
return Some(range_start_anchor..range_end_anchor);
}
}
// If exact match fails or is not unique, try with line-based context
// Add a newline to the end of before context and beginning of after context
let line_based_before = if context_before_range.ends_with('\n') {
context_before_range.to_string()
} else {
format!("{context_before_range}\n")
};
let line_based_after = if context_after_range.starts_with('\n') {
context_after_range.to_string()
} else {
format!("\n{context_after_range}")
};
let line_search_string = format!("{line_based_before}{text_range}{line_based_after}");
let mut line_positions = text.match_indices(&line_search_string);
let line_position = line_positions.next()?;
// The line-based search string must also appear exactly once
if line_positions.next().is_some() {
return None;
}
let line_range_start = line_position.0 + line_based_before.len();
let line_range_end = line_range_start + text_range.len();
let line_range_start_anchor =
snapshot.anchor_before(snapshot.offset_to_point(line_range_start));
let line_range_end_anchor = snapshot.anchor_before(snapshot.offset_to_point(line_range_end));
Some(line_range_start_anchor..line_range_end_anchor)
}

View File

@@ -0,0 +1,19 @@
A tool for applying code actions to specific sections of your code. It uses language servers to provide refactoring capabilities similar to what you'd find in an IDE.
This tool can:
- List all available code actions for a selected text range
- Execute a specific code action on that range
- Rename symbols across your codebase. This tool is the preferred way to rename things, and you should always prefer to rename code symbols using this tool rather than using textual find/replace when both are available.
Use this tool when you want to:
- Discover what code actions are available for a piece of code
- Apply automatic fixes and code transformations
- Rename variables, functions, or other symbols consistently throughout your project
- Clean up imports, implement interfaces, or perform other language-specific operations
- If unsure what actions are available, call the tool without specifying an action to get a list
- For common operations, you can directly specify actions like "quickfix.all" or "source.organizeImports"
- For renaming, use the special "textDocument/rename" action and provide the new name in the arguments field
- Be specific with your text range and context to ensure the tool identifies the correct code location
The tool will automatically save any changes it makes to your files.

View File

@@ -11,32 +11,58 @@ use std::{fmt::Write, path::Path, sync::Arc};
use ui::IconName;
use util::markdown::MarkdownString;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[derive(Debug, Serialize, Deserialize, Default, JsonSchema)]
pub struct DiagnosticsToolInput {
/// The path to get diagnostics for. If not provided, returns a project-wide summary.
/// The specific paths to get detailed diagnostics for (including individual line numbers).
///
/// This path should never be absolute, and the first component
/// of the path should always be a root directory in a project.
/// Regardless of whether any paths are specified here, a count of the total number of warnings
/// and errors in the project will be reported, so providing paths here gets you strictly
/// more information.
///
/// These paths should never be absolute, and the first component
/// of each path should always be a root directory in a project.
///
/// <example>
/// If the project has the following root directories:
///
/// - lorem
/// - ipsum
/// - amet
///
/// If you wanna access diagnostics for `dolor.txt` in `ipsum`, you should use the path `ipsum/dolor.txt`.
/// If you want detailed diagnostics with line numbers for `dolor.txt` in `ipsum` and `consectetur.txt` in `amet`, you should use:
///
/// "paths": ["ipsum/dolor.txt", "amet/consectetur.txt"]
/// </example>
#[serde(deserialize_with = "deserialize_path")]
pub path: Option<String>,
#[serde(default)]
pub paths: Vec<String>,
/// Which severity levels to show. Default is all.
/// To show only errors and warnings, you should use:
///
/// "severity": ["error", "warning"]
#[serde(default)]
pub severity: Vec<Severity>,
}
fn deserialize_path<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
#[derive(
Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Copy, Clone, strum::Display, Hash,
)]
#[serde(rename_all = "camelCase")]
pub enum Severity {
Error,
Warning,
Information,
Hint,
}
fn deserialize_path<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt = Option::<String>::deserialize(deserializer)?;
// The model passes an empty string sometimes
Ok(opt.filter(|s| !s.is_empty()))
let paths = Vec::<String>::deserialize(deserializer)?;
// The model passes an empty string for some paths
Ok(paths.into_iter().filter(|s| !s.is_empty()).collect())
}
pub struct DiagnosticsTool;
@@ -63,17 +89,21 @@ impl Tool for DiagnosticsTool {
}
fn ui_text(&self, input: &serde_json::Value) -> String {
if let Some(path) = serde_json::from_value::<DiagnosticsToolInput>(input.clone())
serde_json::from_value::<DiagnosticsToolInput>(input.clone())
.ok()
.and_then(|input| match input.path {
Some(path) if !path.is_empty() => Some(MarkdownString::inline_code(&path)),
_ => None,
.and_then(|input| {
input.paths.first().map(|first_path| {
if input.paths.len() > 1 {
format!("Check diagnostics for {} paths", input.paths.len())
} else {
format!(
"Check diagnostics for {}",
MarkdownString::inline_code(first_path)
)
}
})
})
{
format!("Check diagnostics for {path}")
} else {
"Check project diagnostics".to_string()
}
.unwrap_or_else(|| "Check project diagnostics".to_string())
}
fn run(
@@ -84,62 +114,20 @@ impl Tool for DiagnosticsTool {
action_log: Entity<ActionLog>,
cx: &mut App,
) -> Task<Result<String>> {
match serde_json::from_value::<DiagnosticsToolInput>(input)
.ok()
.and_then(|input| input.path)
let input = serde_json::from_value::<DiagnosticsToolInput>(input).unwrap_or_default();
let severity_filter = input.severity;
let mut summary_output = String::new();
let mut has_diagnostics = false;
// Always report the global diagnostics summary.
{
Some(path) if !path.is_empty() => {
let Some(project_path) = project.read(cx).find_project_path(&path, cx) else {
return Task::ready(Err(anyhow!("Could not find path {path} in project",)));
};
let buffer =
project.update(cx, |project, cx| project.open_buffer(project_path, cx));
cx.spawn(async move |cx| {
let mut output = String::new();
let buffer = buffer.await?;
let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
for (_, group) in snapshot.diagnostic_groups(None) {
let entry = &group.entries[group.primary_ix];
let range = entry.range.to_point(&snapshot);
let severity = match entry.diagnostic.severity {
DiagnosticSeverity::ERROR => "error",
DiagnosticSeverity::WARNING => "warning",
_ => continue,
};
writeln!(
output,
"{} at line {}: {}",
severity,
range.start.row + 1,
entry.diagnostic.message
)?;
}
if output.is_empty() {
Ok("File doesn't have errors or warnings!".to_string())
} else {
Ok(output)
}
})
}
_ => {
let project = project.read(cx);
let mut output = String::new();
let mut has_diagnostics = false;
for (project_path, _, summary) in project.diagnostic_summaries(true, cx) {
if summary.error_count > 0 || summary.warning_count > 0 {
let Some(worktree) = project.worktree_for_id(project_path.worktree_id, cx)
else {
continue;
};
let project = project.read(cx);
for (project_path, _, summary) in project.diagnostic_summaries(true, cx) {
if summary.error_count > 0 || summary.warning_count > 0 {
if let Some(worktree) = project.worktree_for_id(project_path.worktree_id, cx) {
has_diagnostics = true;
output.push_str(&format!(
summary_output.push_str(&format!(
"{}: {} error(s), {} warning(s)\n",
Path::new(worktree.read(cx).root_name())
.join(project_path.path)
@@ -149,17 +137,113 @@ impl Tool for DiagnosticsTool {
));
}
}
}
action_log.update(cx, |action_log, _cx| {
action_log.checked_project_diagnostics();
});
action_log.update(cx, |action_log, _cx| {
action_log.checked_project_diagnostics();
});
}
if input.paths.is_empty() {
// If no paths specified, just return the summary
if has_diagnostics {
Task::ready(Ok(summary_output))
} else {
Task::ready(Ok("No errors or warnings found.".to_string()))
}
} else {
let buffer_tasks = input
.paths
.into_iter()
.filter_map(|path| {
project
.read(cx)
.find_project_path(&path, cx)
.map(|project_path| {
(
project_path.clone(),
project.update(cx, |project, cx| {
project.open_buffer(project_path, cx)
}),
)
})
})
.collect::<Vec<_>>();
cx.spawn(async move |cx| {
let mut output = String::new();
output.push_str("# Project Summary\n\n");
if has_diagnostics {
Task::ready(Ok(output))
output.push_str(&summary_output);
output.push_str("\n");
} else {
Task::ready(Ok("No errors or warnings found in the project.".to_string()))
output.push_str("No errors or warnings found in the project.\n");
}
}
output.push('\n');
let mut header_printed = false;
for (project_path, buffer_task) in buffer_tasks {
let mut path_printed = false;
let buffer = buffer_task.await?;
let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
for (_, group) in snapshot.diagnostic_groups(None) {
let entry = &group.entries[group.primary_ix];
let range = entry.range.to_point(&snapshot);
if let Ok(severity) = Severity::try_from(&entry.diagnostic.severity) {
if severity_filter.is_empty() || severity_filter.contains(&severity) {
if !header_printed {
output.push_str("# Per-Path Diagnostics\n\n");
header_printed = true;
}
if !path_printed {
writeln!(output, "## {}", project_path.path.display())?;
path_printed = true;
}
writeln!(
output,
"\n### {severity} at line {}\n{}",
range.start.row + 1,
entry.diagnostic.message
)?;
}
}
}
}
if !header_printed {
output.push_str("No specific diagnostics found for the requested paths.");
}
Ok(output)
})
}
}
}
impl TryFrom<&DiagnosticSeverity> for Severity {
type Error = ();
fn try_from(
value: &DiagnosticSeverity,
) -> Result<Self, <Severity as TryFrom<&DiagnosticSeverity>>::Error> {
if *value == DiagnosticSeverity::ERROR {
Ok(Self::Error)
} else if *value == DiagnosticSeverity::WARNING {
Ok(Self::Warning)
} else if *value == DiagnosticSeverity::INFORMATION {
Ok(Self::Information)
} else if *value == DiagnosticSeverity::HINT {
Ok(Self::Hint)
} else {
Err(())
}
}
}

View File

@@ -63,6 +63,16 @@ pub struct FindReplaceFileToolInput {
/// even one character in this string is different in any way from how it appears
/// in the file, then the tool call will fail.
///
/// If you get an error that the `find` string was not found, this means that either
/// you made a mistake, or that the file has changed since you last looked at it.
/// Either way, when this happens, you should retry doing this tool call until it
/// succeeds, up to 3 times. Each time you retry, you should take another look at
/// the exact text of the file in question, to make sure that you are searching for
/// exactly the right string. Regardless of whether it was because you made a mistake
/// or because the file changed since you last looked at it, you should be extra
/// careful when retrying in this way. It's a bad experience for the user if
/// this `find` string isn't found, so be super careful to get it exactly right!
///
/// <example>
/// If a file contains this code:
///

View File

@@ -1,9 +1,13 @@
Find one unique part of a file in the project and replace that text with new text.
This tool is the preferred way to make edits to files. If you have multiple edits to make, including edits across multiple files, then make a plan to respond with a single message containing multiple calls to this tool - one call for each find/replace operation.
This tool is the preferred way to make edits to files *except* when making a rename. When making a rename specifically, the rename tool must always be used instead.
You should use this tool when you want to edit a subset of a file's contents, but not the entire file. You should not use this tool when you want to replace the entire contents of a file with completely different contents. You also should not use this tool when you want to move or rename a file. You absolutely must NEVER use this tool to create new files from scratch. If you ever consider using this tool to create a new file from scratch, for any reason, instead you must reconsider and choose a different approach.
If you have multiple edits to make, including edits across multiple files, then make a plan to respond with a single message containing a batch of calls to this tool - one call for each find/replace operation.
You should only use this tool when you want to edit a subset of a file's contents, but not the entire file. You should not use this tool when you want to replace the entire contents of a file with completely different contents. You also should not use this tool when you want to move or rename a file. You absolutely must NEVER use this tool to create new files from scratch. If you ever consider using this tool to create a new file from scratch, for any reason, instead you must reconsider and choose a different approach.
DO NOT call this tool until the code to be edited appears in the conversation! You must use another tool to read the file's contents into the conversation, or ask the user to add it to context first.
Never call this tool with identical "find" and "replace" strings. Instead, stop and think about what you actually want to do.
REMEMBER: You can use this tool after you just used the `create_file` tool. It's better to edit the file you just created than to recreate a new file from scratch.

View File

@@ -0,0 +1,361 @@
use crate::schema::json_schema_for;
use anyhow::{Result, anyhow};
use assistant_tool::{ActionLog, Tool};
use gpui::AppContext;
use gpui::{App, Entity, Task};
use language::{DiagnosticSeverity, OffsetRangeExt};
use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat};
use lsp_types::CodeActionKind;
use project::LspAction;
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::f32::consts::E;
use std::{fmt::Write, path::Path, sync::Arc};
use ui::IconName;
use util::markdown::MarkdownString;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct QuickfixToolInput {
/// The path to get diagnostics for and apply quickfixes. If not provided, checks the entire project.
///
/// This path should never be absolute, and the first component
/// of the path should always be a root directory in a project.
///
/// <example>
/// If the project has the following root directories:
///
/// - lorem
/// - ipsum
///
/// If you want to apply quickfixes for `dolor.txt` in `ipsum`, you should use the path `ipsum/dolor.txt`.
/// </example>
pub path: Option<String>,
}
pub struct QuickfixTool;
impl Tool for QuickfixTool {
fn name(&self) -> String {
"quickfix".into()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
false
}
fn description(&self) -> String {
include_str!("./quickfix_tool/description.md").into()
}
fn icon(&self) -> IconName {
IconName::Check
}
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value {
json_schema_for::<QuickfixToolInput>(format)
}
fn ui_text(&self, input: &serde_json::Value) -> String {
if let Some(path) = serde_json::from_value::<QuickfixToolInput>(input.clone())
.ok()
.and_then(|input| match input.path {
Some(path) if !path.is_empty() => Some(MarkdownString::inline_code(&path)),
_ => None,
})
{
format!("Apply quickfixes for {path}")
} else {
"Apply project-wide quickfixes".to_string()
}
}
fn run(
self: Arc<Self>,
input: serde_json::Value,
_messages: &[LanguageModelRequestMessage],
project: Entity<Project>,
action_log: Entity<ActionLog>,
cx: &mut App,
) -> Task<Result<String>> {
match serde_json::from_value::<QuickfixToolInput>(input)
.ok()
.and_then(|input| input.path)
{
Some(path) if !path.is_empty() => {
let Some(project_path) = project.read(cx).find_project_path(&path, cx) else {
return Task::ready(Err(anyhow!("Could not find path {path} in project")));
};
let buffer =
project.update(cx, |project, cx| project.open_buffer(project_path, cx));
cx.spawn(async move |cx| {
let mut output = String::new();
let mut fixes_applied = 0;
let buffer = buffer.await?;
let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
let mut unfixed = Vec::new();
// Collect diagnostics to apply quickfixes for
for entry in snapshot.diagnostic_groups(None).into_iter().flat_map(
|(_, group)| group.entries.into_iter()
) {
let range = entry.range.to_point(&snapshot);
let actions = project
.update(cx, |project, cx| {
project.code_actions(
&buffer,
range.start..range.end,
None,
cx
)
})?.await?;
let quickfixes = actions.into_iter().filter(|action| {
if let LspAction::Action(code_action) = &action.lsp_action {
code_action.kind == Some(CodeActionKind::QUICKFIX)
} else {
false
}
}).collect::<Vec<_>>();
match quickfixes.first() {
Some(default_quickfix) => {
// If there's a quickfix marked as preferred, use that.
// Otherwise fall back on the first available quickfix in the list.
let preferred_action = quickfixes.iter().find(|action| {
if let LspAction::Action(code_action) = &action.lsp_action {
code_action.is_preferred.unwrap_or(false)
} else {
false
}
}).unwrap_or(default_quickfix);
project
.update(cx, |project, cx| {
project.apply_code_action(buffer.clone(), preferred_action.clone(), true, cx)
})?
.await?;
log::info!("Applied quickfix: {}", preferred_action.lsp_action.title());
fixes_applied += 1;
}
None => {
unfixed.push(entry);
}
}
}
// Save the buffer after applying fixes
if fixes_applied > 0 {
project
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
.await?;
action_log.update(cx, |log, cx| {
log.buffer_edited(buffer.clone(), cx)
})?;
}
// Generate summary
if output.is_empty() {
Ok("No issues found in the file!".to_string())
} else {
writeln!(output, "\nSummary: Applied {} quickfixes. Remaining issues: {} - use the diagnostics tool to see them.",
fixes_applied, unfixed.len())?;
Ok(output)
}
})
}
_ => {
todo!("Share code with the other branch.");
let mut output = String::new();
let mut files_with_diagnostics = Vec::new();
// Collect all files with diagnostics for processing
{
let project_ref = project.read(cx);
for (project_path, _, summary) in project_ref.diagnostic_summaries(true, cx) {
if summary.error_count > 0 || summary.warning_count > 0 {
if let Some(worktree) =
project_ref.worktree_for_id(project_path.worktree_id, cx)
{
let path_str = Path::new(worktree.read(cx).root_name())
.join(project_path.path)
.display()
.to_string();
files_with_diagnostics.push(path_str);
}
}
}
}
// Create a task to process all files with diagnostics
let project = project.clone();
let action_log = action_log.clone();
let files_to_process = files_with_diagnostics.clone();
cx.spawn(async move |cx| {
let mut total_fixes_applied = 0;
let mut total_errors_unfixed = 0;
let mut total_warnings_unfixed = 0;
// Process each file with diagnostics
for file_path in files_to_process {
writeln!(output, "Processing {}...", file_path)?;
let Ok(Some(project_path)) = project.read_with(cx, |project, cx| project.find_project_path(&file_path, cx)) else {
writeln!(output, " Could not resolve project path for {}", file_path)?;
continue;
};
let buffer_open_result = project.update(cx, |project, cx|
project.open_buffer(project_path, cx));
let Ok(buffer_handle) = buffer_open_result else {
writeln!(output, " Failed to open buffer for {}", file_path)?;
continue;
};
let buffer = match buffer_handle.await {
Ok(buffer) => buffer,
Err(err) => {
writeln!(output, " Failed to load buffer for {}: {}", file_path, err)?;
continue;
}
};
let mut file_fixes_applied = 0;
let mut file_errors_unfixed = 0;
let mut file_warnings_unfixed = 0;
let mut needs_save = false;
let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
// Process diagnostics for this file
for (_, group) in snapshot.diagnostic_groups(None) {
let entry = &group.entries[group.primary_ix];
let range = entry.range.to_point(&snapshot);
let severity = entry.diagnostic.severity;
// Skip informational and hint diagnostics
if severity != DiagnosticSeverity::ERROR &&
severity != DiagnosticSeverity::WARNING {
continue;
}
// Get code actions (quickfixes) for this diagnostic
let actions = project
.update(cx, |project, cx| {
project.code_actions(
&buffer,
range.start..range.end,
None,
cx
)
})?;
let actions_result = actions.await;
match actions_result {
Ok(actions) => {
// Find quickfix actions
let quickfixes = actions.into_iter().filter(|action| {
if let LspAction::Action(code_action) = &action.lsp_action {
if let Some(kind) = &code_action.kind {
kind.as_str().starts_with("quickfix")
} else {
false
}
} else {
false
}
}).collect::<Vec<_>>();
if !quickfixes.is_empty() {
// Find the preferred quickfix (marked as isPreferred or the first one)
let preferred_action = quickfixes.iter().find(|action| {
if let LspAction::Action(code_action) = &action.lsp_action {
code_action.is_preferred.unwrap_or(false)
} else {
false
}
}).unwrap_or(&quickfixes[0]);
// Apply the quickfix
let title = preferred_action.lsp_action.title().to_string();
project
.update(cx, |project, cx| {
project.apply_code_action(buffer.clone(), preferred_action.clone(), true, cx)
})?
.await?;
writeln!(output, " Applied quickfix: {title}")?;
file_fixes_applied += 1;
needs_save = true;
} else {
// Track unfixed diagnostics
match severity {
DiagnosticSeverity::ERROR => file_errors_unfixed += 1,
DiagnosticSeverity::WARNING => file_warnings_unfixed += 1,
_ => {}
}
}
},
Err(_) => {
// Track unfixed diagnostics
match severity {
DiagnosticSeverity::ERROR => file_errors_unfixed += 1,
DiagnosticSeverity::WARNING => file_warnings_unfixed += 1,
_ => {}
}
}
}
}
// Save the buffer after applying fixes
if needs_save {
project
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
.await?;
action_log.update(cx, |log, cx| {
log.buffer_edited(buffer.clone(), cx)
})?;
}
// Update totals
total_fixes_applied += file_fixes_applied;
total_errors_unfixed += file_errors_unfixed;
total_warnings_unfixed += file_warnings_unfixed;
// File summary
if file_fixes_applied > 0 || file_errors_unfixed > 0 || file_warnings_unfixed > 0 {
writeln!(output, " {} quickfixes applied. Remaining: {} errors, {} warnings\n",
file_fixes_applied, file_errors_unfixed, file_warnings_unfixed)?;
} else {
writeln!(output, " No issues fixed or found\n")?;
}
}
// Mark that we've checked diagnostics
action_log.update(cx, |action_log, _cx| {
action_log.checked_project_diagnostics();
})?;
// Generate overall summary
if files_with_diagnostics.is_empty() {
Ok("No issues found in the project!".to_string())
} else {
writeln!(output, "\nProject-wide summary: Applied {} quickfixes. Remaining issues: {} errors, {} warnings.",
total_fixes_applied, total_errors_unfixed, total_warnings_unfixed)?;
Ok(output)
}
})
}
}
}
}

View File

@@ -0,0 +1,16 @@
Get errors and warnings for the project or a specific file and apply quickfixes automatically where possible.
This tool can be invoked to find diagnostics in your code and automatically apply available quickfixes to resolve them. Quickfixes are code edits suggested by language servers that can automatically fix common issues.
When a path is provided, it checks that specific file for diagnostics and applies quickfixes.
When no path is provided, it finds diagnostics project-wide and applies quickfixes where possible.
<example>
To automatically fix issues in a specific file:
{
"path": "src/main.rs"
}
To find and fix issues across the entire project:
{}
</example>

View File

@@ -0,0 +1,205 @@
use anyhow::{Context as _, Result, anyhow};
use assistant_tool::{ActionLog, Tool};
use gpui::{App, Entity, Task};
use language::{self, Buffer, ToPointUtf16};
use language_model::LanguageModelRequestMessage;
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use ui::IconName;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct RenameToolInput {
/// The relative path to the file containing the symbol to rename.
///
/// WARNING: you MUST start this path with one of the project's root directories.
pub path: String,
/// The new name to give to the symbol.
pub new_name: String,
/// The text that comes immediately before the symbol in the file.
pub context_before_symbol: String,
/// The symbol to rename. This text must appear in the file right between
/// `context_before_symbol` and `context_after_symbol`.
///
/// The file must contain exactly one occurrence of `context_before_symbol` followed by
/// `symbol` followed by `context_after_symbol`. If the file contains zero occurrences,
/// or if it contains more than one occurrence, the tool will fail, so it is absolutely
/// critical that you verify ahead of time that the string is unique. You can search
/// the file's contents to verify this ahead of time.
///
/// To make the string more likely to be unique, include a minimum of 1 line of context
/// before the symbol, as well as a minimum of 1 line of context after the symbol.
/// If these lines of context are not enough to obtain a string that appears only once
/// in the file, then double the number of context lines until the string becomes unique.
/// (Start with 1 line before and 1 line after though, because too much context is
/// needlessly costly.)
///
/// Do not alter the context lines of code in any way, and make sure to preserve all
/// whitespace and indentation for all lines of code. The combined string must be exactly
/// as it appears in the file, or else this tool call will fail.
pub symbol: String,
/// The text that comes immediately after the symbol in the file.
pub context_after_symbol: String,
}
pub struct RenameTool;
impl Tool for RenameTool {
fn name(&self) -> String {
"rename".into()
}
fn needs_confirmation(&self, _input: &serde_json::Value, _cx: &App) -> bool {
false
}
fn description(&self) -> String {
include_str!("./rename_tool/description.md").into()
}
fn icon(&self) -> IconName {
IconName::Pencil
}
fn input_schema(
&self,
_format: language_model::LanguageModelToolSchemaFormat,
) -> serde_json::Value {
let schema = schemars::schema_for!(RenameToolInput);
serde_json::to_value(&schema).unwrap()
}
fn ui_text(&self, input: &serde_json::Value) -> String {
match serde_json::from_value::<RenameToolInput>(input.clone()) {
Ok(input) => {
format!("Rename '{}' to '{}'", input.symbol, input.new_name)
}
Err(_) => "Rename symbol".to_string(),
}
}
fn run(
self: Arc<Self>,
input: serde_json::Value,
_messages: &[LanguageModelRequestMessage],
project: Entity<Project>,
action_log: Entity<ActionLog>,
cx: &mut App,
) -> Task<Result<String>> {
let input = match serde_json::from_value::<RenameToolInput>(input) {
Ok(input) => input,
Err(err) => return Task::ready(Err(anyhow!(err))),
};
cx.spawn(async move |cx| {
let buffer = {
let project_path = project.read_with(cx, |project, cx| {
project
.find_project_path(&input.path, cx)
.context("Path not found in project")
})??;
project.update(cx, |project, cx| project.open_buffer(project_path, cx))?.await?
};
action_log.update(cx, |action_log, cx| {
action_log.buffer_read(buffer.clone(), cx);
})?;
let position = {
let Some(position) = buffer.read_with(cx, |buffer, _cx| {
find_symbol_position(&buffer, &input.context_before_symbol, &input.symbol, &input.context_after_symbol)
})? else {
return Err(anyhow!(
"Failed to locate the symbol specified by context_before_symbol, symbol, and context_after_symbol. Make sure context_before_symbol and context_after_symbol each match exactly once in the file."
));
};
buffer.read_with(cx, |buffer, _| {
position.to_point_utf16(&buffer.snapshot())
})?
};
project
.update(cx, |project, cx| {
project.perform_rename(buffer.clone(), position, input.new_name.clone(), cx)
})?
.await?;
project
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
.await?;
action_log.update(cx, |log, cx| {
log.buffer_edited(buffer.clone(), cx)
})?;
Ok(format!("Renamed '{}' to '{}'", input.symbol, input.new_name))
})
}
}
/// Finds the position of the symbol in the buffer, if it appears between context_before_symbol
/// and context_after_symbol, and if that combined string has one unique result in the buffer.
///
/// If an exact match fails, it tries adding a newline to the end of context_before_symbol and
/// to the beginning of context_after_symbol to accommodate line-based context matching.
fn find_symbol_position(
buffer: &Buffer,
context_before_symbol: &str,
symbol: &str,
context_after_symbol: &str,
) -> Option<language::Anchor> {
let snapshot = buffer.snapshot();
let text = snapshot.text();
// First try with exact match
let search_string = format!("{context_before_symbol}{symbol}{context_after_symbol}");
let mut positions = text.match_indices(&search_string);
let position_result = positions.next();
if let Some(position) = position_result {
// Check if the matched string is unique
if positions.next().is_none() {
let symbol_start = position.0 + context_before_symbol.len();
let symbol_start_anchor =
snapshot.anchor_before(snapshot.offset_to_point(symbol_start));
return Some(symbol_start_anchor);
}
}
// If exact match fails or is not unique, try with line-based context
// Add a newline to the end of before context and beginning of after context
let line_based_before = if context_before_symbol.ends_with('\n') {
context_before_symbol.to_string()
} else {
format!("{context_before_symbol}\n")
};
let line_based_after = if context_after_symbol.starts_with('\n') {
context_after_symbol.to_string()
} else {
format!("\n{context_after_symbol}")
};
let line_search_string = format!("{line_based_before}{symbol}{line_based_after}");
let mut line_positions = text.match_indices(&line_search_string);
let line_position = line_positions.next()?;
// The line-based search string must also appear exactly once
if line_positions.next().is_some() {
return None;
}
let line_symbol_start = line_position.0 + line_based_before.len();
let line_symbol_start_anchor =
snapshot.anchor_before(snapshot.offset_to_point(line_symbol_start));
Some(line_symbol_start_anchor)
}

View File

@@ -0,0 +1,15 @@
Renames a symbol across your codebase using the language server's semantic knowledge.
This tool performs a rename refactoring operation on a specified symbol. It uses the project's language server to analyze the code and perform the rename correctly across all files where the symbol is referenced.
Unlike a simple find and replace, this tool understands the semantic meaning of the code, so it only renames the specific symbol you specify and not unrelated text that happens to have the same name.
Examples of symbols you can rename:
- Variables
- Functions
- Classes/structs
- Fields/properties
- Methods
- Interfaces/traits
The language server handles updating all references to the renamed symbol throughout the codebase.

View File

@@ -33,7 +33,7 @@ impl Tool for ThinkingTool {
}
fn icon(&self) -> IconName {
IconName::Brain
IconName::LightBulb
}
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value {

View File

@@ -93,7 +93,7 @@ pub struct TcpArguments {
pub port: u16,
pub timeout: Option<u64>,
}
#[derive(Debug, Clone)]
#[derive(Default, Debug, Clone)]
pub struct DebugAdapterBinary {
pub command: String,
pub arguments: Option<Vec<OsString>>,
@@ -102,6 +102,7 @@ pub struct DebugAdapterBinary {
pub connection: Option<TcpArguments>,
}
#[derive(Debug)]
pub struct AdapterVersion {
pub tag_name: String,
pub url: String,

View File

@@ -0,0 +1,141 @@
use std::{path::PathBuf, sync::OnceLock};
use anyhow::{Result, bail};
use async_trait::async_trait;
use dap::adapters::latest_github_release;
use gpui::AsyncApp;
use task::{DebugAdapterConfig, DebugRequestType, DebugTaskDefinition};
use crate::*;
#[derive(Default)]
pub(crate) struct CodeLldbDebugAdapter {
last_known_version: OnceLock<String>,
}
impl CodeLldbDebugAdapter {
const ADAPTER_NAME: &'static str = "CodeLLDB";
}
#[async_trait(?Send)]
impl DebugAdapter for CodeLldbDebugAdapter {
fn name(&self) -> DebugAdapterName {
DebugAdapterName(Self::ADAPTER_NAME.into())
}
async fn install_binary(
&self,
version: AdapterVersion,
delegate: &dyn DapDelegate,
) -> Result<()> {
adapters::download_adapter_from_github(
self.name(),
version,
adapters::DownloadedFileType::Vsix,
delegate,
)
.await?;
Ok(())
}
async fn fetch_latest_adapter_version(
&self,
delegate: &dyn DapDelegate,
) -> Result<AdapterVersion> {
let release =
latest_github_release("vadimcn/codelldb", true, false, delegate.http_client()).await?;
let arch = match std::env::consts::ARCH {
"aarch64" => "arm64",
"x86_64" => "x64",
_ => {
return Err(anyhow!(
"unsupported architecture {}",
std::env::consts::ARCH
));
}
};
let platform = match std::env::consts::OS {
"macos" => "darwin",
"linux" => "linux",
"windows" => "win32",
_ => {
return Err(anyhow!(
"unsupported operating system {}",
std::env::consts::OS
));
}
};
let asset_name = format!("codelldb-{platform}-{arch}.vsix");
let _ = self.last_known_version.set(release.tag_name.clone());
let ret = AdapterVersion {
tag_name: release.tag_name,
url: release
.assets
.iter()
.find(|asset| asset.name == asset_name)
.ok_or_else(|| anyhow!("no asset found matching {:?}", asset_name))?
.browser_download_url
.clone(),
};
Ok(ret)
}
async fn get_installed_binary(
&self,
_: &dyn DapDelegate,
_: &DebugAdapterConfig,
_: Option<PathBuf>,
_: &mut AsyncApp,
) -> Result<DebugAdapterBinary> {
let Some(version) = self.last_known_version.get() else {
bail!("Could not determine latest CodeLLDB version");
};
let adapter_path = paths::debug_adapters_dir().join(&Self::ADAPTER_NAME);
let version_path = adapter_path.join(format!("{}_{}", Self::ADAPTER_NAME, version));
let adapter_dir = version_path.join("extension").join("adapter");
let command = adapter_dir.join("codelldb");
let command = command
.to_str()
.map(ToOwned::to_owned)
.ok_or_else(|| anyhow!("Adapter path is expected to be valid UTF-8"))?;
Ok(DebugAdapterBinary {
command,
cwd: Some(adapter_dir),
..Default::default()
})
}
fn request_args(&self, config: &DebugTaskDefinition) -> Value {
let mut args = json!({
"request": match config.request {
DebugRequestType::Launch(_) => "launch",
DebugRequestType::Attach(_) => "attach",
},
});
let map = args.as_object_mut().unwrap();
match &config.request {
DebugRequestType::Attach(attach) => {
map.insert("pid".into(), attach.process_id.into());
}
DebugRequestType::Launch(launch) => {
map.insert("program".into(), launch.program.clone().into());
if !launch.args.is_empty() {
map.insert("args".into(), launch.args.clone().into());
}
if let Some(stop_on_entry) = config.stop_on_entry {
map.insert("stopOnEntry".into(), stop_on_entry.into());
}
if let Some(cwd) = launch.cwd.as_ref() {
map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
}
}
}
args
}
}

View File

@@ -1,3 +1,4 @@
mod codelldb;
mod gdb;
mod go;
mod javascript;
@@ -9,6 +10,7 @@ use std::{net::Ipv4Addr, sync::Arc};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use codelldb::CodeLldbDebugAdapter;
use dap::{
DapRegistry,
adapters::{
@@ -26,6 +28,7 @@ use serde_json::{Value, json};
use task::{DebugAdapterConfig, TCPHost};
pub fn init(registry: Arc<DapRegistry>) {
registry.add_adapter(Arc::from(CodeLldbDebugAdapter::default()));
registry.add_adapter(Arc::from(PythonDebugAdapter));
registry.add_adapter(Arc::from(PhpDebugAdapter));
registry.add_adapter(Arc::from(JsDebugAdapter::default()));

View File

@@ -25,7 +25,9 @@ use project::{
};
use rpc::proto::{self};
use settings::Settings;
use std::{any::TypeId, path::PathBuf};
use std::any::TypeId;
use std::path::Path;
use std::sync::Arc;
use task::DebugTaskDefinition;
use terminal_view::terminal_panel::TerminalPanel;
use ui::{ContextMenu, Divider, DropdownMenu, Tooltip, prelude::*};
@@ -272,7 +274,7 @@ impl DebugPanel {
fn handle_run_in_terminal_request(
&self,
title: Option<String>,
cwd: PathBuf,
cwd: Option<Arc<Path>>,
command: Option<String>,
args: Vec<String>,
envs: HashMap<String, String>,

View File

@@ -420,6 +420,7 @@ actions!(
Tab,
Backtab,
ToggleBreakpoint,
ToggleCase,
DisableBreakpoint,
EnableBreakpoint,
EditLogBreakpoint,

View File

@@ -1262,6 +1262,7 @@ impl Editor {
clone.selections.clone_state(&self.selections);
clone.scroll_manager.clone_state(&self.scroll_manager);
clone.searchable = self.searchable;
clone.read_only = self.read_only;
clone
}
@@ -4271,6 +4272,7 @@ impl Editor {
buffer
.update(cx, |buffer, _| {
buffer.push_transaction(transaction, Instant::now());
buffer.finalize_last_transaction();
})
.ok();
}
@@ -9141,6 +9143,17 @@ impl Editor {
});
}
pub fn toggle_case(&mut self, _: &ToggleCase, window: &mut Window, cx: &mut Context<Self>) {
self.manipulate_text(window, cx, |text| {
let has_upper_case_characters = text.chars().any(|c| c.is_uppercase());
if has_upper_case_characters {
text.to_lowercase()
} else {
text.to_uppercase()
}
})
}
pub fn convert_to_upper_case(
&mut self,
_: &ConvertToUpperCase,

View File

@@ -3875,6 +3875,41 @@ async fn test_manipulate_lines_with_multi_selection(cx: &mut TestAppContext) {
"});
}
#[gpui::test]
async fn test_toggle_case(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
// If all lower case -> upper case
cx.set_state(indoc! {"
«hello worldˇ»
"});
cx.update_editor(|e, window, cx| e.toggle_case(&ToggleCase, window, cx));
cx.assert_editor_state(indoc! {"
«HELLO WORLDˇ»
"});
// If all upper case -> lower case
cx.set_state(indoc! {"
«HELLO WORLDˇ»
"});
cx.update_editor(|e, window, cx| e.toggle_case(&ToggleCase, window, cx));
cx.assert_editor_state(indoc! {"
«hello worldˇ»
"});
// If any upper case characters are identified -> lower case
// This matches JetBrains IDEs
cx.set_state(indoc! {"
«hEllo worldˇ»
"});
cx.update_editor(|e, window, cx| e.toggle_case(&ToggleCase, window, cx));
cx.assert_editor_state(indoc! {"
«hello worldˇ»
"});
}
#[gpui::test]
async fn test_manipulate_text(cx: &mut TestAppContext) {
init_test(cx, |_| {});

View File

@@ -211,6 +211,7 @@ impl EditorElement {
register_action(editor, window, Editor::sort_lines_case_insensitive);
register_action(editor, window, Editor::reverse_lines);
register_action(editor, window, Editor::shuffle_lines);
register_action(editor, window, Editor::toggle_case);
register_action(editor, window, Editor::convert_to_upper_case);
register_action(editor, window, Editor::convert_to_lower_case);
register_action(editor, window, Editor::convert_to_title_case);

View File

@@ -3,6 +3,7 @@ use std::time::{Duration, Instant};
use crate::{AnyElement, App, Element, ElementId, GlobalElementId, IntoElement, Window};
pub use easing::*;
use smallvec::SmallVec;
/// An animation that can be applied to an element.
pub struct Animation {
@@ -50,6 +51,24 @@ pub trait AnimationExt {
animation: Animation,
animator: impl Fn(Self, f32) -> Self + 'static,
) -> AnimationElement<Self>
where
Self: Sized,
{
AnimationElement {
id: id.into(),
element: Some(self),
animator: Box::new(move |this, _, value| animator(this, value)),
animations: smallvec::smallvec![animation],
}
}
/// Render this component or element with a chain of animations
fn with_animations(
self,
id: impl Into<ElementId>,
animations: Vec<Animation>,
animator: impl Fn(Self, usize, f32) -> Self + 'static,
) -> AnimationElement<Self>
where
Self: Sized,
{
@@ -57,7 +76,7 @@ pub trait AnimationExt {
id: id.into(),
element: Some(self),
animator: Box::new(animator),
animation,
animations: animations.into(),
}
}
}
@@ -68,8 +87,8 @@ impl<E> AnimationExt for E {}
pub struct AnimationElement<E> {
id: ElementId,
element: Option<E>,
animation: Animation,
animator: Box<dyn Fn(E, f32) -> E + 'static>,
animations: SmallVec<[Animation; 1]>,
animator: Box<dyn Fn(E, usize, f32) -> E + 'static>,
}
impl<E> AnimationElement<E> {
@@ -91,6 +110,7 @@ impl<E: IntoElement + 'static> IntoElement for AnimationElement<E> {
struct AnimationState {
start: Instant,
animation_ix: usize,
}
impl<E: IntoElement + 'static> Element for AnimationElement<E> {
@@ -108,22 +128,30 @@ impl<E: IntoElement + 'static> Element for AnimationElement<E> {
cx: &mut App,
) -> (crate::LayoutId, Self::RequestLayoutState) {
window.with_element_state(global_id.unwrap(), |state, window| {
let state = state.unwrap_or_else(|| AnimationState {
let mut state = state.unwrap_or_else(|| AnimationState {
start: Instant::now(),
animation_ix: 0,
});
let mut delta =
state.start.elapsed().as_secs_f32() / self.animation.duration.as_secs_f32();
let animation_ix = state.animation_ix;
let mut delta = state.start.elapsed().as_secs_f32()
/ self.animations[animation_ix].duration.as_secs_f32();
let mut done = false;
if delta > 1.0 {
if self.animation.oneshot {
done = true;
if self.animations[animation_ix].oneshot {
if animation_ix >= self.animations.len() - 1 {
done = true;
} else {
state.start = Instant::now();
state.animation_ix += 1;
}
delta = 1.0;
} else {
delta %= 1.0;
}
}
let delta = (self.animation.easing)(delta);
let delta = (self.animations[animation_ix].easing)(delta);
debug_assert!(
(0.0..=1.0).contains(&delta),
@@ -131,7 +159,7 @@ impl<E: IntoElement + 'static> Element for AnimationElement<E> {
);
let element = self.element.take().expect("should only be called once");
let mut element = (self.animator)(element, delta).into_any_element();
let mut element = (self.animator)(element, animation_ix, delta).into_any_element();
if !done {
window.request_animation_frame();

View File

@@ -698,7 +698,7 @@ fn fs_quad(input: QuadVarying) -> @location(0) vec4<f32> {
if (is_near_rounded_corner) {
let radians = atan2(corner_center_to_point.y,
corner_center_to_point.x);
let corner_t = radians * corner_radius * dash_velocity;
let corner_t = radians * corner_radius;
if (center_to_point.x >= 0.0) {
if (center_to_point.y < 0.0) {
@@ -706,12 +706,12 @@ fn fs_quad(input: QuadVarying) -> @location(0) vec4<f32> {
// Subtracted because radians is pi/2 to 0 when
// going clockwise around the top right corner,
// since the y axis has been flipped
t = upto_r - corner_t;
t = upto_r - corner_t * dash_velocity;
} else {
dash_velocity = corner_dash_velocity_br;
// Added because radians is 0 to pi/2 when going
// clockwise around the bottom-right corner
t = upto_br + corner_t;
t = upto_br + corner_t * dash_velocity;
}
} else {
if (center_to_point.y >= 0.0) {
@@ -719,13 +719,13 @@ fn fs_quad(input: QuadVarying) -> @location(0) vec4<f32> {
// Subtracted because radians is pi/2 to 0 when
// going clockwise around the bottom-left corner,
// since the x axis has been flipped
t = upto_l - corner_t;
t = upto_l - corner_t * dash_velocity;
} else {
dash_velocity = corner_dash_velocity_tl;
// Added because radians is 0 to pi/2 when going
// clockwise around the top-left corner, since both
// axis were flipped
t = upto_tl + corner_t;
t = upto_tl + corner_t * dash_velocity;
}
}
} else {

View File

@@ -298,7 +298,7 @@ fragment float4 quad_fragment(QuadFragmentInput input [[stage_in]],
if (is_near_rounded_corner) {
float radians = atan2(corner_center_to_point.y, corner_center_to_point.x);
float corner_t = radians * corner_radius * dash_velocity;
float corner_t = radians * corner_radius;
if (center_to_point.x >= 0.0) {
if (center_to_point.y < 0.0) {
@@ -306,12 +306,12 @@ fragment float4 quad_fragment(QuadFragmentInput input [[stage_in]],
// Subtracted because radians is pi/2 to 0 when
// going clockwise around the top right corner,
// since the y axis has been flipped
t = upto_r - corner_t;
t = upto_r - corner_t * dash_velocity;
} else {
dash_velocity = corner_dash_velocity_br;
// Added because radians is 0 to pi/2 when going
// clockwise around the bottom-right corner
t = upto_br + corner_t;
t = upto_br + corner_t * dash_velocity;
}
} else {
if (center_to_point.y >= 0.0) {
@@ -319,13 +319,13 @@ fragment float4 quad_fragment(QuadFragmentInput input [[stage_in]],
// Subtracted because radians is pi/1 to 0 when
// going clockwise around the bottom-left corner,
// since the x axis has been flipped
t = upto_l - corner_t;
t = upto_l - corner_t * dash_velocity;
} else {
dash_velocity = corner_dash_velocity_tl;
// Added because radians is 0 to pi/2 when going
// clockwise around the top-left corner, since both
// axis were flipped
t = upto_tl + corner_t;
t = upto_tl + corner_t * dash_velocity;
}
}
} else {

View File

@@ -141,6 +141,7 @@ pub enum IconName {
InlayHint,
Keyboard,
Library,
LightBulb,
LineHeight,
Link,
ListTree,

View File

@@ -2015,11 +2015,16 @@ impl Buffer {
}
/// Manually remove a transaction from the buffer's undo history
pub fn forget_transaction(&mut self, transaction_id: TransactionId) {
self.text.forget_transaction(transaction_id);
pub fn forget_transaction(&mut self, transaction_id: TransactionId) -> Option<Transaction> {
self.text.forget_transaction(transaction_id)
}
/// Manually merge two adjacent transactions in the buffer's undo history.
/// Retrieve a transaction from the buffer's undo history
pub fn get_transaction(&self, transaction_id: TransactionId) -> Option<&Transaction> {
self.text.get_transaction(transaction_id)
}
/// Manually merge two transactions in the buffer's undo history.
pub fn merge_transactions(&mut self, transaction: TransactionId, destination: TransactionId) {
self.text.merge_transactions(transaction, destination);
}

View File

@@ -22,7 +22,7 @@ collections.workspace = true
futures.workspace = true
gpui.workspace = true
log.workspace = true
lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "1fff0dd12e2071c5667327394cfec163d2a466ab" }
lsp-types.workspace = true
parking_lot.workspace = true
postage.workspace = true
serde.workspace = true

View File

@@ -98,19 +98,6 @@ pub fn parse_markdown(
// HTML entities or smart punctuation has occurred. When these substitutions occur,
// `parsed` only consists of the result of a single substitution.
if !cow_str_points_inside(&parsed, text) {
// Attempt to detect cases where the assumptions here are not valid or the
// behavior has changed.
if parsed.len() > 4 {
log::error!(
"Bug in markdown parser. \
pulldown_cmark::Event::Text expected to a substituted HTML entity, \
but it was longer than expected.\n\
Source: {}\n\
Parsed: {}",
&text[range.clone()],
parsed
);
}
events.push((range, MarkdownEvent::SubstitutedText(parsed.into())));
} else {
// Automatically detect links in text if not already within a markdown link.
@@ -432,12 +419,18 @@ impl From<pulldown_cmark::Tag<'_>> for MarkdownTag {
/// more efficient - it fits within a `pulldown_cmark::InlineStr` in all known cases.
///
/// Same as `pulldown_cmark::CowStr` but without the `Borrow` case.
#[derive(Clone, Debug)]
#[derive(Clone)]
pub enum CompactStr {
Boxed(Box<str>),
Inlined(InlineStr),
}
impl std::fmt::Debug for CompactStr {
fn fmt(&self, formatter: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
self.deref().fmt(formatter)
}
}
impl Deref for CompactStr {
type Target = str;
@@ -551,10 +544,10 @@ mod tests {
#[test]
fn test_smart_punctuation() {
assert_eq!(
parse_markdown("-- --- ... \"double quoted\" 'single quoted'"),
parse_markdown("-- --- ... \"double quoted\" 'single quoted' ----------"),
(
vec![
(0..42, Start(Paragraph)),
(0..53, Start(Paragraph)),
(0..2, SubstitutedText("".into())),
(2..3, Text),
(3..6, SubstitutedText("".into())),
@@ -568,7 +561,9 @@ mod tests {
(27..28, SubstitutedText("".into())),
(28..41, Text),
(41..42, SubstitutedText("".into())),
(0..42, End(MarkdownTagEnd::Paragraph))
(42..43, Text),
(43..53, SubstitutedText("".into())),
(0..53, End(MarkdownTagEnd::Paragraph))
],
HashSet::new(),
HashSet::new()

View File

@@ -275,6 +275,7 @@ impl RemoteBufferStore {
if push_to_history {
buffer.update(cx, |buffer, _| {
buffer.push_transaction(transaction.clone(), Instant::now());
buffer.finalize_last_transaction();
})?;
}
}

View File

@@ -38,7 +38,7 @@ use std::{
borrow::Borrow,
collections::{BTreeMap, HashSet},
ffi::OsStr,
path::PathBuf,
path::{Path, PathBuf},
sync::{Arc, atomic::Ordering::SeqCst},
};
use std::{collections::VecDeque, sync::atomic::AtomicU32};
@@ -57,7 +57,7 @@ pub enum DapStoreEvent {
RunInTerminal {
session_id: SessionId,
title: Option<String>,
cwd: PathBuf,
cwd: Option<Arc<Path>>,
command: Option<String>,
args: Vec<String>,
envs: HashMap<String, String>,
@@ -339,8 +339,7 @@ impl DapStore {
local_store.language_registry.clone(),
local_store.toolchain_store.clone(),
local_store.environment.update(cx, |env, cx| {
let worktree = worktree.read(cx);
env.get_environment(worktree.abs_path().into(), cx)
env.get_worktree_environment(worktree.clone(), cx)
}),
);
let session_id = local_store.next_session_id();
@@ -414,8 +413,7 @@ impl DapStore {
local_store.language_registry.clone(),
local_store.toolchain_store.clone(),
local_store.environment.update(cx, |env, cx| {
let worktree = worktree.read(cx);
env.get_environment(Some(worktree.abs_path()), cx)
env.get_worktree_environment(worktree.clone(), cx)
}),
);
let session_id = local_store.next_session_id();
@@ -551,10 +549,10 @@ impl DapStore {
let seq = request.seq;
let cwd = PathBuf::from(request_args.cwd);
let cwd = Path::new(&request_args.cwd);
match cwd.try_exists() {
Ok(true) => (),
Ok(false) | Err(_) => {
Ok(false) | Err(_) if !request_args.cwd.is_empty() => {
return session.update(cx, |session, cx| {
session.respond_to_client(
seq,
@@ -576,8 +574,8 @@ impl DapStore {
)
});
}
_ => (),
}
let mut args = request_args.args.clone();
// Handle special case for NodeJS debug adapter
@@ -604,7 +602,19 @@ impl DapStore {
}
let (tx, mut rx) = mpsc::channel::<Result<u32>>(1);
let cwd = Some(cwd)
.filter(|cwd| cwd.as_os_str().len() > 0)
.map(Arc::from)
.or_else(|| {
self.session_by_id(session_id)
.and_then(|session| {
session
.read(cx)
.configuration()
.and_then(|config| config.request.cwd())
})
.map(Arc::from)
});
cx.emit(DapStoreEvent::RunInTerminal {
session_id,
title: request_args.title,

View File

@@ -1,22 +1,21 @@
use futures::{
FutureExt,
future::{Shared, WeakShared},
};
use futures::{FutureExt, future::Shared};
use language::Buffer;
use std::{path::Path, sync::Arc};
use util::ResultExt;
use worktree::Worktree;
use collections::HashMap;
use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task};
use gpui::{AppContext as _, Context, Entity, EventEmitter, Task};
use settings::Settings as _;
use crate::{
project_settings::{DirenvSettings, ProjectSettings},
worktree_store::{WorktreeStore, WorktreeStoreEvent},
worktree_store::WorktreeStore,
};
pub struct ProjectEnvironment {
cli_environment: Option<HashMap<String, String>>,
environments: HashMap<Arc<Path>, WeakShared<Task<Option<HashMap<String, String>>>>>,
environments: HashMap<Arc<Path>, Shared<Task<Option<HashMap<String, String>>>>>,
environment_error_messages: HashMap<Arc<Path>, EnvironmentErrorMessage>,
}
@@ -27,27 +26,12 @@ pub enum ProjectEnvironmentEvent {
impl EventEmitter<ProjectEnvironmentEvent> for ProjectEnvironment {}
impl ProjectEnvironment {
pub fn new(
worktree_store: &Entity<WorktreeStore>,
cli_environment: Option<HashMap<String, String>>,
cx: &mut App,
) -> Entity<Self> {
cx.new(|cx| {
cx.subscribe(worktree_store, |this: &mut Self, _, event, _| {
if let WorktreeStoreEvent::WorktreeRemoved(_, _) = event {
this.environments.retain(|_, weak| weak.upgrade().is_some());
this.environment_error_messages
.retain(|abs_path, _| this.environments.contains_key(abs_path));
}
})
.detach();
Self {
cli_environment,
environments: Default::default(),
environment_error_messages: Default::default(),
}
})
pub fn new(cli_environment: Option<HashMap<String, String>>) -> Self {
Self {
cli_environment,
environments: Default::default(),
environment_error_messages: Default::default(),
}
}
/// Returns the inherited CLI environment, if this project was opened from the Zed CLI.
@@ -73,54 +57,85 @@ impl ProjectEnvironment {
cx.emit(ProjectEnvironmentEvent::ErrorsUpdated);
}
/// Returns the project environment, if possible.
/// If the project was opened from the CLI, then the inherited CLI environment is returned.
/// If it wasn't opened from the CLI, and an absolute path is given, then a shell is spawned in
/// that directory, to get environment variables as if the user has `cd`'d there.
pub(crate) fn get_environment(
pub(crate) fn get_buffer_environment(
&mut self,
abs_path: Option<Arc<Path>>,
cx: &Context<Self>,
buffer: Entity<Buffer>,
worktree_store: Entity<WorktreeStore>,
cx: &mut Context<Self>,
) -> Shared<Task<Option<HashMap<String, String>>>> {
if cfg!(any(test, feature = "test-support")) {
return Task::ready(Some(HashMap::default())).shared();
}
if let Some(cli_environment) = self.get_cli_environment() {
return cx
.spawn(async move |_, _| {
let path = cli_environment
.get("PATH")
.map(|path| path.as_str())
.unwrap_or_default();
log::info!(
"using project environment variables from CLI. PATH={:?}",
path
);
Some(cli_environment)
})
.shared();
log::info!("using project environment variables from CLI");
return Task::ready(Some(cli_environment)).shared();
}
let Some(abs_path) = abs_path else {
let Some(worktree) = buffer
.read(cx)
.file()
.map(|f| f.worktree_id(cx))
.and_then(|worktree_id| worktree_store.read(cx).worktree_for_id(worktree_id, cx))
else {
return Task::ready(None).shared();
};
if let Some(existing) = self
.environments
.get(&abs_path)
.and_then(|weak| weak.upgrade())
{
existing
} else {
let env = get_directory_env(abs_path.clone(), cx).shared();
self.environments.insert(
abs_path.clone(),
env.downgrade()
.expect("environment task has not been polled yet"),
);
env
self.get_worktree_environment(worktree, cx)
}
pub(crate) fn get_worktree_environment(
&mut self,
worktree: Entity<Worktree>,
cx: &mut Context<Self>,
) -> Shared<Task<Option<HashMap<String, String>>>> {
if cfg!(any(test, feature = "test-support")) {
return Task::ready(Some(HashMap::default())).shared();
}
if let Some(cli_environment) = self.get_cli_environment() {
log::info!("using project environment variables from CLI");
return Task::ready(Some(cli_environment)).shared();
}
let mut abs_path = worktree.read(cx).abs_path();
if !worktree.read(cx).is_local() {
log::error!(
"attempted to get project environment for a non-local worktree at {abs_path:?}"
);
return Task::ready(None).shared();
} else if worktree.read(cx).is_single_file() {
let Some(parent) = abs_path.parent() else {
return Task::ready(None).shared();
};
abs_path = parent.into();
}
self.get_directory_environment(abs_path, cx)
}
/// Returns the project environment, if possible.
/// If the project was opened from the CLI, then the inherited CLI environment is returned.
/// If it wasn't opened from the CLI, and an absolute path is given, then a shell is spawned in
/// that directory, to get environment variables as if the user has `cd`'d there.
pub(crate) fn get_directory_environment(
&mut self,
abs_path: Arc<Path>,
cx: &mut Context<Self>,
) -> Shared<Task<Option<HashMap<String, String>>>> {
if cfg!(any(test, feature = "test-support")) {
return Task::ready(Some(HashMap::default())).shared();
}
if let Some(cli_environment) = self.get_cli_environment() {
log::info!("using project environment variables from CLI");
return Task::ready(Some(cli_environment)).shared();
}
self.environments
.entry(abs_path.clone())
.or_insert_with(|| get_directory_env_impl(abs_path.clone(), cx).shared())
.clone()
}
}
@@ -338,7 +353,7 @@ async fn load_shell_environment(
(Some(parsed_env), direnv_error)
}
fn get_directory_env(
fn get_directory_env_impl(
abs_path: Arc<Path>,
cx: &Context<ProjectEnvironment>,
) -> Task<Option<HashMap<String, String>>> {
@@ -368,10 +383,7 @@ fn get_directory_env(
if let Some(error) = error_message {
this.update(cx, |this, cx| {
log::error!(
"error fetching environment for path {abs_path:?}: {}",
error
);
log::error!("{error}",);
this.environment_error_messages.insert(abs_path, error);
cx.emit(ProjectEnvironmentEvent::ErrorsUpdated)
})

View File

@@ -3263,7 +3263,7 @@ impl Repository {
remote: SharedString,
options: Option<PushOptions>,
askpass: AskPassDelegate,
_cx: &mut App,
cx: &mut Context<Self>,
) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
let askpass_delegates = self.askpass_delegates.clone();
let askpass_id = util::post_inc(&mut self.latest_askpass_id);
@@ -3276,25 +3276,52 @@ impl Repository {
})
.unwrap_or("");
let updates_tx = self
.git_store()
.and_then(|git_store| match &git_store.read(cx).state {
GitStoreState::Local { downstream, .. } => downstream
.as_ref()
.map(|downstream| downstream.updates_tx.clone()),
_ => None,
});
let this = cx.weak_entity();
self.send_job(
Some(format!("git push{} {} {}", args, branch, remote).into()),
move |git_repo, cx| async move {
Some(format!("git push {} {} {}", args, branch, remote).into()),
move |git_repo, mut cx| async move {
match git_repo {
RepositoryState::Local {
backend,
environment,
..
} => {
backend
let result = backend
.push(
branch.to_string(),
remote.to_string(),
options,
askpass,
environment.clone(),
cx,
cx.clone(),
)
.await
.await;
if result.is_ok() {
let branches = backend.branches().await?;
let branch = branches.into_iter().find(|branch| branch.is_head);
log::info!("head branch after scan is {branch:?}");
let snapshot = this.update(&mut cx, |this, cx| {
this.snapshot.branch = branch;
let snapshot = this.snapshot.clone();
cx.emit(RepositoryEvent::Updated { full_scan: false });
snapshot
})?;
if let Some(updates_tx) = updates_tx {
updates_tx
.unbounded_send(DownstreamUpdate::UpdateRepository(snapshot))
.ok();
}
}
result
}
RepositoryState::Remote { project_id, client } => {
askpass_delegates.lock().insert(askpass_id, askpass);
@@ -3749,7 +3776,7 @@ impl Repository {
.upgrade()
.ok_or_else(|| anyhow!("missing project environment"))?
.update(cx, |project_environment, cx| {
project_environment.get_environment(Some(work_directory_abs_path.clone()), cx)
project_environment.get_directory_environment(work_directory_abs_path.clone(), cx)
})?
.await
.unwrap_or_else(|| {

View File

@@ -86,7 +86,8 @@ use text::{Anchor, BufferId, LineEnding, OffsetRangeExt};
use url::Url;
use util::{
ResultExt, TryFutureExt as _, debug_panic, defer, maybe, merge_json_value_into,
paths::SanitizedPath, post_inc,
paths::{PathExt, SanitizedPath},
post_inc,
};
pub use fs::*;
@@ -1220,8 +1221,61 @@ impl LocalLspStore {
buffer.finalize_last_transaction();
})?;
// handle whitespace formatting
// helper function to avoid duplicate logic between formatter handlers below
// We want to avoid continuing to format the buffer if it has been edited since the start
// so we check that the last transaction id on the undo stack matches the one we expect
// This check should be done after each "gather" step where we generate a diff or edits to apply,
// and before applying them to the buffer to avoid messing up the user's buffer
fn err_if_buffer_edited_since_start(
buffer: &FormattableBuffer,
transaction_id_format: Option<text::TransactionId>,
cx: &AsyncApp,
) -> Option<anyhow::Error> {
let transaction_id_last = buffer
.handle
.read_with(cx, |buffer, _| {
buffer.peek_undo_stack().map(|t| t.transaction_id())
})
.ok()
.flatten();
let should_continue_formatting = match (transaction_id_format, transaction_id_last)
{
(Some(format), Some(last)) => format == last,
(Some(_), None) => false,
(_, _) => true,
};
if !should_continue_formatting {
return Some(anyhow::anyhow!("Buffer edited while formatting. Aborting"));
}
return None;
}
// variable used to track errors that occur during the formatting process below,
// but that need to not be returned right away (with `?` for example) because we
// still need to clean up the transaction history and update the project transaction
// at the very end
let mut result = anyhow::Ok(());
// see handling of code action formatting for why there might already be transactions
// in project_transaction for this buffer
if let Some(transaction_existing) = project_transaction.0.remove(&buffer.handle) {
transaction_id_format = Some(transaction_existing.id);
buffer.handle.update(cx, |buffer, _| {
// ensure the transaction is in the history so we can group with it
if buffer.get_transaction(transaction_existing.id).is_none() {
buffer.push_transaction(transaction_existing, Instant::now());
}
})?;
}
if let Some(err) = err_if_buffer_edited_since_start(buffer, transaction_id_format, &cx)
{
zlog::warn!(logger => "Buffer edited while formatting. Aborting");
result = Err(err);
}
// handle whitespace formatting
if result.is_ok() {
if settings.remove_trailing_whitespace_on_save {
zlog::trace!(logger => "removing trailing whitespace");
let diff = buffer
@@ -1297,41 +1351,12 @@ impl LocalLspStore {
let formatters = code_actions_on_format_formatter.iter().chain(formatters);
// helper function to avoid duplicate logic between formatter handlers below
// We want to avoid continuing to format the buffer if it has been edited since the start
// so we check that the last transaction id on the undo stack matches the one we expect
// This check should be done after each "gather" step where we generate a diff or edits to apply,
// and before applying them to the buffer to avoid messing up the user's buffer
fn err_if_buffer_edited_since_start(
buffer: &FormattableBuffer,
transaction_id_format: Option<text::TransactionId>,
cx: &AsyncApp,
) -> Option<anyhow::Error> {
let transaction_id_last = buffer
.handle
.read_with(cx, |buffer, _| {
buffer.peek_undo_stack().map(|t| t.transaction_id())
})
.ok()
.flatten();
let should_continue_formatting = match (transaction_id_format, transaction_id_last)
{
(Some(format), Some(last)) => format == last,
(Some(_), None) => false,
(_, _) => true,
};
if !should_continue_formatting {
return Some(anyhow::anyhow!("Buffer edited while formatting. Aborting"));
}
return None;
}
// variable used to track errors that occur during the formatting process below,
// but that need to not be returned right away (with `?` for example) because we
// still need to clean up the transaction history and update the project transaction
let mut result = anyhow::Ok(());
'formatters: for formatter in formatters {
if result.is_err() {
// may have been set above, instead of indenting this whole block with an if, just don't do anything
// destructive if we're aborting
continue;
}
match formatter {
Formatter::Prettier => {
let logger = zlog::scoped!(logger => "prettier");
@@ -1583,32 +1608,19 @@ impl LocalLspStore {
let describe_code_action = |action: &CodeAction| {
format!(
"code action '{}' with title \"{}\"",
"code action '{}' with title \"{}\" on server {}",
action
.lsp_action
.action_kind()
.unwrap_or("unknown".into())
.as_str(),
action.lsp_action.title()
action.lsp_action.title(),
server.name(),
)
};
zlog::trace!(logger => "Executing {}", describe_code_action(&action));
// NOTE: code below duplicated from `Self::deserialize_workspace_edit`
// but filters out and logs warnings for code actions that cause unreasonably
// difficult handling on our part, such as:
// - applying edits that call commands
// which can result in arbitrary workspace edits being sent from the server that
// have no way of being tied back to the command that initiated them (i.e. we
// can't know which edits are part of the format request, or if the server is done sending
// actions in response to the command)
// - actions that create/delete/modify/rename files other than the one we are formatting
// as we then would need to handle such changes correctly in the local history as well
// as the remote history through the ProjectTransaction
// - actions with snippet edits, as these simply don't make sense in the context of a format request
// Supporting these actions is not impossible, but not supported as of yet.
if let Err(err) =
Self::try_resolve_code_action(server, &mut action).await
{
@@ -1620,163 +1632,339 @@ impl LocalLspStore {
);
continue 'actions;
}
if let Some(_) = action.lsp_action.command() {
zlog::warn!(
logger =>
"Code actions with commands are not supported while formatting. Skipping {}",
describe_code_action(&action),
);
continue 'actions;
}
let Some(edit) = action.lsp_action.edit().cloned() else {
zlog::warn!(
logger =>
"No edit found for while formatting. Skipping {}",
describe_code_action(&action),
);
continue 'actions;
};
if edit.changes.is_none() && edit.document_changes.is_none() {
zlog::trace!(
logger =>
"No changes for code action. Skipping {}",
describe_code_action(&action),
);
continue 'actions;
}
let mut operations = Vec::new();
if let Some(document_changes) = edit.document_changes {
match document_changes {
lsp::DocumentChanges::Edits(edits) => operations.extend(
edits.into_iter().map(lsp::DocumentChangeOperation::Edit),
),
lsp::DocumentChanges::Operations(ops) => operations = ops,
if let Some(edit) = action.lsp_action.edit().cloned() {
// NOTE: code below duplicated from `Self::deserialize_workspace_edit`
// but filters out and logs warnings for code actions that cause unreasonably
// difficult handling on our part, such as:
// - applying edits that call commands
// which can result in arbitrary workspace edits being sent from the server that
// have no way of being tied back to the command that initiated them (i.e. we
// can't know which edits are part of the format request, or if the server is done sending
// actions in response to the command)
// - actions that create/delete/modify/rename files other than the one we are formatting
// as we then would need to handle such changes correctly in the local history as well
// as the remote history through the ProjectTransaction
// - actions with snippet edits, as these simply don't make sense in the context of a format request
// Supporting these actions is not impossible, but not supported as of yet.
if edit.changes.is_none() && edit.document_changes.is_none() {
zlog::trace!(
logger =>
"No changes for code action. Skipping {}",
describe_code_action(&action),
);
continue 'actions;
}
} else if let Some(changes) = edit.changes {
operations.extend(changes.into_iter().map(|(uri, edits)| {
lsp::DocumentChangeOperation::Edit(lsp::TextDocumentEdit {
text_document:
lsp::OptionalVersionedTextDocumentIdentifier {
uri,
version: None,
},
edits: edits.into_iter().map(Edit::Plain).collect(),
})
}));
}
let mut edits = Vec::with_capacity(operations.len());
if operations.is_empty() {
zlog::trace!(
logger =>
"No changes for code action. Skipping {}",
describe_code_action(&action),
);
continue 'actions;
}
for operation in operations {
let op = match operation {
lsp::DocumentChangeOperation::Edit(op) => op,
lsp::DocumentChangeOperation::Op(_) => {
zlog::warn!(
logger =>
"Code actions which create, delete, or rename files are not supported on format. Skipping {}",
describe_code_action(&action),
);
continue 'actions;
let mut operations = Vec::new();
if let Some(document_changes) = edit.document_changes {
match document_changes {
lsp::DocumentChanges::Edits(edits) => operations.extend(
edits
.into_iter()
.map(lsp::DocumentChangeOperation::Edit),
),
lsp::DocumentChanges::Operations(ops) => operations = ops,
}
};
let Ok(file_path) = op.text_document.uri.to_file_path() else {
zlog::warn!(
} else if let Some(changes) = edit.changes {
operations.extend(changes.into_iter().map(|(uri, edits)| {
lsp::DocumentChangeOperation::Edit(lsp::TextDocumentEdit {
text_document:
lsp::OptionalVersionedTextDocumentIdentifier {
uri,
version: None,
},
edits: edits.into_iter().map(Edit::Plain).collect(),
})
}));
}
let mut edits = Vec::with_capacity(operations.len());
if operations.is_empty() {
zlog::trace!(
logger =>
"Failed to convert URI '{:?}' to file path. Skipping {}",
&op.text_document.uri,
describe_code_action(&action),
);
continue 'actions;
};
if &file_path != buffer_path_abs {
zlog::warn!(
logger =>
"File path '{:?}' does not match buffer path '{:?}'. Skipping {}",
file_path,
buffer_path_abs,
"No changes for code action. Skipping {}",
describe_code_action(&action),
);
continue 'actions;
}
let mut lsp_edits = Vec::new();
for edit in op.edits {
match edit {
Edit::Plain(edit) => {
if !lsp_edits.contains(&edit) {
lsp_edits.push(edit);
}
}
Edit::Annotated(edit) => {
if !lsp_edits.contains(&edit.text_edit) {
lsp_edits.push(edit.text_edit);
}
}
Edit::Snippet(_) => {
for operation in operations {
let op = match operation {
lsp::DocumentChangeOperation::Edit(op) => op,
lsp::DocumentChangeOperation::Op(_) => {
zlog::warn!(
logger =>
"Code actions which produce snippet edits are not supported during formatting. Skipping {}",
"Code actions which create, delete, or rename files are not supported on format. Skipping {}",
describe_code_action(&action),
);
continue 'actions;
}
};
let Ok(file_path) = op.text_document.uri.to_file_path() else {
zlog::warn!(
logger =>
"Failed to convert URI '{:?}' to file path. Skipping {}",
&op.text_document.uri,
describe_code_action(&action),
);
continue 'actions;
};
if &file_path != buffer_path_abs {
zlog::warn!(
logger =>
"File path '{:?}' does not match buffer path '{:?}'. Skipping {}",
file_path,
buffer_path_abs,
describe_code_action(&action),
);
continue 'actions;
}
let mut lsp_edits = Vec::new();
for edit in op.edits {
match edit {
Edit::Plain(edit) => {
if !lsp_edits.contains(&edit) {
lsp_edits.push(edit);
}
}
Edit::Annotated(edit) => {
if !lsp_edits.contains(&edit.text_edit) {
lsp_edits.push(edit.text_edit);
}
}
Edit::Snippet(_) => {
zlog::warn!(
logger =>
"Code actions which produce snippet edits are not supported during formatting. Skipping {}",
describe_code_action(&action),
);
continue 'actions;
}
}
}
let edits_result = lsp_store
.update(cx, |lsp_store, cx| {
lsp_store.as_local_mut().unwrap().edits_from_lsp(
&buffer.handle,
lsp_edits,
server.server_id(),
op.text_document.version,
cx,
)
})?
.await;
let Ok(resolved_edits) = edits_result else {
zlog::warn!(
logger =>
"Failed to resolve edits from LSP for buffer {:?} while handling {}",
buffer_path_abs.as_path(),
describe_code_action(&action),
);
continue 'actions;
};
edits.extend(resolved_edits);
}
if edits.is_empty() {
zlog::warn!(logger => "No edits resolved from LSP");
continue 'actions;
}
if let Some(err) = err_if_buffer_edited_since_start(
buffer,
transaction_id_format,
&cx,
) {
zlog::warn!(logger => "Buffer edited while formatting. Aborting");
result = Err(err);
break 'formatters;
}
zlog::info!(logger => "Applying changes");
buffer.handle.update(cx, |buffer, cx| {
buffer.start_transaction();
buffer.edit(edits, None, cx);
transaction_id_format =
transaction_id_format.or(buffer.end_transaction(cx));
if let Some(transaction_id) = transaction_id_format {
buffer.group_until_transaction(transaction_id);
}
})?;
}
if let Some(command) = action.lsp_action.command() {
zlog::warn!(
logger =>
"Executing code action command '{}'. This may cause formatting to abort unnecessarily as well as splitting formatting into two entries in the undo history",
&command.command,
);
// bail early and command is invalid
{
let server_capabilities = server.capabilities();
let available_commands = server_capabilities
.execute_command_provider
.as_ref()
.map(|options| options.commands.as_slice())
.unwrap_or_default();
if !available_commands.contains(&command.command) {
zlog::warn!(
logger =>
"Cannot execute a command {} not listed in the language server capabilities of server {}",
command.command,
server.name(),
);
continue 'actions;
}
}
let edits_result = lsp_store
.update(cx, |lsp_store, cx| {
lsp_store.as_local_mut().unwrap().edits_from_lsp(
&buffer.handle,
lsp_edits,
server.server_id(),
op.text_document.version,
cx,
)
})?
if let Some(err) = err_if_buffer_edited_since_start(
buffer,
transaction_id_format,
&cx,
) {
zlog::warn!(logger => "Buffer edited while formatting. Aborting");
result = Err(err);
break 'formatters;
}
zlog::info!(logger => "Executing command {}", &command.command);
lsp_store.update(cx, |this, _| {
this.as_local_mut()
.unwrap()
.last_workspace_edits_by_language_server
.remove(&server.server_id());
})?;
let execute_command_result = server
.request::<lsp::request::ExecuteCommand>(
lsp::ExecuteCommandParams {
command: command.command.clone(),
arguments: command
.arguments
.clone()
.unwrap_or_default(),
..Default::default()
},
)
.await;
let Ok(resolved_edits) = edits_result else {
zlog::warn!(
if execute_command_result.is_err() {
zlog::error!(
logger =>
"Failed to resolve edits from LSP for buffer {:?} while handling {}",
buffer_path_abs.as_path(),
"Failed to execute command '{}' as part of {}",
&command.command,
describe_code_action(&action),
);
continue 'actions;
};
edits.extend(resolved_edits);
}
if edits.is_empty() {
zlog::warn!(logger => "No edits resolved from LSP");
continue 'actions;
}
if let Some(err) =
err_if_buffer_edited_since_start(buffer, transaction_id_format, &cx)
{
zlog::warn!(logger => "Buffer edited while formatting. Aborting");
result = Err(err);
break 'formatters;
}
zlog::info!(logger => "Applying changes");
buffer.handle.update(cx, |buffer, cx| {
buffer.start_transaction();
buffer.edit(edits, None, cx);
transaction_id_format =
transaction_id_format.or(buffer.end_transaction(cx));
if let Some(transaction_id) = transaction_id_format {
buffer.group_until_transaction(transaction_id);
}
})?;
let mut project_transaction_command =
lsp_store.update(cx, |this, _| {
this.as_local_mut()
.unwrap()
.last_workspace_edits_by_language_server
.remove(&server.server_id())
.unwrap_or_default()
})?;
if let Some(transaction) =
project_transaction_command.0.remove(&buffer.handle)
{
zlog::trace!(
logger =>
"Successfully captured {} edits that resulted from command {}",
transaction.edit_ids.len(),
&command.command,
);
if let Some(transaction_id_format) = transaction_id_format {
let transaction_id_project_transaction = transaction.id;
buffer.handle.update(cx, |buffer, _| {
// it may have been removed from history if push_to_history was
// false in deserialize_workspace_edit. If so push it so we
// can merge it with the format transaction
// and pop the combined transaction off the history stack
// later if push_to_history is false
if buffer.get_transaction(transaction.id).is_none() {
buffer
.push_transaction(transaction, Instant::now());
}
buffer.merge_transactions(
transaction_id_project_transaction,
transaction_id_format,
);
})?;
} else {
transaction_id_format = Some(transaction.id);
}
}
if !project_transaction_command.0.is_empty() {
let extra_buffers = project_transaction_command
.0
.keys()
.filter_map(|buffer_handle| {
buffer_handle
.read_with(cx, |b, cx| b.project_path(cx))
.ok()
.flatten()
})
.map(|p| p.path.to_sanitized_string())
.join(", ");
zlog::warn!(
logger =>
"Unexpected edits to buffers other than the buffer actively being formatted due to command {}. Impacted buffers: [{}].",
&command.command,
extra_buffers,
);
for (buffer_handle, transaction) in
project_transaction_command.0
{
let entry = project_transaction.0.entry(buffer_handle);
use std::collections::hash_map::Entry;
match entry {
// if project_transaction already contains a transaction for this buffer, then
// we already formatted it, and we need to merge the new transaction into the one
// in project_transaction that we created while formatting
Entry::Occupied(mut occupied_entry) => {
let buffer_handle = occupied_entry.key();
buffer_handle.update(cx, |buffer, _| {
// if push_to_history is true, then we need to make sure it is merged in the
// buffer history as well
if push_to_history {
let transaction_id_project_transaction =
transaction.id;
// ensure transaction from command project transaction
// is in history so we can merge
if buffer
.get_transaction(transaction.id)
.is_none()
{
buffer.push_transaction(
transaction.clone(),
Instant::now(),
);
}
let transaction_id_existing =
occupied_entry.get().id;
buffer.merge_transactions(
transaction_id_project_transaction,
transaction_id_existing,
);
}
})?;
let transaction_existing = occupied_entry.get_mut();
transaction_existing.merge_in(transaction);
}
// if there is no transaction in project_transaction, we either haven't formatted the buffer yet,
// or we won't format this buffer
// later iterations over the formattable_buffers list will use this as the starting transaction
// for the formatting on that buffer
Entry::Vacant(vacant_entry) => {
vacant_entry.insert(transaction);
}
}
}
}
}
}
}
}
@@ -2898,20 +3086,35 @@ impl LocalLspStore {
.await?;
let transaction = buffer_to_edit.update(cx, |buffer, cx| {
buffer.finalize_last_transaction();
let is_formatting = this.read_with(cx, |lsp_store, _| {
lsp_store
.as_local()
.map_or(false, |local| !local.buffers_being_formatted.is_empty())
});
// finalizing last transaction breaks workspace edits received due to
// code actions that are run as part of formatting because formatting
// groups transactions from format steps together to allow undoing all formatting with
// a single undo, and to bail when the user modifies the buffer during formatting
// finalizing the transaction prevents the necessary grouping from happening
if !is_formatting {
buffer.finalize_last_transaction();
}
buffer.start_transaction();
for (range, text) in edits {
buffer.edit([(range, text)], None, cx);
}
let transaction = if buffer.end_transaction(cx).is_some() {
let transaction = buffer.finalize_last_transaction().unwrap().clone();
if !push_to_history {
buffer.forget_transaction(transaction.id);
let transaction = buffer.end_transaction(cx).and_then(|transaction_id| {
if push_to_history {
if !is_formatting {
// see comment above about finalizing during formatting
buffer.finalize_last_transaction();
}
buffer.get_transaction(transaction_id).cloned()
} else {
buffer.forget_transaction(transaction_id)
}
Some(transaction)
} else {
None
};
});
transaction
})?;
@@ -5451,6 +5654,7 @@ impl LspStore {
if push_to_history {
buffer_handle.update(cx, |buffer, _| {
buffer.push_transaction(transaction.clone(), Instant::now());
buffer.finalize_last_transaction();
})?;
}
Ok(Some(transaction))
@@ -8087,16 +8291,10 @@ impl LspStore {
buffer: &Entity<Buffer>,
cx: &mut Context<Self>,
) -> Shared<Task<Option<HashMap<String, String>>>> {
let worktree_id = buffer.read(cx).file().map(|file| file.worktree_id(cx));
let worktree_abs_path = worktree_id.and_then(|worktree_id| {
self.worktree_store
.read(cx)
.worktree_for_id(worktree_id, cx)
.map(|entry| entry.read(cx).abs_path().clone())
});
if let Some(environment) = &self.as_local().map(|local| local.environment.clone()) {
environment.update(cx, |env, cx| env.get_environment(worktree_abs_path, cx))
environment.update(cx, |env, cx| {
env.get_buffer_environment(buffer.clone(), self.worktree_store.clone(), cx)
})
} else {
Task::ready(None).shared()
}
@@ -9930,10 +10128,8 @@ impl LocalLspAdapterDelegate {
fs: Arc<dyn Fs>,
cx: &mut App,
) -> Arc<Self> {
let worktree_abs_path = worktree.read(cx).abs_path();
let load_shell_env_task = environment.update(cx, |env, cx| {
env.get_environment(Some(worktree_abs_path), cx)
env.get_worktree_environment(worktree.clone(), cx)
});
Arc::new(Self {

View File

@@ -841,7 +841,7 @@ impl Project {
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
.detach();
let environment = ProjectEnvironment::new(&worktree_store, env, cx);
let environment = cx.new(|_| ProjectEnvironment::new(env));
let toolchain_store = cx.new(|cx| {
ToolchainStore::local(
languages.clone(),
@@ -1043,7 +1043,7 @@ impl Project {
cx.subscribe(&settings_observer, Self::on_settings_observer_event)
.detach();
let environment = ProjectEnvironment::new(&worktree_store, None, cx);
let environment = cx.new(|_| ProjectEnvironment::new(None));
let lsp_store = cx.new(|cx| {
LspStore::new_remote(
@@ -1242,7 +1242,7 @@ impl Project {
ImageStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx)
})?;
let environment = cx.update(|cx| ProjectEnvironment::new(&worktree_store, None, cx))?;
let environment = cx.new(|_| ProjectEnvironment::new(None))?;
let breakpoint_store =
cx.new(|_| BreakpointStore::remote(remote_id, client.clone().into()))?;

View File

@@ -295,10 +295,13 @@ fn local_task_context_for_location(
.and_then(|worktree| worktree.read(cx).root_dir());
cx.spawn(async move |cx| {
let worktree_abs_path = worktree_abs_path.clone();
let project_env = environment
.update(cx, |environment, cx| {
environment.get_environment(worktree_abs_path.clone(), cx)
environment.get_buffer_environment(
location.buffer.clone(),
worktree_store.clone(),
cx,
)
})
.ok()?
.await;

View File

@@ -40,7 +40,7 @@ pub enum TerminalKind {
command: Option<String>,
args: Vec<String>,
envs: HashMap<String, String>,
cwd: PathBuf,
cwd: Option<Arc<Path>>,
title: Option<String>,
},
}
@@ -101,7 +101,7 @@ impl Project {
self.active_project_directory(cx)
}
}
TerminalKind::Debug { cwd, .. } => Some(Arc::from(cwd.as_path())),
TerminalKind::Debug { cwd, .. } => cwd.clone(),
};
let mut settings_location = None;
@@ -205,7 +205,7 @@ impl Project {
this.active_project_directory(cx)
}
}
TerminalKind::Debug { cwd, .. } => Some(Arc::from(cwd.as_path())),
TerminalKind::Debug { cwd, .. } => cwd.clone(),
};
let ssh_details = this.ssh_details(cx);

View File

@@ -317,21 +317,14 @@ impl LocalToolchainStore {
cx: &App,
) -> Task<Option<ToolchainList>> {
let registry = self.languages.clone();
let Some(root) = self
.worktree_store
.read(cx)
.worktree_for_id(path.worktree_id, cx)
.map(|worktree| worktree.read(cx).abs_path())
else {
let Some(abs_path) = self.worktree_store.read(cx).absolutize(&path, cx) else {
return Task::ready(None);
};
let abs_path = root.join(path.path);
let environment = self.project_environment.clone();
cx.spawn(async move |cx| {
let project_env = environment
.update(cx, |environment, cx| {
environment.get_environment(Some(root.clone()), cx)
environment.get_directory_environment(abs_path.as_path().into(), cx)
})
.ok()?
.await;

View File

@@ -38,12 +38,12 @@ impl AssistantSystemPromptContext {
pub struct WorktreeInfoForSystemPrompt {
pub root_name: String,
pub abs_path: Arc<Path>,
pub rules_file: Option<RulesFile>,
pub rules_file: Option<SystemPromptRulesFile>,
}
#[derive(Serialize)]
pub struct RulesFile {
pub rel_path: Arc<Path>,
pub struct SystemPromptRulesFile {
pub path_in_worktree: Arc<Path>,
pub abs_path: Arc<Path>,
pub text: String,
}
@@ -261,6 +261,12 @@ impl PromptBuilder {
.render("assistant_system_prompt", context)
}
pub fn generate_assistant_system_prompt_reminder(&self) -> Result<String, RenderError> {
self.handlebars
.lock()
.render("assistant_system_prompt_reminder", &())
}
pub fn generate_inline_transformation_prompt(
&self,
user_prompt: String,

View File

@@ -1336,7 +1336,7 @@ impl RemoteServerProjects {
Modal::new("remote-projects", None)
.header(
ModalHeader::new()
.child(Headline::new("Remote Projects (beta)").size(HeadlineSize::XSmall)),
.child(Headline::new("Remote Projects").size(HeadlineSize::XSmall)),
)
.section(
Section::new().padded(false).child(

View File

@@ -9,7 +9,8 @@ use http_client::HttpClient;
use language::{Buffer, BufferEvent, LanguageRegistry, proto::serialize_operation};
use node_runtime::NodeRuntime;
use project::{
LspStore, LspStoreEvent, PrettierStore, ProjectPath, ToolchainStore, WorktreeId,
LspStore, LspStoreEvent, PrettierStore, ProjectEnvironment, ProjectPath, ToolchainStore,
WorktreeId,
buffer_store::{BufferStore, BufferStoreEvent},
debugger::{breakpoint_store::BreakpointStore, dap_store::DapStore},
git_store::GitStore,
@@ -85,7 +86,7 @@ impl HeadlessProject {
store
});
let environment = project::ProjectEnvironment::new(&worktree_store, None, cx);
let environment = cx.new(|_| ProjectEnvironment::new(None));
let toolchain_store = cx.new(|cx| {
ToolchainStore::local(

View File

@@ -12,6 +12,7 @@ use workspace::{Toast, Workspace};
pub mod buffer_search;
pub mod project_search;
pub(crate) mod search_bar;
pub mod search_status_button;
pub fn init(cx: &mut App) {
menu::init();

View File

@@ -0,0 +1,47 @@
use ui::{
ButtonCommon, ButtonLike, Clickable, Color, Context, Icon, IconName, IconSize, ParentElement,
Render, Styled, Tooltip, Window, h_flex,
};
use workspace::{ItemHandle, StatusItemView};
pub struct SearchButton;
impl SearchButton {
pub fn new() -> Self {
Self {}
}
}
impl Render for SearchButton {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
h_flex().gap_2().child(
ButtonLike::new("project-search-indicator")
.child(
Icon::new(IconName::MagnifyingGlass)
.size(IconSize::Small)
.color(Color::Default),
)
.tooltip(|window, cx| {
Tooltip::for_action(
"Project Search",
&workspace::DeploySearch::default(),
window,
cx,
)
})
.on_click(cx.listener(|_this, _, window, cx| {
window.dispatch_action(Box::new(workspace::DeploySearch::default()), cx);
})),
)
}
}
impl StatusItemView for SearchButton {
fn set_active_pane_item(
&mut self,
_active_pane_item: Option<&dyn ItemHandle>,
_window: &mut Window,
_cx: &mut Context<Self>,
) {
}
}

View File

@@ -130,6 +130,12 @@ pub struct Transaction {
pub start: clock::Global,
}
impl Transaction {
pub fn merge_in(&mut self, other: Transaction) {
self.edit_ids.extend(other.edit_ids);
}
}
impl HistoryEntry {
pub fn transaction_id(&self) -> TransactionId {
self.transaction.id
@@ -1423,8 +1429,12 @@ impl Buffer {
.collect()
}
pub fn forget_transaction(&mut self, transaction_id: TransactionId) {
self.history.forget(transaction_id);
pub fn forget_transaction(&mut self, transaction_id: TransactionId) -> Option<Transaction> {
self.history.forget(transaction_id)
}
pub fn get_transaction(&self, transaction_id: TransactionId) -> Option<&Transaction> {
self.history.transaction(transaction_id)
}
pub fn merge_transactions(&mut self, transaction: TransactionId, destination: TransactionId) {
@@ -1482,7 +1492,6 @@ impl Buffer {
pub fn push_transaction(&mut self, transaction: Transaction, now: Instant) {
self.history.push_transaction(transaction, now);
self.history.finalize_last_transaction();
}
pub fn edited_ranges_for_transaction_id<D>(

View File

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

View File

@@ -222,6 +222,7 @@ pub fn initialize_workspace(
}
});
let search_button = cx.new(|_| search::search_status_button::SearchButton::new());
let diagnostic_summary =
cx.new(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx));
let activity_indicator = activity_indicator::ActivityIndicator::new(
@@ -239,6 +240,7 @@ pub fn initialize_workspace(
let cursor_position =
cx.new(|_| go_to_line::cursor_position::CursorPosition::new(workspace));
workspace.status_bar().update(cx, |status_bar, cx| {
status_bar.add_left_item(search_button, window, cx);
status_bar.add_left_item(diagnostic_summary, window, cx);
status_bar.add_left_item(activity_indicator, window, cx);
status_bar.add_right_item(inline_completion_button, window, cx);

View File

@@ -38,7 +38,7 @@ Putting binary assets such as images in the Git repository will bloat the reposi
The table of contents files (`theme/page-toc.js` and `theme/page-doc.css`) were initially generated by [`mdbook-pagetoc`](https://crates.io/crates/mdbook-pagetoc).
Since all this preprocessor does does is generate the static assets, we don't need to keep it around once they have been generated.
Since all this preprocessor does is generate the static assets, we don't need to keep it around once they have been generated.
## Referencing Keybindings and Actions

View File

@@ -2,8 +2,6 @@
Remote Development allows you to code at the speed of thought, even when your codebase is not on your local machine. You use Zed locally so the UI is immediately responsive, but offload heavy computation to the development server so that you can work effectively.
> **Note:** Remoting is still "beta". We are still refining the reliability and performance.
## Overview
Remote development requires two computers, your local machine that runs the Zed UI and the remote server which runs a Zed headless server. The two communicate over SSH, so you will need to be able to SSH from your local machine into the remote server to use this feature.