Merge branch 'main' into ui-scrollbar-teardown
This commit is contained in:
commit
6414589243
205 changed files with 10305 additions and 4966 deletions
|
@ -25,6 +25,8 @@ third-party = [
|
|||
{ name = "reqwest", version = "0.11.27" },
|
||||
# build of remote_server should not include scap / its x11 dependency
|
||||
{ name = "scap", git = "https://github.com/zed-industries/scap", rev = "808aa5c45b41e8f44729d02e38fd00a2fe2722e7" },
|
||||
# build of remote_server should not need to include on libalsa through rodio
|
||||
{ name = "rodio" },
|
||||
]
|
||||
|
||||
[final-excludes]
|
||||
|
|
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -718,7 +718,7 @@ jobs:
|
|||
timeout-minutes: 60
|
||||
runs-on: github-8vcpu-ubuntu-2404
|
||||
if: |
|
||||
( startsWith(github.ref, 'refs/tags/v')
|
||||
false && ( startsWith(github.ref, 'refs/tags/v')
|
||||
|| contains(github.event.pull_request.labels.*.name, 'run-bundling') )
|
||||
needs: [linux_tests]
|
||||
name: Build Zed on FreeBSD
|
||||
|
|
66
Cargo.lock
generated
66
Cargo.lock
generated
|
@ -7,12 +7,14 @@ name = "acp_thread"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"action_log",
|
||||
"agent",
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
"buffer_diff",
|
||||
"collections",
|
||||
"editor",
|
||||
"env_logger 0.11.8",
|
||||
"file_icons",
|
||||
"futures 0.3.31",
|
||||
"gpui",
|
||||
"indoc",
|
||||
|
@ -21,6 +23,7 @@ dependencies = [
|
|||
"markdown",
|
||||
"parking_lot",
|
||||
"project",
|
||||
"prompt_store",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
@ -31,6 +34,7 @@ dependencies = [
|
|||
"ui",
|
||||
"url",
|
||||
"util",
|
||||
"uuid",
|
||||
"watch",
|
||||
"workspace-hack",
|
||||
]
|
||||
|
@ -168,9 +172,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "agent-client-protocol"
|
||||
version = "0.0.23"
|
||||
version = "0.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fad72b7b8ee4331b3a4c8d43c107e982a4725564b4ee658ae5c4e79d2b486e8"
|
||||
checksum = "8fd68bbbef8e424fb8a605c5f0b00c360f682c4528b0a5feb5ec928aaf5ce28e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures 0.3.31",
|
||||
|
@ -391,6 +395,7 @@ dependencies = [
|
|||
"ui",
|
||||
"ui_input",
|
||||
"unindent",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"util",
|
||||
"uuid",
|
||||
|
@ -1299,9 +1304,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
|||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.88"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
@ -6446,6 +6451,7 @@ dependencies = [
|
|||
"log",
|
||||
"parking_lot",
|
||||
"pretty_assertions",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"rope",
|
||||
"schemars",
|
||||
|
@ -7877,6 +7883,12 @@ dependencies = [
|
|||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hound"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.27.0"
|
||||
|
@ -9705,6 +9717,7 @@ dependencies = [
|
|||
"objc",
|
||||
"parking_lot",
|
||||
"postage",
|
||||
"rodio",
|
||||
"scap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
@ -11150,12 +11163,10 @@ dependencies = [
|
|||
"ai_onboarding",
|
||||
"anyhow",
|
||||
"client",
|
||||
"command_palette_hooks",
|
||||
"component",
|
||||
"db",
|
||||
"documented",
|
||||
"editor",
|
||||
"feature_flags",
|
||||
"fs",
|
||||
"fuzzy",
|
||||
"git",
|
||||
|
@ -11170,6 +11181,7 @@ dependencies = [
|
|||
"schemars",
|
||||
"serde",
|
||||
"settings",
|
||||
"telemetry",
|
||||
"theme",
|
||||
"ui",
|
||||
"util",
|
||||
|
@ -13967,6 +13979,7 @@ checksum = "e40ecf59e742e03336be6a3d53755e789fd05a059fa22dfa0ed624722319e183"
|
|||
dependencies = [
|
||||
"cpal",
|
||||
"dasp_sample",
|
||||
"hound",
|
||||
"num-rational",
|
||||
"symphonia",
|
||||
"tracing",
|
||||
|
@ -15049,8 +15062,10 @@ dependencies = [
|
|||
"ui",
|
||||
"ui_input",
|
||||
"util",
|
||||
"vim",
|
||||
"workspace",
|
||||
"workspace-hack",
|
||||
"zed_actions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -18886,33 +18901,6 @@ version = "0.1.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082"
|
||||
|
||||
[[package]]
|
||||
name = "welcome"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"client",
|
||||
"component",
|
||||
"db",
|
||||
"documented",
|
||||
"editor",
|
||||
"fuzzy",
|
||||
"gpui",
|
||||
"install_cli",
|
||||
"language",
|
||||
"picker",
|
||||
"project",
|
||||
"serde",
|
||||
"settings",
|
||||
"telemetry",
|
||||
"ui",
|
||||
"util",
|
||||
"vim_mode_setting",
|
||||
"workspace",
|
||||
"workspace-hack",
|
||||
"zed_actions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "4.4.2"
|
||||
|
@ -20298,7 +20286,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "xim"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/XDeme1/xim-rs?rev=d50d461764c2213655cd9cf65a0ea94c70d3c4fd#d50d461764c2213655cd9cf65a0ea94c70d3c4fd"
|
||||
source = "git+https://github.com/zed-industries/xim-rs?rev=c0a70c1bd2ce197364216e5e818a2cb3adb99a8d#c0a70c1bd2ce197364216e5e818a2cb3adb99a8d"
|
||||
dependencies = [
|
||||
"ahash 0.8.11",
|
||||
"hashbrown 0.14.5",
|
||||
|
@ -20311,7 +20299,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "xim-ctext"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/XDeme1/xim-rs?rev=d50d461764c2213655cd9cf65a0ea94c70d3c4fd#d50d461764c2213655cd9cf65a0ea94c70d3c4fd"
|
||||
source = "git+https://github.com/zed-industries/xim-rs?rev=c0a70c1bd2ce197364216e5e818a2cb3adb99a8d#c0a70c1bd2ce197364216e5e818a2cb3adb99a8d"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
]
|
||||
|
@ -20319,7 +20307,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "xim-parser"
|
||||
version = "0.2.1"
|
||||
source = "git+https://github.com/XDeme1/xim-rs?rev=d50d461764c2213655cd9cf65a0ea94c70d3c4fd#d50d461764c2213655cd9cf65a0ea94c70d3c4fd"
|
||||
source = "git+https://github.com/zed-industries/xim-rs?rev=c0a70c1bd2ce197364216e5e818a2cb3adb99a8d#c0a70c1bd2ce197364216e5e818a2cb3adb99a8d"
|
||||
dependencies = [
|
||||
"bitflags 2.9.0",
|
||||
]
|
||||
|
@ -20527,7 +20515,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "0.200.0"
|
||||
version = "0.201.0"
|
||||
dependencies = [
|
||||
"activity_indicator",
|
||||
"agent",
|
||||
|
@ -20597,6 +20585,7 @@ dependencies = [
|
|||
"language_tools",
|
||||
"languages",
|
||||
"libc",
|
||||
"livekit_client",
|
||||
"log",
|
||||
"markdown",
|
||||
"markdown_preview",
|
||||
|
@ -20667,7 +20656,6 @@ dependencies = [
|
|||
"watch",
|
||||
"web_search",
|
||||
"web_search_providers",
|
||||
"welcome",
|
||||
"windows 0.61.1",
|
||||
"winresource",
|
||||
"workspace",
|
||||
|
@ -20691,7 +20679,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "zed_emmet"
|
||||
version = "0.0.5"
|
||||
version = "0.0.6"
|
||||
dependencies = [
|
||||
"zed_extension_api 0.1.0",
|
||||
]
|
||||
|
|
|
@ -185,7 +185,6 @@ members = [
|
|||
"crates/watch",
|
||||
"crates/web_search",
|
||||
"crates/web_search_providers",
|
||||
"crates/welcome",
|
||||
"crates/workspace",
|
||||
"crates/worktree",
|
||||
"crates/x_ai",
|
||||
|
@ -364,6 +363,7 @@ remote_server = { path = "crates/remote_server" }
|
|||
repl = { path = "crates/repl" }
|
||||
reqwest_client = { path = "crates/reqwest_client" }
|
||||
rich_text = { path = "crates/rich_text" }
|
||||
rodio = { version = "0.21.1", default-features = false }
|
||||
rope = { path = "crates/rope" }
|
||||
rpc = { path = "crates/rpc" }
|
||||
rules_library = { path = "crates/rules_library" }
|
||||
|
@ -412,7 +412,6 @@ vim_mode_setting = { path = "crates/vim_mode_setting" }
|
|||
watch = { path = "crates/watch" }
|
||||
web_search = { path = "crates/web_search" }
|
||||
web_search_providers = { path = "crates/web_search_providers" }
|
||||
welcome = { path = "crates/welcome" }
|
||||
workspace = { path = "crates/workspace" }
|
||||
worktree = { path = "crates/worktree" }
|
||||
x_ai = { path = "crates/x_ai" }
|
||||
|
@ -427,7 +426,7 @@ zlog_settings = { path = "crates/zlog_settings" }
|
|||
#
|
||||
|
||||
agentic-coding-protocol = "0.0.10"
|
||||
agent-client-protocol = "0.0.23"
|
||||
agent-client-protocol = "0.0.24"
|
||||
aho-corasick = "1.1"
|
||||
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
|
||||
any_vec = "0.14"
|
||||
|
|
BIN
assets/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf
Normal file
BIN
assets/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ibm-plex-sans/IBMPlexSans-BoldItalic.ttf
Normal file
BIN
assets/fonts/ibm-plex-sans/IBMPlexSans-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf
Normal file
BIN
assets/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf
Normal file
BIN
assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/lilex/Lilex-Bold.ttf
Normal file
BIN
assets/fonts/lilex/Lilex-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/lilex/Lilex-BoldItalic.ttf
Normal file
BIN
assets/fonts/lilex/Lilex-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/lilex/Lilex-Italic.ttf
Normal file
BIN
assets/fonts/lilex/Lilex-Italic.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/lilex/Lilex-Regular.ttf
Normal file
BIN
assets/fonts/lilex/Lilex-Regular.ttf
Normal file
Binary file not shown.
|
@ -1,8 +1,9 @@
|
|||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
Copyright 2019 The Lilex Project Authors (https://github.com/mishamyrt/Lilex)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
|
@ -89,4 +90,4 @@ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
4
assets/icons/json.svg
Normal file
4
assets/icons/json.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.78125 3C3.90625 3 3.90625 4.5 3.90625 5.5C3.90625 6.5 3.40625 7.50106 2.40625 8C3.40625 8.50106 3.90625 9.5 3.90625 10.5C3.90625 11.5 3.90625 13 5.78125 13" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.2422 3C12.1172 3 12.1172 4.5 12.1172 5.5C12.1172 6.5 12.6172 7.50106 13.6172 8C12.6172 8.50106 12.1172 9.5 12.1172 10.5C12.1172 11.5 12.1172 13 10.2422 13" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 607 B |
|
@ -239,6 +239,7 @@
|
|||
"ctrl-shift-a": "agent::ToggleContextPicker",
|
||||
"ctrl-shift-j": "agent::ToggleNavigationMenu",
|
||||
"ctrl-shift-i": "agent::ToggleOptionsMenu",
|
||||
"ctrl-alt-shift-n": "agent::ToggleNewThreadMenu",
|
||||
"shift-alt-escape": "agent::ExpandMessageEditor",
|
||||
"ctrl->": "assistant::QuoteSelection",
|
||||
"ctrl-alt-e": "agent::RemoveAllContext",
|
||||
|
@ -330,8 +331,6 @@
|
|||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"enter": "agent::Chat",
|
||||
"up": "agent::PreviousHistoryMessage",
|
||||
"down": "agent::NextHistoryMessage",
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||
"ctrl-shift-y": "agent::KeepAll",
|
||||
"ctrl-shift-n": "agent::RejectAll"
|
||||
|
|
|
@ -279,6 +279,7 @@
|
|||
"cmd-shift-a": "agent::ToggleContextPicker",
|
||||
"cmd-shift-j": "agent::ToggleNavigationMenu",
|
||||
"cmd-shift-i": "agent::ToggleOptionsMenu",
|
||||
"cmd-alt-shift-n": "agent::ToggleNewThreadMenu",
|
||||
"shift-alt-escape": "agent::ExpandMessageEditor",
|
||||
"cmd->": "assistant::QuoteSelection",
|
||||
"cmd-alt-e": "agent::RemoveAllContext",
|
||||
|
@ -382,8 +383,6 @@
|
|||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"enter": "agent::Chat",
|
||||
"up": "agent::PreviousHistoryMessage",
|
||||
"down": "agent::NextHistoryMessage",
|
||||
"shift-ctrl-r": "agent::OpenAgentDiff",
|
||||
"cmd-shift-y": "agent::KeepAll",
|
||||
"cmd-shift-n": "agent::RejectAll"
|
||||
|
|
|
@ -58,6 +58,8 @@
|
|||
"[ space": "vim::InsertEmptyLineAbove",
|
||||
"[ e": "editor::MoveLineUp",
|
||||
"] e": "editor::MoveLineDown",
|
||||
"[ f": "workspace::FollowNextCollaborator",
|
||||
"] f": "workspace::FollowNextCollaborator",
|
||||
|
||||
// Word motions
|
||||
"w": "vim::NextWordStart",
|
||||
|
@ -390,7 +392,7 @@
|
|||
"right": "vim::WrappingRight",
|
||||
"h": "vim::WrappingLeft",
|
||||
"l": "vim::WrappingRight",
|
||||
"y": "editor::Copy",
|
||||
"y": "vim::HelixYank",
|
||||
"alt-;": "vim::OtherEnd",
|
||||
"ctrl-r": "vim::Redo",
|
||||
"f": ["vim::PushFindForward", { "before": false, "multiline": true }],
|
||||
|
@ -407,6 +409,7 @@
|
|||
"g w": "vim::PushRewrap",
|
||||
"insert": "vim::InsertBefore",
|
||||
"alt-.": "vim::RepeatFind",
|
||||
"alt-s": ["editor::SplitSelectionIntoLines", { "keep_selections": true }],
|
||||
// tree-sitter related commands
|
||||
"[ x": "editor::SelectLargerSyntaxNode",
|
||||
"] x": "editor::SelectSmallerSyntaxNode",
|
||||
|
|
|
@ -28,7 +28,9 @@
|
|||
"edit_prediction_provider": "zed"
|
||||
},
|
||||
// The name of a font to use for rendering text in the editor
|
||||
"buffer_font_family": "Zed Plex Mono",
|
||||
// ".ZedMono" currently aliases to Lilex
|
||||
// but this may change in the future.
|
||||
"buffer_font_family": ".ZedMono",
|
||||
// Set the buffer text's font fallbacks, this will be merged with
|
||||
// the platform's default fallbacks.
|
||||
"buffer_font_fallbacks": null,
|
||||
|
@ -54,7 +56,9 @@
|
|||
"buffer_line_height": "comfortable",
|
||||
// The name of a font to use for rendering text in the UI
|
||||
// You can set this to ".SystemUIFont" to use the system font
|
||||
"ui_font_family": "Zed Plex Sans",
|
||||
// ".ZedSans" currently aliases to "IBM Plex Sans", but this may
|
||||
// change in the future
|
||||
"ui_font_family": ".ZedSans",
|
||||
// Set the UI's font fallbacks, this will be merged with the platform's
|
||||
// default font fallbacks.
|
||||
"ui_font_fallbacks": null,
|
||||
|
@ -82,10 +86,10 @@
|
|||
// Layout mode of the bottom dock. Defaults to "contained"
|
||||
// choices: contained, full, left_aligned, right_aligned
|
||||
"bottom_dock_layout": "contained",
|
||||
// The direction that you want to split panes horizontally. Defaults to "up"
|
||||
"pane_split_direction_horizontal": "up",
|
||||
// The direction that you want to split panes vertically. Defaults to "left"
|
||||
"pane_split_direction_vertical": "left",
|
||||
// The direction that you want to split panes horizontally. Defaults to "down"
|
||||
"pane_split_direction_horizontal": "down",
|
||||
// The direction that you want to split panes vertically. Defaults to "right"
|
||||
"pane_split_direction_vertical": "right",
|
||||
// Centered layout related settings.
|
||||
"centered_layout": {
|
||||
// The relative width of the left padding of the central pane from the
|
||||
|
@ -1402,7 +1406,7 @@
|
|||
// "font_size": 15,
|
||||
// Set the terminal's font family. If this option is not included,
|
||||
// the terminal will default to matching the buffer's font family.
|
||||
// "font_family": "Zed Plex Mono",
|
||||
// "font_family": ".ZedMono",
|
||||
// Set the terminal's font fallbacks. If this option is not included,
|
||||
// the terminal will default to matching the buffer's font fallbacks.
|
||||
// This will be merged with the platform's default font fallbacks
|
||||
|
|
|
@ -13,21 +13,25 @@ path = "src/acp_thread.rs"
|
|||
doctest = false
|
||||
|
||||
[features]
|
||||
test-support = ["gpui/test-support", "project/test-support"]
|
||||
test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot"]
|
||||
|
||||
[dependencies]
|
||||
action_log.workspace = true
|
||||
agent-client-protocol.workspace = true
|
||||
agent.workspace = true
|
||||
anyhow.workspace = true
|
||||
buffer_diff.workspace = true
|
||||
collections.workspace = true
|
||||
editor.workspace = true
|
||||
file_icons.workspace = true
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
itertools.workspace = true
|
||||
language.workspace = true
|
||||
markdown.workspace = true
|
||||
parking_lot = { workspace = true, optional = true }
|
||||
project.workspace = true
|
||||
prompt_store.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
|
@ -36,6 +40,7 @@ terminal.workspace = true
|
|||
ui.workspace = true
|
||||
url.workspace = true
|
||||
util.workspace = true
|
||||
uuid.workspace = true
|
||||
watch.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
|
||||
|
|
|
@ -9,18 +9,19 @@ pub use mention::*;
|
|||
pub use terminal::*;
|
||||
|
||||
use action_log::ActionLog;
|
||||
use agent_client_protocol::{self as acp};
|
||||
use anyhow::{Context as _, Result};
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use editor::Bias;
|
||||
use futures::{FutureExt, channel::oneshot, future::BoxFuture};
|
||||
use gpui::{AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity};
|
||||
use itertools::Itertools;
|
||||
use language::{Anchor, Buffer, BufferSnapshot, LanguageRegistry, Point, ToPoint, text_diff};
|
||||
use markdown::Markdown;
|
||||
use project::{AgentLocation, Project};
|
||||
use project::{AgentLocation, Project, git_store::GitStoreCheckpoint};
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::fmt::Formatter;
|
||||
use std::fmt::{Formatter, Write};
|
||||
use std::ops::Range;
|
||||
use std::process::ExitStatus;
|
||||
use std::rc::Rc;
|
||||
use std::{fmt::Display, mem, path::PathBuf, sync::Arc};
|
||||
|
@ -29,24 +30,34 @@ use util::ResultExt;
|
|||
|
||||
#[derive(Debug)]
|
||||
pub struct UserMessage {
|
||||
pub id: Option<UserMessageId>,
|
||||
pub content: ContentBlock,
|
||||
pub chunks: Vec<acp::ContentBlock>,
|
||||
pub checkpoint: Option<Checkpoint>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Checkpoint {
|
||||
git_checkpoint: GitStoreCheckpoint,
|
||||
pub show: bool,
|
||||
}
|
||||
|
||||
impl UserMessage {
|
||||
pub fn from_acp(
|
||||
message: impl IntoIterator<Item = acp::ContentBlock>,
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
cx: &mut App,
|
||||
) -> Self {
|
||||
let mut content = ContentBlock::Empty;
|
||||
for chunk in message {
|
||||
content.append(chunk, &language_registry, cx)
|
||||
}
|
||||
Self { content: content }
|
||||
}
|
||||
|
||||
fn to_markdown(&self, cx: &App) -> String {
|
||||
format!("## User\n\n{}\n\n", self.content.to_markdown(cx))
|
||||
let mut markdown = String::new();
|
||||
if self
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
.map_or(false, |checkpoint| checkpoint.show)
|
||||
{
|
||||
writeln!(markdown, "## User (checkpoint)").unwrap();
|
||||
} else {
|
||||
writeln!(markdown, "## User").unwrap();
|
||||
}
|
||||
writeln!(markdown).unwrap();
|
||||
writeln!(markdown, "{}", self.content.to_markdown(cx)).unwrap();
|
||||
writeln!(markdown).unwrap();
|
||||
markdown
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -399,7 +410,7 @@ impl ContentBlock {
|
|||
}
|
||||
}
|
||||
|
||||
let new_content = self.extract_content_from_block(block);
|
||||
let new_content = self.block_string_contents(block);
|
||||
|
||||
match self {
|
||||
ContentBlock::Empty => {
|
||||
|
@ -409,7 +420,7 @@ impl ContentBlock {
|
|||
markdown.update(cx, |markdown, cx| markdown.append(&new_content, cx));
|
||||
}
|
||||
ContentBlock::ResourceLink { resource_link } => {
|
||||
let existing_content = Self::resource_link_to_content(&resource_link.uri);
|
||||
let existing_content = Self::resource_link_md(&resource_link.uri);
|
||||
let combined = format!("{}\n{}", existing_content, new_content);
|
||||
|
||||
*self = Self::create_markdown_block(combined, language_registry, cx);
|
||||
|
@ -417,14 +428,6 @@ impl ContentBlock {
|
|||
}
|
||||
}
|
||||
|
||||
fn resource_link_to_content(uri: &str) -> String {
|
||||
if let Some(uri) = MentionUri::parse(&uri).log_err() {
|
||||
uri.to_link()
|
||||
} else {
|
||||
uri.to_string().clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn create_markdown_block(
|
||||
content: String,
|
||||
language_registry: &Arc<LanguageRegistry>,
|
||||
|
@ -436,11 +439,11 @@ impl ContentBlock {
|
|||
}
|
||||
}
|
||||
|
||||
fn extract_content_from_block(&self, block: acp::ContentBlock) -> String {
|
||||
fn block_string_contents(&self, block: acp::ContentBlock) -> String {
|
||||
match block {
|
||||
acp::ContentBlock::Text(text_content) => text_content.text.clone(),
|
||||
acp::ContentBlock::ResourceLink(resource_link) => {
|
||||
Self::resource_link_to_content(&resource_link.uri)
|
||||
Self::resource_link_md(&resource_link.uri)
|
||||
}
|
||||
acp::ContentBlock::Resource(acp::EmbeddedResource {
|
||||
resource:
|
||||
|
@ -449,13 +452,24 @@ impl ContentBlock {
|
|||
..
|
||||
}),
|
||||
..
|
||||
}) => Self::resource_link_to_content(&uri),
|
||||
acp::ContentBlock::Image(_)
|
||||
| acp::ContentBlock::Audio(_)
|
||||
| acp::ContentBlock::Resource(_) => String::new(),
|
||||
}) => Self::resource_link_md(&uri),
|
||||
acp::ContentBlock::Image(image) => Self::image_md(&image),
|
||||
acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn resource_link_md(uri: &str) -> String {
|
||||
if let Some(uri) = MentionUri::parse(&uri).log_err() {
|
||||
uri.as_link().to_string()
|
||||
} else {
|
||||
uri.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn image_md(_image: &acp::ImageContent) -> String {
|
||||
"`Image`".into()
|
||||
}
|
||||
|
||||
fn to_markdown<'a>(&'a self, cx: &'a App) -> &'a str {
|
||||
match self {
|
||||
ContentBlock::Empty => "",
|
||||
|
@ -633,6 +647,7 @@ pub struct AcpThread {
|
|||
pub enum AcpThreadEvent {
|
||||
NewEntry,
|
||||
EntryUpdated(usize),
|
||||
EntriesRemoved(Range<usize>),
|
||||
ToolAuthorizationRequired,
|
||||
Stopped,
|
||||
Error,
|
||||
|
@ -772,7 +787,7 @@ impl AcpThread {
|
|||
) -> Result<()> {
|
||||
match update {
|
||||
acp::SessionUpdate::UserMessageChunk { content } => {
|
||||
self.push_user_content_block(content, cx);
|
||||
self.push_user_content_block(None, content, cx);
|
||||
}
|
||||
acp::SessionUpdate::AgentMessageChunk { content } => {
|
||||
self.push_assistant_content_block(content, false, cx);
|
||||
|
@ -793,18 +808,39 @@ impl AcpThread {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn push_user_content_block(&mut self, chunk: acp::ContentBlock, cx: &mut Context<Self>) {
|
||||
pub fn push_user_content_block(
|
||||
&mut self,
|
||||
message_id: Option<UserMessageId>,
|
||||
chunk: acp::ContentBlock,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let language_registry = self.project.read(cx).languages().clone();
|
||||
let entries_len = self.entries.len();
|
||||
|
||||
if let Some(last_entry) = self.entries.last_mut()
|
||||
&& let AgentThreadEntry::UserMessage(UserMessage { content }) = last_entry
|
||||
&& let AgentThreadEntry::UserMessage(UserMessage {
|
||||
id,
|
||||
content,
|
||||
chunks,
|
||||
..
|
||||
}) = last_entry
|
||||
{
|
||||
content.append(chunk, &language_registry, cx);
|
||||
cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1));
|
||||
*id = message_id.or(id.take());
|
||||
content.append(chunk.clone(), &language_registry, cx);
|
||||
chunks.push(chunk);
|
||||
let idx = entries_len - 1;
|
||||
cx.emit(AcpThreadEvent::EntryUpdated(idx));
|
||||
} else {
|
||||
let content = ContentBlock::new(chunk, &language_registry, cx);
|
||||
self.push_entry(AgentThreadEntry::UserMessage(UserMessage { content }), cx);
|
||||
let content = ContentBlock::new(chunk.clone(), &language_registry, cx);
|
||||
self.push_entry(
|
||||
AgentThreadEntry::UserMessage(UserMessage {
|
||||
id: message_id,
|
||||
content,
|
||||
chunks: vec![chunk],
|
||||
checkpoint: None,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -819,7 +855,8 @@ impl AcpThread {
|
|||
if let Some(last_entry) = self.entries.last_mut()
|
||||
&& let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) = last_entry
|
||||
{
|
||||
cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1));
|
||||
let idx = entries_len - 1;
|
||||
cx.emit(AcpThreadEvent::EntryUpdated(idx));
|
||||
match (chunks.last_mut(), is_thought) {
|
||||
(Some(AssistantMessageChunk::Message { block }), false)
|
||||
| (Some(AssistantMessageChunk::Thought { block }), true) => {
|
||||
|
@ -1118,69 +1155,112 @@ impl AcpThread {
|
|||
self.project.read(cx).languages().clone(),
|
||||
cx,
|
||||
);
|
||||
let request = acp::PromptRequest {
|
||||
prompt: message.clone(),
|
||||
session_id: self.session_id.clone(),
|
||||
};
|
||||
let git_store = self.project.read(cx).git_store().clone();
|
||||
|
||||
let message_id = if self
|
||||
.connection
|
||||
.session_editor(&self.session_id, cx)
|
||||
.is_some()
|
||||
{
|
||||
Some(UserMessageId::new())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.push_entry(
|
||||
AgentThreadEntry::UserMessage(UserMessage { content: block }),
|
||||
AgentThreadEntry::UserMessage(UserMessage {
|
||||
id: message_id.clone(),
|
||||
content: block,
|
||||
chunks: message,
|
||||
checkpoint: None,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
|
||||
self.run_turn(cx, async move |this, cx| {
|
||||
let old_checkpoint = git_store
|
||||
.update(cx, |git, cx| git.checkpoint(cx))?
|
||||
.await
|
||||
.context("failed to get old checkpoint")
|
||||
.log_err();
|
||||
this.update(cx, |this, cx| {
|
||||
if let Some((_ix, message)) = this.last_user_message() {
|
||||
message.checkpoint = old_checkpoint.map(|git_checkpoint| Checkpoint {
|
||||
git_checkpoint,
|
||||
show: false,
|
||||
});
|
||||
}
|
||||
this.connection.prompt(message_id, request, cx)
|
||||
})?
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resume(&mut self, cx: &mut Context<Self>) -> BoxFuture<'static, Result<()>> {
|
||||
self.run_turn(cx, async move |this, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.connection
|
||||
.resume(&this.session_id, cx)
|
||||
.map(|resume| resume.run(cx))
|
||||
})?
|
||||
.context("resuming a session is not supported")?
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
fn run_turn(
|
||||
&mut self,
|
||||
cx: &mut Context<Self>,
|
||||
f: impl 'static + AsyncFnOnce(WeakEntity<Self>, &mut AsyncApp) -> Result<acp::PromptResponse>,
|
||||
) -> BoxFuture<'static, Result<()>> {
|
||||
self.clear_completed_plan_entries(cx);
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let cancel_task = self.cancel(cx);
|
||||
|
||||
self.send_task = Some(cx.spawn(async move |this, cx| {
|
||||
async {
|
||||
cancel_task.await;
|
||||
|
||||
let result = this
|
||||
.update(cx, |this, cx| {
|
||||
this.connection.prompt(
|
||||
acp::PromptRequest {
|
||||
prompt: message,
|
||||
session_id: this.session_id.clone(),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await;
|
||||
|
||||
tx.send(result).log_err();
|
||||
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.await
|
||||
.log_err();
|
||||
cancel_task.await;
|
||||
tx.send(f(this, cx).await).ok();
|
||||
}));
|
||||
|
||||
cx.spawn(async move |this, cx| match rx.await {
|
||||
Ok(Err(e)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.send_task.take();
|
||||
cx.emit(AcpThreadEvent::Error)
|
||||
})
|
||||
.log_err();
|
||||
Err(e)?
|
||||
}
|
||||
result => {
|
||||
let cancelled = matches!(
|
||||
result,
|
||||
Ok(Ok(acp::PromptResponse {
|
||||
stop_reason: acp::StopReason::Cancelled
|
||||
}))
|
||||
);
|
||||
cx.spawn(async move |this, cx| {
|
||||
let response = rx.await;
|
||||
|
||||
// We only take the task if the current prompt wasn't cancelled.
|
||||
//
|
||||
// This prompt may have been cancelled because another one was sent
|
||||
// while it was still generating. In these cases, dropping `send_task`
|
||||
// would cause the next generation to be cancelled.
|
||||
if !cancelled {
|
||||
this.update(cx, |this, _cx| this.send_task.take()).ok();
|
||||
this.update(cx, |this, cx| this.update_last_checkpoint(cx))?
|
||||
.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
match response {
|
||||
Ok(Err(e)) => {
|
||||
this.send_task.take();
|
||||
cx.emit(AcpThreadEvent::Error);
|
||||
Err(e)
|
||||
}
|
||||
result => {
|
||||
let cancelled = matches!(
|
||||
result,
|
||||
Ok(Ok(acp::PromptResponse {
|
||||
stop_reason: acp::StopReason::Cancelled
|
||||
}))
|
||||
);
|
||||
|
||||
// We only take the task if the current prompt wasn't cancelled.
|
||||
//
|
||||
// This prompt may have been cancelled because another one was sent
|
||||
// while it was still generating. In these cases, dropping `send_task`
|
||||
// would cause the next generation to be cancelled.
|
||||
if !cancelled {
|
||||
this.send_task.take();
|
||||
}
|
||||
|
||||
cx.emit(AcpThreadEvent::Stopped);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
this.update(cx, |_, cx| cx.emit(AcpThreadEvent::Stopped))
|
||||
.log_err();
|
||||
Ok(())
|
||||
}
|
||||
})?
|
||||
})
|
||||
.boxed()
|
||||
}
|
||||
|
@ -1212,6 +1292,122 @@ impl AcpThread {
|
|||
cx.foreground_executor().spawn(send_task)
|
||||
}
|
||||
|
||||
/// Rewinds this thread to before the entry at `index`, removing it and all
|
||||
/// subsequent entries while reverting any changes made from that point.
|
||||
pub fn rewind(&mut self, id: UserMessageId, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
let Some(session_editor) = self.connection.session_editor(&self.session_id, cx) else {
|
||||
return Task::ready(Err(anyhow!("not supported")));
|
||||
};
|
||||
let Some(message) = self.user_message(&id) else {
|
||||
return Task::ready(Err(anyhow!("message not found")));
|
||||
};
|
||||
|
||||
let checkpoint = message
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
.map(|c| c.git_checkpoint.clone());
|
||||
|
||||
let git_store = self.project.read(cx).git_store().clone();
|
||||
cx.spawn(async move |this, cx| {
|
||||
if let Some(checkpoint) = checkpoint {
|
||||
git_store
|
||||
.update(cx, |git, cx| git.restore_checkpoint(checkpoint, cx))?
|
||||
.await?;
|
||||
}
|
||||
|
||||
cx.update(|cx| session_editor.truncate(id.clone(), cx))?
|
||||
.await?;
|
||||
this.update(cx, |this, cx| {
|
||||
if let Some((ix, _)) = this.user_message_mut(&id) {
|
||||
let range = ix..this.entries.len();
|
||||
this.entries.truncate(ix);
|
||||
cx.emit(AcpThreadEvent::EntriesRemoved(range));
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn update_last_checkpoint(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
let git_store = self.project.read(cx).git_store().clone();
|
||||
|
||||
let old_checkpoint = if let Some((_, message)) = self.last_user_message() {
|
||||
if let Some(checkpoint) = message.checkpoint.as_ref() {
|
||||
checkpoint.git_checkpoint.clone()
|
||||
} else {
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
} else {
|
||||
return Task::ready(Ok(()));
|
||||
};
|
||||
|
||||
let new_checkpoint = git_store.update(cx, |git, cx| git.checkpoint(cx));
|
||||
cx.spawn(async move |this, cx| {
|
||||
let new_checkpoint = new_checkpoint
|
||||
.await
|
||||
.context("failed to get new checkpoint")
|
||||
.log_err();
|
||||
if let Some(new_checkpoint) = new_checkpoint {
|
||||
let equal = git_store
|
||||
.update(cx, |git, cx| {
|
||||
git.compare_checkpoints(old_checkpoint.clone(), new_checkpoint, cx)
|
||||
})?
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
this.update(cx, |this, cx| {
|
||||
let (ix, message) = this.last_user_message().context("no user message")?;
|
||||
let checkpoint = message.checkpoint.as_mut().context("no checkpoint")?;
|
||||
checkpoint.show = !equal;
|
||||
cx.emit(AcpThreadEvent::EntryUpdated(ix));
|
||||
anyhow::Ok(())
|
||||
})??;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn last_user_message(&mut self) -> Option<(usize, &mut UserMessage)> {
|
||||
self.entries
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find_map(|(ix, entry)| {
|
||||
if let AgentThreadEntry::UserMessage(message) = entry {
|
||||
Some((ix, message))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn user_message(&self, id: &UserMessageId) -> Option<&UserMessage> {
|
||||
self.entries.iter().find_map(|entry| {
|
||||
if let AgentThreadEntry::UserMessage(message) = entry {
|
||||
if message.id.as_ref() == Some(&id) {
|
||||
Some(message)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn user_message_mut(&mut self, id: &UserMessageId) -> Option<(usize, &mut UserMessage)> {
|
||||
self.entries.iter_mut().enumerate().find_map(|(ix, entry)| {
|
||||
if let AgentThreadEntry::UserMessage(message) = entry {
|
||||
if message.id.as_ref() == Some(&id) {
|
||||
Some((ix, message))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_text_file(
|
||||
&self,
|
||||
path: PathBuf,
|
||||
|
@ -1414,13 +1610,19 @@ mod tests {
|
|||
use futures::{channel::mpsc, future::LocalBoxFuture, select};
|
||||
use gpui::{AsyncApp, TestAppContext, WeakEntity};
|
||||
use indoc::indoc;
|
||||
use project::FakeFs;
|
||||
use project::{FakeFs, Fs};
|
||||
use rand::Rng as _;
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
use smol::stream::StreamExt as _;
|
||||
use std::{cell::RefCell, path::Path, rc::Rc, time::Duration};
|
||||
|
||||
use std::{
|
||||
any::Any,
|
||||
cell::RefCell,
|
||||
path::Path,
|
||||
rc::Rc,
|
||||
sync::atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst},
|
||||
time::Duration,
|
||||
};
|
||||
use util::path;
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) {
|
||||
|
@ -1441,17 +1643,14 @@ mod tests {
|
|||
let project = Project::test(fs, [], cx).await;
|
||||
let connection = Rc::new(FakeAgentConnection::new());
|
||||
let thread = cx
|
||||
.spawn(async move |mut cx| {
|
||||
connection
|
||||
.new_thread(project, Path::new(path!("/test")), &mut cx)
|
||||
.await
|
||||
})
|
||||
.update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Test creating a new user message
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.push_user_content_block(
|
||||
None,
|
||||
acp::ContentBlock::Text(acp::TextContent {
|
||||
annotations: None,
|
||||
text: "Hello, ".to_string(),
|
||||
|
@ -1463,6 +1662,7 @@ mod tests {
|
|||
thread.update(cx, |thread, cx| {
|
||||
assert_eq!(thread.entries.len(), 1);
|
||||
if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[0] {
|
||||
assert_eq!(user_msg.id, None);
|
||||
assert_eq!(user_msg.content.to_markdown(cx), "Hello, ");
|
||||
} else {
|
||||
panic!("Expected UserMessage");
|
||||
|
@ -1470,8 +1670,10 @@ mod tests {
|
|||
});
|
||||
|
||||
// Test appending to existing user message
|
||||
let message_1_id = UserMessageId::new();
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.push_user_content_block(
|
||||
Some(message_1_id.clone()),
|
||||
acp::ContentBlock::Text(acp::TextContent {
|
||||
annotations: None,
|
||||
text: "world!".to_string(),
|
||||
|
@ -1483,6 +1685,7 @@ mod tests {
|
|||
thread.update(cx, |thread, cx| {
|
||||
assert_eq!(thread.entries.len(), 1);
|
||||
if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[0] {
|
||||
assert_eq!(user_msg.id, Some(message_1_id));
|
||||
assert_eq!(user_msg.content.to_markdown(cx), "Hello, world!");
|
||||
} else {
|
||||
panic!("Expected UserMessage");
|
||||
|
@ -1501,8 +1704,10 @@ mod tests {
|
|||
);
|
||||
});
|
||||
|
||||
let message_2_id = UserMessageId::new();
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.push_user_content_block(
|
||||
Some(message_2_id.clone()),
|
||||
acp::ContentBlock::Text(acp::TextContent {
|
||||
annotations: None,
|
||||
text: "New user message".to_string(),
|
||||
|
@ -1514,6 +1719,7 @@ mod tests {
|
|||
thread.update(cx, |thread, cx| {
|
||||
assert_eq!(thread.entries.len(), 3);
|
||||
if let AgentThreadEntry::UserMessage(user_msg) = &thread.entries[2] {
|
||||
assert_eq!(user_msg.id, Some(message_2_id));
|
||||
assert_eq!(user_msg.content.to_markdown(cx), "New user message");
|
||||
} else {
|
||||
panic!("Expected UserMessage at index 2");
|
||||
|
@ -1557,11 +1763,7 @@ mod tests {
|
|||
));
|
||||
|
||||
let thread = cx
|
||||
.spawn(async move |mut cx| {
|
||||
connection
|
||||
.new_thread(project, Path::new(path!("/test")), &mut cx)
|
||||
.await
|
||||
})
|
||||
.update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
@ -1644,7 +1846,7 @@ mod tests {
|
|||
.unwrap();
|
||||
|
||||
let thread = cx
|
||||
.spawn(|mut cx| connection.new_thread(project, Path::new(path!("/tmp")), &mut cx))
|
||||
.update(|cx| connection.new_thread(project, Path::new(path!("/tmp")), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
@ -1707,11 +1909,7 @@ mod tests {
|
|||
}));
|
||||
|
||||
let thread = cx
|
||||
.spawn(async move |mut cx| {
|
||||
connection
|
||||
.new_thread(project, Path::new(path!("/test")), &mut cx)
|
||||
.await
|
||||
})
|
||||
.update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
@ -1819,10 +2017,11 @@ mod tests {
|
|||
}
|
||||
}));
|
||||
|
||||
let thread = connection
|
||||
.new_thread(project, Path::new(path!("/test")), &mut cx.to_async())
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["Hi".into()], cx)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
@ -1830,6 +2029,180 @@ mod tests {
|
|||
assert!(cx.read(|cx| !thread.read(cx).has_pending_edit_tool_calls()));
|
||||
}
|
||||
|
||||
#[gpui::test(iterations = 10)]
|
||||
async fn test_checkpoints(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.background_executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/test"),
|
||||
json!({
|
||||
".git": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
|
||||
|
||||
let simulate_changes = Arc::new(AtomicBool::new(true));
|
||||
let next_filename = Arc::new(AtomicUsize::new(0));
|
||||
let connection = Rc::new(FakeAgentConnection::new().on_user_message({
|
||||
let simulate_changes = simulate_changes.clone();
|
||||
let next_filename = next_filename.clone();
|
||||
let fs = fs.clone();
|
||||
move |request, thread, mut cx| {
|
||||
let fs = fs.clone();
|
||||
let simulate_changes = simulate_changes.clone();
|
||||
let next_filename = next_filename.clone();
|
||||
async move {
|
||||
if simulate_changes.load(SeqCst) {
|
||||
let filename = format!("/test/file-{}", next_filename.fetch_add(1, SeqCst));
|
||||
fs.write(Path::new(&filename), b"").await?;
|
||||
}
|
||||
|
||||
let acp::ContentBlock::Text(content) = &request.prompt[0] else {
|
||||
panic!("expected text content block");
|
||||
};
|
||||
thread.update(&mut cx, |thread, cx| {
|
||||
thread
|
||||
.handle_session_update(
|
||||
acp::SessionUpdate::AgentMessageChunk {
|
||||
content: content.text.to_uppercase().into(),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.unwrap();
|
||||
})?;
|
||||
Ok(acp::PromptResponse {
|
||||
stop_reason: acp::StopReason::EndTurn,
|
||||
})
|
||||
}
|
||||
.boxed_local()
|
||||
}
|
||||
}));
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["Lorem".into()], cx)))
|
||||
.await
|
||||
.unwrap();
|
||||
thread.read_with(cx, |thread, cx| {
|
||||
assert_eq!(
|
||||
thread.to_markdown(cx),
|
||||
indoc! {"
|
||||
## User (checkpoint)
|
||||
|
||||
Lorem
|
||||
|
||||
## Assistant
|
||||
|
||||
LOREM
|
||||
|
||||
"}
|
||||
);
|
||||
});
|
||||
assert_eq!(fs.files(), vec![Path::new("/test/file-0")]);
|
||||
|
||||
cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["ipsum".into()], cx)))
|
||||
.await
|
||||
.unwrap();
|
||||
thread.read_with(cx, |thread, cx| {
|
||||
assert_eq!(
|
||||
thread.to_markdown(cx),
|
||||
indoc! {"
|
||||
## User (checkpoint)
|
||||
|
||||
Lorem
|
||||
|
||||
## Assistant
|
||||
|
||||
LOREM
|
||||
|
||||
## User (checkpoint)
|
||||
|
||||
ipsum
|
||||
|
||||
## Assistant
|
||||
|
||||
IPSUM
|
||||
|
||||
"}
|
||||
);
|
||||
});
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![Path::new("/test/file-0"), Path::new("/test/file-1")]
|
||||
);
|
||||
|
||||
// Checkpoint isn't stored when there are no changes.
|
||||
simulate_changes.store(false, SeqCst);
|
||||
cx.update(|cx| thread.update(cx, |thread, cx| thread.send(vec!["dolor".into()], cx)))
|
||||
.await
|
||||
.unwrap();
|
||||
thread.read_with(cx, |thread, cx| {
|
||||
assert_eq!(
|
||||
thread.to_markdown(cx),
|
||||
indoc! {"
|
||||
## User (checkpoint)
|
||||
|
||||
Lorem
|
||||
|
||||
## Assistant
|
||||
|
||||
LOREM
|
||||
|
||||
## User (checkpoint)
|
||||
|
||||
ipsum
|
||||
|
||||
## Assistant
|
||||
|
||||
IPSUM
|
||||
|
||||
## User
|
||||
|
||||
dolor
|
||||
|
||||
## Assistant
|
||||
|
||||
DOLOR
|
||||
|
||||
"}
|
||||
);
|
||||
});
|
||||
assert_eq!(
|
||||
fs.files(),
|
||||
vec![Path::new("/test/file-0"), Path::new("/test/file-1")]
|
||||
);
|
||||
|
||||
// Rewinding the conversation truncates the history and restores the checkpoint.
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
let AgentThreadEntry::UserMessage(message) = &thread.entries[2] else {
|
||||
panic!("unexpected entries {:?}", thread.entries)
|
||||
};
|
||||
thread.rewind(message.id.clone().unwrap(), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
thread.read_with(cx, |thread, cx| {
|
||||
assert_eq!(
|
||||
thread.to_markdown(cx),
|
||||
indoc! {"
|
||||
## User (checkpoint)
|
||||
|
||||
Lorem
|
||||
|
||||
## Assistant
|
||||
|
||||
LOREM
|
||||
|
||||
"}
|
||||
);
|
||||
});
|
||||
assert_eq!(fs.files(), vec![Path::new("/test/file-0")]);
|
||||
}
|
||||
|
||||
async fn run_until_first_tool_call(
|
||||
thread: &Entity<AcpThread>,
|
||||
cx: &mut TestAppContext,
|
||||
|
@ -1911,7 +2284,7 @@ mod tests {
|
|||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
_cwd: &Path,
|
||||
cx: &mut gpui::AsyncApp,
|
||||
cx: &mut gpui::App,
|
||||
) -> Task<gpui::Result<Entity<AcpThread>>> {
|
||||
let session_id = acp::SessionId(
|
||||
rand::thread_rng()
|
||||
|
@ -1921,9 +2294,8 @@ mod tests {
|
|||
.collect::<String>()
|
||||
.into(),
|
||||
);
|
||||
let thread = cx
|
||||
.new(|cx| AcpThread::new("Test", self.clone(), project, session_id.clone(), cx))
|
||||
.unwrap();
|
||||
let thread =
|
||||
cx.new(|cx| AcpThread::new("Test", self.clone(), project, session_id.clone(), cx));
|
||||
self.sessions.lock().insert(session_id, thread.downgrade());
|
||||
Task::ready(Ok(thread))
|
||||
}
|
||||
|
@ -1938,6 +2310,7 @@ mod tests {
|
|||
|
||||
fn prompt(
|
||||
&self,
|
||||
_id: Option<UserMessageId>,
|
||||
params: acp::PromptRequest,
|
||||
cx: &mut App,
|
||||
) -> Task<gpui::Result<acp::PromptResponse>> {
|
||||
|
@ -1966,5 +2339,29 @@ mod tests {
|
|||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn session_editor(
|
||||
&self,
|
||||
session_id: &acp::SessionId,
|
||||
_cx: &mut App,
|
||||
) -> Option<Rc<dyn AgentSessionEditor>> {
|
||||
Some(Rc::new(FakeAgentSessionEditor {
|
||||
_session_id: session_id.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeAgentSessionEditor {
|
||||
_session_id: acp::SessionId,
|
||||
}
|
||||
|
||||
impl AgentSessionEditor for FakeAgentSessionEditor {
|
||||
fn truncate(&self, _message_id: UserMessageId, _cx: &mut App) -> Task<Result<()>> {
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,31 +1,59 @@
|
|||
use std::{error::Error, fmt, path::Path, rc::Rc};
|
||||
|
||||
use crate::AcpThread;
|
||||
use agent_client_protocol::{self as acp};
|
||||
use anyhow::Result;
|
||||
use collections::IndexMap;
|
||||
use gpui::{AsyncApp, Entity, SharedString, Task};
|
||||
use gpui::{Entity, SharedString, Task};
|
||||
use project::Project;
|
||||
use std::{any::Any, error::Error, fmt, path::Path, rc::Rc, sync::Arc};
|
||||
use ui::{App, IconName};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AcpThread;
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct UserMessageId(Arc<str>);
|
||||
|
||||
impl UserMessageId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4().to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AgentConnection {
|
||||
fn new_thread(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
cwd: &Path,
|
||||
cx: &mut AsyncApp,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>>;
|
||||
|
||||
fn auth_methods(&self) -> &[acp::AuthMethod];
|
||||
|
||||
fn authenticate(&self, method: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>>;
|
||||
|
||||
fn prompt(&self, params: acp::PromptRequest, cx: &mut App)
|
||||
-> Task<Result<acp::PromptResponse>>;
|
||||
fn prompt(
|
||||
&self,
|
||||
user_message_id: Option<UserMessageId>,
|
||||
params: acp::PromptRequest,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<acp::PromptResponse>>;
|
||||
|
||||
fn resume(
|
||||
&self,
|
||||
_session_id: &acp::SessionId,
|
||||
_cx: &mut App,
|
||||
) -> Option<Rc<dyn AgentSessionResume>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn cancel(&self, session_id: &acp::SessionId, cx: &mut App);
|
||||
|
||||
fn session_editor(
|
||||
&self,
|
||||
_session_id: &acp::SessionId,
|
||||
_cx: &mut App,
|
||||
) -> Option<Rc<dyn AgentSessionEditor>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns this agent as an [Rc<dyn ModelSelector>] if the model selection capability is supported.
|
||||
///
|
||||
/// If the agent does not support model selection, returns [None].
|
||||
|
@ -33,6 +61,22 @@ pub trait AgentConnection {
|
|||
fn model_selector(&self) -> Option<Rc<dyn AgentModelSelector>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
|
||||
}
|
||||
|
||||
impl dyn AgentConnection {
|
||||
pub fn downcast<T: 'static + AgentConnection + Sized>(self: Rc<Self>) -> Option<Rc<T>> {
|
||||
self.into_any().downcast().ok()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AgentSessionEditor {
|
||||
fn truncate(&self, message_id: UserMessageId, cx: &mut App) -> Task<Result<()>>;
|
||||
}
|
||||
|
||||
pub trait AgentSessionResume {
|
||||
fn run(&self, cx: &mut App) -> Task<Result<acp::PromptResponse>>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
@ -136,3 +180,159 @@ impl AgentModelList {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
mod test_support {
|
||||
use std::sync::Arc;
|
||||
|
||||
use collections::HashMap;
|
||||
use futures::future::try_join_all;
|
||||
use gpui::{AppContext as _, WeakEntity};
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct StubAgentConnection {
|
||||
sessions: Arc<Mutex<HashMap<acp::SessionId, WeakEntity<AcpThread>>>>,
|
||||
permission_requests: HashMap<acp::ToolCallId, Vec<acp::PermissionOption>>,
|
||||
next_prompt_updates: Arc<Mutex<Vec<acp::SessionUpdate>>>,
|
||||
}
|
||||
|
||||
impl StubAgentConnection {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
next_prompt_updates: Default::default(),
|
||||
permission_requests: HashMap::default(),
|
||||
sessions: Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_next_prompt_updates(&self, updates: Vec<acp::SessionUpdate>) {
|
||||
*self.next_prompt_updates.lock() = updates;
|
||||
}
|
||||
|
||||
pub fn with_permission_requests(
|
||||
mut self,
|
||||
permission_requests: HashMap<acp::ToolCallId, Vec<acp::PermissionOption>>,
|
||||
) -> Self {
|
||||
self.permission_requests = permission_requests;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn send_update(
|
||||
&self,
|
||||
session_id: acp::SessionId,
|
||||
update: acp::SessionUpdate,
|
||||
cx: &mut App,
|
||||
) {
|
||||
self.sessions
|
||||
.lock()
|
||||
.get(&session_id)
|
||||
.unwrap()
|
||||
.update(cx, |thread, cx| {
|
||||
thread.handle_session_update(update.clone(), cx).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentConnection for StubAgentConnection {
|
||||
fn auth_methods(&self) -> &[acp::AuthMethod] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn new_thread(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
_cwd: &Path,
|
||||
cx: &mut gpui::App,
|
||||
) -> Task<gpui::Result<Entity<AcpThread>>> {
|
||||
let session_id = acp::SessionId(self.sessions.lock().len().to_string().into());
|
||||
let thread =
|
||||
cx.new(|cx| AcpThread::new("Test", self.clone(), project, session_id.clone(), cx));
|
||||
self.sessions.lock().insert(session_id, thread.downgrade());
|
||||
Task::ready(Ok(thread))
|
||||
}
|
||||
|
||||
fn authenticate(
|
||||
&self,
|
||||
_method_id: acp::AuthMethodId,
|
||||
_cx: &mut App,
|
||||
) -> Task<gpui::Result<()>> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn prompt(
|
||||
&self,
|
||||
_id: Option<UserMessageId>,
|
||||
params: acp::PromptRequest,
|
||||
cx: &mut App,
|
||||
) -> Task<gpui::Result<acp::PromptResponse>> {
|
||||
let sessions = self.sessions.lock();
|
||||
let thread = sessions.get(¶ms.session_id).unwrap();
|
||||
let mut tasks = vec![];
|
||||
for update in self.next_prompt_updates.lock().drain(..) {
|
||||
let thread = thread.clone();
|
||||
let update = update.clone();
|
||||
let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) = &update
|
||||
&& let Some(options) = self.permission_requests.get(&tool_call.id)
|
||||
{
|
||||
Some((tool_call.clone(), options.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let task = cx.spawn(async move |cx| {
|
||||
if let Some((tool_call, options)) = permission_request {
|
||||
let permission = thread.update(cx, |thread, cx| {
|
||||
thread.request_tool_call_authorization(
|
||||
tool_call.clone(),
|
||||
options.clone(),
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
permission.await?;
|
||||
}
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.handle_session_update(update.clone(), cx).unwrap();
|
||||
})?;
|
||||
anyhow::Ok(())
|
||||
});
|
||||
tasks.push(task);
|
||||
}
|
||||
cx.spawn(async move |_| {
|
||||
try_join_all(tasks).await?;
|
||||
Ok(acp::PromptResponse {
|
||||
stop_reason: acp::StopReason::EndTurn,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn session_editor(
|
||||
&self,
|
||||
_session_id: &agent_client_protocol::SessionId,
|
||||
_cx: &mut App,
|
||||
) -> Option<Rc<dyn AgentSessionEditor>> {
|
||||
Some(Rc::new(StubAgentSessionEditor))
|
||||
}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct StubAgentSessionEditor;
|
||||
|
||||
impl AgentSessionEditor for StubAgentSessionEditor {
|
||||
fn truncate(&self, _: UserMessageId, _: &mut App) -> Task<Result<()>> {
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
pub use test_support::*;
|
||||
|
|
|
@ -1,13 +1,46 @@
|
|||
use agent_client_protocol as acp;
|
||||
use anyhow::{Result, bail};
|
||||
use std::path::PathBuf;
|
||||
use agent::ThreadId;
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use file_icons::FileIcons;
|
||||
use prompt_store::{PromptId, UserPromptId};
|
||||
use std::{
|
||||
fmt,
|
||||
ops::Range,
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
};
|
||||
use ui::{App, IconName, SharedString};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MentionUri {
|
||||
File(PathBuf),
|
||||
Symbol(PathBuf, String),
|
||||
Thread(acp::SessionId),
|
||||
Rule(String),
|
||||
File {
|
||||
abs_path: PathBuf,
|
||||
is_directory: bool,
|
||||
},
|
||||
Symbol {
|
||||
path: PathBuf,
|
||||
name: String,
|
||||
line_range: Range<u32>,
|
||||
},
|
||||
Thread {
|
||||
id: ThreadId,
|
||||
name: String,
|
||||
},
|
||||
TextThread {
|
||||
path: PathBuf,
|
||||
name: String,
|
||||
},
|
||||
Rule {
|
||||
id: PromptId,
|
||||
name: String,
|
||||
},
|
||||
Selection {
|
||||
path: PathBuf,
|
||||
line_range: Range<u32>,
|
||||
},
|
||||
Fetch {
|
||||
url: Url,
|
||||
},
|
||||
}
|
||||
|
||||
impl MentionUri {
|
||||
|
@ -17,58 +50,219 @@ impl MentionUri {
|
|||
match url.scheme() {
|
||||
"file" => {
|
||||
if let Some(fragment) = url.fragment() {
|
||||
Ok(Self::Symbol(path.into(), fragment.into()))
|
||||
let range = fragment
|
||||
.strip_prefix("L")
|
||||
.context("Line range must start with \"L\"")?;
|
||||
let (start, end) = range
|
||||
.split_once(":")
|
||||
.context("Line range must use colon as separator")?;
|
||||
let line_range = start
|
||||
.parse::<u32>()
|
||||
.context("Parsing line range start")?
|
||||
.checked_sub(1)
|
||||
.context("Line numbers should be 1-based")?
|
||||
..end
|
||||
.parse::<u32>()
|
||||
.context("Parsing line range end")?
|
||||
.checked_sub(1)
|
||||
.context("Line numbers should be 1-based")?;
|
||||
if let Some(name) = single_query_param(&url, "symbol")? {
|
||||
Ok(Self::Symbol {
|
||||
name,
|
||||
path: path.into(),
|
||||
line_range,
|
||||
})
|
||||
} else {
|
||||
Ok(Self::Selection {
|
||||
path: path.into(),
|
||||
line_range,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
let file_path =
|
||||
PathBuf::from(format!("{}{}", url.host_str().unwrap_or(""), path));
|
||||
let is_directory = input.ends_with("/");
|
||||
|
||||
Ok(Self::File(file_path))
|
||||
Ok(Self::File {
|
||||
abs_path: file_path,
|
||||
is_directory,
|
||||
})
|
||||
}
|
||||
}
|
||||
"zed" => {
|
||||
if let Some(thread) = path.strip_prefix("/agent/thread/") {
|
||||
Ok(Self::Thread(acp::SessionId(thread.into())))
|
||||
} else if let Some(rule) = path.strip_prefix("/agent/rule/") {
|
||||
Ok(Self::Rule(rule.into()))
|
||||
if let Some(thread_id) = path.strip_prefix("/agent/thread/") {
|
||||
let name = single_query_param(&url, "name")?.context("Missing thread name")?;
|
||||
Ok(Self::Thread {
|
||||
id: thread_id.into(),
|
||||
name,
|
||||
})
|
||||
} else if let Some(path) = path.strip_prefix("/agent/text-thread/") {
|
||||
let name = single_query_param(&url, "name")?.context("Missing thread name")?;
|
||||
Ok(Self::TextThread {
|
||||
path: path.into(),
|
||||
name,
|
||||
})
|
||||
} else if let Some(rule_id) = path.strip_prefix("/agent/rule/") {
|
||||
let name = single_query_param(&url, "name")?.context("Missing rule name")?;
|
||||
let rule_id = UserPromptId(rule_id.parse()?);
|
||||
Ok(Self::Rule {
|
||||
id: rule_id.into(),
|
||||
name,
|
||||
})
|
||||
} else {
|
||||
bail!("invalid zed url: {:?}", input);
|
||||
}
|
||||
}
|
||||
"http" | "https" => Ok(MentionUri::Fetch { url }),
|
||||
other => bail!("unrecognized scheme {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> String {
|
||||
match self {
|
||||
MentionUri::File(path) => path.file_name().unwrap().to_string_lossy().into_owned(),
|
||||
MentionUri::Symbol(_path, name) => name.clone(),
|
||||
MentionUri::Thread(thread) => thread.to_string(),
|
||||
MentionUri::Rule(rule) => rule.clone(),
|
||||
MentionUri::File { abs_path, .. } => abs_path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
MentionUri::Symbol { name, .. } => name.clone(),
|
||||
MentionUri::Thread { name, .. } => name.clone(),
|
||||
MentionUri::TextThread { name, .. } => name.clone(),
|
||||
MentionUri::Rule { name, .. } => name.clone(),
|
||||
MentionUri::Selection {
|
||||
path, line_range, ..
|
||||
} => selection_name(path, line_range),
|
||||
MentionUri::Fetch { url } => url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_link(&self) -> String {
|
||||
let name = self.name();
|
||||
let uri = self.to_uri();
|
||||
format!("[{name}]({uri})")
|
||||
}
|
||||
|
||||
pub fn to_uri(&self) -> String {
|
||||
pub fn icon_path(&self, cx: &mut App) -> SharedString {
|
||||
match self {
|
||||
MentionUri::File(path) => {
|
||||
format!("file://{}", path.display())
|
||||
}
|
||||
MentionUri::Symbol(path, name) => {
|
||||
format!("file://{}#{}", path.display(), name)
|
||||
}
|
||||
MentionUri::Thread(thread) => {
|
||||
format!("zed:///agent/thread/{}", thread.0)
|
||||
}
|
||||
MentionUri::Rule(rule) => {
|
||||
format!("zed:///agent/rule/{}", rule)
|
||||
MentionUri::File {
|
||||
abs_path,
|
||||
is_directory,
|
||||
} => {
|
||||
if *is_directory {
|
||||
FileIcons::get_folder_icon(false, cx)
|
||||
.unwrap_or_else(|| IconName::Folder.path().into())
|
||||
} else {
|
||||
FileIcons::get_icon(&abs_path, cx)
|
||||
.unwrap_or_else(|| IconName::File.path().into())
|
||||
}
|
||||
}
|
||||
MentionUri::Symbol { .. } => IconName::Code.path().into(),
|
||||
MentionUri::Thread { .. } => IconName::Thread.path().into(),
|
||||
MentionUri::TextThread { .. } => IconName::Thread.path().into(),
|
||||
MentionUri::Rule { .. } => IconName::Reader.path().into(),
|
||||
MentionUri::Selection { .. } => IconName::Reader.path().into(),
|
||||
MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_link<'a>(&'a self) -> MentionLink<'a> {
|
||||
MentionLink(self)
|
||||
}
|
||||
|
||||
pub fn to_uri(&self) -> Url {
|
||||
match self {
|
||||
MentionUri::File {
|
||||
abs_path,
|
||||
is_directory,
|
||||
} => {
|
||||
let mut url = Url::parse("file:///").unwrap();
|
||||
let mut path = abs_path.to_string_lossy().to_string();
|
||||
if *is_directory && !path.ends_with("/") {
|
||||
path.push_str("/");
|
||||
}
|
||||
url.set_path(&path);
|
||||
url
|
||||
}
|
||||
MentionUri::Symbol {
|
||||
path,
|
||||
name,
|
||||
line_range,
|
||||
} => {
|
||||
let mut url = Url::parse("file:///").unwrap();
|
||||
url.set_path(&path.to_string_lossy());
|
||||
url.query_pairs_mut().append_pair("symbol", name);
|
||||
url.set_fragment(Some(&format!(
|
||||
"L{}:{}",
|
||||
line_range.start + 1,
|
||||
line_range.end + 1
|
||||
)));
|
||||
url
|
||||
}
|
||||
MentionUri::Selection { path, line_range } => {
|
||||
let mut url = Url::parse("file:///").unwrap();
|
||||
url.set_path(&path.to_string_lossy());
|
||||
url.set_fragment(Some(&format!(
|
||||
"L{}:{}",
|
||||
line_range.start + 1,
|
||||
line_range.end + 1
|
||||
)));
|
||||
url
|
||||
}
|
||||
MentionUri::Thread { name, id } => {
|
||||
let mut url = Url::parse("zed:///").unwrap();
|
||||
url.set_path(&format!("/agent/thread/{id}"));
|
||||
url.query_pairs_mut().append_pair("name", name);
|
||||
url
|
||||
}
|
||||
MentionUri::TextThread { path, name } => {
|
||||
let mut url = Url::parse("zed:///").unwrap();
|
||||
url.set_path(&format!("/agent/text-thread/{}", path.to_string_lossy()));
|
||||
url.query_pairs_mut().append_pair("name", name);
|
||||
url
|
||||
}
|
||||
MentionUri::Rule { name, id } => {
|
||||
let mut url = Url::parse("zed:///").unwrap();
|
||||
url.set_path(&format!("/agent/rule/{id}"));
|
||||
url.query_pairs_mut().append_pair("name", name);
|
||||
url
|
||||
}
|
||||
MentionUri::Fetch { url } => url.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for MentionUri {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> anyhow::Result<Self> {
|
||||
Self::parse(s)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MentionLink<'a>(&'a MentionUri);
|
||||
|
||||
impl fmt::Display for MentionLink<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "[@{}]({})", self.0.name(), self.0.to_uri())
|
||||
}
|
||||
}
|
||||
|
||||
fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
|
||||
let pairs = url.query_pairs().collect::<Vec<_>>();
|
||||
match pairs.as_slice() {
|
||||
[] => Ok(None),
|
||||
[(k, v)] => {
|
||||
if k != name {
|
||||
bail!("invalid query parameter")
|
||||
}
|
||||
|
||||
Ok(Some(v.to_string()))
|
||||
}
|
||||
_ => bail!("too many query pairs"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selection_name(path: &Path, line_range: &Range<u32>) -> String {
|
||||
format!(
|
||||
"{} ({}:{})",
|
||||
path.file_name().unwrap_or_default().display(),
|
||||
line_range.start + 1,
|
||||
line_range.end + 1
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
@ -76,50 +270,191 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mention_uri_parse_and_display() {
|
||||
// Test file URI
|
||||
fn test_parse_file_uri() {
|
||||
let file_uri = "file:///path/to/file.rs";
|
||||
let parsed = MentionUri::parse(file_uri).unwrap();
|
||||
match &parsed {
|
||||
MentionUri::File(path) => assert_eq!(path.to_str().unwrap(), "/path/to/file.rs"),
|
||||
MentionUri::File {
|
||||
abs_path,
|
||||
is_directory,
|
||||
} => {
|
||||
assert_eq!(abs_path.to_str().unwrap(), "/path/to/file.rs");
|
||||
assert!(!is_directory);
|
||||
}
|
||||
_ => panic!("Expected File variant"),
|
||||
}
|
||||
assert_eq!(parsed.to_uri(), file_uri);
|
||||
assert_eq!(parsed.to_uri().to_string(), file_uri);
|
||||
}
|
||||
|
||||
// Test symbol URI
|
||||
let symbol_uri = "file:///path/to/file.rs#MySymbol";
|
||||
#[test]
|
||||
fn test_parse_directory_uri() {
|
||||
let file_uri = "file:///path/to/dir/";
|
||||
let parsed = MentionUri::parse(file_uri).unwrap();
|
||||
match &parsed {
|
||||
MentionUri::File {
|
||||
abs_path,
|
||||
is_directory,
|
||||
} => {
|
||||
assert_eq!(abs_path.to_str().unwrap(), "/path/to/dir/");
|
||||
assert!(is_directory);
|
||||
}
|
||||
_ => panic!("Expected File variant"),
|
||||
}
|
||||
assert_eq!(parsed.to_uri().to_string(), file_uri);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_directory_uri_with_slash() {
|
||||
let uri = MentionUri::File {
|
||||
abs_path: PathBuf::from("/path/to/dir/"),
|
||||
is_directory: true,
|
||||
};
|
||||
assert_eq!(uri.to_uri().to_string(), "file:///path/to/dir/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_directory_uri_without_slash() {
|
||||
let uri = MentionUri::File {
|
||||
abs_path: PathBuf::from("/path/to/dir"),
|
||||
is_directory: true,
|
||||
};
|
||||
assert_eq!(uri.to_uri().to_string(), "file:///path/to/dir/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_symbol_uri() {
|
||||
let symbol_uri = "file:///path/to/file.rs?symbol=MySymbol#L10:20";
|
||||
let parsed = MentionUri::parse(symbol_uri).unwrap();
|
||||
match &parsed {
|
||||
MentionUri::Symbol(path, symbol) => {
|
||||
MentionUri::Symbol {
|
||||
path,
|
||||
name,
|
||||
line_range,
|
||||
} => {
|
||||
assert_eq!(path.to_str().unwrap(), "/path/to/file.rs");
|
||||
assert_eq!(symbol, "MySymbol");
|
||||
assert_eq!(name, "MySymbol");
|
||||
assert_eq!(line_range.start, 9);
|
||||
assert_eq!(line_range.end, 19);
|
||||
}
|
||||
_ => panic!("Expected Symbol variant"),
|
||||
}
|
||||
assert_eq!(parsed.to_uri(), symbol_uri);
|
||||
assert_eq!(parsed.to_uri().to_string(), symbol_uri);
|
||||
}
|
||||
|
||||
// Test thread URI
|
||||
let thread_uri = "zed:///agent/thread/session123";
|
||||
#[test]
|
||||
fn test_parse_selection_uri() {
|
||||
let selection_uri = "file:///path/to/file.rs#L5:15";
|
||||
let parsed = MentionUri::parse(selection_uri).unwrap();
|
||||
match &parsed {
|
||||
MentionUri::Selection { path, line_range } => {
|
||||
assert_eq!(path.to_str().unwrap(), "/path/to/file.rs");
|
||||
assert_eq!(line_range.start, 4);
|
||||
assert_eq!(line_range.end, 14);
|
||||
}
|
||||
_ => panic!("Expected Selection variant"),
|
||||
}
|
||||
assert_eq!(parsed.to_uri().to_string(), selection_uri);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_thread_uri() {
|
||||
let thread_uri = "zed:///agent/thread/session123?name=Thread+name";
|
||||
let parsed = MentionUri::parse(thread_uri).unwrap();
|
||||
match &parsed {
|
||||
MentionUri::Thread(session_id) => assert_eq!(session_id.0.as_ref(), "session123"),
|
||||
MentionUri::Thread {
|
||||
id: thread_id,
|
||||
name,
|
||||
} => {
|
||||
assert_eq!(thread_id.to_string(), "session123");
|
||||
assert_eq!(name, "Thread name");
|
||||
}
|
||||
_ => panic!("Expected Thread variant"),
|
||||
}
|
||||
assert_eq!(parsed.to_uri(), thread_uri);
|
||||
assert_eq!(parsed.to_uri().to_string(), thread_uri);
|
||||
}
|
||||
|
||||
// Test rule URI
|
||||
let rule_uri = "zed:///agent/rule/my_rule";
|
||||
#[test]
|
||||
fn test_parse_rule_uri() {
|
||||
let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule";
|
||||
let parsed = MentionUri::parse(rule_uri).unwrap();
|
||||
match &parsed {
|
||||
MentionUri::Rule(rule) => assert_eq!(rule, "my_rule"),
|
||||
MentionUri::Rule { id, name } => {
|
||||
assert_eq!(id.to_string(), "d8694ff2-90d5-4b6f-be33-33c1763acd52");
|
||||
assert_eq!(name, "Some rule");
|
||||
}
|
||||
_ => panic!("Expected Rule variant"),
|
||||
}
|
||||
assert_eq!(parsed.to_uri(), rule_uri);
|
||||
assert_eq!(parsed.to_uri().to_string(), rule_uri);
|
||||
}
|
||||
|
||||
// Test invalid scheme
|
||||
assert!(MentionUri::parse("http://example.com").is_err());
|
||||
#[test]
|
||||
fn test_parse_fetch_http_uri() {
|
||||
let http_uri = "http://example.com/path?query=value#fragment";
|
||||
let parsed = MentionUri::parse(http_uri).unwrap();
|
||||
match &parsed {
|
||||
MentionUri::Fetch { url } => {
|
||||
assert_eq!(url.to_string(), http_uri);
|
||||
}
|
||||
_ => panic!("Expected Fetch variant"),
|
||||
}
|
||||
assert_eq!(parsed.to_uri().to_string(), http_uri);
|
||||
}
|
||||
|
||||
// Test invalid zed path
|
||||
#[test]
|
||||
fn test_parse_fetch_https_uri() {
|
||||
let https_uri = "https://example.com/api/endpoint";
|
||||
let parsed = MentionUri::parse(https_uri).unwrap();
|
||||
match &parsed {
|
||||
MentionUri::Fetch { url } => {
|
||||
assert_eq!(url.to_string(), https_uri);
|
||||
}
|
||||
_ => panic!("Expected Fetch variant"),
|
||||
}
|
||||
assert_eq!(parsed.to_uri().to_string(), https_uri);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_scheme() {
|
||||
assert!(MentionUri::parse("ftp://example.com").is_err());
|
||||
assert!(MentionUri::parse("ssh://example.com").is_err());
|
||||
assert!(MentionUri::parse("unknown://example.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_zed_path() {
|
||||
assert!(MentionUri::parse("zed:///invalid/path").is_err());
|
||||
assert!(MentionUri::parse("zed:///agent/unknown/test").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_line_range_format() {
|
||||
// Missing L prefix
|
||||
assert!(MentionUri::parse("file:///path/to/file.rs#10:20").is_err());
|
||||
|
||||
// Missing colon separator
|
||||
assert!(MentionUri::parse("file:///path/to/file.rs#L1020").is_err());
|
||||
|
||||
// Invalid numbers
|
||||
assert!(MentionUri::parse("file:///path/to/file.rs#L10:abc").is_err());
|
||||
assert!(MentionUri::parse("file:///path/to/file.rs#Labc:20").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_query_parameters() {
|
||||
// Invalid query parameter name
|
||||
assert!(MentionUri::parse("file:///path/to/file.rs#L10:20?invalid=test").is_err());
|
||||
|
||||
// Too many query parameters
|
||||
assert!(
|
||||
MentionUri::parse("file:///path/to/file.rs#L10:20?symbol=test&another=param").is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_based_line_numbers() {
|
||||
// Test that 0-based line numbers are rejected (should be 1-based)
|
||||
assert!(MentionUri::parse("file:///path/to/file.rs#L0:10").is_err());
|
||||
assert!(MentionUri::parse("file:///path/to/file.rs#L1:0").is_err());
|
||||
assert!(MentionUri::parse("file:///path/to/file.rs#L0:0").is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -844,11 +844,17 @@ impl Thread {
|
|||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if !equal {
|
||||
this.update(cx, |this, cx| {
|
||||
this.insert_checkpoint(pending_checkpoint, cx)
|
||||
})?;
|
||||
}
|
||||
this.update(cx, |this, cx| {
|
||||
this.pending_checkpoint = if equal {
|
||||
Some(pending_checkpoint)
|
||||
} else {
|
||||
this.insert_checkpoint(pending_checkpoint, cx);
|
||||
Some(ThreadCheckpoint {
|
||||
message_id: this.next_message_id,
|
||||
git_checkpoint: final_checkpoint,
|
||||
})
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -205,6 +205,22 @@ impl ThreadStore {
|
|||
(this, ready_rx)
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn fake(project: Entity<Project>, cx: &mut App) -> Self {
|
||||
Self {
|
||||
project,
|
||||
tools: cx.new(|_| ToolWorkingSet::default()),
|
||||
prompt_builder: Arc::new(PromptBuilder::new(None).unwrap()),
|
||||
prompt_store: None,
|
||||
context_server_tool_ids: HashMap::default(),
|
||||
threads: Vec::new(),
|
||||
project_context: SharedProjectContext::default(),
|
||||
reload_system_prompt_tx: mpsc::channel(0).0,
|
||||
_reload_system_prompt_task: Task::ready(()),
|
||||
_subscriptions: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_project_event(
|
||||
&mut self,
|
||||
_project: Entity<Project>,
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
use crate::{AgentResponseEvent, Thread, templates::Templates};
|
||||
use crate::{
|
||||
ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DiagnosticsTool, EditFileTool,
|
||||
FetchTool, FindPathTool, GrepTool, ListDirectoryTool, MessageContent, MovePathTool, NowTool,
|
||||
OpenTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization, WebSearchTool,
|
||||
AgentResponseEvent, ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DeletePathTool,
|
||||
DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool, ListDirectoryTool,
|
||||
MovePathTool, NowTool, OpenTool, ReadFileTool, TerminalTool, ThinkingTool, Thread,
|
||||
ToolCallAuthorization, UserMessageContent, WebSearchTool, templates::Templates,
|
||||
};
|
||||
use acp_thread::AgentModelSelector;
|
||||
use agent_client_protocol as acp;
|
||||
|
@ -10,6 +10,7 @@ use agent_settings::AgentSettings;
|
|||
use anyhow::{Context as _, Result, anyhow};
|
||||
use collections::{HashSet, IndexMap};
|
||||
use fs::Fs;
|
||||
use futures::channel::mpsc;
|
||||
use futures::{StreamExt, future};
|
||||
use gpui::{
|
||||
App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
|
||||
|
@ -20,6 +21,7 @@ use prompt_store::{
|
|||
ProjectContext, PromptId, PromptStore, RulesFileContext, UserRulesContext, WorktreeContext,
|
||||
};
|
||||
use settings::update_settings_file;
|
||||
use std::any::Any;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
@ -425,9 +427,9 @@ impl NativeAgent {
|
|||
self.models.refresh_list(cx);
|
||||
for session in self.sessions.values_mut() {
|
||||
session.thread.update(cx, |thread, _| {
|
||||
let model_id = LanguageModels::model_id(&thread.selected_model);
|
||||
let model_id = LanguageModels::model_id(&thread.model());
|
||||
if let Some(model) = self.models.model_from_id(&model_id) {
|
||||
thread.selected_model = model.clone();
|
||||
thread.set_model(model.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -438,244 +440,40 @@ impl NativeAgent {
|
|||
#[derive(Clone)]
|
||||
pub struct NativeAgentConnection(pub Entity<NativeAgent>);
|
||||
|
||||
impl AgentModelSelector for NativeAgentConnection {
|
||||
fn list_models(&self, cx: &mut App) -> Task<Result<acp_thread::AgentModelList>> {
|
||||
log::debug!("NativeAgentConnection::list_models called");
|
||||
let list = self.0.read(cx).models.model_list.clone();
|
||||
Task::ready(if list.is_empty() {
|
||||
Err(anyhow::anyhow!("No models available"))
|
||||
} else {
|
||||
Ok(list)
|
||||
})
|
||||
impl NativeAgentConnection {
|
||||
pub fn thread(&self, session_id: &acp::SessionId, cx: &App) -> Option<Entity<Thread>> {
|
||||
self.0
|
||||
.read(cx)
|
||||
.sessions
|
||||
.get(session_id)
|
||||
.map(|session| session.thread.clone())
|
||||
}
|
||||
|
||||
fn select_model(
|
||||
fn run_turn(
|
||||
&self,
|
||||
session_id: acp::SessionId,
|
||||
model_id: acp_thread::AgentModelId,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<()>> {
|
||||
log::info!("Setting model for session {}: {}", session_id, model_id);
|
||||
let Some(thread) = self
|
||||
.0
|
||||
.read(cx)
|
||||
.sessions
|
||||
.get(&session_id)
|
||||
.map(|session| session.thread.clone())
|
||||
else {
|
||||
return Task::ready(Err(anyhow!("Session not found")));
|
||||
};
|
||||
|
||||
let Some(model) = self.0.read(cx).models.model_from_id(&model_id) else {
|
||||
return Task::ready(Err(anyhow!("Invalid model ID {}", model_id)));
|
||||
};
|
||||
|
||||
thread.update(cx, |thread, _cx| {
|
||||
thread.selected_model = model.clone();
|
||||
});
|
||||
|
||||
update_settings_file::<AgentSettings>(
|
||||
self.0.read(cx).fs.clone(),
|
||||
cx,
|
||||
move |settings, _cx| {
|
||||
settings.set_model(model);
|
||||
},
|
||||
);
|
||||
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
|
||||
fn selected_model(
|
||||
&self,
|
||||
session_id: &acp::SessionId,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<acp_thread::AgentModelInfo>> {
|
||||
let session_id = session_id.clone();
|
||||
|
||||
let Some(thread) = self
|
||||
.0
|
||||
.read(cx)
|
||||
.sessions
|
||||
.get(&session_id)
|
||||
.map(|session| session.thread.clone())
|
||||
else {
|
||||
return Task::ready(Err(anyhow!("Session not found")));
|
||||
};
|
||||
let model = thread.read(cx).selected_model.clone();
|
||||
let Some(provider) = LanguageModelRegistry::read_global(cx).provider(&model.provider_id())
|
||||
else {
|
||||
return Task::ready(Err(anyhow!("Provider not found")));
|
||||
};
|
||||
Task::ready(Ok(LanguageModels::map_language_model_to_info(
|
||||
&model, &provider,
|
||||
)))
|
||||
}
|
||||
|
||||
fn watch(&self, cx: &mut App) -> watch::Receiver<()> {
|
||||
self.0.read(cx).models.watch()
|
||||
}
|
||||
}
|
||||
|
||||
impl acp_thread::AgentConnection for NativeAgentConnection {
|
||||
fn new_thread(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
cwd: &Path,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Task<Result<Entity<acp_thread::AcpThread>>> {
|
||||
let agent = self.0.clone();
|
||||
log::info!("Creating new thread for project at: {:?}", cwd);
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
log::debug!("Starting thread creation in async context");
|
||||
|
||||
// Generate session ID
|
||||
let session_id = acp::SessionId(uuid::Uuid::new_v4().to_string().into());
|
||||
log::info!("Created session with ID: {}", session_id);
|
||||
|
||||
// Create AcpThread
|
||||
let acp_thread = cx.update(|cx| {
|
||||
cx.new(|cx| {
|
||||
acp_thread::AcpThread::new(
|
||||
"agent2",
|
||||
self.clone(),
|
||||
project.clone(),
|
||||
session_id.clone(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})?;
|
||||
let action_log = cx.update(|cx| acp_thread.read(cx).action_log().clone())?;
|
||||
|
||||
// Create Thread
|
||||
let thread = agent.update(
|
||||
cx,
|
||||
|agent, cx: &mut gpui::Context<NativeAgent>| -> Result<_> {
|
||||
// Fetch default model from registry settings
|
||||
let registry = LanguageModelRegistry::read_global(cx);
|
||||
|
||||
// Log available models for debugging
|
||||
let available_count = registry.available_models(cx).count();
|
||||
log::debug!("Total available models: {}", available_count);
|
||||
|
||||
let default_model = registry
|
||||
.default_model()
|
||||
.and_then(|default_model| {
|
||||
agent
|
||||
.models
|
||||
.model_from_id(&LanguageModels::model_id(&default_model.model))
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
log::warn!("No default model configured in settings");
|
||||
anyhow!(
|
||||
"No default model. Please configure a default model in settings."
|
||||
)
|
||||
})?;
|
||||
|
||||
let thread = cx.new(|cx| {
|
||||
let mut thread = Thread::new(
|
||||
project.clone(),
|
||||
agent.project_context.clone(),
|
||||
agent.context_server_registry.clone(),
|
||||
action_log.clone(),
|
||||
agent.templates.clone(),
|
||||
default_model,
|
||||
cx,
|
||||
);
|
||||
thread.add_tool(CreateDirectoryTool::new(project.clone()));
|
||||
thread.add_tool(CopyPathTool::new(project.clone()));
|
||||
thread.add_tool(DiagnosticsTool::new(project.clone()));
|
||||
thread.add_tool(MovePathTool::new(project.clone()));
|
||||
thread.add_tool(ListDirectoryTool::new(project.clone()));
|
||||
thread.add_tool(OpenTool::new(project.clone()));
|
||||
thread.add_tool(ThinkingTool);
|
||||
thread.add_tool(FindPathTool::new(project.clone()));
|
||||
thread.add_tool(FetchTool::new(project.read(cx).client().http_client()));
|
||||
thread.add_tool(GrepTool::new(project.clone()));
|
||||
thread.add_tool(ReadFileTool::new(project.clone(), action_log));
|
||||
thread.add_tool(EditFileTool::new(cx.entity()));
|
||||
thread.add_tool(NowTool);
|
||||
thread.add_tool(TerminalTool::new(project.clone(), cx));
|
||||
// TODO: Needs to be conditional based on zed model or not
|
||||
thread.add_tool(WebSearchTool);
|
||||
thread
|
||||
});
|
||||
|
||||
Ok(thread)
|
||||
},
|
||||
)??;
|
||||
|
||||
// Store the session
|
||||
agent.update(cx, |agent, cx| {
|
||||
agent.sessions.insert(
|
||||
session_id,
|
||||
Session {
|
||||
thread,
|
||||
acp_thread: acp_thread.downgrade(),
|
||||
_subscription: cx.observe_release(&acp_thread, |this, acp_thread, _cx| {
|
||||
this.sessions.remove(acp_thread.session_id());
|
||||
}),
|
||||
},
|
||||
);
|
||||
})?;
|
||||
|
||||
Ok(acp_thread)
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_methods(&self) -> &[acp::AuthMethod] {
|
||||
&[] // No auth for in-process
|
||||
}
|
||||
|
||||
fn authenticate(&self, _method: acp::AuthMethodId, _cx: &mut App) -> Task<Result<()>> {
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
|
||||
fn model_selector(&self) -> Option<Rc<dyn AgentModelSelector>> {
|
||||
Some(Rc::new(self.clone()) as Rc<dyn AgentModelSelector>)
|
||||
}
|
||||
|
||||
fn prompt(
|
||||
&self,
|
||||
params: acp::PromptRequest,
|
||||
cx: &mut App,
|
||||
f: impl 'static
|
||||
+ FnOnce(
|
||||
Entity<Thread>,
|
||||
&mut App,
|
||||
) -> Result<mpsc::UnboundedReceiver<Result<AgentResponseEvent>>>,
|
||||
) -> Task<Result<acp::PromptResponse>> {
|
||||
let session_id = params.session_id.clone();
|
||||
let agent = self.0.clone();
|
||||
log::info!("Received prompt request for session: {}", session_id);
|
||||
log::debug!("Prompt blocks count: {}", params.prompt.len());
|
||||
let Some((thread, acp_thread)) = self.0.update(cx, |agent, _cx| {
|
||||
agent
|
||||
.sessions
|
||||
.get_mut(&session_id)
|
||||
.map(|s| (s.thread.clone(), s.acp_thread.clone()))
|
||||
}) else {
|
||||
return Task::ready(Err(anyhow!("Session not found")));
|
||||
};
|
||||
log::debug!("Found session for: {}", session_id);
|
||||
|
||||
let mut response_stream = match f(thread, cx) {
|
||||
Ok(stream) => stream,
|
||||
Err(err) => return Task::ready(Err(err)),
|
||||
};
|
||||
cx.spawn(async move |cx| {
|
||||
// Get session
|
||||
let (thread, acp_thread) = agent
|
||||
.update(cx, |agent, _| {
|
||||
agent
|
||||
.sessions
|
||||
.get_mut(&session_id)
|
||||
.map(|s| (s.thread.clone(), s.acp_thread.clone()))
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
log::error!("Session not found: {}", session_id);
|
||||
anyhow::anyhow!("Session not found")
|
||||
})?;
|
||||
log::debug!("Found session for: {}", session_id);
|
||||
|
||||
let message: Vec<MessageContent> = params
|
||||
.prompt
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect::<Vec<_>>();
|
||||
log::info!("Converted prompt to message: {} chars", message.len());
|
||||
log::debug!("Message content: {:?}", message);
|
||||
|
||||
// Get model using the ModelSelector capability (always available for agent2)
|
||||
// Get the selected model from the thread directly
|
||||
let model = thread.read_with(cx, |thread, _| thread.selected_model.clone())?;
|
||||
|
||||
// Send to thread
|
||||
log::info!("Sending message to thread with model: {:?}", model.name());
|
||||
let mut response_stream = thread.update(cx, |thread, cx| thread.send(message, cx))?;
|
||||
|
||||
// Handle response stream and forward to session.acp_thread
|
||||
while let Some(result) = response_stream.next().await {
|
||||
match result {
|
||||
|
@ -747,8 +545,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
|
|||
}
|
||||
Err(e) => {
|
||||
log::error!("Error in model response stream: {:?}", e);
|
||||
// TODO: Consider sending an error message to the UI
|
||||
break;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -759,6 +556,246 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
|
|||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentModelSelector for NativeAgentConnection {
|
||||
fn list_models(&self, cx: &mut App) -> Task<Result<acp_thread::AgentModelList>> {
|
||||
log::debug!("NativeAgentConnection::list_models called");
|
||||
let list = self.0.read(cx).models.model_list.clone();
|
||||
Task::ready(if list.is_empty() {
|
||||
Err(anyhow::anyhow!("No models available"))
|
||||
} else {
|
||||
Ok(list)
|
||||
})
|
||||
}
|
||||
|
||||
fn select_model(
|
||||
&self,
|
||||
session_id: acp::SessionId,
|
||||
model_id: acp_thread::AgentModelId,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<()>> {
|
||||
log::info!("Setting model for session {}: {}", session_id, model_id);
|
||||
let Some(thread) = self
|
||||
.0
|
||||
.read(cx)
|
||||
.sessions
|
||||
.get(&session_id)
|
||||
.map(|session| session.thread.clone())
|
||||
else {
|
||||
return Task::ready(Err(anyhow!("Session not found")));
|
||||
};
|
||||
|
||||
let Some(model) = self.0.read(cx).models.model_from_id(&model_id) else {
|
||||
return Task::ready(Err(anyhow!("Invalid model ID {}", model_id)));
|
||||
};
|
||||
|
||||
thread.update(cx, |thread, _cx| {
|
||||
thread.set_model(model.clone());
|
||||
});
|
||||
|
||||
update_settings_file::<AgentSettings>(
|
||||
self.0.read(cx).fs.clone(),
|
||||
cx,
|
||||
move |settings, _cx| {
|
||||
settings.set_model(model);
|
||||
},
|
||||
);
|
||||
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
|
||||
fn selected_model(
|
||||
&self,
|
||||
session_id: &acp::SessionId,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<acp_thread::AgentModelInfo>> {
|
||||
let session_id = session_id.clone();
|
||||
|
||||
let Some(thread) = self
|
||||
.0
|
||||
.read(cx)
|
||||
.sessions
|
||||
.get(&session_id)
|
||||
.map(|session| session.thread.clone())
|
||||
else {
|
||||
return Task::ready(Err(anyhow!("Session not found")));
|
||||
};
|
||||
let model = thread.read(cx).model().clone();
|
||||
let Some(provider) = LanguageModelRegistry::read_global(cx).provider(&model.provider_id())
|
||||
else {
|
||||
return Task::ready(Err(anyhow!("Provider not found")));
|
||||
};
|
||||
Task::ready(Ok(LanguageModels::map_language_model_to_info(
|
||||
&model, &provider,
|
||||
)))
|
||||
}
|
||||
|
||||
fn watch(&self, cx: &mut App) -> watch::Receiver<()> {
|
||||
self.0.read(cx).models.watch()
|
||||
}
|
||||
}
|
||||
|
||||
impl acp_thread::AgentConnection for NativeAgentConnection {
|
||||
fn new_thread(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
cwd: &Path,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<acp_thread::AcpThread>>> {
|
||||
let agent = self.0.clone();
|
||||
log::info!("Creating new thread for project at: {:?}", cwd);
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
log::debug!("Starting thread creation in async context");
|
||||
|
||||
// Generate session ID
|
||||
let session_id = acp::SessionId(uuid::Uuid::new_v4().to_string().into());
|
||||
log::info!("Created session with ID: {}", session_id);
|
||||
|
||||
// Create AcpThread
|
||||
let acp_thread = cx.update(|cx| {
|
||||
cx.new(|cx| {
|
||||
acp_thread::AcpThread::new(
|
||||
"agent2",
|
||||
self.clone(),
|
||||
project.clone(),
|
||||
session_id.clone(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})?;
|
||||
let action_log = cx.update(|cx| acp_thread.read(cx).action_log().clone())?;
|
||||
|
||||
// Create Thread
|
||||
let thread = agent.update(
|
||||
cx,
|
||||
|agent, cx: &mut gpui::Context<NativeAgent>| -> Result<_> {
|
||||
// Fetch default model from registry settings
|
||||
let registry = LanguageModelRegistry::read_global(cx);
|
||||
|
||||
// Log available models for debugging
|
||||
let available_count = registry.available_models(cx).count();
|
||||
log::debug!("Total available models: {}", available_count);
|
||||
|
||||
let default_model = registry
|
||||
.default_model()
|
||||
.and_then(|default_model| {
|
||||
agent
|
||||
.models
|
||||
.model_from_id(&LanguageModels::model_id(&default_model.model))
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
log::warn!("No default model configured in settings");
|
||||
anyhow!(
|
||||
"No default model. Please configure a default model in settings."
|
||||
)
|
||||
})?;
|
||||
|
||||
let thread = cx.new(|cx| {
|
||||
let mut thread = Thread::new(
|
||||
project.clone(),
|
||||
agent.project_context.clone(),
|
||||
agent.context_server_registry.clone(),
|
||||
action_log.clone(),
|
||||
agent.templates.clone(),
|
||||
default_model,
|
||||
cx,
|
||||
);
|
||||
thread.add_tool(CopyPathTool::new(project.clone()));
|
||||
thread.add_tool(CreateDirectoryTool::new(project.clone()));
|
||||
thread.add_tool(DeletePathTool::new(project.clone(), action_log.clone()));
|
||||
thread.add_tool(DiagnosticsTool::new(project.clone()));
|
||||
thread.add_tool(EditFileTool::new(cx.entity()));
|
||||
thread.add_tool(FetchTool::new(project.read(cx).client().http_client()));
|
||||
thread.add_tool(FindPathTool::new(project.clone()));
|
||||
thread.add_tool(GrepTool::new(project.clone()));
|
||||
thread.add_tool(ListDirectoryTool::new(project.clone()));
|
||||
thread.add_tool(MovePathTool::new(project.clone()));
|
||||
thread.add_tool(NowTool);
|
||||
thread.add_tool(OpenTool::new(project.clone()));
|
||||
thread.add_tool(ReadFileTool::new(project.clone(), action_log));
|
||||
thread.add_tool(TerminalTool::new(project.clone(), cx));
|
||||
thread.add_tool(ThinkingTool);
|
||||
thread.add_tool(WebSearchTool); // TODO: Enable this only if it's a zed model.
|
||||
thread
|
||||
});
|
||||
|
||||
Ok(thread)
|
||||
},
|
||||
)??;
|
||||
|
||||
// Store the session
|
||||
agent.update(cx, |agent, cx| {
|
||||
agent.sessions.insert(
|
||||
session_id,
|
||||
Session {
|
||||
thread,
|
||||
acp_thread: acp_thread.downgrade(),
|
||||
_subscription: cx.observe_release(&acp_thread, |this, acp_thread, _cx| {
|
||||
this.sessions.remove(acp_thread.session_id());
|
||||
}),
|
||||
},
|
||||
);
|
||||
})?;
|
||||
|
||||
Ok(acp_thread)
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_methods(&self) -> &[acp::AuthMethod] {
|
||||
&[] // No auth for in-process
|
||||
}
|
||||
|
||||
fn authenticate(&self, _method: acp::AuthMethodId, _cx: &mut App) -> Task<Result<()>> {
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
|
||||
fn model_selector(&self) -> Option<Rc<dyn AgentModelSelector>> {
|
||||
Some(Rc::new(self.clone()) as Rc<dyn AgentModelSelector>)
|
||||
}
|
||||
|
||||
fn prompt(
|
||||
&self,
|
||||
id: Option<acp_thread::UserMessageId>,
|
||||
params: acp::PromptRequest,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<acp::PromptResponse>> {
|
||||
let id = id.expect("UserMessageId is required");
|
||||
let session_id = params.session_id.clone();
|
||||
log::info!("Received prompt request for session: {}", session_id);
|
||||
log::debug!("Prompt blocks count: {}", params.prompt.len());
|
||||
|
||||
self.run_turn(session_id, cx, |thread, cx| {
|
||||
let content: Vec<UserMessageContent> = params
|
||||
.prompt
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect::<Vec<_>>();
|
||||
log::info!("Converted prompt to message: {} chars", content.len());
|
||||
log::debug!("Message id: {:?}", id);
|
||||
log::debug!("Message content: {:?}", content);
|
||||
|
||||
Ok(thread.update(cx, |thread, cx| {
|
||||
log::info!(
|
||||
"Sending message to thread with model: {:?}",
|
||||
thread.model().name()
|
||||
);
|
||||
thread.send(id, content, cx)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn resume(
|
||||
&self,
|
||||
session_id: &acp::SessionId,
|
||||
_cx: &mut App,
|
||||
) -> Option<Rc<dyn acp_thread::AgentSessionResume>> {
|
||||
Some(Rc::new(NativeAgentSessionResume {
|
||||
connection: self.clone(),
|
||||
session_id: session_id.clone(),
|
||||
}) as _)
|
||||
}
|
||||
|
||||
fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) {
|
||||
log::info!("Cancelling on session: {}", session_id);
|
||||
|
@ -768,6 +805,45 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn session_editor(
|
||||
&self,
|
||||
session_id: &agent_client_protocol::SessionId,
|
||||
cx: &mut App,
|
||||
) -> Option<Rc<dyn acp_thread::AgentSessionEditor>> {
|
||||
self.0.update(cx, |agent, _cx| {
|
||||
agent
|
||||
.sessions
|
||||
.get(session_id)
|
||||
.map(|session| Rc::new(NativeAgentSessionEditor(session.thread.clone())) as _)
|
||||
})
|
||||
}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeAgentSessionEditor(Entity<Thread>);
|
||||
|
||||
impl acp_thread::AgentSessionEditor for NativeAgentSessionEditor {
|
||||
fn truncate(&self, message_id: acp_thread::UserMessageId, cx: &mut App) -> Task<Result<()>> {
|
||||
Task::ready(self.0.update(cx, |thread, _cx| thread.truncate(message_id)))
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeAgentSessionResume {
|
||||
connection: NativeAgentConnection,
|
||||
session_id: acp::SessionId,
|
||||
}
|
||||
|
||||
impl acp_thread::AgentSessionResume for NativeAgentSessionResume {
|
||||
fn run(&self, cx: &mut App) -> Task<Result<acp::PromptResponse>> {
|
||||
self.connection
|
||||
.run_turn(self.session_id.clone(), cx, |thread, cx| {
|
||||
thread.update(cx, |thread, cx| thread.resume(cx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
@ -914,11 +990,7 @@ mod tests {
|
|||
// Create a thread/session
|
||||
let acp_thread = cx
|
||||
.update(|cx| {
|
||||
Rc::new(connection.clone()).new_thread(
|
||||
project.clone(),
|
||||
Path::new("/a"),
|
||||
&mut cx.to_async(),
|
||||
)
|
||||
Rc::new(connection.clone()).new_thread(project.clone(), Path::new("/a"), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
@ -935,7 +1007,7 @@ mod tests {
|
|||
agent.read_with(cx, |agent, _| {
|
||||
let session = agent.sessions.get(&session_id).unwrap();
|
||||
session.thread.read_with(cx, |thread, _| {
|
||||
assert_eq!(thread.selected_model.id().0, "fake");
|
||||
assert_eq!(thread.model().id().0, "fake");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
use super::*;
|
||||
use crate::MessageContent;
|
||||
use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelList};
|
||||
use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelList, UserMessageId};
|
||||
use action_log::ActionLog;
|
||||
use agent_client_protocol::{self as acp};
|
||||
use agent_settings::AgentProfileId;
|
||||
|
@ -13,10 +12,11 @@ use gpui::{
|
|||
};
|
||||
use indoc::indoc;
|
||||
use language_model::{
|
||||
LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId,
|
||||
LanguageModelRegistry, LanguageModelToolResult, LanguageModelToolUse, Role, StopReason,
|
||||
fake_provider::FakeLanguageModel,
|
||||
LanguageModel, LanguageModelCompletionEvent, LanguageModelId, LanguageModelRegistry,
|
||||
LanguageModelRequestMessage, LanguageModelToolResult, LanguageModelToolUse, MessageContent,
|
||||
Role, StopReason, fake_provider::FakeLanguageModel,
|
||||
};
|
||||
use pretty_assertions::assert_eq;
|
||||
use project::Project;
|
||||
use prompt_store::ProjectContext;
|
||||
use reqwest_client::ReqwestClient;
|
||||
|
@ -38,15 +38,19 @@ async fn test_echo(cx: &mut TestAppContext) {
|
|||
|
||||
let events = thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send("Testing: Reply with 'Hello'", cx)
|
||||
thread.send(UserMessageId::new(), ["Testing: Reply with 'Hello'"], cx)
|
||||
})
|
||||
.collect()
|
||||
.await;
|
||||
thread.update(cx, |thread, _cx| {
|
||||
assert_eq!(
|
||||
thread.messages().last().unwrap().content,
|
||||
vec![MessageContent::Text("Hello".to_string())]
|
||||
);
|
||||
thread.last_message().unwrap().to_markdown(),
|
||||
indoc! {"
|
||||
## Assistant
|
||||
|
||||
Hello
|
||||
"}
|
||||
)
|
||||
});
|
||||
assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]);
|
||||
}
|
||||
|
@ -59,12 +63,13 @@ async fn test_thinking(cx: &mut TestAppContext) {
|
|||
let events = thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send(
|
||||
indoc! {"
|
||||
UserMessageId::new(),
|
||||
[indoc! {"
|
||||
Testing:
|
||||
|
||||
Generate a thinking step where you just think the word 'Think',
|
||||
and have your final answer be 'Hello'
|
||||
"},
|
||||
"}],
|
||||
cx,
|
||||
)
|
||||
})
|
||||
|
@ -72,9 +77,10 @@ async fn test_thinking(cx: &mut TestAppContext) {
|
|||
.await;
|
||||
thread.update(cx, |thread, _cx| {
|
||||
assert_eq!(
|
||||
thread.messages().last().unwrap().to_markdown(),
|
||||
thread.last_message().unwrap().to_markdown(),
|
||||
indoc! {"
|
||||
## assistant
|
||||
## Assistant
|
||||
|
||||
<think>Think</think>
|
||||
Hello
|
||||
"}
|
||||
|
@ -95,7 +101,9 @@ async fn test_system_prompt(cx: &mut TestAppContext) {
|
|||
|
||||
project_context.borrow_mut().shell = "test-shell".into();
|
||||
thread.update(cx, |thread, _| thread.add_tool(EchoTool));
|
||||
thread.update(cx, |thread, cx| thread.send("abc", cx));
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.send(UserMessageId::new(), ["abc"], cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let mut pending_completions = fake_model.pending_completions();
|
||||
assert_eq!(
|
||||
|
@ -122,6 +130,134 @@ async fn test_system_prompt(cx: &mut TestAppContext) {
|
|||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_prompt_caching(cx: &mut TestAppContext) {
|
||||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
|
||||
let fake_model = model.as_fake();
|
||||
|
||||
// Send initial user message and verify it's cached
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.send(UserMessageId::new(), ["Message 1"], cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let completion = fake_model.pending_completions().pop().unwrap();
|
||||
assert_eq!(
|
||||
completion.messages[1..],
|
||||
vec![LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec!["Message 1".into()],
|
||||
cache: true
|
||||
}]
|
||||
);
|
||||
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Text(
|
||||
"Response to Message 1".into(),
|
||||
));
|
||||
fake_model.end_last_completion_stream();
|
||||
cx.run_until_parked();
|
||||
|
||||
// Send another user message and verify only the latest is cached
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.send(UserMessageId::new(), ["Message 2"], cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let completion = fake_model.pending_completions().pop().unwrap();
|
||||
assert_eq!(
|
||||
completion.messages[1..],
|
||||
vec![
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec!["Message 1".into()],
|
||||
cache: false
|
||||
},
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::Assistant,
|
||||
content: vec!["Response to Message 1".into()],
|
||||
cache: false
|
||||
},
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec!["Message 2".into()],
|
||||
cache: true
|
||||
}
|
||||
]
|
||||
);
|
||||
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Text(
|
||||
"Response to Message 2".into(),
|
||||
));
|
||||
fake_model.end_last_completion_stream();
|
||||
cx.run_until_parked();
|
||||
|
||||
// Simulate a tool call and verify that the latest tool result is cached
|
||||
thread.update(cx, |thread, _| thread.add_tool(EchoTool));
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.send(UserMessageId::new(), ["Use the echo tool"], cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let tool_use = LanguageModelToolUse {
|
||||
id: "tool_1".into(),
|
||||
name: EchoTool.name().into(),
|
||||
raw_input: json!({"text": "test"}).to_string(),
|
||||
input: json!({"text": "test"}),
|
||||
is_input_complete: true,
|
||||
};
|
||||
fake_model
|
||||
.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone()));
|
||||
fake_model.end_last_completion_stream();
|
||||
cx.run_until_parked();
|
||||
|
||||
let completion = fake_model.pending_completions().pop().unwrap();
|
||||
let tool_result = LanguageModelToolResult {
|
||||
tool_use_id: "tool_1".into(),
|
||||
tool_name: EchoTool.name().into(),
|
||||
is_error: false,
|
||||
content: "test".into(),
|
||||
output: Some("test".into()),
|
||||
};
|
||||
assert_eq!(
|
||||
completion.messages[1..],
|
||||
vec![
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec!["Message 1".into()],
|
||||
cache: false
|
||||
},
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::Assistant,
|
||||
content: vec!["Response to Message 1".into()],
|
||||
cache: false
|
||||
},
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec!["Message 2".into()],
|
||||
cache: false
|
||||
},
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::Assistant,
|
||||
content: vec!["Response to Message 2".into()],
|
||||
cache: false
|
||||
},
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec!["Use the echo tool".into()],
|
||||
cache: false
|
||||
},
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::Assistant,
|
||||
content: vec![MessageContent::ToolUse(tool_use)],
|
||||
cache: false
|
||||
},
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec![MessageContent::ToolResult(tool_result)],
|
||||
cache: true
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[ignore = "can't run on CI yet"]
|
||||
async fn test_basic_tool_calls(cx: &mut TestAppContext) {
|
||||
|
@ -132,7 +268,8 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) {
|
|||
.update(cx, |thread, cx| {
|
||||
thread.add_tool(EchoTool);
|
||||
thread.send(
|
||||
"Now test the echo tool with 'Hello'. Does it work? Say 'Yes' or 'No'.",
|
||||
UserMessageId::new(),
|
||||
["Now test the echo tool with 'Hello'. Does it work? Say 'Yes' or 'No'."],
|
||||
cx,
|
||||
)
|
||||
})
|
||||
|
@ -146,7 +283,11 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) {
|
|||
thread.remove_tool(&AgentTool::name(&EchoTool));
|
||||
thread.add_tool(DelayTool);
|
||||
thread.send(
|
||||
"Now call the delay tool with 200ms. When the timer goes off, then you echo the output of the tool.",
|
||||
UserMessageId::new(),
|
||||
[
|
||||
"Now call the delay tool with 200ms.",
|
||||
"When the timer goes off, then you echo the output of the tool.",
|
||||
],
|
||||
cx,
|
||||
)
|
||||
})
|
||||
|
@ -156,13 +297,14 @@ async fn test_basic_tool_calls(cx: &mut TestAppContext) {
|
|||
thread.update(cx, |thread, _cx| {
|
||||
assert!(
|
||||
thread
|
||||
.messages()
|
||||
.last()
|
||||
.last_message()
|
||||
.unwrap()
|
||||
.as_agent_message()
|
||||
.unwrap()
|
||||
.content
|
||||
.iter()
|
||||
.any(|content| {
|
||||
if let MessageContent::Text(text) = content {
|
||||
if let AgentMessageContent::Text(text) = content {
|
||||
text.contains("Ding")
|
||||
} else {
|
||||
false
|
||||
|
@ -182,7 +324,7 @@ async fn test_streaming_tool_calls(cx: &mut TestAppContext) {
|
|||
// Test a tool call that's likely to complete *before* streaming stops.
|
||||
let mut events = thread.update(cx, |thread, cx| {
|
||||
thread.add_tool(WordListTool);
|
||||
thread.send("Test the word_list tool.", cx)
|
||||
thread.send(UserMessageId::new(), ["Test the word_list tool."], cx)
|
||||
});
|
||||
|
||||
let mut saw_partial_tool_use = false;
|
||||
|
@ -190,8 +332,10 @@ async fn test_streaming_tool_calls(cx: &mut TestAppContext) {
|
|||
if let Ok(AgentResponseEvent::ToolCall(tool_call)) = event {
|
||||
thread.update(cx, |thread, _cx| {
|
||||
// Look for a tool use in the thread's last message
|
||||
let last_content = thread.messages().last().unwrap().content.last().unwrap();
|
||||
if let MessageContent::ToolUse(last_tool_use) = last_content {
|
||||
let message = thread.last_message().unwrap();
|
||||
let agent_message = message.as_agent_message().unwrap();
|
||||
let last_content = agent_message.content.last().unwrap();
|
||||
if let AgentMessageContent::ToolUse(last_tool_use) = last_content {
|
||||
assert_eq!(last_tool_use.name.as_ref(), "word_list");
|
||||
if tool_call.status == acp::ToolCallStatus::Pending {
|
||||
if !last_tool_use.is_input_complete
|
||||
|
@ -229,7 +373,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
|
|||
|
||||
let mut events = thread.update(cx, |thread, cx| {
|
||||
thread.add_tool(ToolRequiringPermission);
|
||||
thread.send("abc", cx)
|
||||
thread.send(UserMessageId::new(), ["abc"], cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
|
||||
|
@ -357,7 +501,9 @@ async fn test_tool_hallucination(cx: &mut TestAppContext) {
|
|||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
|
||||
let fake_model = model.as_fake();
|
||||
|
||||
let mut events = thread.update(cx, |thread, cx| thread.send("abc", cx));
|
||||
let mut events = thread.update(cx, |thread, cx| {
|
||||
thread.send(UserMessageId::new(), ["abc"], cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
|
||||
LanguageModelToolUse {
|
||||
|
@ -377,8 +523,194 @@ async fn test_tool_hallucination(cx: &mut TestAppContext) {
|
|||
assert_eq!(update.fields.status, Some(acp::ToolCallStatus::Failed));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_resume_after_tool_use_limit(cx: &mut TestAppContext) {
|
||||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
|
||||
let fake_model = model.as_fake();
|
||||
|
||||
let events = thread.update(cx, |thread, cx| {
|
||||
thread.add_tool(EchoTool);
|
||||
thread.send(UserMessageId::new(), ["abc"], cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let tool_use = LanguageModelToolUse {
|
||||
id: "tool_id_1".into(),
|
||||
name: EchoTool.name().into(),
|
||||
raw_input: "{}".into(),
|
||||
input: serde_json::to_value(&EchoToolInput { text: "def".into() }).unwrap(),
|
||||
is_input_complete: true,
|
||||
};
|
||||
fake_model
|
||||
.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone()));
|
||||
fake_model.end_last_completion_stream();
|
||||
|
||||
cx.run_until_parked();
|
||||
let completion = fake_model.pending_completions().pop().unwrap();
|
||||
let tool_result = LanguageModelToolResult {
|
||||
tool_use_id: "tool_id_1".into(),
|
||||
tool_name: EchoTool.name().into(),
|
||||
is_error: false,
|
||||
content: "def".into(),
|
||||
output: Some("def".into()),
|
||||
};
|
||||
assert_eq!(
|
||||
completion.messages[1..],
|
||||
vec![
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec!["abc".into()],
|
||||
cache: false
|
||||
},
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::Assistant,
|
||||
content: vec![MessageContent::ToolUse(tool_use.clone())],
|
||||
cache: false
|
||||
},
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec![MessageContent::ToolResult(tool_result.clone())],
|
||||
cache: true
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// Simulate reaching tool use limit.
|
||||
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::StatusUpdate(
|
||||
cloud_llm_client::CompletionRequestStatus::ToolUseLimitReached,
|
||||
));
|
||||
fake_model.end_last_completion_stream();
|
||||
let last_event = events.collect::<Vec<_>>().await.pop().unwrap();
|
||||
assert!(
|
||||
last_event
|
||||
.unwrap_err()
|
||||
.is::<language_model::ToolUseLimitReachedError>()
|
||||
);
|
||||
|
||||
let events = thread.update(cx, |thread, cx| thread.resume(cx)).unwrap();
|
||||
cx.run_until_parked();
|
||||
let completion = fake_model.pending_completions().pop().unwrap();
|
||||
assert_eq!(
|
||||
completion.messages[1..],
|
||||
vec![
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec!["abc".into()],
|
||||
cache: false
|
||||
},
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::Assistant,
|
||||
content: vec![MessageContent::ToolUse(tool_use)],
|
||||
cache: false
|
||||
},
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec![MessageContent::ToolResult(tool_result)],
|
||||
cache: false
|
||||
},
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec!["Continue where you left off".into()],
|
||||
cache: true
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::Text("Done".into()));
|
||||
fake_model.end_last_completion_stream();
|
||||
events.collect::<Vec<_>>().await;
|
||||
thread.read_with(cx, |thread, _cx| {
|
||||
assert_eq!(
|
||||
thread.last_message().unwrap().to_markdown(),
|
||||
indoc! {"
|
||||
## Assistant
|
||||
|
||||
Done
|
||||
"}
|
||||
)
|
||||
});
|
||||
|
||||
// Ensure we error if calling resume when tool use limit was *not* reached.
|
||||
let error = thread
|
||||
.update(cx, |thread, cx| thread.resume(cx))
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"can only resume after tool use limit is reached"
|
||||
)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_send_after_tool_use_limit(cx: &mut TestAppContext) {
|
||||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
|
||||
let fake_model = model.as_fake();
|
||||
|
||||
let events = thread.update(cx, |thread, cx| {
|
||||
thread.add_tool(EchoTool);
|
||||
thread.send(UserMessageId::new(), ["abc"], cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let tool_use = LanguageModelToolUse {
|
||||
id: "tool_id_1".into(),
|
||||
name: EchoTool.name().into(),
|
||||
raw_input: "{}".into(),
|
||||
input: serde_json::to_value(&EchoToolInput { text: "def".into() }).unwrap(),
|
||||
is_input_complete: true,
|
||||
};
|
||||
let tool_result = LanguageModelToolResult {
|
||||
tool_use_id: "tool_id_1".into(),
|
||||
tool_name: EchoTool.name().into(),
|
||||
is_error: false,
|
||||
content: "def".into(),
|
||||
output: Some("def".into()),
|
||||
};
|
||||
fake_model
|
||||
.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(tool_use.clone()));
|
||||
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::StatusUpdate(
|
||||
cloud_llm_client::CompletionRequestStatus::ToolUseLimitReached,
|
||||
));
|
||||
fake_model.end_last_completion_stream();
|
||||
let last_event = events.collect::<Vec<_>>().await.pop().unwrap();
|
||||
assert!(
|
||||
last_event
|
||||
.unwrap_err()
|
||||
.is::<language_model::ToolUseLimitReachedError>()
|
||||
);
|
||||
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.send(UserMessageId::new(), vec!["ghi"], cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let completion = fake_model.pending_completions().pop().unwrap();
|
||||
assert_eq!(
|
||||
completion.messages[1..],
|
||||
vec![
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec!["abc".into()],
|
||||
cache: false
|
||||
},
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::Assistant,
|
||||
content: vec![MessageContent::ToolUse(tool_use)],
|
||||
cache: false
|
||||
},
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec![MessageContent::ToolResult(tool_result)],
|
||||
cache: false
|
||||
},
|
||||
LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec!["ghi".into()],
|
||||
cache: true
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async fn expect_tool_call(
|
||||
events: &mut UnboundedReceiver<Result<AgentResponseEvent, LanguageModelCompletionError>>,
|
||||
events: &mut UnboundedReceiver<Result<AgentResponseEvent>>,
|
||||
) -> acp::ToolCall {
|
||||
let event = events
|
||||
.next()
|
||||
|
@ -394,7 +726,7 @@ async fn expect_tool_call(
|
|||
}
|
||||
|
||||
async fn expect_tool_call_update_fields(
|
||||
events: &mut UnboundedReceiver<Result<AgentResponseEvent, LanguageModelCompletionError>>,
|
||||
events: &mut UnboundedReceiver<Result<AgentResponseEvent>>,
|
||||
) -> acp::ToolCallUpdate {
|
||||
let event = events
|
||||
.next()
|
||||
|
@ -412,7 +744,7 @@ async fn expect_tool_call_update_fields(
|
|||
}
|
||||
|
||||
async fn next_tool_call_authorization(
|
||||
events: &mut UnboundedReceiver<Result<AgentResponseEvent, LanguageModelCompletionError>>,
|
||||
events: &mut UnboundedReceiver<Result<AgentResponseEvent>>,
|
||||
) -> ToolCallAuthorization {
|
||||
loop {
|
||||
let event = events
|
||||
|
@ -449,7 +781,12 @@ async fn test_concurrent_tool_calls(cx: &mut TestAppContext) {
|
|||
.update(cx, |thread, cx| {
|
||||
thread.add_tool(DelayTool);
|
||||
thread.send(
|
||||
"Call the delay tool twice in the same message. Once with 100ms. Once with 300ms. When both timers are complete, describe the outputs.",
|
||||
UserMessageId::new(),
|
||||
[
|
||||
"Call the delay tool twice in the same message.",
|
||||
"Once with 100ms. Once with 300ms.",
|
||||
"When both timers are complete, describe the outputs.",
|
||||
],
|
||||
cx,
|
||||
)
|
||||
})
|
||||
|
@ -460,12 +797,13 @@ async fn test_concurrent_tool_calls(cx: &mut TestAppContext) {
|
|||
assert_eq!(stop_reasons, vec![acp::StopReason::EndTurn]);
|
||||
|
||||
thread.update(cx, |thread, _cx| {
|
||||
let last_message = thread.messages().last().unwrap();
|
||||
let text = last_message
|
||||
let last_message = thread.last_message().unwrap();
|
||||
let agent_message = last_message.as_agent_message().unwrap();
|
||||
let text = agent_message
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|content| {
|
||||
if let MessageContent::Text(text) = content {
|
||||
if let AgentMessageContent::Text(text) = content {
|
||||
Some(text.as_str())
|
||||
} else {
|
||||
None
|
||||
|
@ -521,7 +859,7 @@ async fn test_profiles(cx: &mut TestAppContext) {
|
|||
// Test that test-1 profile (default) has echo and delay tools
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.set_profile(AgentProfileId("test-1".into()));
|
||||
thread.send("test", cx);
|
||||
thread.send(UserMessageId::new(), ["test"], cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
|
@ -539,7 +877,7 @@ async fn test_profiles(cx: &mut TestAppContext) {
|
|||
// Switch to test-2 profile, and verify that it has only the infinite tool.
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.set_profile(AgentProfileId("test-2".into()));
|
||||
thread.send("test2", cx)
|
||||
thread.send(UserMessageId::new(), ["test2"], cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let mut pending_completions = fake_model.pending_completions();
|
||||
|
@ -562,7 +900,8 @@ async fn test_cancellation(cx: &mut TestAppContext) {
|
|||
thread.add_tool(InfiniteTool);
|
||||
thread.add_tool(EchoTool);
|
||||
thread.send(
|
||||
"Call the echo tool and then call the infinite tool, then explain their output",
|
||||
UserMessageId::new(),
|
||||
["Call the echo tool, then call the infinite tool, then explain their output"],
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
@ -607,14 +946,20 @@ async fn test_cancellation(cx: &mut TestAppContext) {
|
|||
// Ensure we can still send a new message after cancellation.
|
||||
let events = thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.send("Testing: reply with 'Hello' then stop.", cx)
|
||||
thread.send(
|
||||
UserMessageId::new(),
|
||||
["Testing: reply with 'Hello' then stop."],
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
thread.update(cx, |thread, _cx| {
|
||||
let message = thread.last_message().unwrap();
|
||||
let agent_message = message.as_agent_message().unwrap();
|
||||
assert_eq!(
|
||||
thread.messages().last().unwrap().content,
|
||||
vec![MessageContent::Text("Hello".to_string())]
|
||||
agent_message.content,
|
||||
vec![AgentMessageContent::Text("Hello".to_string())]
|
||||
);
|
||||
});
|
||||
assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]);
|
||||
|
@ -625,13 +970,16 @@ async fn test_refusal(cx: &mut TestAppContext) {
|
|||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
|
||||
let fake_model = model.as_fake();
|
||||
|
||||
let events = thread.update(cx, |thread, cx| thread.send("Hello", cx));
|
||||
let events = thread.update(cx, |thread, cx| {
|
||||
thread.send(UserMessageId::new(), ["Hello"], cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert_eq!(
|
||||
thread.to_markdown(),
|
||||
indoc! {"
|
||||
## user
|
||||
## User
|
||||
|
||||
Hello
|
||||
"}
|
||||
);
|
||||
|
@ -643,9 +991,12 @@ async fn test_refusal(cx: &mut TestAppContext) {
|
|||
assert_eq!(
|
||||
thread.to_markdown(),
|
||||
indoc! {"
|
||||
## user
|
||||
## User
|
||||
|
||||
Hello
|
||||
## assistant
|
||||
|
||||
## Assistant
|
||||
|
||||
Hey!
|
||||
"}
|
||||
);
|
||||
|
@ -661,6 +1012,85 @@ async fn test_refusal(cx: &mut TestAppContext) {
|
|||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_truncate(cx: &mut TestAppContext) {
|
||||
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
|
||||
let fake_model = model.as_fake();
|
||||
|
||||
let message_id = UserMessageId::new();
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.send(message_id.clone(), ["Hello"], cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert_eq!(
|
||||
thread.to_markdown(),
|
||||
indoc! {"
|
||||
## User
|
||||
|
||||
Hello
|
||||
"}
|
||||
);
|
||||
});
|
||||
|
||||
fake_model.send_last_completion_stream_text_chunk("Hey!");
|
||||
cx.run_until_parked();
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert_eq!(
|
||||
thread.to_markdown(),
|
||||
indoc! {"
|
||||
## User
|
||||
|
||||
Hello
|
||||
|
||||
## Assistant
|
||||
|
||||
Hey!
|
||||
"}
|
||||
);
|
||||
});
|
||||
|
||||
thread
|
||||
.update(cx, |thread, _cx| thread.truncate(message_id))
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert_eq!(thread.to_markdown(), "");
|
||||
});
|
||||
|
||||
// Ensure we can still send a new message after truncation.
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.send(UserMessageId::new(), ["Hi"], cx)
|
||||
});
|
||||
thread.update(cx, |thread, _cx| {
|
||||
assert_eq!(
|
||||
thread.to_markdown(),
|
||||
indoc! {"
|
||||
## User
|
||||
|
||||
Hi
|
||||
"}
|
||||
);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
fake_model.send_last_completion_stream_text_chunk("Ahoy!");
|
||||
cx.run_until_parked();
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert_eq!(
|
||||
thread.to_markdown(),
|
||||
indoc! {"
|
||||
## User
|
||||
|
||||
Hi
|
||||
|
||||
## Assistant
|
||||
|
||||
Ahoy!
|
||||
"}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_agent_connection(cx: &mut TestAppContext) {
|
||||
cx.update(settings::init);
|
||||
|
@ -726,7 +1156,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
|
|||
// Create a thread using new_thread
|
||||
let connection_rc = Rc::new(connection.clone());
|
||||
let acp_thread = cx
|
||||
.update(|cx| connection_rc.new_thread(project, cwd, &mut cx.to_async()))
|
||||
.update(|cx| connection_rc.new_thread(project, cwd, cx))
|
||||
.await
|
||||
.expect("new_thread should succeed");
|
||||
|
||||
|
@ -774,6 +1204,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
|
|||
let result = cx
|
||||
.update(|cx| {
|
||||
connection.prompt(
|
||||
Some(acp_thread::UserMessageId::new()),
|
||||
acp::PromptRequest {
|
||||
session_id: session_id.clone(),
|
||||
prompt: vec!["ghi".into()],
|
||||
|
@ -796,7 +1227,9 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
|
|||
thread.update(cx, |thread, _cx| thread.add_tool(ThinkingTool));
|
||||
let fake_model = model.as_fake();
|
||||
|
||||
let mut events = thread.update(cx, |thread, cx| thread.send("Think", cx));
|
||||
let mut events = thread.update(cx, |thread, cx| {
|
||||
thread.send(UserMessageId::new(), ["Think"], cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// Simulate streaming partial input.
|
||||
|
@ -889,9 +1322,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
|
|||
}
|
||||
|
||||
/// Filters out the stop events for asserting against in tests
|
||||
fn stop_events(
|
||||
result_events: Vec<Result<AgentResponseEvent, LanguageModelCompletionError>>,
|
||||
) -> Vec<acp::StopReason> {
|
||||
fn stop_events(result_events: Vec<Result<AgentResponseEvent>>) -> Vec<acp::StopReason> {
|
||||
result_events
|
||||
.into_iter()
|
||||
.filter_map(|event| match event.unwrap() {
|
||||
|
|
|
@ -7,7 +7,7 @@ use std::future;
|
|||
#[derive(JsonSchema, Serialize, Deserialize)]
|
||||
pub struct EchoToolInput {
|
||||
/// The text to echo.
|
||||
text: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
pub struct EchoTool;
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -241,7 +241,7 @@ impl AgentTool for EditFileTool {
|
|||
thread.build_completion_request(CompletionIntent::ToolResults, cx)
|
||||
});
|
||||
let thread = self.thread.read(cx);
|
||||
let model = thread.selected_model.clone();
|
||||
let model = thread.model().clone();
|
||||
let action_log = thread.action_log().clone();
|
||||
|
||||
let authorize = self.authorize(&input, &event_stream, cx);
|
||||
|
|
|
@ -5,7 +5,9 @@ use agent_client_protocol as acp;
|
|||
use anyhow::{Result, anyhow};
|
||||
use cloud_llm_client::WebSearchResponse;
|
||||
use gpui::{App, AppContext, Task};
|
||||
use language_model::LanguageModelToolResultContent;
|
||||
use language_model::{
|
||||
LanguageModelProviderId, LanguageModelToolResultContent, ZED_CLOUD_PROVIDER_ID,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ui::prelude::*;
|
||||
|
@ -50,6 +52,11 @@ impl AgentTool for WebSearchTool {
|
|||
"Searching the Web".into()
|
||||
}
|
||||
|
||||
/// We currently only support Zed Cloud as a provider.
|
||||
fn supported_provider(&self, provider: &LanguageModelProviderId) -> bool {
|
||||
provider == &ZED_CLOUD_PROVIDER_ID
|
||||
}
|
||||
|
||||
fn run(
|
||||
self: Arc<Self>,
|
||||
input: Self::Input,
|
||||
|
|
|
@ -5,7 +5,7 @@ use anyhow::{Context as _, Result, anyhow};
|
|||
use futures::channel::oneshot;
|
||||
use gpui::{AppContext as _, AsyncApp, Entity, Task, WeakEntity};
|
||||
use project::Project;
|
||||
use std::{cell::RefCell, path::Path, rc::Rc};
|
||||
use std::{any::Any, cell::RefCell, path::Path, rc::Rc};
|
||||
use ui::App;
|
||||
use util::ResultExt as _;
|
||||
|
||||
|
@ -423,7 +423,7 @@ impl AgentConnection for AcpConnection {
|
|||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
_cwd: &Path,
|
||||
cx: &mut AsyncApp,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>> {
|
||||
let task = self.connection.request_any(
|
||||
acp_old::InitializeParams {
|
||||
|
@ -467,6 +467,7 @@ impl AgentConnection for AcpConnection {
|
|||
|
||||
fn prompt(
|
||||
&self,
|
||||
_id: Option<acp_thread::UserMessageId>,
|
||||
params: acp::PromptRequest,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<acp::PromptResponse>> {
|
||||
|
@ -506,4 +507,8 @@ impl AgentConnection for AcpConnection {
|
|||
})
|
||||
.detach_and_log_err(cx)
|
||||
}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
use agent_client_protocol::{self as acp, Agent as _};
|
||||
use anyhow::anyhow;
|
||||
use collections::HashMap;
|
||||
use futures::AsyncBufReadExt as _;
|
||||
use futures::channel::oneshot;
|
||||
use futures::io::BufReader;
|
||||
use project::Project;
|
||||
use std::cell::RefCell;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use std::{any::Any, cell::RefCell};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity};
|
||||
|
@ -40,12 +42,13 @@ impl AcpConnection {
|
|||
.current_dir(root_dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
let stdout = child.stdout.take().expect("Failed to take stdout");
|
||||
let stdin = child.stdin.take().expect("Failed to take stdin");
|
||||
let stdout = child.stdout.take().context("Failed to take stdout")?;
|
||||
let stdin = child.stdin.take().context("Failed to take stdin")?;
|
||||
let stderr = child.stderr.take().context("Failed to take stderr")?;
|
||||
log::trace!("Spawned (pid: {})", child.id());
|
||||
|
||||
let sessions = Rc::new(RefCell::new(HashMap::default()));
|
||||
|
@ -63,6 +66,18 @@ impl AcpConnection {
|
|||
|
||||
let io_task = cx.background_spawn(io_task);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut stderr = BufReader::new(stderr);
|
||||
let mut line = String::new();
|
||||
while let Ok(n) = stderr.read_line(&mut line).await
|
||||
&& n > 0
|
||||
{
|
||||
log::warn!("agent stderr: {}", &line);
|
||||
line.clear();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.spawn({
|
||||
let sessions = sessions.clone();
|
||||
async move |cx| {
|
||||
|
@ -111,7 +126,7 @@ impl AgentConnection for AcpConnection {
|
|||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
cwd: &Path,
|
||||
cx: &mut AsyncApp,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>> {
|
||||
let conn = self.connection.clone();
|
||||
let sessions = self.sessions.clone();
|
||||
|
@ -171,6 +186,7 @@ impl AgentConnection for AcpConnection {
|
|||
|
||||
fn prompt(
|
||||
&self,
|
||||
_id: Option<acp_thread::UserMessageId>,
|
||||
params: acp::PromptRequest,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<acp::PromptResponse>> {
|
||||
|
@ -190,6 +206,10 @@ impl AgentConnection for AcpConnection {
|
|||
.spawn(async move { conn.cancel(params).await })
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct ClientDelegate {
|
||||
|
|
|
@ -6,6 +6,7 @@ use context_server::listener::McpServerTool;
|
|||
use project::Project;
|
||||
use settings::SettingsStore;
|
||||
use smol::process::Child;
|
||||
use std::any::Any;
|
||||
use std::cell::RefCell;
|
||||
use std::fmt::Display;
|
||||
use std::path::Path;
|
||||
|
@ -13,7 +14,7 @@ use std::rc::Rc;
|
|||
use uuid::Uuid;
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::{Result, anyhow};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use futures::channel::oneshot;
|
||||
use futures::{AsyncBufReadExt, AsyncWriteExt};
|
||||
use futures::{
|
||||
|
@ -74,7 +75,7 @@ impl AgentConnection for ClaudeAgentConnection {
|
|||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
cwd: &Path,
|
||||
cx: &mut AsyncApp,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<AcpThread>>> {
|
||||
let cwd = cwd.to_owned();
|
||||
cx.spawn(async move |cx| {
|
||||
|
@ -129,12 +130,25 @@ impl AgentConnection for ClaudeAgentConnection {
|
|||
&cwd,
|
||||
)?;
|
||||
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let stdout = child.stdout.take().context("Failed to take stdout")?;
|
||||
let stdin = child.stdin.take().context("Failed to take stdin")?;
|
||||
let stderr = child.stderr.take().context("Failed to take stderr")?;
|
||||
|
||||
let pid = child.id();
|
||||
log::trace!("Spawned (pid: {})", pid);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut stderr = BufReader::new(stderr);
|
||||
let mut line = String::new();
|
||||
while let Ok(n) = stderr.read_line(&mut line).await
|
||||
&& n > 0
|
||||
{
|
||||
log::warn!("agent stderr: {}", &line);
|
||||
line.clear();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let mut outgoing_rx = Some(outgoing_rx);
|
||||
|
||||
|
@ -210,6 +224,7 @@ impl AgentConnection for ClaudeAgentConnection {
|
|||
|
||||
fn prompt(
|
||||
&self,
|
||||
_id: Option<acp_thread::UserMessageId>,
|
||||
params: acp::PromptRequest,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<acp::PromptResponse>> {
|
||||
|
@ -288,6 +303,10 @@ impl AgentConnection for ClaudeAgentConnection {
|
|||
})
|
||||
.log_err();
|
||||
}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
|
@ -339,7 +358,7 @@ fn spawn_claude(
|
|||
.current_dir(root_dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
|
@ -423,7 +442,7 @@ impl ClaudeAgentSession {
|
|||
if !turn_state.borrow().is_cancelled() {
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.push_user_content_block(text.into(), cx)
|
||||
thread.push_user_content_block(None, text.into(), cx)
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
|
|
|
@ -422,8 +422,8 @@ pub async fn new_test_thread(
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
let thread = connection
|
||||
.new_thread(project.clone(), current_dir.as_ref(), &mut cx.to_async())
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_thread(project.clone(), current_dir.as_ref(), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
|
@ -93,6 +93,7 @@ time.workspace = true
|
|||
time_format.workspace = true
|
||||
ui.workspace = true
|
||||
ui_input.workspace = true
|
||||
url.workspace = true
|
||||
urlencoding.workspace = true
|
||||
util.workspace = true
|
||||
uuid.workspace = true
|
||||
|
@ -102,6 +103,9 @@ workspace.workspace = true
|
|||
zed_actions.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
acp_thread = { workspace = true, features = ["test-support"] }
|
||||
agent = { workspace = true, features = ["test-support"] }
|
||||
assistant_context = { workspace = true, features = ["test-support"] }
|
||||
assistant_tools.workspace = true
|
||||
buffer_diff = { workspace = true, features = ["test-support"] }
|
||||
editor = { workspace = true, features = ["test-support"] }
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
mod completion_provider;
|
||||
mod message_history;
|
||||
mod entry_view_state;
|
||||
mod message_editor;
|
||||
mod model_selector;
|
||||
mod model_selector_popover;
|
||||
mod thread_view;
|
||||
|
||||
pub use message_history::MessageHistory;
|
||||
pub use model_selector::AcpModelSelector;
|
||||
pub use model_selector_popover::AcpModelSelectorPopover;
|
||||
pub use thread_view::AcpThreadView;
|
||||
|
|
File diff suppressed because it is too large
Load diff
351
crates/agent_ui/src/acp/entry_view_state.rs
Normal file
351
crates/agent_ui/src/acp/entry_view_state.rs
Normal file
|
@ -0,0 +1,351 @@
|
|||
use std::{collections::HashMap, ops::Range};
|
||||
|
||||
use acp_thread::AcpThread;
|
||||
use editor::{Editor, EditorMode, MinimapVisibility, MultiBuffer};
|
||||
use gpui::{
|
||||
AnyEntity, App, AppContext as _, Entity, EntityId, TextStyleRefinement, WeakEntity, Window,
|
||||
};
|
||||
use language::language_settings::SoftWrap;
|
||||
use settings::Settings as _;
|
||||
use terminal_view::TerminalView;
|
||||
use theme::ThemeSettings;
|
||||
use ui::TextSize;
|
||||
use workspace::Workspace;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct EntryViewState {
|
||||
entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
impl EntryViewState {
|
||||
pub fn entry(&self, index: usize) -> Option<&Entry> {
|
||||
self.entries.get(index)
|
||||
}
|
||||
|
||||
pub fn sync_entry(
|
||||
&mut self,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
thread: Entity<AcpThread>,
|
||||
index: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
debug_assert!(index <= self.entries.len());
|
||||
let entry = if let Some(entry) = self.entries.get_mut(index) {
|
||||
entry
|
||||
} else {
|
||||
self.entries.push(Entry::default());
|
||||
self.entries.last_mut().unwrap()
|
||||
};
|
||||
|
||||
entry.sync_diff_multibuffers(&thread, index, window, cx);
|
||||
entry.sync_terminals(&workspace, &thread, index, window, cx);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, range: Range<usize>) {
|
||||
self.entries.drain(range);
|
||||
}
|
||||
|
||||
pub fn settings_changed(&mut self, cx: &mut App) {
|
||||
for entry in self.entries.iter() {
|
||||
for view in entry.views.values() {
|
||||
if let Ok(diff_editor) = view.clone().downcast::<Editor>() {
|
||||
diff_editor.update(cx, |diff_editor, cx| {
|
||||
diff_editor
|
||||
.set_text_style_refinement(diff_editor_text_style_refinement(cx));
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Entry {
|
||||
views: HashMap<EntityId, AnyEntity>,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
pub fn editor_for_diff(&self, diff: &Entity<MultiBuffer>) -> Option<Entity<Editor>> {
|
||||
self.views
|
||||
.get(&diff.entity_id())
|
||||
.cloned()
|
||||
.map(|entity| entity.downcast::<Editor>().unwrap())
|
||||
}
|
||||
|
||||
pub fn terminal(
|
||||
&self,
|
||||
terminal: &Entity<acp_thread::Terminal>,
|
||||
) -> Option<Entity<TerminalView>> {
|
||||
self.views
|
||||
.get(&terminal.entity_id())
|
||||
.cloned()
|
||||
.map(|entity| entity.downcast::<TerminalView>().unwrap())
|
||||
}
|
||||
|
||||
fn sync_diff_multibuffers(
|
||||
&mut self,
|
||||
thread: &Entity<AcpThread>,
|
||||
index: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let Some(entry) = thread.read(cx).entries().get(index) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let multibuffers = entry
|
||||
.diffs()
|
||||
.map(|diff| diff.read(cx).multibuffer().clone());
|
||||
|
||||
let multibuffers = multibuffers.collect::<Vec<_>>();
|
||||
|
||||
for multibuffer in multibuffers {
|
||||
if self.views.contains_key(&multibuffer.entity_id()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let editor = cx.new(|cx| {
|
||||
let mut editor = Editor::new(
|
||||
EditorMode::Full {
|
||||
scale_ui_elements_with_buffer_font_size: false,
|
||||
show_active_line_background: false,
|
||||
sized_by_content: true,
|
||||
},
|
||||
multibuffer.clone(),
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
editor.set_show_gutter(false, cx);
|
||||
editor.disable_inline_diagnostics();
|
||||
editor.disable_expand_excerpt_buttons(cx);
|
||||
editor.set_show_vertical_scrollbar(false, cx);
|
||||
editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
|
||||
editor.set_soft_wrap_mode(SoftWrap::None, cx);
|
||||
editor.scroll_manager.set_forbid_vertical_scroll(true);
|
||||
editor.set_show_indent_guides(false, cx);
|
||||
editor.set_read_only(true);
|
||||
editor.set_show_breakpoints(false, cx);
|
||||
editor.set_show_code_actions(false, cx);
|
||||
editor.set_show_git_diff_gutter(false, cx);
|
||||
editor.set_expand_all_diff_hunks(cx);
|
||||
editor.set_text_style_refinement(diff_editor_text_style_refinement(cx));
|
||||
editor
|
||||
});
|
||||
|
||||
let entity_id = multibuffer.entity_id();
|
||||
self.views.insert(entity_id, editor.into_any());
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_terminals(
|
||||
&mut self,
|
||||
workspace: &WeakEntity<Workspace>,
|
||||
thread: &Entity<AcpThread>,
|
||||
index: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let Some(entry) = thread.read(cx).entries().get(index) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let terminals = entry
|
||||
.terminals()
|
||||
.map(|terminal| terminal.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for terminal in terminals {
|
||||
if self.views.contains_key(&terminal.entity_id()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(strong_workspace) = workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let terminal_view = cx.new(|cx| {
|
||||
let mut view = TerminalView::new(
|
||||
terminal.read(cx).inner().clone(),
|
||||
workspace.clone(),
|
||||
None,
|
||||
strong_workspace.read(cx).project().downgrade(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
view.set_embedded_mode(Some(1000), cx);
|
||||
view
|
||||
});
|
||||
|
||||
let entity_id = terminal.entity_id();
|
||||
self.views.insert(entity_id, terminal_view.into_any());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.views.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_editor_text_style_refinement(cx: &mut App) -> TextStyleRefinement {
|
||||
TextStyleRefinement {
|
||||
font_size: Some(
|
||||
TextSize::Small
|
||||
.rems(cx)
|
||||
.to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
|
||||
.into(),
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Entry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// Avoid allocating in the heap by default
|
||||
views: HashMap::with_capacity(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{path::Path, rc::Rc};
|
||||
|
||||
use acp_thread::{AgentConnection, StubAgentConnection};
|
||||
use agent_client_protocol as acp;
|
||||
use agent_settings::AgentSettings;
|
||||
use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
|
||||
use editor::{EditorSettings, RowInfo};
|
||||
use fs::FakeFs;
|
||||
use gpui::{SemanticVersion, TestAppContext};
|
||||
use multi_buffer::MultiBufferRow;
|
||||
use pretty_assertions::assert_matches;
|
||||
use project::Project;
|
||||
use serde_json::json;
|
||||
use settings::{Settings as _, SettingsStore};
|
||||
use theme::ThemeSettings;
|
||||
use util::path;
|
||||
use workspace::Workspace;
|
||||
|
||||
use crate::acp::entry_view_state::EntryViewState;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_diff_sync(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
"/project",
|
||||
json!({
|
||||
"hello.txt": "hi world"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
|
||||
|
||||
let (workspace, cx) =
|
||||
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
|
||||
let tool_call = acp::ToolCall {
|
||||
id: acp::ToolCallId("tool".into()),
|
||||
title: "Tool call".into(),
|
||||
kind: acp::ToolKind::Other,
|
||||
status: acp::ToolCallStatus::InProgress,
|
||||
content: vec![acp::ToolCallContent::Diff {
|
||||
diff: acp::Diff {
|
||||
path: "/project/hello.txt".into(),
|
||||
old_text: Some("hi world".into()),
|
||||
new_text: "hello world".into(),
|
||||
},
|
||||
}],
|
||||
locations: vec![],
|
||||
raw_input: None,
|
||||
raw_output: None,
|
||||
};
|
||||
let connection = Rc::new(StubAgentConnection::new());
|
||||
let thread = cx
|
||||
.update(|_, cx| {
|
||||
connection
|
||||
.clone()
|
||||
.new_thread(project, Path::new(path!("/project")), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let session_id = thread.update(cx, |thread, _| thread.session_id().clone());
|
||||
|
||||
cx.update(|_, cx| {
|
||||
connection.send_update(session_id, acp::SessionUpdate::ToolCall(tool_call), cx)
|
||||
});
|
||||
|
||||
let mut view_state = EntryViewState::default();
|
||||
cx.update(|window, cx| {
|
||||
view_state.sync_entry(workspace.downgrade(), thread.clone(), 0, window, cx);
|
||||
});
|
||||
|
||||
let multibuffer = thread.read_with(cx, |thread, cx| {
|
||||
thread
|
||||
.entries()
|
||||
.get(0)
|
||||
.unwrap()
|
||||
.diffs()
|
||||
.next()
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.multibuffer()
|
||||
.clone()
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let entry = view_state.entry(0).unwrap();
|
||||
let diff_editor = entry.editor_for_diff(&multibuffer).unwrap();
|
||||
assert_eq!(
|
||||
diff_editor.read_with(cx, |editor, cx| editor.text(cx)),
|
||||
"hi world\nhello world"
|
||||
);
|
||||
let row_infos = diff_editor.read_with(cx, |editor, cx| {
|
||||
let multibuffer = editor.buffer().read(cx);
|
||||
multibuffer
|
||||
.snapshot(cx)
|
||||
.row_infos(MultiBufferRow(0))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
assert_matches!(
|
||||
row_infos.as_slice(),
|
||||
[
|
||||
RowInfo {
|
||||
multibuffer_row: Some(MultiBufferRow(0)),
|
||||
diff_status: Some(DiffHunkStatus {
|
||||
kind: DiffHunkStatusKind::Deleted,
|
||||
..
|
||||
}),
|
||||
..
|
||||
},
|
||||
RowInfo {
|
||||
multibuffer_row: Some(MultiBufferRow(1)),
|
||||
diff_status: Some(DiffHunkStatus {
|
||||
kind: DiffHunkStatusKind::Added,
|
||||
..
|
||||
}),
|
||||
..
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
language::init(cx);
|
||||
Project::init_settings(cx);
|
||||
AgentSettings::register(cx);
|
||||
workspace::init_settings(cx);
|
||||
ThemeSettings::register(cx);
|
||||
release_channel::init(SemanticVersion::default(), cx);
|
||||
EditorSettings::register(cx);
|
||||
});
|
||||
}
|
||||
}
|
684
crates/agent_ui/src/acp/message_editor.rs
Normal file
684
crates/agent_ui/src/acp/message_editor.rs
Normal file
|
@ -0,0 +1,684 @@
|
|||
use crate::acp::completion_provider::ContextPickerCompletionProvider;
|
||||
use crate::acp::completion_provider::MentionImage;
|
||||
use crate::acp::completion_provider::MentionSet;
|
||||
use acp_thread::MentionUri;
|
||||
use agent::TextThreadStore;
|
||||
use agent::ThreadStore;
|
||||
use agent_client_protocol as acp;
|
||||
use anyhow::Result;
|
||||
use collections::HashSet;
|
||||
use editor::ExcerptId;
|
||||
use editor::actions::Paste;
|
||||
use editor::display_map::CreaseId;
|
||||
use editor::{
|
||||
AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode,
|
||||
EditorStyle, MultiBuffer,
|
||||
};
|
||||
use futures::FutureExt as _;
|
||||
use gpui::ClipboardEntry;
|
||||
use gpui::Image;
|
||||
use gpui::ImageFormat;
|
||||
use gpui::{
|
||||
AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Task, TextStyle, WeakEntity,
|
||||
};
|
||||
use language::Buffer;
|
||||
use language::Language;
|
||||
use language_model::LanguageModelImage;
|
||||
use parking_lot::Mutex;
|
||||
use project::{CompletionIntent, Project};
|
||||
use settings::Settings;
|
||||
use std::fmt::Write;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use theme::ThemeSettings;
|
||||
use ui::IconName;
|
||||
use ui::SharedString;
|
||||
use ui::{
|
||||
ActiveTheme, App, InteractiveElement, IntoElement, ParentElement, Render, Styled, TextSize,
|
||||
Window, div,
|
||||
};
|
||||
use util::ResultExt;
|
||||
use workspace::Workspace;
|
||||
use workspace::notifications::NotifyResultExt as _;
|
||||
use zed_actions::agent::Chat;
|
||||
|
||||
use super::completion_provider::Mention;
|
||||
|
||||
pub struct MessageEditor {
|
||||
editor: Entity<Editor>,
|
||||
project: Entity<Project>,
|
||||
thread_store: Entity<ThreadStore>,
|
||||
text_thread_store: Entity<TextThreadStore>,
|
||||
mention_set: Arc<Mutex<MentionSet>>,
|
||||
}
|
||||
|
||||
pub enum MessageEditorEvent {
|
||||
Send,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
impl EventEmitter<MessageEditorEvent> for MessageEditor {}
|
||||
|
||||
impl MessageEditor {
|
||||
pub fn new(
|
||||
workspace: WeakEntity<Workspace>,
|
||||
project: Entity<Project>,
|
||||
thread_store: Entity<ThreadStore>,
|
||||
text_thread_store: Entity<TextThreadStore>,
|
||||
mode: EditorMode,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let language = Language::new(
|
||||
language::LanguageConfig {
|
||||
completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
|
||||
let mention_set = Arc::new(Mutex::new(MentionSet::default()));
|
||||
let editor = cx.new(|cx| {
|
||||
let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
|
||||
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
|
||||
|
||||
let mut editor = Editor::new(mode, buffer, None, window, cx);
|
||||
editor.set_placeholder_text("Message the agent - @ to include files", cx);
|
||||
editor.set_show_indent_guides(false, cx);
|
||||
editor.set_soft_wrap();
|
||||
editor.set_use_modal_editing(true);
|
||||
editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
|
||||
mention_set.clone(),
|
||||
workspace,
|
||||
thread_store.downgrade(),
|
||||
text_thread_store.downgrade(),
|
||||
cx.weak_entity(),
|
||||
))));
|
||||
editor.set_context_menu_options(ContextMenuOptions {
|
||||
min_entries_visible: 12,
|
||||
max_entries_visible: 12,
|
||||
placement: Some(ContextMenuPlacement::Above),
|
||||
});
|
||||
editor
|
||||
});
|
||||
|
||||
Self {
|
||||
editor,
|
||||
project,
|
||||
mention_set,
|
||||
thread_store,
|
||||
text_thread_store,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self, cx: &App) -> bool {
|
||||
self.editor.read(cx).is_empty(cx)
|
||||
}
|
||||
|
||||
pub fn contents(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Vec<acp::ContentBlock>>> {
|
||||
let contents = self.mention_set.lock().contents(
|
||||
self.project.clone(),
|
||||
self.thread_store.clone(),
|
||||
self.text_thread_store.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let editor = self.editor.clone();
|
||||
|
||||
cx.spawn(async move |_, cx| {
|
||||
let contents = contents.await?;
|
||||
|
||||
editor.update(cx, |editor, cx| {
|
||||
let mut ix = 0;
|
||||
let mut chunks: Vec<acp::ContentBlock> = Vec::new();
|
||||
let text = editor.text(cx);
|
||||
editor.display_map.update(cx, |map, cx| {
|
||||
let snapshot = map.snapshot(cx);
|
||||
for (crease_id, crease) in snapshot.crease_snapshot.creases() {
|
||||
// Skip creases that have been edited out of the message buffer.
|
||||
if !crease.range().start.is_valid(&snapshot.buffer_snapshot) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(mention) = contents.get(&crease_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
|
||||
if crease_range.start > ix {
|
||||
chunks.push(text[ix..crease_range.start].into());
|
||||
}
|
||||
let chunk = match mention {
|
||||
Mention::Text { uri, content } => {
|
||||
acp::ContentBlock::Resource(acp::EmbeddedResource {
|
||||
annotations: None,
|
||||
resource: acp::EmbeddedResourceResource::TextResourceContents(
|
||||
acp::TextResourceContents {
|
||||
mime_type: None,
|
||||
text: content.clone(),
|
||||
uri: uri.to_uri().to_string(),
|
||||
},
|
||||
),
|
||||
})
|
||||
}
|
||||
Mention::Image(mention_image) => {
|
||||
acp::ContentBlock::Image(acp::ImageContent {
|
||||
annotations: None,
|
||||
data: mention_image.data.to_string(),
|
||||
mime_type: mention_image.format.mime_type().into(),
|
||||
uri: mention_image
|
||||
.abs_path
|
||||
.as_ref()
|
||||
.map(|path| format!("file://{}", path.display())),
|
||||
})
|
||||
}
|
||||
};
|
||||
chunks.push(chunk);
|
||||
ix = crease_range.end;
|
||||
}
|
||||
|
||||
if ix < text.len() {
|
||||
let last_chunk = text[ix..].trim_end();
|
||||
if !last_chunk.is_empty() {
|
||||
chunks.push(last_chunk.into());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chunks
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.clear(window, cx);
|
||||
editor.remove_creases(self.mention_set.lock().drain(), cx)
|
||||
});
|
||||
}
|
||||
|
||||
fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.emit(MessageEditorEvent::Send)
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.emit(MessageEditorEvent::Cancel)
|
||||
}
|
||||
|
||||
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let images = cx
|
||||
.read_from_clipboard()
|
||||
.map(|item| {
|
||||
item.into_entries()
|
||||
.filter_map(|entry| {
|
||||
if let ClipboardEntry::Image(image) = entry {
|
||||
Some(image)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if images.is_empty() {
|
||||
return;
|
||||
}
|
||||
cx.stop_propagation();
|
||||
|
||||
let replacement_text = "image";
|
||||
for image in images {
|
||||
let (excerpt_id, anchor) = self.editor.update(cx, |message_editor, cx| {
|
||||
let snapshot = message_editor.snapshot(window, cx);
|
||||
let (excerpt_id, _, snapshot) = snapshot.buffer_snapshot.as_singleton().unwrap();
|
||||
|
||||
let anchor = snapshot.anchor_before(snapshot.len());
|
||||
message_editor.edit(
|
||||
[(
|
||||
multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
|
||||
format!("{replacement_text} "),
|
||||
)],
|
||||
cx,
|
||||
);
|
||||
(*excerpt_id, anchor)
|
||||
});
|
||||
|
||||
self.insert_image(
|
||||
excerpt_id,
|
||||
anchor,
|
||||
replacement_text.len(),
|
||||
Arc::new(image),
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_dragged_files(
|
||||
&self,
|
||||
paths: Vec<project::ProjectPath>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let buffer = self.editor.read(cx).buffer().clone();
|
||||
let Some((&excerpt_id, _, _)) = buffer.read(cx).snapshot(cx).as_singleton() else {
|
||||
return;
|
||||
};
|
||||
let Some(buffer) = buffer.read(cx).as_singleton() else {
|
||||
return;
|
||||
};
|
||||
for path in paths {
|
||||
let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else {
|
||||
continue;
|
||||
};
|
||||
let Some(abs_path) = self.project.read(cx).absolute_path(&path, cx) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
|
||||
let path_prefix = abs_path
|
||||
.file_name()
|
||||
.unwrap_or(path.path.as_os_str())
|
||||
.display()
|
||||
.to_string();
|
||||
let Some(completion) = ContextPickerCompletionProvider::completion_for_path(
|
||||
path,
|
||||
&path_prefix,
|
||||
false,
|
||||
entry.is_dir(),
|
||||
excerpt_id,
|
||||
anchor..anchor,
|
||||
self.editor.clone(),
|
||||
self.mention_set.clone(),
|
||||
self.project.clone(),
|
||||
cx,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
self.editor.update(cx, |message_editor, cx| {
|
||||
message_editor.edit(
|
||||
[(
|
||||
multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
|
||||
completion.new_text,
|
||||
)],
|
||||
cx,
|
||||
);
|
||||
});
|
||||
if let Some(confirm) = completion.confirm.clone() {
|
||||
confirm(CompletionIntent::Complete, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_image(
|
||||
&mut self,
|
||||
excerpt_id: ExcerptId,
|
||||
crease_start: text::Anchor,
|
||||
content_len: usize,
|
||||
image: Arc<Image>,
|
||||
abs_path: Option<Arc<Path>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(crease_id) = insert_crease_for_image(
|
||||
excerpt_id,
|
||||
crease_start,
|
||||
content_len,
|
||||
self.editor.clone(),
|
||||
window,
|
||||
cx,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
self.editor.update(cx, |_editor, cx| {
|
||||
let format = image.format;
|
||||
let convert = LanguageModelImage::from_image(image, cx);
|
||||
|
||||
let task = cx
|
||||
.spawn_in(window, async move |editor, cx| {
|
||||
if let Some(image) = convert.await {
|
||||
Ok(MentionImage {
|
||||
abs_path,
|
||||
data: image.source,
|
||||
format,
|
||||
})
|
||||
} else {
|
||||
editor
|
||||
.update(cx, |editor, cx| {
|
||||
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
||||
let Some(anchor) =
|
||||
snapshot.anchor_in_excerpt(excerpt_id, crease_start)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
editor.display_map.update(cx, |display_map, cx| {
|
||||
display_map.unfold_intersecting(vec![anchor..anchor], true, cx);
|
||||
});
|
||||
editor.remove_creases([crease_id], cx);
|
||||
})
|
||||
.ok();
|
||||
Err("Failed to convert image".to_string())
|
||||
}
|
||||
})
|
||||
.shared();
|
||||
|
||||
cx.spawn_in(window, {
|
||||
let task = task.clone();
|
||||
async move |_, cx| task.clone().await.notify_async_err(cx)
|
||||
})
|
||||
.detach();
|
||||
|
||||
self.mention_set.lock().insert_image(crease_id, task);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.set_mode(mode);
|
||||
cx.notify()
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_message(
|
||||
&mut self,
|
||||
message: Vec<acp::ContentBlock>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let mut text = String::new();
|
||||
let mut mentions = Vec::new();
|
||||
let mut images = Vec::new();
|
||||
|
||||
for chunk in message {
|
||||
match chunk {
|
||||
acp::ContentBlock::Text(text_content) => {
|
||||
text.push_str(&text_content.text);
|
||||
}
|
||||
acp::ContentBlock::Resource(acp::EmbeddedResource {
|
||||
resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
|
||||
..
|
||||
}) => {
|
||||
if let Some(mention_uri) = MentionUri::parse(&resource.uri).log_err() {
|
||||
let start = text.len();
|
||||
write!(&mut text, "{}", mention_uri.as_link()).ok();
|
||||
let end = text.len();
|
||||
mentions.push((start..end, mention_uri));
|
||||
}
|
||||
}
|
||||
acp::ContentBlock::Image(content) => {
|
||||
let start = text.len();
|
||||
text.push_str("image");
|
||||
let end = text.len();
|
||||
images.push((start..end, content));
|
||||
}
|
||||
acp::ContentBlock::Audio(_)
|
||||
| acp::ContentBlock::Resource(_)
|
||||
| acp::ContentBlock::ResourceLink(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot = self.editor.update(cx, |editor, cx| {
|
||||
editor.set_text(text, window, cx);
|
||||
editor.buffer().read(cx).snapshot(cx)
|
||||
});
|
||||
|
||||
self.mention_set.lock().clear();
|
||||
for (range, mention_uri) in mentions {
|
||||
let anchor = snapshot.anchor_before(range.start);
|
||||
let crease_id = crate::context_picker::insert_crease_for_mention(
|
||||
anchor.excerpt_id,
|
||||
anchor.text_anchor,
|
||||
range.end - range.start,
|
||||
mention_uri.name().into(),
|
||||
mention_uri.icon_path(cx),
|
||||
self.editor.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
if let Some(crease_id) = crease_id {
|
||||
self.mention_set.lock().insert_uri(crease_id, mention_uri);
|
||||
}
|
||||
}
|
||||
for (range, content) in images {
|
||||
let Some(format) = ImageFormat::from_mime_type(&content.mime_type) else {
|
||||
continue;
|
||||
};
|
||||
let anchor = snapshot.anchor_before(range.start);
|
||||
let abs_path = content
|
||||
.uri
|
||||
.as_ref()
|
||||
.and_then(|uri| uri.strip_prefix("file://").map(|s| Path::new(s).into()));
|
||||
|
||||
let name = content
|
||||
.uri
|
||||
.as_ref()
|
||||
.and_then(|uri| {
|
||||
uri.strip_prefix("file://")
|
||||
.and_then(|path| Path::new(path).file_name())
|
||||
})
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or("Image".to_owned());
|
||||
let crease_id = crate::context_picker::insert_crease_for_mention(
|
||||
anchor.excerpt_id,
|
||||
anchor.text_anchor,
|
||||
range.end - range.start,
|
||||
name.into(),
|
||||
IconName::Image.path().into(),
|
||||
self.editor.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let data: SharedString = content.data.to_string().into();
|
||||
|
||||
if let Some(crease_id) = crease_id {
|
||||
self.mention_set.lock().insert_image(
|
||||
crease_id,
|
||||
Task::ready(Ok(MentionImage {
|
||||
abs_path,
|
||||
data,
|
||||
format,
|
||||
}))
|
||||
.shared(),
|
||||
);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.set_text(text, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for MessageEditor {
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
self.editor.focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for MessageEditor {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.key_context("MessageEditor")
|
||||
.on_action(cx.listener(Self::chat))
|
||||
.on_action(cx.listener(Self::cancel))
|
||||
.capture_action(cx.listener(Self::paste))
|
||||
.flex_1()
|
||||
.child({
|
||||
let settings = ThemeSettings::get_global(cx);
|
||||
let font_size = TextSize::Small
|
||||
.rems(cx)
|
||||
.to_pixels(settings.agent_font_size(cx));
|
||||
let line_height = settings.buffer_line_height.value() * font_size;
|
||||
|
||||
let text_style = TextStyle {
|
||||
color: cx.theme().colors().text,
|
||||
font_family: settings.buffer_font.family.clone(),
|
||||
font_fallbacks: settings.buffer_font.fallbacks.clone(),
|
||||
font_features: settings.buffer_font.features.clone(),
|
||||
font_size: font_size.into(),
|
||||
line_height: line_height.into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
EditorElement::new(
|
||||
&self.editor,
|
||||
EditorStyle {
|
||||
background: cx.theme().colors().editor_background,
|
||||
local_player: cx.theme().players().local(),
|
||||
text: text_style,
|
||||
syntax: cx.theme().syntax().clone(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn insert_crease_for_image(
|
||||
excerpt_id: ExcerptId,
|
||||
anchor: text::Anchor,
|
||||
content_len: usize,
|
||||
editor: Entity<Editor>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<CreaseId> {
|
||||
crate::context_picker::insert_crease_for_mention(
|
||||
excerpt_id,
|
||||
anchor,
|
||||
content_len,
|
||||
"Image".into(),
|
||||
IconName::Image.path().into(),
|
||||
editor,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use agent::{TextThreadStore, ThreadStore};
|
||||
use agent_client_protocol as acp;
|
||||
use editor::EditorMode;
|
||||
use fs::FakeFs;
|
||||
use gpui::{AppContext, TestAppContext};
|
||||
use lsp::{CompletionContext, CompletionTriggerKind};
|
||||
use project::{CompletionIntent, Project};
|
||||
use serde_json::json;
|
||||
use util::path;
|
||||
use workspace::Workspace;
|
||||
|
||||
use crate::acp::{message_editor::MessageEditor, thread_view::tests::init_test};
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_at_mention_removal(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree("/project", json!({"file": ""})).await;
|
||||
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
|
||||
|
||||
let (workspace, cx) =
|
||||
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
|
||||
let thread_store = cx.new(|cx| ThreadStore::fake(project.clone(), cx));
|
||||
let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
|
||||
|
||||
let message_editor = cx.update(|window, cx| {
|
||||
cx.new(|cx| {
|
||||
MessageEditor::new(
|
||||
workspace.downgrade(),
|
||||
project.clone(),
|
||||
thread_store.clone(),
|
||||
text_thread_store.clone(),
|
||||
EditorMode::AutoHeight {
|
||||
min_lines: 1,
|
||||
max_lines: None,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
});
|
||||
let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let excerpt_id = editor.update(cx, |editor, cx| {
|
||||
editor
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.excerpt_ids()
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap()
|
||||
});
|
||||
let completions = editor.update_in(cx, |editor, window, cx| {
|
||||
editor.set_text("Hello @file ", window, cx);
|
||||
let buffer = editor.buffer().read(cx).as_singleton().unwrap();
|
||||
let completion_provider = editor.completion_provider().unwrap();
|
||||
completion_provider.completions(
|
||||
excerpt_id,
|
||||
&buffer,
|
||||
text::Anchor::MAX,
|
||||
CompletionContext {
|
||||
trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
|
||||
trigger_character: Some("@".into()),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let [_, completion]: [_; 2] = completions
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.flat_map(|response| response.completions)
|
||||
.collect::<Vec<_>>()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
||||
let start = snapshot
|
||||
.anchor_in_excerpt(excerpt_id, completion.replace_range.start)
|
||||
.unwrap();
|
||||
let end = snapshot
|
||||
.anchor_in_excerpt(excerpt_id, completion.replace_range.end)
|
||||
.unwrap();
|
||||
editor.edit([(start..end, completion.new_text)], cx);
|
||||
(completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
// Backspace over the inserted crease (and the following space).
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.backspace(&Default::default(), window, cx);
|
||||
editor.backspace(&Default::default(), window, cx);
|
||||
});
|
||||
|
||||
let content = message_editor
|
||||
.update_in(cx, |message_editor, window, cx| {
|
||||
message_editor.contents(window, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// We don't send a resource link for the deleted crease.
|
||||
pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
|
||||
}
|
||||
}
|
|
@ -1,92 +0,0 @@
|
|||
pub struct MessageHistory<T> {
|
||||
items: Vec<T>,
|
||||
current: Option<usize>,
|
||||
}
|
||||
|
||||
impl<T> Default for MessageHistory<T> {
|
||||
fn default() -> Self {
|
||||
MessageHistory {
|
||||
items: Vec::new(),
|
||||
current: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> MessageHistory<T> {
|
||||
pub fn push(&mut self, message: T) {
|
||||
self.current.take();
|
||||
self.items.push(message);
|
||||
}
|
||||
|
||||
pub fn reset_position(&mut self) {
|
||||
self.current.take();
|
||||
}
|
||||
|
||||
pub fn prev(&mut self) -> Option<&T> {
|
||||
if self.items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let new_ix = self
|
||||
.current
|
||||
.get_or_insert(self.items.len())
|
||||
.saturating_sub(1);
|
||||
|
||||
self.current = Some(new_ix);
|
||||
self.items.get(new_ix)
|
||||
}
|
||||
|
||||
pub fn next(&mut self) -> Option<&T> {
|
||||
let current = self.current.as_mut()?;
|
||||
*current += 1;
|
||||
|
||||
self.items.get(*current).or_else(|| {
|
||||
self.current.take();
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn items(&self) -> &[T] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_prev_next() {
|
||||
let mut history = MessageHistory::default();
|
||||
|
||||
// Test empty history
|
||||
assert_eq!(history.prev(), None);
|
||||
assert_eq!(history.next(), None);
|
||||
|
||||
// Add some messages
|
||||
history.push("first");
|
||||
history.push("second");
|
||||
history.push("third");
|
||||
|
||||
// Test prev navigation
|
||||
assert_eq!(history.prev(), Some(&"third"));
|
||||
assert_eq!(history.prev(), Some(&"second"));
|
||||
assert_eq!(history.prev(), Some(&"first"));
|
||||
assert_eq!(history.prev(), Some(&"first"));
|
||||
|
||||
assert_eq!(history.next(), Some(&"second"));
|
||||
|
||||
// Test mixed navigation
|
||||
history.push("fourth");
|
||||
assert_eq!(history.prev(), Some(&"fourth"));
|
||||
assert_eq!(history.prev(), Some(&"third"));
|
||||
assert_eq!(history.next(), Some(&"fourth"));
|
||||
assert_eq!(history.next(), None);
|
||||
|
||||
// Test that push resets navigation
|
||||
history.prev();
|
||||
history.prev();
|
||||
history.push("fifth");
|
||||
assert_eq!(history.prev(), Some(&"fifth"));
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -1521,7 +1521,8 @@ impl AgentDiff {
|
|||
self.update_reviewing_editors(workspace, window, cx);
|
||||
}
|
||||
}
|
||||
AcpThreadEvent::Stopped
|
||||
AcpThreadEvent::EntriesRemoved(_)
|
||||
| AcpThreadEvent::Stopped
|
||||
| AcpThreadEvent::ToolAuthorizationRequired
|
||||
| AcpThreadEvent::Error
|
||||
| AcpThreadEvent::ServerExited(_) => {}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
use std::cell::RefCell;
|
||||
use std::ops::{Not, Range};
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
|
@ -11,13 +10,12 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::NewExternalAgentThread;
|
||||
use crate::agent_diff::AgentDiffThread;
|
||||
use crate::message_editor::{MAX_EDITOR_LINES, MIN_EDITOR_LINES};
|
||||
use crate::ui::NewThreadButton;
|
||||
use crate::{
|
||||
AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode,
|
||||
DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread,
|
||||
NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell,
|
||||
ResetTrialUpsell, ToggleBurnMode, ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu,
|
||||
ResetTrialUpsell, ToggleBurnMode, ToggleContextPicker, ToggleNavigationMenu,
|
||||
ToggleNewThreadMenu, ToggleOptionsMenu,
|
||||
acp::AcpThreadView,
|
||||
active_thread::{self, ActiveThread, ActiveThreadEvent},
|
||||
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
|
||||
|
@ -86,6 +84,7 @@ const AGENT_PANEL_KEY: &str = "agent_panel";
|
|||
#[derive(Serialize, Deserialize)]
|
||||
struct SerializedAgentPanel {
|
||||
width: Option<Pixels>,
|
||||
selected_agent: Option<AgentType>,
|
||||
}
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
|
@ -179,6 +178,14 @@ pub fn init(cx: &mut App) {
|
|||
});
|
||||
}
|
||||
})
|
||||
.register_action(|workspace, _: &ToggleNewThreadMenu, window, cx| {
|
||||
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
|
||||
workspace.focus_panel::<AgentPanel>(window, cx);
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.toggle_new_thread_menu(&ToggleNewThreadMenu, window, cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
.register_action(|workspace, _: &OpenOnboardingModal, window, cx| {
|
||||
AgentOnboardingModal::toggle(workspace, window, cx)
|
||||
})
|
||||
|
@ -223,6 +230,36 @@ enum WhichFontSize {
|
|||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AgentType {
|
||||
#[default]
|
||||
Zed,
|
||||
TextThread,
|
||||
Gemini,
|
||||
ClaudeCode,
|
||||
NativeAgent,
|
||||
}
|
||||
|
||||
impl AgentType {
|
||||
fn label(self) -> impl Into<SharedString> {
|
||||
match self {
|
||||
Self::Zed | Self::TextThread => "Zed",
|
||||
Self::NativeAgent => "Agent 2",
|
||||
Self::Gemini => "Gemini",
|
||||
Self::ClaudeCode => "Claude Code",
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(self) -> IconName {
|
||||
match self {
|
||||
Self::Zed | Self::TextThread => IconName::AiZed,
|
||||
Self::NativeAgent => IconName::ZedAssistant,
|
||||
Self::Gemini => IconName::AiGemini,
|
||||
Self::ClaudeCode => IconName::AiClaude,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveView {
|
||||
pub fn which_font_size_used(&self) -> WhichFontSize {
|
||||
match self {
|
||||
|
@ -438,8 +475,6 @@ pub struct AgentPanel {
|
|||
configuration_subscription: Option<Subscription>,
|
||||
local_timezone: UtcOffset,
|
||||
active_view: ActiveView,
|
||||
acp_message_history:
|
||||
Rc<RefCell<crate::acp::MessageHistory<Vec<agent_client_protocol::ContentBlock>>>>,
|
||||
previous_view: Option<ActiveView>,
|
||||
history_store: Entity<HistoryStore>,
|
||||
history: Entity<ThreadHistory>,
|
||||
|
@ -453,16 +488,21 @@ pub struct AgentPanel {
|
|||
zoomed: bool,
|
||||
pending_serialization: Option<Task<Result<()>>>,
|
||||
onboarding: Entity<AgentPanelOnboarding>,
|
||||
selected_agent: AgentType,
|
||||
}
|
||||
|
||||
impl AgentPanel {
|
||||
fn serialize(&mut self, cx: &mut Context<Self>) {
|
||||
let width = self.width;
|
||||
let selected_agent = self.selected_agent;
|
||||
self.pending_serialization = Some(cx.background_spawn(async move {
|
||||
KEY_VALUE_STORE
|
||||
.write_kvp(
|
||||
AGENT_PANEL_KEY.into(),
|
||||
serde_json::to_string(&SerializedAgentPanel { width })?,
|
||||
serde_json::to_string(&SerializedAgentPanel {
|
||||
width,
|
||||
selected_agent: Some(selected_agent),
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
anyhow::Ok(())
|
||||
|
@ -531,6 +571,9 @@ impl AgentPanel {
|
|||
if let Some(serialized_panel) = serialized_panel {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.width = serialized_panel.width.map(|w| w.round());
|
||||
if let Some(selected_agent) = serialized_panel.selected_agent {
|
||||
panel.selected_agent = selected_agent;
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
@ -719,7 +762,6 @@ impl AgentPanel {
|
|||
.unwrap(),
|
||||
inline_assist_context_store,
|
||||
previous_view: None,
|
||||
acp_message_history: Default::default(),
|
||||
history_store: history_store.clone(),
|
||||
history: cx.new(|cx| ThreadHistory::new(weak_self, history_store, window, cx)),
|
||||
hovered_recent_history_item: None,
|
||||
|
@ -732,6 +774,7 @@ impl AgentPanel {
|
|||
zoomed: false,
|
||||
pending_serialization: None,
|
||||
onboarding,
|
||||
selected_agent: AgentType::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -776,7 +819,9 @@ impl AgentPanel {
|
|||
thread.update(cx, |thread, cx| thread.cancel_last_completion(window, cx));
|
||||
}
|
||||
ActiveView::ExternalAgentThread { thread_view, .. } => {
|
||||
thread_view.update(cx, |thread_element, cx| thread_element.cancel(cx));
|
||||
thread_view.update(cx, |thread_element, cx| {
|
||||
thread_element.cancel_generation(cx)
|
||||
});
|
||||
}
|
||||
ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => {}
|
||||
}
|
||||
|
@ -915,7 +960,6 @@ impl AgentPanel {
|
|||
) {
|
||||
let workspace = self.workspace.clone();
|
||||
let project = self.project.clone();
|
||||
let message_history = self.acp_message_history.clone();
|
||||
let fs = self.fs.clone();
|
||||
|
||||
const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
|
||||
|
@ -925,6 +969,9 @@ impl AgentPanel {
|
|||
agent: crate::ExternalAgent,
|
||||
}
|
||||
|
||||
let thread_store = self.thread_store.clone();
|
||||
let text_thread_store = self.context_store.clone();
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let server: Rc<dyn AgentServer> = match agent_choice {
|
||||
Some(agent) => {
|
||||
|
@ -963,9 +1010,8 @@ impl AgentPanel {
|
|||
server,
|
||||
workspace.clone(),
|
||||
project,
|
||||
message_history,
|
||||
MIN_EDITOR_LINES,
|
||||
Some(MAX_EDITOR_LINES),
|
||||
thread_store.clone(),
|
||||
text_thread_store.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
|
@ -1174,6 +1220,15 @@ impl AgentPanel {
|
|||
self.agent_panel_menu_handle.toggle(window, cx);
|
||||
}
|
||||
|
||||
pub fn toggle_new_thread_menu(
|
||||
&mut self,
|
||||
_: &ToggleNewThreadMenu,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.new_thread_menu_handle.toggle(window, cx);
|
||||
}
|
||||
|
||||
pub fn increase_font_size(
|
||||
&mut self,
|
||||
action: &IncreaseBufferFontSize,
|
||||
|
@ -1513,8 +1568,6 @@ impl AgentPanel {
|
|||
self.active_view = new_view;
|
||||
}
|
||||
|
||||
self.acp_message_history.borrow_mut().reset_position();
|
||||
|
||||
self.focus_handle(cx).focus(window);
|
||||
}
|
||||
|
||||
|
@ -1581,6 +1634,17 @@ impl AgentPanel {
|
|||
|
||||
menu
|
||||
}
|
||||
|
||||
pub fn set_selected_agent(&mut self, agent: AgentType, cx: &mut Context<Self>) {
|
||||
if self.selected_agent != agent {
|
||||
self.selected_agent = agent;
|
||||
self.serialize(cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_agent(&self) -> AgentType {
|
||||
self.selected_agent
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for AgentPanel {
|
||||
|
@ -1811,200 +1875,24 @@ impl AgentPanel {
|
|||
.into_any()
|
||||
}
|
||||
|
||||
fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render_panel_options_menu(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let user_store = self.user_store.read(cx);
|
||||
let usage = user_store.model_request_usage();
|
||||
|
||||
let account_url = zed_urls::account_url(cx);
|
||||
|
||||
let focus_handle = self.focus_handle(cx);
|
||||
|
||||
let go_back_button = div().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,
|
||||
)
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let recent_entries_menu = div().child(
|
||||
PopoverMenu::new("agent-nav-menu")
|
||||
.trigger_with_tooltip(
|
||||
IconButton::new("agent-nav-menu", IconName::MenuAlt)
|
||||
.icon_size(IconSize::Small)
|
||||
.style(ui::ButtonStyle::Subtle),
|
||||
{
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Toggle Panel Menu",
|
||||
&ToggleNavigationMenu,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
.anchor(Corner::TopLeft)
|
||||
.with_handle(self.assistant_navigation_menu_handle.clone())
|
||||
.menu({
|
||||
let menu = self.assistant_navigation_menu.clone();
|
||||
move |window, cx| {
|
||||
if let Some(menu) = menu.as_ref() {
|
||||
menu.update(cx, |_, cx| {
|
||||
cx.defer_in(window, |menu, window, cx| {
|
||||
menu.rebuild(window, cx);
|
||||
});
|
||||
})
|
||||
}
|
||||
menu.clone()
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let full_screen_label = if self.is_zoomed(window, cx) {
|
||||
"Disable Full Screen"
|
||||
} else {
|
||||
"Enable Full Screen"
|
||||
};
|
||||
|
||||
let active_thread = match &self.active_view {
|
||||
ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()),
|
||||
ActiveView::ExternalAgentThread { .. }
|
||||
| ActiveView::TextThread { .. }
|
||||
| ActiveView::History
|
||||
| ActiveView::Configuration => None,
|
||||
};
|
||||
|
||||
let new_thread_menu = PopoverMenu::new("new_thread_menu")
|
||||
.trigger_with_tooltip(
|
||||
IconButton::new("new_thread_menu_btn", IconName::Plus).icon_size(IconSize::Small),
|
||||
Tooltip::text("New Thread…"),
|
||||
)
|
||||
.anchor(Corner::TopRight)
|
||||
.with_handle(self.new_thread_menu_handle.clone())
|
||||
.menu({
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |window, cx| {
|
||||
let active_thread = active_thread.clone();
|
||||
Some(ContextMenu::build(window, cx, |mut menu, _window, cx| {
|
||||
menu = menu
|
||||
.context(focus_handle.clone())
|
||||
.when(cx.has_flag::<feature_flags::AcpFeatureFlag>(), |this| {
|
||||
this.header("Zed Agent")
|
||||
})
|
||||
.when_some(active_thread, |this, active_thread| {
|
||||
let thread = active_thread.read(cx);
|
||||
|
||||
if !thread.is_empty() {
|
||||
let thread_id = thread.id().clone();
|
||||
this.item(
|
||||
ContextMenuEntry::new("New From Summary")
|
||||
.icon(IconName::ThreadFromSummary)
|
||||
.icon_color(Color::Muted)
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(NewThread {
|
||||
from_thread_id: Some(thread_id.clone()),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
.item(
|
||||
ContextMenuEntry::new("New Thread")
|
||||
.icon(IconName::Thread)
|
||||
.icon_color(Color::Muted)
|
||||
.action(NewThread::default().boxed_clone())
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(
|
||||
NewThread::default().boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
ContextMenuEntry::new("New Text Thread")
|
||||
.icon(IconName::TextThread)
|
||||
.icon_color(Color::Muted)
|
||||
.action(NewTextThread.boxed_clone())
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(NewTextThread.boxed_clone(), cx);
|
||||
}),
|
||||
)
|
||||
.when(cx.has_flag::<feature_flags::AcpFeatureFlag>(), |this| {
|
||||
this.separator()
|
||||
.header("External Agents")
|
||||
.item(
|
||||
ContextMenuEntry::new("New Gemini Thread")
|
||||
.icon(IconName::AiGemini)
|
||||
.icon_color(Color::Muted)
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(
|
||||
NewExternalAgentThread {
|
||||
agent: Some(crate::ExternalAgent::Gemini),
|
||||
}
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
ContextMenuEntry::new("New Claude Code Thread")
|
||||
.icon(IconName::AiClaude)
|
||||
.icon_color(Color::Muted)
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(
|
||||
NewExternalAgentThread {
|
||||
agent: Some(
|
||||
crate::ExternalAgent::ClaudeCode,
|
||||
),
|
||||
}
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
ContextMenuEntry::new("New Native Agent Thread")
|
||||
.icon(IconName::ZedAssistant)
|
||||
.icon_color(Color::Muted)
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(
|
||||
NewExternalAgentThread {
|
||||
agent: Some(
|
||||
crate::ExternalAgent::NativeAgent,
|
||||
),
|
||||
}
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
});
|
||||
menu
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
let agent_panel_menu = PopoverMenu::new("agent-options-menu")
|
||||
PopoverMenu::new("agent-options-menu")
|
||||
.trigger_with_tooltip(
|
||||
IconButton::new("agent-options-menu", IconName::Ellipsis)
|
||||
.icon_size(IconSize::Small),
|
||||
|
@ -2087,6 +1975,137 @@ impl AgentPanel {
|
|||
menu
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn render_recent_entries_menu(
|
||||
&self,
|
||||
icon: IconName,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let focus_handle = self.focus_handle(cx);
|
||||
|
||||
PopoverMenu::new("agent-nav-menu")
|
||||
.trigger_with_tooltip(
|
||||
IconButton::new("agent-nav-menu", icon).icon_size(IconSize::Small),
|
||||
{
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Toggle Panel Menu",
|
||||
&ToggleNavigationMenu,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
.anchor(Corner::TopLeft)
|
||||
.with_handle(self.assistant_navigation_menu_handle.clone())
|
||||
.menu({
|
||||
let menu = self.assistant_navigation_menu.clone();
|
||||
move |window, cx| {
|
||||
if let Some(menu) = menu.as_ref() {
|
||||
menu.update(cx, |_, cx| {
|
||||
cx.defer_in(window, |menu, window, cx| {
|
||||
menu.rebuild(window, cx);
|
||||
});
|
||||
})
|
||||
}
|
||||
menu.clone()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn render_toolbar_back_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let focus_handle = self.focus_handle(cx);
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn render_toolbar_old(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let focus_handle = self.focus_handle(cx);
|
||||
|
||||
let active_thread = match &self.active_view {
|
||||
ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()),
|
||||
ActiveView::ExternalAgentThread { .. }
|
||||
| ActiveView::TextThread { .. }
|
||||
| ActiveView::History
|
||||
| ActiveView::Configuration => None,
|
||||
};
|
||||
|
||||
let new_thread_menu = PopoverMenu::new("new_thread_menu")
|
||||
.trigger_with_tooltip(
|
||||
IconButton::new("new_thread_menu_btn", IconName::Plus).icon_size(IconSize::Small),
|
||||
Tooltip::text("New Thread…"),
|
||||
)
|
||||
.anchor(Corner::TopRight)
|
||||
.with_handle(self.new_thread_menu_handle.clone())
|
||||
.menu({
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |window, cx| {
|
||||
let active_thread = active_thread.clone();
|
||||
Some(ContextMenu::build(window, cx, |mut menu, _window, cx| {
|
||||
menu = menu
|
||||
.context(focus_handle.clone())
|
||||
.when_some(active_thread, |this, active_thread| {
|
||||
let thread = active_thread.read(cx);
|
||||
|
||||
if !thread.is_empty() {
|
||||
let thread_id = thread.id().clone();
|
||||
this.item(
|
||||
ContextMenuEntry::new("New From Summary")
|
||||
.icon(IconName::ThreadFromSummary)
|
||||
.icon_color(Color::Muted)
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(NewThread {
|
||||
from_thread_id: Some(thread_id.clone()),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
.item(
|
||||
ContextMenuEntry::new("New Thread")
|
||||
.icon(IconName::Thread)
|
||||
.icon_color(Color::Muted)
|
||||
.action(NewThread::default().boxed_clone())
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(
|
||||
NewThread::default().boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
ContextMenuEntry::new("New Text Thread")
|
||||
.icon(IconName::TextThread)
|
||||
.icon_color(Color::Muted)
|
||||
.action(NewTextThread.boxed_clone())
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(NewTextThread.boxed_clone(), cx);
|
||||
}),
|
||||
);
|
||||
menu
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
h_flex()
|
||||
|
@ -2105,8 +2124,13 @@ impl AgentPanel {
|
|||
.pl_1()
|
||||
.gap_1()
|
||||
.child(match &self.active_view {
|
||||
ActiveView::History | ActiveView::Configuration => go_back_button,
|
||||
_ => recent_entries_menu,
|
||||
ActiveView::History | ActiveView::Configuration => div()
|
||||
.pl(DynamicSpacing::Base04.rems(cx))
|
||||
.child(self.render_toolbar_back_button(cx))
|
||||
.into_any_element(),
|
||||
_ => self
|
||||
.render_recent_entries_menu(IconName::MenuAlt, cx)
|
||||
.into_any_element(),
|
||||
})
|
||||
.child(self.render_title_view(window, cx)),
|
||||
)
|
||||
|
@ -2123,11 +2147,292 @@ impl AgentPanel {
|
|||
.border_l_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.child(new_thread_menu)
|
||||
.child(agent_panel_menu),
|
||||
.child(self.render_panel_options_menu(window, cx)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_toolbar_new(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let focus_handle = self.focus_handle(cx);
|
||||
|
||||
let active_thread = match &self.active_view {
|
||||
ActiveView::Thread { thread, .. } => Some(thread.read(cx).thread().clone()),
|
||||
ActiveView::ExternalAgentThread { .. }
|
||||
| ActiveView::TextThread { .. }
|
||||
| ActiveView::History
|
||||
| ActiveView::Configuration => None,
|
||||
};
|
||||
|
||||
let new_thread_menu = PopoverMenu::new("new_thread_menu")
|
||||
.trigger_with_tooltip(
|
||||
IconButton::new("new_thread_menu_btn", IconName::Plus).icon_size(IconSize::Small),
|
||||
{
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"New…",
|
||||
&ToggleNewThreadMenu,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
.anchor(Corner::TopLeft)
|
||||
.with_handle(self.new_thread_menu_handle.clone())
|
||||
.menu({
|
||||
let focus_handle = focus_handle.clone();
|
||||
let workspace = self.workspace.clone();
|
||||
|
||||
move |window, cx| {
|
||||
let active_thread = active_thread.clone();
|
||||
Some(ContextMenu::build(window, cx, |mut menu, _window, cx| {
|
||||
menu = menu
|
||||
.context(focus_handle.clone())
|
||||
.header("Zed Agent")
|
||||
.when_some(active_thread, |this, active_thread| {
|
||||
let thread = active_thread.read(cx);
|
||||
|
||||
if !thread.is_empty() {
|
||||
let thread_id = thread.id().clone();
|
||||
this.item(
|
||||
ContextMenuEntry::new("New From Summary")
|
||||
.icon(IconName::ThreadFromSummary)
|
||||
.icon_color(Color::Muted)
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(NewThread {
|
||||
from_thread_id: Some(thread_id.clone()),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
.item(
|
||||
ContextMenuEntry::new("New Thread")
|
||||
.icon(IconName::Thread)
|
||||
.icon_color(Color::Muted)
|
||||
.action(NewThread::default().boxed_clone())
|
||||
.handler({
|
||||
let workspace = workspace.clone();
|
||||
move |window, cx| {
|
||||
if let Some(workspace) = workspace.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let Some(panel) =
|
||||
workspace.panel::<AgentPanel>(cx)
|
||||
{
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.set_selected_agent(
|
||||
AgentType::Zed,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
window.dispatch_action(
|
||||
NewThread::default().boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
ContextMenuEntry::new("New Text Thread")
|
||||
.icon(IconName::TextThread)
|
||||
.icon_color(Color::Muted)
|
||||
.action(NewTextThread.boxed_clone())
|
||||
.handler({
|
||||
let workspace = workspace.clone();
|
||||
move |window, cx| {
|
||||
if let Some(workspace) = workspace.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let Some(panel) =
|
||||
workspace.panel::<AgentPanel>(cx)
|
||||
{
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.set_selected_agent(
|
||||
AgentType::TextThread,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
window.dispatch_action(NewTextThread.boxed_clone(), cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
ContextMenuEntry::new("New Native Agent Thread")
|
||||
.icon(IconName::ZedAssistant)
|
||||
.icon_color(Color::Muted)
|
||||
.handler({
|
||||
let workspace = workspace.clone();
|
||||
move |window, cx| {
|
||||
if let Some(workspace) = workspace.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let Some(panel) =
|
||||
workspace.panel::<AgentPanel>(cx)
|
||||
{
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.set_selected_agent(
|
||||
AgentType::NativeAgent,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
window.dispatch_action(
|
||||
NewExternalAgentThread {
|
||||
agent: Some(crate::ExternalAgent::NativeAgent),
|
||||
}
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.separator()
|
||||
.header("External Agents")
|
||||
.item(
|
||||
ContextMenuEntry::new("New Gemini Thread")
|
||||
.icon(IconName::AiGemini)
|
||||
.icon_color(Color::Muted)
|
||||
.handler({
|
||||
let workspace = workspace.clone();
|
||||
move |window, cx| {
|
||||
if let Some(workspace) = workspace.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let Some(panel) =
|
||||
workspace.panel::<AgentPanel>(cx)
|
||||
{
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.set_selected_agent(
|
||||
AgentType::Gemini,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
window.dispatch_action(
|
||||
NewExternalAgentThread {
|
||||
agent: Some(crate::ExternalAgent::Gemini),
|
||||
}
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
ContextMenuEntry::new("New Claude Code Thread")
|
||||
.icon(IconName::AiClaude)
|
||||
.icon_color(Color::Muted)
|
||||
.handler({
|
||||
let workspace = workspace.clone();
|
||||
move |window, cx| {
|
||||
if let Some(workspace) = workspace.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let Some(panel) =
|
||||
workspace.panel::<AgentPanel>(cx)
|
||||
{
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.set_selected_agent(
|
||||
AgentType::ClaudeCode,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
window.dispatch_action(
|
||||
NewExternalAgentThread {
|
||||
agent: Some(crate::ExternalAgent::ClaudeCode),
|
||||
}
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
menu
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
h_flex()
|
||||
.id("agent-panel-toolbar")
|
||||
.h(Tab::container_height(cx))
|
||||
.max_w_full()
|
||||
.flex_none()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.bg(cx.theme().colors().tab_bar_background)
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.child(
|
||||
h_flex()
|
||||
.size_full()
|
||||
.gap(DynamicSpacing::Base08.rems(cx))
|
||||
.child(match &self.active_view {
|
||||
ActiveView::History | ActiveView::Configuration => div()
|
||||
.pl(DynamicSpacing::Base04.rems(cx))
|
||||
.child(self.render_toolbar_back_button(cx))
|
||||
.into_any_element(),
|
||||
_ => h_flex()
|
||||
.h_full()
|
||||
.px(DynamicSpacing::Base04.rems(cx))
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.child(
|
||||
h_flex()
|
||||
.px_0p5()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
Icon::new(self.selected_agent.icon()).color(Color::Muted),
|
||||
)
|
||||
.child(Label::new(self.selected_agent.label())),
|
||||
)
|
||||
.into_any_element(),
|
||||
})
|
||||
.child(self.render_title_view(window, cx)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.h_full()
|
||||
.gap_2()
|
||||
.children(self.render_token_count(cx))
|
||||
.child(
|
||||
h_flex()
|
||||
.h_full()
|
||||
.gap(DynamicSpacing::Base02.rems(cx))
|
||||
.pl(DynamicSpacing::Base04.rems(cx))
|
||||
.pr(DynamicSpacing::Base06.rems(cx))
|
||||
.border_l_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.child(new_thread_menu)
|
||||
.child(self.render_recent_entries_menu(IconName::HistoryRerun, cx))
|
||||
.child(self.render_panel_options_menu(window, cx)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if cx.has_flag::<feature_flags::AcpFeatureFlag>() {
|
||||
self.render_toolbar_new(window, cx).into_any_element()
|
||||
} else {
|
||||
self.render_toolbar_old(window, cx).into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_token_count(&self, cx: &App) -> Option<AnyElement> {
|
||||
match &self.active_view {
|
||||
ActiveView::Thread {
|
||||
|
@ -2576,138 +2881,6 @@ impl AgentPanel {
|
|||
},
|
||||
)),
|
||||
)
|
||||
.child(self.render_empty_state_section_header("Start", None, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.p_1()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(
|
||||
NewThreadButton::new(
|
||||
"new-thread-btn",
|
||||
"New Thread",
|
||||
IconName::Thread,
|
||||
)
|
||||
.keybinding(KeyBinding::for_action_in(
|
||||
&NewThread::default(),
|
||||
&self.focus_handle(cx),
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
.on_click(
|
||||
|window, cx| {
|
||||
window.dispatch_action(
|
||||
NewThread::default().boxed_clone(),
|
||||
cx,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
.child(
|
||||
NewThreadButton::new(
|
||||
"new-text-thread-btn",
|
||||
"New Text Thread",
|
||||
IconName::TextThread,
|
||||
)
|
||||
.keybinding(KeyBinding::for_action_in(
|
||||
&NewTextThread,
|
||||
&self.focus_handle(cx),
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
.on_click(
|
||||
|window, cx| {
|
||||
window.dispatch_action(Box::new(NewTextThread), cx)
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.when(cx.has_flag::<feature_flags::AcpFeatureFlag>(), |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(
|
||||
NewThreadButton::new(
|
||||
"new-gemini-thread-btn",
|
||||
"New Gemini Thread",
|
||||
IconName::AiGemini,
|
||||
)
|
||||
// .keybinding(KeyBinding::for_action_in(
|
||||
// &OpenHistory,
|
||||
// &self.focus_handle(cx),
|
||||
// window,
|
||||
// cx,
|
||||
// ))
|
||||
.on_click(
|
||||
|window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(NewExternalAgentThread {
|
||||
agent: Some(
|
||||
crate::ExternalAgent::Gemini,
|
||||
),
|
||||
}),
|
||||
cx,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
.child(
|
||||
NewThreadButton::new(
|
||||
"new-claude-thread-btn",
|
||||
"New Claude Code Thread",
|
||||
IconName::AiClaude,
|
||||
)
|
||||
// .keybinding(KeyBinding::for_action_in(
|
||||
// &OpenHistory,
|
||||
// &self.focus_handle(cx),
|
||||
// window,
|
||||
// cx,
|
||||
// ))
|
||||
.on_click(
|
||||
|window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(NewExternalAgentThread {
|
||||
agent: Some(
|
||||
crate::ExternalAgent::ClaudeCode,
|
||||
),
|
||||
}),
|
||||
cx,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
.child(
|
||||
NewThreadButton::new(
|
||||
"new-native-agent-thread-btn",
|
||||
"New Native Agent Thread",
|
||||
IconName::ZedAssistant,
|
||||
)
|
||||
// .keybinding(KeyBinding::for_action_in(
|
||||
// &OpenHistory,
|
||||
// &self.focus_handle(cx),
|
||||
// window,
|
||||
// cx,
|
||||
// ))
|
||||
.on_click(
|
||||
|window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(NewExternalAgentThread {
|
||||
agent: Some(
|
||||
crate::ExternalAgent::NativeAgent,
|
||||
),
|
||||
}),
|
||||
cx,
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when_some(configuration_error.as_ref(), |this, err| {
|
||||
this.child(self.render_configuration_error(err, &focus_handle, window, cx))
|
||||
})
|
||||
|
|
|
@ -5,7 +5,6 @@ mod agent_diff;
|
|||
mod agent_model_selector;
|
||||
mod agent_panel;
|
||||
mod buffer_codegen;
|
||||
mod burn_mode_tooltip;
|
||||
mod context_picker;
|
||||
mod context_server_configuration;
|
||||
mod context_strip;
|
||||
|
@ -64,6 +63,8 @@ actions!(
|
|||
NewTextThread,
|
||||
/// Toggles the context picker interface for adding files, symbols, or other context.
|
||||
ToggleContextPicker,
|
||||
/// Toggles the menu to create new agent threads.
|
||||
ToggleNewThreadMenu,
|
||||
/// Toggles the navigation menu for switching between threads and views.
|
||||
ToggleNavigationMenu,
|
||||
/// Toggles the options menu for agent settings and preferences.
|
||||
|
|
|
@ -1,61 +0,0 @@
|
|||
use gpui::{Context, FontWeight, IntoElement, Render, Window};
|
||||
use ui::{prelude::*, tooltip_container};
|
||||
|
||||
pub struct BurnModeTooltip {
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
impl BurnModeTooltip {
|
||||
pub fn new() -> Self {
|
||||
Self { selected: false }
|
||||
}
|
||||
|
||||
pub fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for BurnModeTooltip {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (icon, color) = if self.selected {
|
||||
(IconName::ZedBurnModeOn, Color::Error)
|
||||
} else {
|
||||
(IconName::ZedBurnMode, Color::Default)
|
||||
};
|
||||
|
||||
let turned_on = h_flex()
|
||||
.h_4()
|
||||
.px_1()
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.bg(cx.theme().colors().text_accent.opacity(0.1))
|
||||
.rounded_sm()
|
||||
.child(
|
||||
Label::new("ON")
|
||||
.size(LabelSize::XSmall)
|
||||
.weight(FontWeight::SEMIBOLD)
|
||||
.color(Color::Accent),
|
||||
);
|
||||
|
||||
let title = h_flex()
|
||||
.gap_1p5()
|
||||
.child(Icon::new(icon).size(IconSize::Small).color(color))
|
||||
.child(Label::new("Burn Mode"))
|
||||
.when(self.selected, |title| title.child(turned_on));
|
||||
|
||||
tooltip_container(window, cx, |this, _, _| {
|
||||
this
|
||||
.child(title)
|
||||
.child(
|
||||
div()
|
||||
.max_w_64()
|
||||
.child(
|
||||
Label::new("Enables models to use large context windows, unlimited tool calls, and other capabilities for expanded reasoning.")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,15 +1,16 @@
|
|||
mod completion_provider;
|
||||
mod fetch_context_picker;
|
||||
pub(crate) mod fetch_context_picker;
|
||||
pub(crate) mod file_context_picker;
|
||||
mod rules_context_picker;
|
||||
mod symbol_context_picker;
|
||||
mod thread_context_picker;
|
||||
pub(crate) mod rules_context_picker;
|
||||
pub(crate) mod symbol_context_picker;
|
||||
pub(crate) mod thread_context_picker;
|
||||
|
||||
use std::ops::Range;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use collections::HashSet;
|
||||
pub use completion_provider::ContextPickerCompletionProvider;
|
||||
use editor::display_map::{Crease, CreaseId, CreaseMetadata, FoldId};
|
||||
use editor::{Anchor, AnchorRangeExt as _, Editor, ExcerptId, FoldPlaceholder, ToOffset};
|
||||
|
@ -45,7 +46,7 @@ use agent::{
|
|||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ContextPickerEntry {
|
||||
pub(crate) enum ContextPickerEntry {
|
||||
Mode(ContextPickerMode),
|
||||
Action(ContextPickerAction),
|
||||
}
|
||||
|
@ -74,7 +75,7 @@ impl ContextPickerEntry {
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ContextPickerMode {
|
||||
pub(crate) enum ContextPickerMode {
|
||||
File,
|
||||
Symbol,
|
||||
Fetch,
|
||||
|
@ -83,7 +84,7 @@ enum ContextPickerMode {
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ContextPickerAction {
|
||||
pub(crate) enum ContextPickerAction {
|
||||
AddSelections,
|
||||
}
|
||||
|
||||
|
@ -531,7 +532,7 @@ impl ContextPicker {
|
|||
return vec![];
|
||||
};
|
||||
|
||||
recent_context_picker_entries(
|
||||
recent_context_picker_entries_with_store(
|
||||
context_store,
|
||||
self.thread_store.clone(),
|
||||
self.text_thread_store.clone(),
|
||||
|
@ -585,7 +586,8 @@ impl Render for ContextPicker {
|
|||
})
|
||||
}
|
||||
}
|
||||
enum RecentEntry {
|
||||
|
||||
pub(crate) enum RecentEntry {
|
||||
File {
|
||||
project_path: ProjectPath,
|
||||
path_prefix: Arc<str>,
|
||||
|
@ -593,7 +595,7 @@ enum RecentEntry {
|
|||
Thread(ThreadContextEntry),
|
||||
}
|
||||
|
||||
fn available_context_picker_entries(
|
||||
pub(crate) fn available_context_picker_entries(
|
||||
prompt_store: &Option<Entity<PromptStore>>,
|
||||
thread_store: &Option<WeakEntity<ThreadStore>>,
|
||||
workspace: &Entity<Workspace>,
|
||||
|
@ -630,24 +632,56 @@ fn available_context_picker_entries(
|
|||
entries
|
||||
}
|
||||
|
||||
fn recent_context_picker_entries(
|
||||
fn recent_context_picker_entries_with_store(
|
||||
context_store: Entity<ContextStore>,
|
||||
thread_store: Option<WeakEntity<ThreadStore>>,
|
||||
text_thread_store: Option<WeakEntity<TextThreadStore>>,
|
||||
workspace: Entity<Workspace>,
|
||||
exclude_path: Option<ProjectPath>,
|
||||
cx: &App,
|
||||
) -> Vec<RecentEntry> {
|
||||
let project = workspace.read(cx).project();
|
||||
|
||||
let mut exclude_paths = context_store.read(cx).file_paths(cx);
|
||||
exclude_paths.extend(exclude_path);
|
||||
|
||||
let exclude_paths = exclude_paths
|
||||
.into_iter()
|
||||
.filter_map(|project_path| project.read(cx).absolute_path(&project_path, cx))
|
||||
.collect();
|
||||
|
||||
let exclude_threads = context_store.read(cx).thread_ids();
|
||||
|
||||
recent_context_picker_entries(
|
||||
thread_store,
|
||||
text_thread_store,
|
||||
workspace,
|
||||
&exclude_paths,
|
||||
exclude_threads,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn recent_context_picker_entries(
|
||||
thread_store: Option<WeakEntity<ThreadStore>>,
|
||||
text_thread_store: Option<WeakEntity<TextThreadStore>>,
|
||||
workspace: Entity<Workspace>,
|
||||
exclude_paths: &HashSet<PathBuf>,
|
||||
exclude_threads: &HashSet<ThreadId>,
|
||||
cx: &App,
|
||||
) -> Vec<RecentEntry> {
|
||||
let mut recent = Vec::with_capacity(6);
|
||||
let mut current_files = context_store.read(cx).file_paths(cx);
|
||||
current_files.extend(exclude_path);
|
||||
let workspace = workspace.read(cx);
|
||||
let project = workspace.project().read(cx);
|
||||
|
||||
recent.extend(
|
||||
workspace
|
||||
.recent_navigation_history_iter(cx)
|
||||
.filter(|(path, _)| !current_files.contains(path))
|
||||
.filter(|(_, abs_path)| {
|
||||
abs_path
|
||||
.as_ref()
|
||||
.map_or(true, |path| !exclude_paths.contains(path.as_path()))
|
||||
})
|
||||
.take(4)
|
||||
.filter_map(|(project_path, _)| {
|
||||
project
|
||||
|
@ -659,8 +693,6 @@ fn recent_context_picker_entries(
|
|||
}),
|
||||
);
|
||||
|
||||
let current_threads = context_store.read(cx).thread_ids();
|
||||
|
||||
let active_thread_id = workspace
|
||||
.panel::<AgentPanel>(cx)
|
||||
.and_then(|panel| Some(panel.read(cx).active_thread(cx)?.read(cx).id()));
|
||||
|
@ -672,7 +704,7 @@ fn recent_context_picker_entries(
|
|||
let mut threads = unordered_thread_entries(thread_store, text_thread_store, cx)
|
||||
.filter(|(_, thread)| match thread {
|
||||
ThreadContextEntry::Thread { id, .. } => {
|
||||
Some(id) != active_thread_id && !current_threads.contains(id)
|
||||
Some(id) != active_thread_id && !exclude_threads.contains(id)
|
||||
}
|
||||
ThreadContextEntry::Context { .. } => true,
|
||||
})
|
||||
|
@ -710,7 +742,7 @@ fn add_selections_as_context(
|
|||
})
|
||||
}
|
||||
|
||||
fn selection_ranges(
|
||||
pub(crate) fn selection_ranges(
|
||||
workspace: &Entity<Workspace>,
|
||||
cx: &mut App,
|
||||
) -> Vec<(Entity<Buffer>, Range<text::Anchor>)> {
|
||||
|
|
|
@ -35,7 +35,7 @@ use super::symbol_context_picker::search_symbols;
|
|||
use super::thread_context_picker::{ThreadContextEntry, ThreadMatch, search_threads};
|
||||
use super::{
|
||||
ContextPickerAction, ContextPickerEntry, ContextPickerMode, MentionLink, RecentEntry,
|
||||
available_context_picker_entries, recent_context_picker_entries, selection_ranges,
|
||||
available_context_picker_entries, recent_context_picker_entries_with_store, selection_ranges,
|
||||
};
|
||||
use crate::message_editor::ContextCreasesAddon;
|
||||
|
||||
|
@ -787,7 +787,7 @@ impl CompletionProvider for ContextPickerCompletionProvider {
|
|||
.and_then(|b| b.read(cx).file())
|
||||
.map(|file| ProjectPath::from_file(file.as_ref(), cx));
|
||||
|
||||
let recent_entries = recent_context_picker_entries(
|
||||
let recent_entries = recent_context_picker_entries_with_store(
|
||||
context_store.clone(),
|
||||
thread_store.clone(),
|
||||
text_thread_store.clone(),
|
||||
|
|
|
@ -6,7 +6,7 @@ use crate::agent_diff::AgentDiffThread;
|
|||
use crate::agent_model_selector::AgentModelSelector;
|
||||
use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip};
|
||||
use crate::ui::{
|
||||
MaxModeTooltip,
|
||||
BurnModeTooltip,
|
||||
preview::{AgentPreview, UsageCallout},
|
||||
};
|
||||
use agent::history_store::HistoryStore;
|
||||
|
@ -14,7 +14,7 @@ use agent::{
|
|||
context::{AgentContextKey, ContextLoadResult, load_context},
|
||||
context_store::ContextStoreEvent,
|
||||
};
|
||||
use agent_settings::{AgentSettings, CompletionMode};
|
||||
use agent_settings::{AgentProfileId, AgentSettings, CompletionMode};
|
||||
use ai_onboarding::ApiKeysWithProviders;
|
||||
use buffer_diff::BufferDiff;
|
||||
use cloud_llm_client::CompletionIntent;
|
||||
|
@ -55,7 +55,7 @@ use zed_actions::agent::ToggleModelSelector;
|
|||
|
||||
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention};
|
||||
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
|
||||
use crate::profile_selector::ProfileSelector;
|
||||
use crate::profile_selector::{ProfileProvider, ProfileSelector};
|
||||
use crate::{
|
||||
ActiveThread, AgentDiffPane, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll,
|
||||
ModelUsageContext, NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode,
|
||||
|
@ -152,6 +152,24 @@ pub(crate) fn create_editor(
|
|||
editor
|
||||
}
|
||||
|
||||
impl ProfileProvider for Entity<Thread> {
|
||||
fn profiles_supported(&self, cx: &App) -> bool {
|
||||
self.read(cx)
|
||||
.configured_model()
|
||||
.map_or(false, |model| model.model.supports_tools())
|
||||
}
|
||||
|
||||
fn profile_id(&self, cx: &App) -> AgentProfileId {
|
||||
self.read(cx).profile().id().clone()
|
||||
}
|
||||
|
||||
fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App) {
|
||||
self.update(cx, |this, cx| {
|
||||
this.set_profile(profile_id, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageEditor {
|
||||
pub fn new(
|
||||
fs: Arc<dyn Fs>,
|
||||
|
@ -221,8 +239,9 @@ impl MessageEditor {
|
|||
)
|
||||
});
|
||||
|
||||
let profile_selector =
|
||||
cx.new(|cx| ProfileSelector::new(fs, thread.clone(), editor.focus_handle(cx), cx));
|
||||
let profile_selector = cx.new(|cx| {
|
||||
ProfileSelector::new(fs, Arc::new(thread.clone()), editor.focus_handle(cx), cx)
|
||||
});
|
||||
|
||||
Self {
|
||||
editor: editor.clone(),
|
||||
|
@ -605,7 +624,7 @@ impl MessageEditor {
|
|||
this.toggle_burn_mode(&ToggleBurnMode, window, cx);
|
||||
}))
|
||||
.tooltip(move |_window, cx| {
|
||||
cx.new(|_| MaxModeTooltip::new().selected(burn_mode_enabled))
|
||||
cx.new(|_| BurnModeTooltip::new().selected(burn_mode_enabled))
|
||||
.into()
|
||||
})
|
||||
.into_any_element(),
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
use crate::{ManageProfiles, ToggleProfileSelector};
|
||||
use agent::{
|
||||
Thread,
|
||||
agent_profile::{AgentProfile, AvailableProfiles},
|
||||
};
|
||||
use agent::agent_profile::{AgentProfile, AvailableProfiles};
|
||||
use agent_settings::{AgentDockPosition, AgentProfileId, AgentSettings, builtin_profiles};
|
||||
use fs::Fs;
|
||||
use gpui::{Action, Empty, Entity, FocusHandle, Subscription, prelude::*};
|
||||
use language_model::LanguageModelRegistry;
|
||||
use gpui::{Action, Entity, FocusHandle, Subscription, prelude::*};
|
||||
use settings::{Settings as _, SettingsStore, update_settings_file};
|
||||
use std::sync::Arc;
|
||||
use ui::{
|
||||
|
@ -14,10 +10,22 @@ use ui::{
|
|||
prelude::*,
|
||||
};
|
||||
|
||||
/// Trait for types that can provide and manage agent profiles
|
||||
pub trait ProfileProvider {
|
||||
/// Get the current profile ID
|
||||
fn profile_id(&self, cx: &App) -> AgentProfileId;
|
||||
|
||||
/// Set the profile ID
|
||||
fn set_profile(&self, profile_id: AgentProfileId, cx: &mut App);
|
||||
|
||||
/// Check if profiles are supported in the current context (e.g. if the model that is selected has tool support)
|
||||
fn profiles_supported(&self, cx: &App) -> bool;
|
||||
}
|
||||
|
||||
pub struct ProfileSelector {
|
||||
profiles: AvailableProfiles,
|
||||
fs: Arc<dyn Fs>,
|
||||
thread: Entity<Thread>,
|
||||
provider: Arc<dyn ProfileProvider>,
|
||||
menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||
focus_handle: FocusHandle,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
|
@ -26,7 +34,7 @@ pub struct ProfileSelector {
|
|||
impl ProfileSelector {
|
||||
pub fn new(
|
||||
fs: Arc<dyn Fs>,
|
||||
thread: Entity<Thread>,
|
||||
provider: Arc<dyn ProfileProvider>,
|
||||
focus_handle: FocusHandle,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
|
@ -37,7 +45,7 @@ impl ProfileSelector {
|
|||
Self {
|
||||
profiles: AgentProfile::available_profiles(cx),
|
||||
fs,
|
||||
thread,
|
||||
provider,
|
||||
menu_handle: PopoverMenuHandle::default(),
|
||||
focus_handle,
|
||||
_subscriptions: vec![settings_subscription],
|
||||
|
@ -113,10 +121,10 @@ impl ProfileSelector {
|
|||
builtin_profiles::MINIMAL => Some("Chat about anything with no tools."),
|
||||
_ => None,
|
||||
};
|
||||
let thread_profile_id = self.thread.read(cx).profile().id();
|
||||
let thread_profile_id = self.provider.profile_id(cx);
|
||||
|
||||
let entry = ContextMenuEntry::new(profile_name.clone())
|
||||
.toggleable(IconPosition::End, &profile_id == thread_profile_id);
|
||||
.toggleable(IconPosition::End, profile_id == thread_profile_id);
|
||||
|
||||
let entry = if let Some(doc_text) = documentation {
|
||||
entry.documentation_aside(documentation_side(settings.dock), move |_| {
|
||||
|
@ -128,7 +136,7 @@ impl ProfileSelector {
|
|||
|
||||
entry.handler({
|
||||
let fs = self.fs.clone();
|
||||
let thread = self.thread.clone();
|
||||
let provider = self.provider.clone();
|
||||
let profile_id = profile_id.clone();
|
||||
move |_window, cx| {
|
||||
update_settings_file::<AgentSettings>(fs.clone(), cx, {
|
||||
|
@ -138,9 +146,7 @@ impl ProfileSelector {
|
|||
}
|
||||
});
|
||||
|
||||
thread.update(cx, |this, cx| {
|
||||
this.set_profile(profile_id.clone(), cx);
|
||||
});
|
||||
provider.set_profile(profile_id.clone(), cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -149,22 +155,14 @@ impl ProfileSelector {
|
|||
impl Render for ProfileSelector {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
let profile_id = self.thread.read(cx).profile().id();
|
||||
let profile = settings.profiles.get(profile_id);
|
||||
let profile_id = self.provider.profile_id(cx);
|
||||
let profile = settings.profiles.get(&profile_id);
|
||||
|
||||
let selected_profile = profile
|
||||
.map(|profile| profile.name.clone())
|
||||
.unwrap_or_else(|| "Unknown".into());
|
||||
|
||||
let configured_model = self.thread.read(cx).configured_model().or_else(|| {
|
||||
let model_registry = LanguageModelRegistry::read_global(cx);
|
||||
model_registry.default_model()
|
||||
});
|
||||
let Some(configured_model) = configured_model else {
|
||||
return Empty.into_any_element();
|
||||
};
|
||||
|
||||
if configured_model.model.supports_tools() {
|
||||
if self.provider.profiles_supported(cx) {
|
||||
let this = cx.entity().clone();
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
let trigger_button = Button::new("profile-selector-model", selected_profile)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use crate::{
|
||||
burn_mode_tooltip::BurnModeTooltip,
|
||||
language_model_selector::{LanguageModelSelector, language_model_selector},
|
||||
ui::BurnModeTooltip,
|
||||
};
|
||||
use agent_settings::{AgentSettings, CompletionMode};
|
||||
use anyhow::Result;
|
||||
|
|
|
@ -2,7 +2,7 @@ mod agent_notification;
|
|||
mod burn_mode_tooltip;
|
||||
mod context_pill;
|
||||
mod end_trial_upsell;
|
||||
mod new_thread_button;
|
||||
// mod new_thread_button;
|
||||
mod onboarding_modal;
|
||||
pub mod preview;
|
||||
|
||||
|
@ -10,5 +10,5 @@ pub use agent_notification::*;
|
|||
pub use burn_mode_tooltip::*;
|
||||
pub use context_pill::*;
|
||||
pub use end_trial_upsell::*;
|
||||
pub use new_thread_button::*;
|
||||
// pub use new_thread_button::*;
|
||||
pub use onboarding_modal::*;
|
||||
|
|
|
@ -2,11 +2,11 @@ use crate::ToggleBurnMode;
|
|||
use gpui::{Context, FontWeight, IntoElement, Render, Window};
|
||||
use ui::{KeyBinding, prelude::*, tooltip_container};
|
||||
|
||||
pub struct MaxModeTooltip {
|
||||
pub struct BurnModeTooltip {
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
impl MaxModeTooltip {
|
||||
impl BurnModeTooltip {
|
||||
pub fn new() -> Self {
|
||||
Self { selected: false }
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ impl MaxModeTooltip {
|
|||
}
|
||||
}
|
||||
|
||||
impl Render for MaxModeTooltip {
|
||||
impl Render for BurnModeTooltip {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (icon, color) = if self.selected {
|
||||
(IconName::ZedBurnModeOn, Color::Error)
|
||||
|
|
|
@ -11,7 +11,7 @@ pub struct NewThreadButton {
|
|||
}
|
||||
|
||||
impl NewThreadButton {
|
||||
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, icon: IconName) -> Self {
|
||||
fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, icon: IconName) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
|
@ -21,12 +21,12 @@ impl NewThreadButton {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn keybinding(mut self, keybinding: Option<ui::KeyBinding>) -> Self {
|
||||
fn keybinding(mut self, keybinding: Option<ui::KeyBinding>) -> Self {
|
||||
self.keybinding = keybinding;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click<F>(mut self, handler: F) -> Self
|
||||
fn on_click<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) + 'static,
|
||||
{
|
||||
|
|
|
@ -58,9 +58,7 @@ impl Assets {
|
|||
pub fn load_test_fonts(&self, cx: &App) {
|
||||
cx.text_system()
|
||||
.add_fonts(vec![
|
||||
self.load("fonts/plex-mono/ZedPlexMono-Regular.ttf")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
self.load("fonts/lilex/Lilex-Regular.ttf").unwrap().unwrap(),
|
||||
])
|
||||
.unwrap()
|
||||
}
|
||||
|
|
|
@ -11,6 +11,9 @@ workspace = true
|
|||
[lib]
|
||||
path = "src/assistant_context.rs"
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
agent_settings.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
|
|
@ -138,6 +138,27 @@ impl ContextStore {
|
|||
})
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn fake(project: Entity<Project>, cx: &mut Context<Self>) -> Self {
|
||||
Self {
|
||||
contexts: Default::default(),
|
||||
contexts_metadata: Default::default(),
|
||||
context_server_slash_command_ids: Default::default(),
|
||||
host_contexts: Default::default(),
|
||||
fs: project.read(cx).fs().clone(),
|
||||
languages: project.read(cx).languages().clone(),
|
||||
slash_commands: Arc::default(),
|
||||
telemetry: project.read(cx).client().telemetry().clone(),
|
||||
_watch_updates: Task::ready(None),
|
||||
client: project.read(cx).client(),
|
||||
project,
|
||||
project_is_shared: false,
|
||||
client_subscription: None,
|
||||
_project_subscriptions: Default::default(),
|
||||
prompt_builder: Arc::new(PromptBuilder::new(None).unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_advertise_contexts(
|
||||
this: Entity<Self>,
|
||||
envelope: TypedEnvelope<proto::AdvertiseContexts>,
|
||||
|
|
|
@ -18,6 +18,6 @@ collections.workspace = true
|
|||
derive_more.workspace = true
|
||||
gpui.workspace = true
|
||||
parking_lot.workspace = true
|
||||
rodio = { version = "0.21.1", default-features = false, features = ["wav", "playback", "tracing"] }
|
||||
rodio = { workspace = true, features = [ "wav", "playback", "tracing" ] }
|
||||
util.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
|
|
|
@ -18,7 +18,7 @@ fn main() {}
|
|||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_impl {
|
||||
use std::path::Path;
|
||||
use std::{borrow::Cow, path::Path};
|
||||
|
||||
use super::dialog::create_dialog_window;
|
||||
use super::updater::perform_update;
|
||||
|
@ -37,9 +37,9 @@ mod windows_impl {
|
|||
pub(crate) const WM_JOB_UPDATED: u32 = WM_USER + 1;
|
||||
pub(crate) const WM_TERMINATE: u32 = WM_USER + 2;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Default)]
|
||||
struct Args {
|
||||
launch: Option<bool>,
|
||||
launch: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn run() -> Result<()> {
|
||||
|
@ -56,9 +56,9 @@ mod windows_impl {
|
|||
log::info!("======= Starting Zed update =======");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let hwnd = create_dialog_window(rx)?.0 as isize;
|
||||
let args = parse_args();
|
||||
let args = parse_args(std::env::args().skip(1));
|
||||
std::thread::spawn(move || {
|
||||
let result = perform_update(app_dir.as_path(), Some(hwnd), args.launch.unwrap_or(true));
|
||||
let result = perform_update(app_dir.as_path(), Some(hwnd), args.launch);
|
||||
tx.send(result).ok();
|
||||
unsafe { PostMessageW(Some(HWND(hwnd as _)), WM_TERMINATE, WPARAM(0), LPARAM(0)) }.ok();
|
||||
});
|
||||
|
@ -83,39 +83,27 @@ mod windows_impl {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_args() -> Args {
|
||||
let mut result = Args { launch: None };
|
||||
if let Some(candidate) = std::env::args().nth(1) {
|
||||
parse_single_arg(&candidate, &mut result);
|
||||
fn parse_args(input: impl IntoIterator<Item = String>) -> Args {
|
||||
let mut args: Args = Args { launch: true };
|
||||
|
||||
let mut input = input.into_iter();
|
||||
if let Some(arg) = input.next() {
|
||||
let launch_arg;
|
||||
|
||||
if arg == "--launch" {
|
||||
launch_arg = input.next().map(Cow::Owned);
|
||||
} else if let Some(rest) = arg.strip_prefix("--launch=") {
|
||||
launch_arg = Some(Cow::Borrowed(rest));
|
||||
} else {
|
||||
launch_arg = None;
|
||||
}
|
||||
|
||||
if launch_arg.as_deref() == Some("false") {
|
||||
args.launch = false;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn parse_single_arg(arg: &str, result: &mut Args) {
|
||||
let Some((key, value)) = arg.strip_prefix("--").and_then(|arg| arg.split_once('=')) else {
|
||||
log::error!(
|
||||
"Invalid argument format: '{}'. Expected format: --key=value",
|
||||
arg
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
match key {
|
||||
"launch" => parse_launch_arg(value, &mut result.launch),
|
||||
_ => log::error!("Unknown argument: --{}", key),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_launch_arg(value: &str, arg: &mut Option<bool>) {
|
||||
match value {
|
||||
"true" => *arg = Some(true),
|
||||
"false" => *arg = Some(false),
|
||||
_ => log::error!(
|
||||
"Invalid value for --launch: '{}'. Expected 'true' or 'false'",
|
||||
value
|
||||
),
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
pub(crate) fn show_error(mut content: String) {
|
||||
|
@ -135,44 +123,28 @@ mod windows_impl {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::windows_impl::{Args, parse_launch_arg, parse_single_arg};
|
||||
use crate::windows_impl::parse_args;
|
||||
|
||||
#[test]
|
||||
fn test_parse_launch_arg() {
|
||||
let mut arg = None;
|
||||
parse_launch_arg("true", &mut arg);
|
||||
assert_eq!(arg, Some(true));
|
||||
fn test_parse_args() {
|
||||
// launch can be specified via two separate arguments
|
||||
assert_eq!(parse_args(["--launch".into(), "true".into()]).launch, true);
|
||||
assert_eq!(
|
||||
parse_args(["--launch".into(), "false".into()]).launch,
|
||||
false
|
||||
);
|
||||
|
||||
let mut arg = None;
|
||||
parse_launch_arg("false", &mut arg);
|
||||
assert_eq!(arg, Some(false));
|
||||
// launch can be specified via one single argument
|
||||
assert_eq!(parse_args(["--launch=true".into()]).launch, true);
|
||||
assert_eq!(parse_args(["--launch=false".into()]).launch, false);
|
||||
|
||||
let mut arg = None;
|
||||
parse_launch_arg("invalid", &mut arg);
|
||||
assert_eq!(arg, None);
|
||||
}
|
||||
// launch defaults to true on no arguments
|
||||
assert_eq!(parse_args([]).launch, true);
|
||||
|
||||
#[test]
|
||||
fn test_parse_single_arg() {
|
||||
let mut args = Args { launch: None };
|
||||
parse_single_arg("--launch=true", &mut args);
|
||||
assert_eq!(args.launch, Some(true));
|
||||
|
||||
let mut args = Args { launch: None };
|
||||
parse_single_arg("--launch=false", &mut args);
|
||||
assert_eq!(args.launch, Some(false));
|
||||
|
||||
let mut args = Args { launch: None };
|
||||
parse_single_arg("--launch=invalid", &mut args);
|
||||
assert_eq!(args.launch, None);
|
||||
|
||||
let mut args = Args { launch: None };
|
||||
parse_single_arg("--launch", &mut args);
|
||||
assert_eq!(args.launch, None);
|
||||
|
||||
let mut args = Args { launch: None };
|
||||
parse_single_arg("--unknown", &mut args);
|
||||
assert_eq!(args.launch, None);
|
||||
// launch defaults to true on invalid arguments
|
||||
assert_eq!(parse_args(["--launch".into()]).launch, true);
|
||||
assert_eq!(parse_args(["--launch=".into()]).launch, true);
|
||||
assert_eq!(parse_args(["--launch=invalid".into()]).launch, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,10 +10,10 @@ use client::{
|
|||
};
|
||||
use collections::{BTreeMap, HashMap, HashSet};
|
||||
use fs::Fs;
|
||||
use futures::{FutureExt, StreamExt};
|
||||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, ScreenCaptureSource,
|
||||
ScreenCaptureStream, Task, WeakEntity,
|
||||
App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FutureExt as _,
|
||||
ScreenCaptureSource, ScreenCaptureStream, Task, Timeout, WeakEntity,
|
||||
};
|
||||
use gpui_tokio::Tokio;
|
||||
use language::LanguageRegistry;
|
||||
|
@ -370,57 +370,53 @@ impl Room {
|
|||
})?;
|
||||
|
||||
// Wait for client to re-establish a connection to the server.
|
||||
{
|
||||
let mut reconnection_timeout =
|
||||
cx.background_executor().timer(RECONNECT_TIMEOUT).fuse();
|
||||
let client_reconnection = async {
|
||||
let mut remaining_attempts = 3;
|
||||
while remaining_attempts > 0 {
|
||||
if client_status.borrow().is_connected() {
|
||||
log::info!("client reconnected, attempting to rejoin room");
|
||||
let executor = cx.background_executor().clone();
|
||||
let client_reconnection = async {
|
||||
let mut remaining_attempts = 3;
|
||||
while remaining_attempts > 0 {
|
||||
if client_status.borrow().is_connected() {
|
||||
log::info!("client reconnected, attempting to rejoin room");
|
||||
|
||||
let Some(this) = this.upgrade() else { break };
|
||||
match this.update(cx, |this, cx| this.rejoin(cx)) {
|
||||
Ok(task) => {
|
||||
if task.await.log_err().is_some() {
|
||||
return true;
|
||||
} else {
|
||||
remaining_attempts -= 1;
|
||||
}
|
||||
let Some(this) = this.upgrade() else { break };
|
||||
match this.update(cx, |this, cx| this.rejoin(cx)) {
|
||||
Ok(task) => {
|
||||
if task.await.log_err().is_some() {
|
||||
return true;
|
||||
} else {
|
||||
remaining_attempts -= 1;
|
||||
}
|
||||
Err(_app_dropped) => return false,
|
||||
}
|
||||
} else if client_status.borrow().is_signed_out() {
|
||||
return false;
|
||||
Err(_app_dropped) => return false,
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"waiting for client status change, remaining attempts {}",
|
||||
remaining_attempts
|
||||
);
|
||||
client_status.next().await;
|
||||
} else if client_status.borrow().is_signed_out() {
|
||||
return false;
|
||||
}
|
||||
false
|
||||
|
||||
log::info!(
|
||||
"waiting for client status change, remaining attempts {}",
|
||||
remaining_attempts
|
||||
);
|
||||
client_status.next().await;
|
||||
}
|
||||
.fuse();
|
||||
futures::pin_mut!(client_reconnection);
|
||||
false
|
||||
};
|
||||
|
||||
futures::select_biased! {
|
||||
reconnected = client_reconnection => {
|
||||
if reconnected {
|
||||
log::info!("successfully reconnected to room");
|
||||
// If we successfully joined the room, go back around the loop
|
||||
// waiting for future connection status changes.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ = reconnection_timeout => {
|
||||
log::info!("room reconnection timeout expired");
|
||||
}
|
||||
match client_reconnection
|
||||
.with_timeout(RECONNECT_TIMEOUT, &executor)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
log::info!("successfully reconnected to room");
|
||||
// If we successfully joined the room, go back around the loop
|
||||
// waiting for future connection status changes.
|
||||
continue;
|
||||
}
|
||||
Ok(false) => break,
|
||||
Err(Timeout) => {
|
||||
log::info!("room reconnection timeout expired");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,9 +11,7 @@ use crate::{
|
|||
db::{User, UserId},
|
||||
rpc,
|
||||
};
|
||||
use ::rpc::proto;
|
||||
use anyhow::Context as _;
|
||||
use axum::extract;
|
||||
use axum::{
|
||||
Extension, Json, Router,
|
||||
body::Body,
|
||||
|
@ -25,7 +23,6 @@ use axum::{
|
|||
routing::{get, post},
|
||||
};
|
||||
use axum_extra::response::ErasedJson;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tower::ServiceBuilder;
|
||||
|
@ -102,8 +99,6 @@ pub fn routes(rpc_server: Arc<rpc::Server>) -> Router<(), Body> {
|
|||
Router::new()
|
||||
.route("/users/look_up", get(look_up_user))
|
||||
.route("/users/:id/access_tokens", post(create_access_token))
|
||||
.route("/users/:id/refresh_llm_tokens", post(refresh_llm_tokens))
|
||||
.route("/users/:id/update_plan", post(update_plan))
|
||||
.route("/rpc_server_snapshot", get(get_rpc_server_snapshot))
|
||||
.merge(contributors::router())
|
||||
.layer(
|
||||
|
@ -295,90 +290,3 @@ async fn create_access_token(
|
|||
encrypted_access_token,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshLlmTokensResponse {}
|
||||
|
||||
async fn refresh_llm_tokens(
|
||||
Path(user_id): Path<UserId>,
|
||||
Extension(rpc_server): Extension<Arc<rpc::Server>>,
|
||||
) -> Result<Json<RefreshLlmTokensResponse>> {
|
||||
rpc_server.refresh_llm_tokens_for_user(user_id).await;
|
||||
|
||||
Ok(Json(RefreshLlmTokensResponse {}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct UpdatePlanBody {
|
||||
pub plan: cloud_llm_client::Plan,
|
||||
pub subscription_period: SubscriptionPeriod,
|
||||
pub usage: cloud_llm_client::CurrentUsage,
|
||||
pub trial_started_at: Option<DateTime<Utc>>,
|
||||
pub is_usage_based_billing_enabled: bool,
|
||||
pub is_account_too_young: bool,
|
||||
pub has_overdue_invoices: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)]
|
||||
struct SubscriptionPeriod {
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub ended_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UpdatePlanResponse {}
|
||||
|
||||
async fn update_plan(
|
||||
Path(user_id): Path<UserId>,
|
||||
Extension(rpc_server): Extension<Arc<rpc::Server>>,
|
||||
extract::Json(body): extract::Json<UpdatePlanBody>,
|
||||
) -> Result<Json<UpdatePlanResponse>> {
|
||||
let plan = match body.plan {
|
||||
cloud_llm_client::Plan::ZedFree => proto::Plan::Free,
|
||||
cloud_llm_client::Plan::ZedPro => proto::Plan::ZedPro,
|
||||
cloud_llm_client::Plan::ZedProTrial => proto::Plan::ZedProTrial,
|
||||
};
|
||||
|
||||
let update_user_plan = proto::UpdateUserPlan {
|
||||
plan: plan.into(),
|
||||
trial_started_at: body
|
||||
.trial_started_at
|
||||
.map(|trial_started_at| trial_started_at.timestamp() as u64),
|
||||
is_usage_based_billing_enabled: Some(body.is_usage_based_billing_enabled),
|
||||
usage: Some(proto::SubscriptionUsage {
|
||||
model_requests_usage_amount: body.usage.model_requests.used,
|
||||
model_requests_usage_limit: Some(usage_limit_to_proto(body.usage.model_requests.limit)),
|
||||
edit_predictions_usage_amount: body.usage.edit_predictions.used,
|
||||
edit_predictions_usage_limit: Some(usage_limit_to_proto(
|
||||
body.usage.edit_predictions.limit,
|
||||
)),
|
||||
}),
|
||||
subscription_period: Some(proto::SubscriptionPeriod {
|
||||
started_at: body.subscription_period.started_at.timestamp() as u64,
|
||||
ended_at: body.subscription_period.ended_at.timestamp() as u64,
|
||||
}),
|
||||
account_too_young: Some(body.is_account_too_young),
|
||||
has_overdue_invoices: Some(body.has_overdue_invoices),
|
||||
};
|
||||
|
||||
rpc_server
|
||||
.update_plan_for_user(user_id, update_user_plan)
|
||||
.await?;
|
||||
|
||||
Ok(Json(UpdatePlanResponse {}))
|
||||
}
|
||||
|
||||
fn usage_limit_to_proto(limit: cloud_llm_client::UsageLimit) -> proto::UsageLimit {
|
||||
proto::UsageLimit {
|
||||
variant: Some(match limit {
|
||||
cloud_llm_client::UsageLimit::Limited(limit) => {
|
||||
proto::usage_limit::Variant::Limited(proto::usage_limit::Limited {
|
||||
limit: limit as u32,
|
||||
})
|
||||
}
|
||||
cloud_llm_client::UsageLimit::Unlimited => {
|
||||
proto::usage_limit::Variant::Unlimited(proto::usage_limit::Unlimited {})
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1081,53 +1081,6 @@ impl Server {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_plan_for_user(
|
||||
self: &Arc<Self>,
|
||||
user_id: UserId,
|
||||
update_user_plan: proto::UpdateUserPlan,
|
||||
) -> Result<()> {
|
||||
let pool = self.connection_pool.lock();
|
||||
for connection_id in pool.user_connection_ids(user_id) {
|
||||
self.peer
|
||||
.send(connection_id, update_user_plan.clone())
|
||||
.trace_err();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This is the legacy way of updating the user's plan, where we fetch the data to construct the `UpdateUserPlan`
|
||||
/// message on the Collab server.
|
||||
///
|
||||
/// The new way is to receive the data from Cloud via the `POST /users/:id/update_plan` endpoint.
|
||||
pub async fn update_plan_for_user_legacy(self: &Arc<Self>, user_id: UserId) -> Result<()> {
|
||||
let user = self
|
||||
.app_state
|
||||
.db
|
||||
.get_user_by_id(user_id)
|
||||
.await?
|
||||
.context("user not found")?;
|
||||
|
||||
let update_user_plan = make_update_user_plan_message(
|
||||
&user,
|
||||
user.admin,
|
||||
&self.app_state.db,
|
||||
self.app_state.llm_db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.update_plan_for_user(user_id, update_user_plan).await
|
||||
}
|
||||
|
||||
pub async fn refresh_llm_tokens_for_user(self: &Arc<Self>, user_id: UserId) {
|
||||
let pool = self.connection_pool.lock();
|
||||
for connection_id in pool.user_connection_ids(user_id) {
|
||||
self.peer
|
||||
.send(connection_id, proto::RefreshLlmToken {})
|
||||
.trace_err();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn snapshot(self: &Arc<Self>) -> ServerSnapshot<'_> {
|
||||
ServerSnapshot {
|
||||
connection_pool: ConnectionPoolGuard {
|
||||
|
|
|
@ -30,7 +30,19 @@ impl fmt::Display for ZedVersion {
|
|||
|
||||
impl ZedVersion {
|
||||
pub fn can_collaborate(&self) -> bool {
|
||||
self.0 >= SemanticVersion::new(0, 157, 0)
|
||||
// v0.198.4 is the first version where we no longer connect to Collab automatically.
|
||||
// We reject any clients older than that to prevent them from connecting to Collab just for authentication.
|
||||
if self.0 < SemanticVersion::new(0, 198, 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Since we hotfixed the changes to no longer connect to Collab automatically to Preview, we also need to reject
|
||||
// versions in the range [v0.199.0, v0.199.1].
|
||||
if self.0 >= SemanticVersion::new(0, 199, 0) && self.0 < SemanticVersion::new(0, 199, 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ use language::{
|
|||
point_from_lsp, point_to_lsp,
|
||||
};
|
||||
use lsp::{LanguageServer, LanguageServerBinary, LanguageServerId, LanguageServerName};
|
||||
use node_runtime::NodeRuntime;
|
||||
use node_runtime::{NodeRuntime, VersionStrategy};
|
||||
use parking_lot::Mutex;
|
||||
use project::DisableAiSettings;
|
||||
use request::StatusNotification;
|
||||
|
@ -349,7 +349,11 @@ impl Copilot {
|
|||
this.start_copilot(true, false, cx);
|
||||
cx.observe_global::<SettingsStore>(move |this, cx| {
|
||||
this.start_copilot(true, false, cx);
|
||||
this.send_configuration_update(cx);
|
||||
if let Ok(server) = this.server.as_running() {
|
||||
notify_did_change_config_to_server(&server.lsp, cx)
|
||||
.context("copilot setting change: did change configuration")
|
||||
.log_err();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
this
|
||||
|
@ -438,43 +442,6 @@ impl Copilot {
|
|||
if env.is_empty() { None } else { Some(env) }
|
||||
}
|
||||
|
||||
fn send_configuration_update(&mut self, cx: &mut Context<Self>) {
|
||||
let copilot_settings = all_language_settings(None, cx)
|
||||
.edit_predictions
|
||||
.copilot
|
||||
.clone();
|
||||
|
||||
let settings = json!({
|
||||
"http": {
|
||||
"proxy": copilot_settings.proxy,
|
||||
"proxyStrictSSL": !copilot_settings.proxy_no_verify.unwrap_or(false)
|
||||
},
|
||||
"github-enterprise": {
|
||||
"uri": copilot_settings.enterprise_uri
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(copilot_chat) = copilot_chat::CopilotChat::global(cx) {
|
||||
copilot_chat.update(cx, |chat, cx| {
|
||||
chat.set_configuration(
|
||||
copilot_chat::CopilotChatConfiguration {
|
||||
enterprise_uri: copilot_settings.enterprise_uri.clone(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if let Ok(server) = self.server.as_running() {
|
||||
server
|
||||
.lsp
|
||||
.notify::<lsp::notification::DidChangeConfiguration>(
|
||||
&lsp::DidChangeConfigurationParams { settings },
|
||||
)
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn fake(cx: &mut gpui::TestAppContext) -> (Entity<Self>, lsp::FakeLanguageServer) {
|
||||
use fs::FakeFs;
|
||||
|
@ -573,6 +540,9 @@ impl Copilot {
|
|||
})?
|
||||
.await?;
|
||||
|
||||
this.update(cx, |_, cx| notify_did_change_config_to_server(&server, cx))?
|
||||
.context("copilot: did change configuration")?;
|
||||
|
||||
let status = server
|
||||
.request::<request::CheckStatus>(request::CheckStatusParams {
|
||||
local_checks_only: false,
|
||||
|
@ -598,8 +568,6 @@ impl Copilot {
|
|||
});
|
||||
cx.emit(Event::CopilotLanguageServerStarted);
|
||||
this.update_sign_in_status(status, cx);
|
||||
// Send configuration now that the LSP is fully started
|
||||
this.send_configuration_update(cx);
|
||||
}
|
||||
Err(error) => {
|
||||
this.server = CopilotServer::Error(error.to_string().into());
|
||||
|
@ -1156,6 +1124,41 @@ fn uri_for_buffer(buffer: &Entity<Buffer>, cx: &App) -> Result<lsp::Url, ()> {
|
|||
}
|
||||
}
|
||||
|
||||
fn notify_did_change_config_to_server(
|
||||
server: &Arc<LanguageServer>,
|
||||
cx: &mut Context<Copilot>,
|
||||
) -> std::result::Result<(), anyhow::Error> {
|
||||
let copilot_settings = all_language_settings(None, cx)
|
||||
.edit_predictions
|
||||
.copilot
|
||||
.clone();
|
||||
|
||||
if let Some(copilot_chat) = copilot_chat::CopilotChat::global(cx) {
|
||||
copilot_chat.update(cx, |chat, cx| {
|
||||
chat.set_configuration(
|
||||
copilot_chat::CopilotChatConfiguration {
|
||||
enterprise_uri: copilot_settings.enterprise_uri.clone(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let settings = json!({
|
||||
"http": {
|
||||
"proxy": copilot_settings.proxy,
|
||||
"proxyStrictSSL": !copilot_settings.proxy_no_verify.unwrap_or(false)
|
||||
},
|
||||
"github-enterprise": {
|
||||
"uri": copilot_settings.enterprise_uri
|
||||
}
|
||||
});
|
||||
|
||||
server.notify::<lsp::notification::DidChangeConfiguration>(&lsp::DidChangeConfigurationParams {
|
||||
settings,
|
||||
})
|
||||
}
|
||||
|
||||
async fn clear_copilot_dir() {
|
||||
remove_matching(paths::copilot_dir(), |_| true).await
|
||||
}
|
||||
|
@ -1181,7 +1184,7 @@ async fn get_copilot_lsp(fs: Arc<dyn Fs>, node_runtime: NodeRuntime) -> anyhow::
|
|||
PACKAGE_NAME,
|
||||
&server_path,
|
||||
paths::copilot_dir(),
|
||||
&latest_version,
|
||||
VersionStrategy::Latest(&latest_version),
|
||||
)
|
||||
.await;
|
||||
if should_install {
|
||||
|
|
|
@ -273,6 +273,16 @@ pub enum UuidVersion {
|
|||
V7,
|
||||
}
|
||||
|
||||
/// Splits selection into individual lines.
|
||||
#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
|
||||
#[action(namespace = editor)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SplitSelectionIntoLines {
|
||||
/// Keep the text selected after splitting instead of collapsing to cursors.
|
||||
#[serde(default)]
|
||||
pub keep_selections: bool,
|
||||
}
|
||||
|
||||
/// Goes to the next diagnostic in the file.
|
||||
#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
|
||||
#[action(namespace = editor)]
|
||||
|
@ -672,8 +682,6 @@ actions!(
|
|||
SortLinesCaseInsensitive,
|
||||
/// Sorts selected lines case-sensitively.
|
||||
SortLinesCaseSensitive,
|
||||
/// Splits selection into individual lines.
|
||||
SplitSelectionIntoLines,
|
||||
/// Stops the language server for the current file.
|
||||
StopLanguageServer,
|
||||
/// Switches between source and header files.
|
||||
|
|
|
@ -2290,8 +2290,6 @@ mod tests {
|
|||
fn test_blocks_on_wrapped_lines(cx: &mut gpui::TestAppContext) {
|
||||
cx.update(init_test);
|
||||
|
||||
let _font_id = cx.text_system().font_id(&font("Helvetica")).unwrap();
|
||||
|
||||
let text = "one two three\nfour five six\nseven eight";
|
||||
|
||||
let buffer = cx.update(|cx| MultiBuffer::build_simple(text, cx));
|
||||
|
|
|
@ -1223,7 +1223,7 @@ mod tests {
|
|||
let tab_size = NonZeroU32::new(rng.gen_range(1..=4)).unwrap();
|
||||
|
||||
let font = test_font();
|
||||
let _font_id = text_system.font_id(&font);
|
||||
let _font_id = text_system.resolve_font(&font);
|
||||
let font_size = px(14.0);
|
||||
|
||||
log::info!("Tab size: {}", tab_size);
|
||||
|
|
|
@ -12176,6 +12176,8 @@ impl Editor {
|
|||
let clipboard_text = Cow::Borrowed(text);
|
||||
|
||||
self.transact(window, cx, |this, window, cx| {
|
||||
let had_active_edit_prediction = this.has_active_edit_prediction();
|
||||
|
||||
if let Some(mut clipboard_selections) = clipboard_selections {
|
||||
let old_selections = this.selections.all::<usize>(cx);
|
||||
let all_selections_were_entire_line =
|
||||
|
@ -12248,6 +12250,11 @@ impl Editor {
|
|||
} else {
|
||||
this.insert(&clipboard_text, window, cx);
|
||||
}
|
||||
|
||||
let trigger_in_words =
|
||||
this.show_edit_predictions_in_menu() || !had_active_edit_prediction;
|
||||
|
||||
this.trigger_completion_on_input(&text, trigger_in_words, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -13605,7 +13612,7 @@ impl Editor {
|
|||
|
||||
pub fn split_selection_into_lines(
|
||||
&mut self,
|
||||
_: &SplitSelectionIntoLines,
|
||||
action: &SplitSelectionIntoLines,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
|
@ -13622,8 +13629,21 @@ impl Editor {
|
|||
let buffer = self.buffer.read(cx).read(cx);
|
||||
for selection in selections {
|
||||
for row in selection.start.row..selection.end.row {
|
||||
let cursor = Point::new(row, buffer.line_len(MultiBufferRow(row)));
|
||||
new_selection_ranges.push(cursor..cursor);
|
||||
let line_start = Point::new(row, 0);
|
||||
let line_end = Point::new(row, buffer.line_len(MultiBufferRow(row)));
|
||||
|
||||
if action.keep_selections {
|
||||
// Keep the selection range for each line
|
||||
let selection_start = if row == selection.start.row {
|
||||
selection.start
|
||||
} else {
|
||||
line_start
|
||||
};
|
||||
new_selection_ranges.push(selection_start..line_end);
|
||||
} else {
|
||||
// Collapse to cursor at end of line
|
||||
new_selection_ranges.push(line_end..line_end);
|
||||
}
|
||||
}
|
||||
|
||||
let is_multiline_selection = selection.start.row != selection.end.row;
|
||||
|
@ -13631,7 +13651,16 @@ impl Editor {
|
|||
// so this action feels more ergonomic when paired with other selection operations
|
||||
let should_skip_last = is_multiline_selection && selection.end.column == 0;
|
||||
if !should_skip_last {
|
||||
new_selection_ranges.push(selection.end..selection.end);
|
||||
if action.keep_selections {
|
||||
if is_multiline_selection {
|
||||
let line_start = Point::new(selection.end.row, 0);
|
||||
new_selection_ranges.push(line_start..selection.end);
|
||||
} else {
|
||||
new_selection_ranges.push(selection.start..selection.end);
|
||||
}
|
||||
} else {
|
||||
new_selection_ranges.push(selection.end..selection.end);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -15835,19 +15864,25 @@ impl Editor {
|
|||
|
||||
let tab_kind = match kind {
|
||||
Some(GotoDefinitionKind::Implementation) => "Implementations",
|
||||
_ => "Definitions",
|
||||
Some(GotoDefinitionKind::Symbol) | None => "Definitions",
|
||||
Some(GotoDefinitionKind::Declaration) => "Declarations",
|
||||
Some(GotoDefinitionKind::Type) => "Types",
|
||||
};
|
||||
let title = editor
|
||||
.update_in(acx, |_, _, cx| {
|
||||
let origin = locations.first().unwrap();
|
||||
let buffer = origin.buffer.read(cx);
|
||||
format!(
|
||||
"{} for {}",
|
||||
tab_kind,
|
||||
buffer
|
||||
.text_for_range(origin.range.clone())
|
||||
.collect::<String>()
|
||||
)
|
||||
let target = locations
|
||||
.iter()
|
||||
.map(|location| {
|
||||
location
|
||||
.buffer
|
||||
.read(cx)
|
||||
.text_for_range(location.range.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.unique()
|
||||
.take(3)
|
||||
.join(", ");
|
||||
format!("{tab_kind} for {target}")
|
||||
})
|
||||
.context("buffer title")?;
|
||||
|
||||
|
@ -16043,19 +16078,19 @@ impl Editor {
|
|||
}
|
||||
|
||||
workspace.update_in(cx, |workspace, window, cx| {
|
||||
let title = locations
|
||||
.first()
|
||||
.as_ref()
|
||||
let target = locations
|
||||
.iter()
|
||||
.map(|location| {
|
||||
let buffer = location.buffer.read(cx);
|
||||
format!(
|
||||
"References to `{}`",
|
||||
buffer
|
||||
.text_for_range(location.range.clone())
|
||||
.collect::<String>()
|
||||
)
|
||||
location
|
||||
.buffer
|
||||
.read(cx)
|
||||
.text_for_range(location.range.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.unwrap();
|
||||
.unique()
|
||||
.take(3)
|
||||
.join(", ");
|
||||
let title = format!("References to {target}");
|
||||
Self::open_locations_in_multibuffer(
|
||||
workspace,
|
||||
locations,
|
||||
|
@ -20200,6 +20235,7 @@ impl Editor {
|
|||
);
|
||||
|
||||
let old_cursor_shape = self.cursor_shape;
|
||||
let old_show_breadcrumbs = self.show_breadcrumbs;
|
||||
|
||||
{
|
||||
let editor_settings = EditorSettings::get_global(cx);
|
||||
|
@ -20213,6 +20249,10 @@ impl Editor {
|
|||
cx.emit(EditorEvent::CursorShapeChanged);
|
||||
}
|
||||
|
||||
if old_show_breadcrumbs != self.show_breadcrumbs {
|
||||
cx.emit(EditorEvent::BreadcrumbsChanged);
|
||||
}
|
||||
|
||||
let project_settings = ProjectSettings::get_global(cx);
|
||||
self.serialize_dirty_buffers =
|
||||
!self.mode.is_minimap() && project_settings.session.restore_unsaved_buffers;
|
||||
|
@ -22834,6 +22874,7 @@ pub enum EditorEvent {
|
|||
},
|
||||
Reloaded,
|
||||
CursorShapeChanged,
|
||||
BreadcrumbsChanged,
|
||||
PushedToNavHistory {
|
||||
anchor: Anchor,
|
||||
is_deactivate: bool,
|
||||
|
|
|
@ -1901,6 +1901,51 @@ fn test_beginning_of_line_stop_at_indent(cx: &mut TestAppContext) {
|
|||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_beginning_of_line_with_cursor_between_line_start_and_indent(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let move_to_beg = MoveToBeginningOfLine {
|
||||
stop_at_soft_wraps: true,
|
||||
stop_at_indent: true,
|
||||
};
|
||||
|
||||
let editor = cx.add_window(|window, cx| {
|
||||
let buffer = MultiBuffer::build_simple(" hello\nworld", cx);
|
||||
build_editor(buffer, window, cx)
|
||||
});
|
||||
|
||||
_ = editor.update(cx, |editor, window, cx| {
|
||||
// test cursor between line_start and indent_start
|
||||
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
||||
s.select_display_ranges([
|
||||
DisplayPoint::new(DisplayRow(0), 3)..DisplayPoint::new(DisplayRow(0), 3)
|
||||
]);
|
||||
});
|
||||
|
||||
// cursor should move to line_start
|
||||
editor.move_to_beginning_of_line(&move_to_beg, window, cx);
|
||||
assert_eq!(
|
||||
editor.selections.display_ranges(cx),
|
||||
&[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)]
|
||||
);
|
||||
|
||||
// cursor should move to indent_start
|
||||
editor.move_to_beginning_of_line(&move_to_beg, window, cx);
|
||||
assert_eq!(
|
||||
editor.selections.display_ranges(cx),
|
||||
&[DisplayPoint::new(DisplayRow(0), 4)..DisplayPoint::new(DisplayRow(0), 4)]
|
||||
);
|
||||
|
||||
// cursor should move to back to line_start
|
||||
editor.move_to_beginning_of_line(&move_to_beg, window, cx);
|
||||
assert_eq!(
|
||||
editor.selections.display_ranges(cx),
|
||||
&[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_prev_next_word_boundary(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
@ -6356,7 +6401,7 @@ async fn test_split_selection_into_lines(cx: &mut TestAppContext) {
|
|||
fn test(cx: &mut EditorTestContext, initial_state: &'static str, expected_state: &'static str) {
|
||||
cx.set_state(initial_state);
|
||||
cx.update_editor(|e, window, cx| {
|
||||
e.split_selection_into_lines(&SplitSelectionIntoLines, window, cx)
|
||||
e.split_selection_into_lines(&Default::default(), window, cx)
|
||||
});
|
||||
cx.assert_editor_state(expected_state);
|
||||
}
|
||||
|
@ -6444,7 +6489,7 @@ async fn test_split_selection_into_lines_interacting_with_creases(cx: &mut TestA
|
|||
DisplayPoint::new(DisplayRow(4), 4)..DisplayPoint::new(DisplayRow(4), 4),
|
||||
])
|
||||
});
|
||||
editor.split_selection_into_lines(&SplitSelectionIntoLines, window, cx);
|
||||
editor.split_selection_into_lines(&Default::default(), window, cx);
|
||||
assert_eq!(
|
||||
editor.display_text(cx),
|
||||
"aaaaa\nbbbbb\nccc⋯eeee\nfffff\nggggg\n⋯i"
|
||||
|
@ -6460,7 +6505,7 @@ async fn test_split_selection_into_lines_interacting_with_creases(cx: &mut TestA
|
|||
DisplayPoint::new(DisplayRow(5), 0)..DisplayPoint::new(DisplayRow(0), 1)
|
||||
])
|
||||
});
|
||||
editor.split_selection_into_lines(&SplitSelectionIntoLines, window, cx);
|
||||
editor.split_selection_into_lines(&Default::default(), window, cx);
|
||||
assert_eq!(
|
||||
editor.display_text(cx),
|
||||
"aaaaa\nbbbbb\nccccc\nddddd\neeeee\nfffff\nggggg\nhhhhh\niiiii"
|
||||
|
|
|
@ -3014,7 +3014,7 @@ impl EditorElement {
|
|||
.icon_color(Color::Custom(cx.theme().colors().editor_line_number))
|
||||
.selected_icon_color(Color::Custom(cx.theme().colors().editor_foreground))
|
||||
.icon_size(IconSize::Custom(rems(editor_font_size / window.rem_size())))
|
||||
.width(width.into())
|
||||
.width(width)
|
||||
.on_click(move |_, window, cx| {
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.expand_excerpt(excerpt_id, direction, window, cx);
|
||||
|
@ -3630,7 +3630,7 @@ impl EditorElement {
|
|||
ButtonLike::new("toggle-buffer-fold")
|
||||
.style(ui::ButtonStyle::Transparent)
|
||||
.height(px(28.).into())
|
||||
.width(px(28.).into())
|
||||
.width(px(28.))
|
||||
.children(toggle_chevron_icon)
|
||||
.tooltip({
|
||||
let focus_handle = focus_handle.clone();
|
||||
|
|
|
@ -654,6 +654,10 @@ impl Item for Editor {
|
|||
}
|
||||
}
|
||||
|
||||
fn suggested_filename(&self, cx: &App) -> SharedString {
|
||||
self.buffer.read(cx).title(cx).to_string().into()
|
||||
}
|
||||
|
||||
fn tab_icon(&self, _: &Window, cx: &App) -> Option<Icon> {
|
||||
ItemSettings::get_global(cx)
|
||||
.file_icons
|
||||
|
@ -1036,6 +1040,10 @@ impl Item for Editor {
|
|||
f(ItemEvent::UpdateBreadcrumbs);
|
||||
}
|
||||
|
||||
EditorEvent::BreadcrumbsChanged => {
|
||||
f(ItemEvent::UpdateBreadcrumbs);
|
||||
}
|
||||
|
||||
EditorEvent::DirtyChanged => {
|
||||
f(ItemEvent::UpdateTab);
|
||||
}
|
||||
|
|
|
@ -230,7 +230,7 @@ pub fn indented_line_beginning(
|
|||
if stop_at_soft_boundaries && soft_line_start > indent_start && display_point != soft_line_start
|
||||
{
|
||||
soft_line_start
|
||||
} else if stop_at_indent && display_point != indent_start {
|
||||
} else if stop_at_indent && (display_point > indent_start || display_point == line_start) {
|
||||
indent_start
|
||||
} else {
|
||||
line_start
|
||||
|
|
|
@ -53,7 +53,7 @@ pub fn marked_display_snapshot(
|
|||
let (unmarked_text, markers) = marked_text_offsets(text);
|
||||
|
||||
let font = Font {
|
||||
family: "Zed Plex Mono".into(),
|
||||
family: ".ZedMono".into(),
|
||||
features: FontFeatures::default(),
|
||||
fallbacks: None,
|
||||
weight: FontWeight::default(),
|
||||
|
|
|
@ -33,13 +33,23 @@ impl FileIcons {
|
|||
// TODO: Associate a type with the languages and have the file's language
|
||||
// override these associations
|
||||
|
||||
// check if file name is in suffixes
|
||||
// e.g. catch file named `eslint.config.js` instead of `.eslint.config.js`
|
||||
if let Some(typ) = path.file_name().and_then(|typ| typ.to_str()) {
|
||||
if let Some(mut typ) = path.file_name().and_then(|typ| typ.to_str()) {
|
||||
// check if file name is in suffixes
|
||||
// e.g. catch file named `eslint.config.js` instead of `.eslint.config.js`
|
||||
let maybe_path = get_icon_from_suffix(typ);
|
||||
if maybe_path.is_some() {
|
||||
return maybe_path;
|
||||
}
|
||||
|
||||
// check if suffix based on first dot is in suffixes
|
||||
// e.g. consider `module.js` as suffix to angular's module file named `auth.module.js`
|
||||
while let Some((_, suffix)) = typ.split_once('.') {
|
||||
let maybe_path = get_icon_from_suffix(suffix);
|
||||
if maybe_path.is_some() {
|
||||
return maybe_path;
|
||||
}
|
||||
typ = suffix;
|
||||
}
|
||||
}
|
||||
|
||||
// primary case: check if the files extension or the hidden file name
|
||||
|
|
|
@ -51,6 +51,7 @@ ashpd.workspace = true
|
|||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
git = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[features]
|
||||
test-support = ["gpui/test-support", "git/test-support"]
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
use crate::{FakeFs, Fs};
|
||||
use crate::{FakeFs, FakeFsEntry, Fs};
|
||||
use anyhow::{Context as _, Result};
|
||||
use collections::{HashMap, HashSet};
|
||||
use futures::future::{self, BoxFuture, join_all};
|
||||
use git::{
|
||||
Oid,
|
||||
blame::Blame,
|
||||
repository::{
|
||||
AskPassDelegate, Branch, CommitDetails, CommitOptions, FetchOptions, GitRepository,
|
||||
|
@ -10,8 +11,9 @@ use git::{
|
|||
},
|
||||
status::{FileStatus, GitStatus, StatusCode, TrackedStatus, UnmergedStatus},
|
||||
};
|
||||
use gpui::{AsyncApp, BackgroundExecutor, SharedString};
|
||||
use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task};
|
||||
use ignore::gitignore::GitignoreBuilder;
|
||||
use parking_lot::Mutex;
|
||||
use rope::Rope;
|
||||
use smol::future::FutureExt as _;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
@ -19,6 +21,7 @@ use std::{path::PathBuf, sync::Arc};
|
|||
#[derive(Clone)]
|
||||
pub struct FakeGitRepository {
|
||||
pub(crate) fs: Arc<FakeFs>,
|
||||
pub(crate) checkpoints: Arc<Mutex<HashMap<Oid, FakeFsEntry>>>,
|
||||
pub(crate) executor: BackgroundExecutor,
|
||||
pub(crate) dot_git_path: PathBuf,
|
||||
pub(crate) repository_dir_path: PathBuf,
|
||||
|
@ -183,7 +186,7 @@ impl GitRepository for FakeGitRepository {
|
|||
async move { None }.boxed()
|
||||
}
|
||||
|
||||
fn status(&self, path_prefixes: &[RepoPath]) -> BoxFuture<'_, Result<GitStatus>> {
|
||||
fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
|
||||
let workdir_path = self.dot_git_path.parent().unwrap();
|
||||
|
||||
// Load gitignores
|
||||
|
@ -311,7 +314,10 @@ impl GitRepository for FakeGitRepository {
|
|||
entries: entries.into(),
|
||||
})
|
||||
});
|
||||
async move { result? }.boxed()
|
||||
Task::ready(match result {
|
||||
Ok(result) => result,
|
||||
Err(e) => Err(e),
|
||||
})
|
||||
}
|
||||
|
||||
fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
|
||||
|
@ -466,22 +472,57 @@ impl GitRepository for FakeGitRepository {
|
|||
}
|
||||
|
||||
fn checkpoint(&self) -> BoxFuture<'static, Result<GitRepositoryCheckpoint>> {
|
||||
unimplemented!()
|
||||
let executor = self.executor.clone();
|
||||
let fs = self.fs.clone();
|
||||
let checkpoints = self.checkpoints.clone();
|
||||
let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
|
||||
async move {
|
||||
executor.simulate_random_delay().await;
|
||||
let oid = Oid::random(&mut executor.rng());
|
||||
let entry = fs.entry(&repository_dir_path)?;
|
||||
checkpoints.lock().insert(oid, entry);
|
||||
Ok(GitRepositoryCheckpoint { commit_sha: oid })
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn restore_checkpoint(
|
||||
&self,
|
||||
_checkpoint: GitRepositoryCheckpoint,
|
||||
) -> BoxFuture<'_, Result<()>> {
|
||||
unimplemented!()
|
||||
fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> {
|
||||
let executor = self.executor.clone();
|
||||
let fs = self.fs.clone();
|
||||
let checkpoints = self.checkpoints.clone();
|
||||
let repository_dir_path = self.repository_dir_path.parent().unwrap().to_path_buf();
|
||||
async move {
|
||||
executor.simulate_random_delay().await;
|
||||
let checkpoints = checkpoints.lock();
|
||||
let entry = checkpoints
|
||||
.get(&checkpoint.commit_sha)
|
||||
.context(format!("invalid checkpoint: {}", checkpoint.commit_sha))?;
|
||||
fs.insert_entry(&repository_dir_path, entry.clone())?;
|
||||
Ok(())
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn compare_checkpoints(
|
||||
&self,
|
||||
_left: GitRepositoryCheckpoint,
|
||||
_right: GitRepositoryCheckpoint,
|
||||
left: GitRepositoryCheckpoint,
|
||||
right: GitRepositoryCheckpoint,
|
||||
) -> BoxFuture<'_, Result<bool>> {
|
||||
unimplemented!()
|
||||
let executor = self.executor.clone();
|
||||
let checkpoints = self.checkpoints.clone();
|
||||
async move {
|
||||
executor.simulate_random_delay().await;
|
||||
let checkpoints = checkpoints.lock();
|
||||
let left = checkpoints
|
||||
.get(&left.commit_sha)
|
||||
.context(format!("invalid left checkpoint: {}", left.commit_sha))?;
|
||||
let right = checkpoints
|
||||
.get(&right.commit_sha)
|
||||
.context(format!("invalid right checkpoint: {}", right.commit_sha))?;
|
||||
|
||||
Ok(left == right)
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn diff_checkpoints(
|
||||
|
@ -496,3 +537,63 @@ impl GitRepository for FakeGitRepository {
|
|||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{FakeFs, Fs};
|
||||
use gpui::BackgroundExecutor;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use util::path;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_checkpoints(executor: BackgroundExecutor) {
|
||||
let fs = FakeFs::new(executor);
|
||||
fs.insert_tree(
|
||||
path!("/"),
|
||||
json!({
|
||||
"bar": {
|
||||
"baz": "qux"
|
||||
},
|
||||
"foo": {
|
||||
".git": {},
|
||||
"a": "lorem",
|
||||
"b": "ipsum",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
fs.with_git_state(Path::new("/foo/.git"), true, |_git| {})
|
||||
.unwrap();
|
||||
let repository = fs.open_repo(Path::new("/foo/.git")).unwrap();
|
||||
|
||||
let checkpoint_1 = repository.checkpoint().await.unwrap();
|
||||
fs.write(Path::new("/foo/b"), b"IPSUM").await.unwrap();
|
||||
fs.write(Path::new("/foo/c"), b"dolor").await.unwrap();
|
||||
let checkpoint_2 = repository.checkpoint().await.unwrap();
|
||||
let checkpoint_3 = repository.checkpoint().await.unwrap();
|
||||
|
||||
assert!(
|
||||
repository
|
||||
.compare_checkpoints(checkpoint_2.clone(), checkpoint_3.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!repository
|
||||
.compare_checkpoints(checkpoint_1.clone(), checkpoint_2.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
|
||||
repository.restore_checkpoint(checkpoint_1).await.unwrap();
|
||||
assert_eq!(
|
||||
fs.files_with_contents(Path::new("")),
|
||||
[
|
||||
(Path::new("/bar/baz").into(), b"qux".into()),
|
||||
(Path::new("/foo/a").into(), b"lorem".into()),
|
||||
(Path::new("/foo/b").into(), b"ipsum".into())
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -924,7 +924,7 @@ pub struct FakeFs {
|
|||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
struct FakeFsState {
|
||||
root: Arc<Mutex<FakeFsEntry>>,
|
||||
root: FakeFsEntry,
|
||||
next_inode: u64,
|
||||
next_mtime: SystemTime,
|
||||
git_event_tx: smol::channel::Sender<PathBuf>,
|
||||
|
@ -939,7 +939,7 @@ struct FakeFsState {
|
|||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
enum FakeFsEntry {
|
||||
File {
|
||||
inode: u64,
|
||||
|
@ -953,7 +953,7 @@ enum FakeFsEntry {
|
|||
inode: u64,
|
||||
mtime: MTime,
|
||||
len: u64,
|
||||
entries: BTreeMap<String, Arc<Mutex<FakeFsEntry>>>,
|
||||
entries: BTreeMap<String, FakeFsEntry>,
|
||||
git_repo_state: Option<Arc<Mutex<FakeGitRepositoryState>>>,
|
||||
},
|
||||
Symlink {
|
||||
|
@ -961,6 +961,67 @@ enum FakeFsEntry {
|
|||
},
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl PartialEq for FakeFsEntry {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(
|
||||
Self::File {
|
||||
inode: l_inode,
|
||||
mtime: l_mtime,
|
||||
len: l_len,
|
||||
content: l_content,
|
||||
git_dir_path: l_git_dir_path,
|
||||
},
|
||||
Self::File {
|
||||
inode: r_inode,
|
||||
mtime: r_mtime,
|
||||
len: r_len,
|
||||
content: r_content,
|
||||
git_dir_path: r_git_dir_path,
|
||||
},
|
||||
) => {
|
||||
l_inode == r_inode
|
||||
&& l_mtime == r_mtime
|
||||
&& l_len == r_len
|
||||
&& l_content == r_content
|
||||
&& l_git_dir_path == r_git_dir_path
|
||||
}
|
||||
(
|
||||
Self::Dir {
|
||||
inode: l_inode,
|
||||
mtime: l_mtime,
|
||||
len: l_len,
|
||||
entries: l_entries,
|
||||
git_repo_state: l_git_repo_state,
|
||||
},
|
||||
Self::Dir {
|
||||
inode: r_inode,
|
||||
mtime: r_mtime,
|
||||
len: r_len,
|
||||
entries: r_entries,
|
||||
git_repo_state: r_git_repo_state,
|
||||
},
|
||||
) => {
|
||||
let same_repo_state = match (l_git_repo_state.as_ref(), r_git_repo_state.as_ref()) {
|
||||
(Some(l), Some(r)) => Arc::ptr_eq(l, r),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
l_inode == r_inode
|
||||
&& l_mtime == r_mtime
|
||||
&& l_len == r_len
|
||||
&& l_entries == r_entries
|
||||
&& same_repo_state
|
||||
}
|
||||
(Self::Symlink { target: l_target }, Self::Symlink { target: r_target }) => {
|
||||
l_target == r_target
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl FakeFsState {
|
||||
fn get_and_increment_mtime(&mut self) -> MTime {
|
||||
|
@ -975,25 +1036,9 @@ impl FakeFsState {
|
|||
inode
|
||||
}
|
||||
|
||||
fn read_path(&self, target: &Path) -> Result<Arc<Mutex<FakeFsEntry>>> {
|
||||
Ok(self
|
||||
.try_read_path(target, true)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("not found: {target:?}")
|
||||
))
|
||||
})?
|
||||
.0)
|
||||
}
|
||||
|
||||
fn try_read_path(
|
||||
&self,
|
||||
target: &Path,
|
||||
follow_symlink: bool,
|
||||
) -> Option<(Arc<Mutex<FakeFsEntry>>, PathBuf)> {
|
||||
let mut path = target.to_path_buf();
|
||||
fn canonicalize(&self, target: &Path, follow_symlink: bool) -> Option<PathBuf> {
|
||||
let mut canonical_path = PathBuf::new();
|
||||
let mut path = target.to_path_buf();
|
||||
let mut entry_stack = Vec::new();
|
||||
'outer: loop {
|
||||
let mut path_components = path.components().peekable();
|
||||
|
@ -1003,7 +1048,7 @@ impl FakeFsState {
|
|||
Component::Prefix(prefix_component) => prefix = Some(prefix_component),
|
||||
Component::RootDir => {
|
||||
entry_stack.clear();
|
||||
entry_stack.push(self.root.clone());
|
||||
entry_stack.push(&self.root);
|
||||
canonical_path.clear();
|
||||
match prefix {
|
||||
Some(prefix_component) => {
|
||||
|
@ -1020,20 +1065,18 @@ impl FakeFsState {
|
|||
canonical_path.pop();
|
||||
}
|
||||
Component::Normal(name) => {
|
||||
let current_entry = entry_stack.last().cloned()?;
|
||||
let current_entry = current_entry.lock();
|
||||
if let FakeFsEntry::Dir { entries, .. } = &*current_entry {
|
||||
let entry = entries.get(name.to_str().unwrap()).cloned()?;
|
||||
let current_entry = *entry_stack.last()?;
|
||||
if let FakeFsEntry::Dir { entries, .. } = current_entry {
|
||||
let entry = entries.get(name.to_str().unwrap())?;
|
||||
if path_components.peek().is_some() || follow_symlink {
|
||||
let entry = entry.lock();
|
||||
if let FakeFsEntry::Symlink { target, .. } = &*entry {
|
||||
if let FakeFsEntry::Symlink { target, .. } = entry {
|
||||
let mut target = target.clone();
|
||||
target.extend(path_components);
|
||||
path = target;
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
entry_stack.push(entry.clone());
|
||||
entry_stack.push(entry);
|
||||
canonical_path = canonical_path.join(name);
|
||||
} else {
|
||||
return None;
|
||||
|
@ -1043,19 +1086,72 @@ impl FakeFsState {
|
|||
}
|
||||
break;
|
||||
}
|
||||
Some((entry_stack.pop()?, canonical_path))
|
||||
|
||||
if entry_stack.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(canonical_path)
|
||||
}
|
||||
}
|
||||
|
||||
fn write_path<Fn, T>(&self, path: &Path, callback: Fn) -> Result<T>
|
||||
fn try_entry(
|
||||
&mut self,
|
||||
target: &Path,
|
||||
follow_symlink: bool,
|
||||
) -> Option<(&mut FakeFsEntry, PathBuf)> {
|
||||
let canonical_path = self.canonicalize(target, follow_symlink)?;
|
||||
|
||||
let mut components = canonical_path.components();
|
||||
let Some(Component::RootDir) = components.next() else {
|
||||
panic!(
|
||||
"the path {:?} was not canonicalized properly {:?}",
|
||||
target, canonical_path
|
||||
)
|
||||
};
|
||||
|
||||
let mut entry = &mut self.root;
|
||||
for component in components {
|
||||
match component {
|
||||
Component::Normal(name) => {
|
||||
if let FakeFsEntry::Dir { entries, .. } = entry {
|
||||
entry = entries.get_mut(name.to_str().unwrap())?;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
panic!(
|
||||
"the path {:?} was not canonicalized properly {:?}",
|
||||
target, canonical_path
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some((entry, canonical_path))
|
||||
}
|
||||
|
||||
fn entry(&mut self, target: &Path) -> Result<&mut FakeFsEntry> {
|
||||
Ok(self
|
||||
.try_entry(target, true)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("not found: {target:?}")
|
||||
))
|
||||
})?
|
||||
.0)
|
||||
}
|
||||
|
||||
fn write_path<Fn, T>(&mut self, path: &Path, callback: Fn) -> Result<T>
|
||||
where
|
||||
Fn: FnOnce(btree_map::Entry<String, Arc<Mutex<FakeFsEntry>>>) -> Result<T>,
|
||||
Fn: FnOnce(btree_map::Entry<String, FakeFsEntry>) -> Result<T>,
|
||||
{
|
||||
let path = normalize_path(path);
|
||||
let filename = path.file_name().context("cannot overwrite the root")?;
|
||||
let parent_path = path.parent().unwrap();
|
||||
|
||||
let parent = self.read_path(parent_path)?;
|
||||
let mut parent = parent.lock();
|
||||
let parent = self.entry(parent_path)?;
|
||||
let new_entry = parent
|
||||
.dir_entries(parent_path)?
|
||||
.entry(filename.to_str().unwrap().into());
|
||||
|
@ -1105,13 +1201,13 @@ impl FakeFs {
|
|||
this: this.clone(),
|
||||
executor: executor.clone(),
|
||||
state: Arc::new(Mutex::new(FakeFsState {
|
||||
root: Arc::new(Mutex::new(FakeFsEntry::Dir {
|
||||
root: FakeFsEntry::Dir {
|
||||
inode: 0,
|
||||
mtime: MTime(UNIX_EPOCH),
|
||||
len: 0,
|
||||
entries: Default::default(),
|
||||
git_repo_state: None,
|
||||
})),
|
||||
},
|
||||
git_event_tx: tx,
|
||||
next_mtime: UNIX_EPOCH + Self::SYSTEMTIME_INTERVAL,
|
||||
next_inode: 1,
|
||||
|
@ -1161,15 +1257,15 @@ impl FakeFs {
|
|||
.write_path(path, move |entry| {
|
||||
match entry {
|
||||
btree_map::Entry::Vacant(e) => {
|
||||
e.insert(Arc::new(Mutex::new(FakeFsEntry::File {
|
||||
e.insert(FakeFsEntry::File {
|
||||
inode: new_inode,
|
||||
mtime: new_mtime,
|
||||
content: Vec::new(),
|
||||
len: 0,
|
||||
git_dir_path: None,
|
||||
})));
|
||||
});
|
||||
}
|
||||
btree_map::Entry::Occupied(mut e) => match &mut *e.get_mut().lock() {
|
||||
btree_map::Entry::Occupied(mut e) => match &mut *e.get_mut() {
|
||||
FakeFsEntry::File { mtime, .. } => *mtime = new_mtime,
|
||||
FakeFsEntry::Dir { mtime, .. } => *mtime = new_mtime,
|
||||
FakeFsEntry::Symlink { .. } => {}
|
||||
|
@ -1188,7 +1284,7 @@ impl FakeFs {
|
|||
pub async fn insert_symlink(&self, path: impl AsRef<Path>, target: PathBuf) {
|
||||
let mut state = self.state.lock();
|
||||
let path = path.as_ref();
|
||||
let file = Arc::new(Mutex::new(FakeFsEntry::Symlink { target }));
|
||||
let file = FakeFsEntry::Symlink { target };
|
||||
state
|
||||
.write_path(path.as_ref(), move |e| match e {
|
||||
btree_map::Entry::Vacant(e) => {
|
||||
|
@ -1221,13 +1317,13 @@ impl FakeFs {
|
|||
match entry {
|
||||
btree_map::Entry::Vacant(e) => {
|
||||
kind = Some(PathEventKind::Created);
|
||||
e.insert(Arc::new(Mutex::new(FakeFsEntry::File {
|
||||
e.insert(FakeFsEntry::File {
|
||||
inode: new_inode,
|
||||
mtime: new_mtime,
|
||||
len: new_len,
|
||||
content: new_content,
|
||||
git_dir_path: None,
|
||||
})));
|
||||
});
|
||||
}
|
||||
btree_map::Entry::Occupied(mut e) => {
|
||||
kind = Some(PathEventKind::Changed);
|
||||
|
@ -1237,7 +1333,7 @@ impl FakeFs {
|
|||
len,
|
||||
content,
|
||||
..
|
||||
} = &mut *e.get_mut().lock()
|
||||
} = e.get_mut()
|
||||
{
|
||||
*mtime = new_mtime;
|
||||
*content = new_content;
|
||||
|
@ -1259,9 +1355,8 @@ impl FakeFs {
|
|||
pub fn read_file_sync(&self, path: impl AsRef<Path>) -> Result<Vec<u8>> {
|
||||
let path = path.as_ref();
|
||||
let path = normalize_path(path);
|
||||
let state = self.state.lock();
|
||||
let entry = state.read_path(&path)?;
|
||||
let entry = entry.lock();
|
||||
let mut state = self.state.lock();
|
||||
let entry = state.entry(&path)?;
|
||||
entry.file_content(&path).cloned()
|
||||
}
|
||||
|
||||
|
@ -1269,9 +1364,8 @@ impl FakeFs {
|
|||
let path = path.as_ref();
|
||||
let path = normalize_path(path);
|
||||
self.simulate_random_delay().await;
|
||||
let state = self.state.lock();
|
||||
let entry = state.read_path(&path)?;
|
||||
let entry = entry.lock();
|
||||
let mut state = self.state.lock();
|
||||
let entry = state.entry(&path)?;
|
||||
entry.file_content(&path).cloned()
|
||||
}
|
||||
|
||||
|
@ -1292,6 +1386,25 @@ impl FakeFs {
|
|||
self.state.lock().flush_events(count);
|
||||
}
|
||||
|
||||
pub(crate) fn entry(&self, target: &Path) -> Result<FakeFsEntry> {
|
||||
self.state.lock().entry(target).cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn insert_entry(&self, target: &Path, new_entry: FakeFsEntry) -> Result<()> {
|
||||
let mut state = self.state.lock();
|
||||
state.write_path(target, |entry| {
|
||||
match entry {
|
||||
btree_map::Entry::Vacant(vacant_entry) => {
|
||||
vacant_entry.insert(new_entry);
|
||||
}
|
||||
btree_map::Entry::Occupied(mut occupied_entry) => {
|
||||
occupied_entry.insert(new_entry);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn insert_tree<'a>(
|
||||
&'a self,
|
||||
|
@ -1361,20 +1474,19 @@ impl FakeFs {
|
|||
F: FnOnce(&mut FakeGitRepositoryState, &Path, &Path) -> T,
|
||||
{
|
||||
let mut state = self.state.lock();
|
||||
let entry = state.read_path(dot_git).context("open .git")?;
|
||||
let mut entry = entry.lock();
|
||||
let git_event_tx = state.git_event_tx.clone();
|
||||
let entry = state.entry(dot_git).context("open .git")?;
|
||||
|
||||
if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry {
|
||||
if let FakeFsEntry::Dir { git_repo_state, .. } = entry {
|
||||
let repo_state = git_repo_state.get_or_insert_with(|| {
|
||||
log::debug!("insert git state for {dot_git:?}");
|
||||
Arc::new(Mutex::new(FakeGitRepositoryState::new(
|
||||
state.git_event_tx.clone(),
|
||||
)))
|
||||
Arc::new(Mutex::new(FakeGitRepositoryState::new(git_event_tx)))
|
||||
});
|
||||
let mut repo_state = repo_state.lock();
|
||||
|
||||
let result = f(&mut repo_state, dot_git, dot_git);
|
||||
|
||||
drop(repo_state);
|
||||
if emit_git_event {
|
||||
state.emit_event([(dot_git, None)]);
|
||||
}
|
||||
|
@ -1398,21 +1510,20 @@ impl FakeFs {
|
|||
}
|
||||
}
|
||||
.clone();
|
||||
drop(entry);
|
||||
let Some((git_dir_entry, canonical_path)) = state.try_read_path(&path, true) else {
|
||||
let Some((git_dir_entry, canonical_path)) = state.try_entry(&path, true) else {
|
||||
anyhow::bail!("pointed-to git dir {path:?} not found")
|
||||
};
|
||||
let FakeFsEntry::Dir {
|
||||
git_repo_state,
|
||||
entries,
|
||||
..
|
||||
} = &mut *git_dir_entry.lock()
|
||||
} = git_dir_entry
|
||||
else {
|
||||
anyhow::bail!("gitfile points to a non-directory")
|
||||
};
|
||||
let common_dir = if let Some(child) = entries.get("commondir") {
|
||||
Path::new(
|
||||
std::str::from_utf8(child.lock().file_content("commondir".as_ref())?)
|
||||
std::str::from_utf8(child.file_content("commondir".as_ref())?)
|
||||
.context("commondir content")?,
|
||||
)
|
||||
.to_owned()
|
||||
|
@ -1420,15 +1531,14 @@ impl FakeFs {
|
|||
canonical_path.clone()
|
||||
};
|
||||
let repo_state = git_repo_state.get_or_insert_with(|| {
|
||||
Arc::new(Mutex::new(FakeGitRepositoryState::new(
|
||||
state.git_event_tx.clone(),
|
||||
)))
|
||||
Arc::new(Mutex::new(FakeGitRepositoryState::new(git_event_tx)))
|
||||
});
|
||||
let mut repo_state = repo_state.lock();
|
||||
|
||||
let result = f(&mut repo_state, &canonical_path, &common_dir);
|
||||
|
||||
if emit_git_event {
|
||||
drop(repo_state);
|
||||
state.emit_event([(canonical_path, None)]);
|
||||
}
|
||||
|
||||
|
@ -1655,14 +1765,12 @@ impl FakeFs {
|
|||
pub fn paths(&self, include_dot_git: bool) -> Vec<PathBuf> {
|
||||
let mut result = Vec::new();
|
||||
let mut queue = collections::VecDeque::new();
|
||||
queue.push_back((
|
||||
PathBuf::from(util::path!("/")),
|
||||
self.state.lock().root.clone(),
|
||||
));
|
||||
let state = &*self.state.lock();
|
||||
queue.push_back((PathBuf::from(util::path!("/")), &state.root));
|
||||
while let Some((path, entry)) = queue.pop_front() {
|
||||
if let FakeFsEntry::Dir { entries, .. } = &*entry.lock() {
|
||||
if let FakeFsEntry::Dir { entries, .. } = entry {
|
||||
for (name, entry) in entries {
|
||||
queue.push_back((path.join(name), entry.clone()));
|
||||
queue.push_back((path.join(name), entry));
|
||||
}
|
||||
}
|
||||
if include_dot_git
|
||||
|
@ -1679,14 +1787,12 @@ impl FakeFs {
|
|||
pub fn directories(&self, include_dot_git: bool) -> Vec<PathBuf> {
|
||||
let mut result = Vec::new();
|
||||
let mut queue = collections::VecDeque::new();
|
||||
queue.push_back((
|
||||
PathBuf::from(util::path!("/")),
|
||||
self.state.lock().root.clone(),
|
||||
));
|
||||
let state = &*self.state.lock();
|
||||
queue.push_back((PathBuf::from(util::path!("/")), &state.root));
|
||||
while let Some((path, entry)) = queue.pop_front() {
|
||||
if let FakeFsEntry::Dir { entries, .. } = &*entry.lock() {
|
||||
if let FakeFsEntry::Dir { entries, .. } = entry {
|
||||
for (name, entry) in entries {
|
||||
queue.push_back((path.join(name), entry.clone()));
|
||||
queue.push_back((path.join(name), entry));
|
||||
}
|
||||
if include_dot_git
|
||||
|| !path
|
||||
|
@ -1703,17 +1809,14 @@ impl FakeFs {
|
|||
pub fn files(&self) -> Vec<PathBuf> {
|
||||
let mut result = Vec::new();
|
||||
let mut queue = collections::VecDeque::new();
|
||||
queue.push_back((
|
||||
PathBuf::from(util::path!("/")),
|
||||
self.state.lock().root.clone(),
|
||||
));
|
||||
let state = &*self.state.lock();
|
||||
queue.push_back((PathBuf::from(util::path!("/")), &state.root));
|
||||
while let Some((path, entry)) = queue.pop_front() {
|
||||
let e = entry.lock();
|
||||
match &*e {
|
||||
match entry {
|
||||
FakeFsEntry::File { .. } => result.push(path),
|
||||
FakeFsEntry::Dir { entries, .. } => {
|
||||
for (name, entry) in entries {
|
||||
queue.push_back((path.join(name), entry.clone()));
|
||||
queue.push_back((path.join(name), entry));
|
||||
}
|
||||
}
|
||||
FakeFsEntry::Symlink { .. } => {}
|
||||
|
@ -1725,13 +1828,10 @@ impl FakeFs {
|
|||
pub fn files_with_contents(&self, prefix: &Path) -> Vec<(PathBuf, Vec<u8>)> {
|
||||
let mut result = Vec::new();
|
||||
let mut queue = collections::VecDeque::new();
|
||||
queue.push_back((
|
||||
PathBuf::from(util::path!("/")),
|
||||
self.state.lock().root.clone(),
|
||||
));
|
||||
let state = &*self.state.lock();
|
||||
queue.push_back((PathBuf::from(util::path!("/")), &state.root));
|
||||
while let Some((path, entry)) = queue.pop_front() {
|
||||
let e = entry.lock();
|
||||
match &*e {
|
||||
match entry {
|
||||
FakeFsEntry::File { content, .. } => {
|
||||
if path.starts_with(prefix) {
|
||||
result.push((path, content.clone()));
|
||||
|
@ -1739,7 +1839,7 @@ impl FakeFs {
|
|||
}
|
||||
FakeFsEntry::Dir { entries, .. } => {
|
||||
for (name, entry) in entries {
|
||||
queue.push_back((path.join(name), entry.clone()));
|
||||
queue.push_back((path.join(name), entry));
|
||||
}
|
||||
}
|
||||
FakeFsEntry::Symlink { .. } => {}
|
||||
|
@ -1805,10 +1905,7 @@ impl FakeFsEntry {
|
|||
}
|
||||
}
|
||||
|
||||
fn dir_entries(
|
||||
&mut self,
|
||||
path: &Path,
|
||||
) -> Result<&mut BTreeMap<String, Arc<Mutex<FakeFsEntry>>>> {
|
||||
fn dir_entries(&mut self, path: &Path) -> Result<&mut BTreeMap<String, FakeFsEntry>> {
|
||||
if let Self::Dir { entries, .. } = self {
|
||||
Ok(entries)
|
||||
} else {
|
||||
|
@ -1855,12 +1952,12 @@ struct FakeHandle {
|
|||
impl FileHandle for FakeHandle {
|
||||
fn current_path(&self, fs: &Arc<dyn Fs>) -> Result<PathBuf> {
|
||||
let fs = fs.as_fake();
|
||||
let state = fs.state.lock();
|
||||
let Some(target) = state.moves.get(&self.inode) else {
|
||||
let mut state = fs.state.lock();
|
||||
let Some(target) = state.moves.get(&self.inode).cloned() else {
|
||||
anyhow::bail!("fake fd not moved")
|
||||
};
|
||||
|
||||
if state.try_read_path(&target, false).is_some() {
|
||||
if state.try_entry(&target, false).is_some() {
|
||||
return Ok(target.clone());
|
||||
}
|
||||
anyhow::bail!("fake fd target not found")
|
||||
|
@ -1888,13 +1985,13 @@ impl Fs for FakeFs {
|
|||
state.write_path(&cur_path, |entry| {
|
||||
entry.or_insert_with(|| {
|
||||
created_dirs.push((cur_path.clone(), Some(PathEventKind::Created)));
|
||||
Arc::new(Mutex::new(FakeFsEntry::Dir {
|
||||
FakeFsEntry::Dir {
|
||||
inode,
|
||||
mtime,
|
||||
len: 0,
|
||||
entries: Default::default(),
|
||||
git_repo_state: None,
|
||||
}))
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})?
|
||||
|
@ -1909,13 +2006,13 @@ impl Fs for FakeFs {
|
|||
let mut state = self.state.lock();
|
||||
let inode = state.get_and_increment_inode();
|
||||
let mtime = state.get_and_increment_mtime();
|
||||
let file = Arc::new(Mutex::new(FakeFsEntry::File {
|
||||
let file = FakeFsEntry::File {
|
||||
inode,
|
||||
mtime,
|
||||
len: 0,
|
||||
content: Vec::new(),
|
||||
git_dir_path: None,
|
||||
}));
|
||||
};
|
||||
let mut kind = Some(PathEventKind::Created);
|
||||
state.write_path(path, |entry| {
|
||||
match entry {
|
||||
|
@ -1939,7 +2036,7 @@ impl Fs for FakeFs {
|
|||
|
||||
async fn create_symlink(&self, path: &Path, target: PathBuf) -> Result<()> {
|
||||
let mut state = self.state.lock();
|
||||
let file = Arc::new(Mutex::new(FakeFsEntry::Symlink { target }));
|
||||
let file = FakeFsEntry::Symlink { target };
|
||||
state
|
||||
.write_path(path.as_ref(), move |e| match e {
|
||||
btree_map::Entry::Vacant(e) => {
|
||||
|
@ -2002,7 +2099,7 @@ impl Fs for FakeFs {
|
|||
}
|
||||
})?;
|
||||
|
||||
let inode = match *moved_entry.lock() {
|
||||
let inode = match moved_entry {
|
||||
FakeFsEntry::File { inode, .. } => inode,
|
||||
FakeFsEntry::Dir { inode, .. } => inode,
|
||||
_ => 0,
|
||||
|
@ -2051,8 +2148,8 @@ impl Fs for FakeFs {
|
|||
let mut state = self.state.lock();
|
||||
let mtime = state.get_and_increment_mtime();
|
||||
let inode = state.get_and_increment_inode();
|
||||
let source_entry = state.read_path(&source)?;
|
||||
let content = source_entry.lock().file_content(&source)?.clone();
|
||||
let source_entry = state.entry(&source)?;
|
||||
let content = source_entry.file_content(&source)?.clone();
|
||||
let mut kind = Some(PathEventKind::Created);
|
||||
state.write_path(&target, |e| match e {
|
||||
btree_map::Entry::Occupied(e) => {
|
||||
|
@ -2066,13 +2163,13 @@ impl Fs for FakeFs {
|
|||
}
|
||||
}
|
||||
btree_map::Entry::Vacant(e) => Ok(Some(
|
||||
e.insert(Arc::new(Mutex::new(FakeFsEntry::File {
|
||||
e.insert(FakeFsEntry::File {
|
||||
inode,
|
||||
mtime,
|
||||
len: content.len() as u64,
|
||||
content,
|
||||
git_dir_path: None,
|
||||
})))
|
||||
})
|
||||
.clone(),
|
||||
)),
|
||||
})?;
|
||||
|
@ -2088,8 +2185,7 @@ impl Fs for FakeFs {
|
|||
let base_name = path.file_name().context("cannot remove the root")?;
|
||||
|
||||
let mut state = self.state.lock();
|
||||
let parent_entry = state.read_path(parent_path)?;
|
||||
let mut parent_entry = parent_entry.lock();
|
||||
let parent_entry = state.entry(parent_path)?;
|
||||
let entry = parent_entry
|
||||
.dir_entries(parent_path)?
|
||||
.entry(base_name.to_str().unwrap().into());
|
||||
|
@ -2100,15 +2196,14 @@ impl Fs for FakeFs {
|
|||
anyhow::bail!("{path:?} does not exist");
|
||||
}
|
||||
}
|
||||
btree_map::Entry::Occupied(e) => {
|
||||
btree_map::Entry::Occupied(mut entry) => {
|
||||
{
|
||||
let mut entry = e.get().lock();
|
||||
let children = entry.dir_entries(&path)?;
|
||||
let children = entry.get_mut().dir_entries(&path)?;
|
||||
if !options.recursive && !children.is_empty() {
|
||||
anyhow::bail!("{path:?} is not empty");
|
||||
}
|
||||
}
|
||||
e.remove();
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
state.emit_event([(path, Some(PathEventKind::Removed))]);
|
||||
|
@ -2122,8 +2217,7 @@ impl Fs for FakeFs {
|
|||
let parent_path = path.parent().context("cannot remove the root")?;
|
||||
let base_name = path.file_name().unwrap();
|
||||
let mut state = self.state.lock();
|
||||
let parent_entry = state.read_path(parent_path)?;
|
||||
let mut parent_entry = parent_entry.lock();
|
||||
let parent_entry = state.entry(parent_path)?;
|
||||
let entry = parent_entry
|
||||
.dir_entries(parent_path)?
|
||||
.entry(base_name.to_str().unwrap().into());
|
||||
|
@ -2133,9 +2227,9 @@ impl Fs for FakeFs {
|
|||
anyhow::bail!("{path:?} does not exist");
|
||||
}
|
||||
}
|
||||
btree_map::Entry::Occupied(e) => {
|
||||
e.get().lock().file_content(&path)?;
|
||||
e.remove();
|
||||
btree_map::Entry::Occupied(mut entry) => {
|
||||
entry.get_mut().file_content(&path)?;
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
state.emit_event([(path, Some(PathEventKind::Removed))]);
|
||||
|
@ -2149,12 +2243,10 @@ impl Fs for FakeFs {
|
|||
|
||||
async fn open_handle(&self, path: &Path) -> Result<Arc<dyn FileHandle>> {
|
||||
self.simulate_random_delay().await;
|
||||
let state = self.state.lock();
|
||||
let entry = state.read_path(&path)?;
|
||||
let entry = entry.lock();
|
||||
let inode = match *entry {
|
||||
FakeFsEntry::File { inode, .. } => inode,
|
||||
FakeFsEntry::Dir { inode, .. } => inode,
|
||||
let mut state = self.state.lock();
|
||||
let inode = match state.entry(&path)? {
|
||||
FakeFsEntry::File { inode, .. } => *inode,
|
||||
FakeFsEntry::Dir { inode, .. } => *inode,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
Ok(Arc::new(FakeHandle { inode }))
|
||||
|
@ -2204,8 +2296,8 @@ impl Fs for FakeFs {
|
|||
let path = normalize_path(path);
|
||||
self.simulate_random_delay().await;
|
||||
let state = self.state.lock();
|
||||
let (_, canonical_path) = state
|
||||
.try_read_path(&path, true)
|
||||
let canonical_path = state
|
||||
.canonicalize(&path, true)
|
||||
.with_context(|| format!("path does not exist: {path:?}"))?;
|
||||
Ok(canonical_path)
|
||||
}
|
||||
|
@ -2213,9 +2305,9 @@ impl Fs for FakeFs {
|
|||
async fn is_file(&self, path: &Path) -> bool {
|
||||
let path = normalize_path(path);
|
||||
self.simulate_random_delay().await;
|
||||
let state = self.state.lock();
|
||||
if let Some((entry, _)) = state.try_read_path(&path, true) {
|
||||
entry.lock().is_file()
|
||||
let mut state = self.state.lock();
|
||||
if let Some((entry, _)) = state.try_entry(&path, true) {
|
||||
entry.is_file()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
@ -2232,17 +2324,16 @@ impl Fs for FakeFs {
|
|||
let path = normalize_path(path);
|
||||
let mut state = self.state.lock();
|
||||
state.metadata_call_count += 1;
|
||||
if let Some((mut entry, _)) = state.try_read_path(&path, false) {
|
||||
let is_symlink = entry.lock().is_symlink();
|
||||
if let Some((mut entry, _)) = state.try_entry(&path, false) {
|
||||
let is_symlink = entry.is_symlink();
|
||||
if is_symlink {
|
||||
if let Some(e) = state.try_read_path(&path, true).map(|e| e.0) {
|
||||
if let Some(e) = state.try_entry(&path, true).map(|e| e.0) {
|
||||
entry = e;
|
||||
} else {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
let entry = entry.lock();
|
||||
Ok(Some(match &*entry {
|
||||
FakeFsEntry::File {
|
||||
inode, mtime, len, ..
|
||||
|
@ -2274,12 +2365,11 @@ impl Fs for FakeFs {
|
|||
async fn read_link(&self, path: &Path) -> Result<PathBuf> {
|
||||
self.simulate_random_delay().await;
|
||||
let path = normalize_path(path);
|
||||
let state = self.state.lock();
|
||||
let mut state = self.state.lock();
|
||||
let (entry, _) = state
|
||||
.try_read_path(&path, false)
|
||||
.try_entry(&path, false)
|
||||
.with_context(|| format!("path does not exist: {path:?}"))?;
|
||||
let entry = entry.lock();
|
||||
if let FakeFsEntry::Symlink { target } = &*entry {
|
||||
if let FakeFsEntry::Symlink { target } = entry {
|
||||
Ok(target.clone())
|
||||
} else {
|
||||
anyhow::bail!("not a symlink: {path:?}")
|
||||
|
@ -2294,8 +2384,7 @@ impl Fs for FakeFs {
|
|||
let path = normalize_path(path);
|
||||
let mut state = self.state.lock();
|
||||
state.read_dir_call_count += 1;
|
||||
let entry = state.read_path(&path)?;
|
||||
let mut entry = entry.lock();
|
||||
let entry = state.entry(&path)?;
|
||||
let children = entry.dir_entries(&path)?;
|
||||
let paths = children
|
||||
.keys()
|
||||
|
@ -2359,6 +2448,7 @@ impl Fs for FakeFs {
|
|||
dot_git_path: abs_dot_git.to_path_buf(),
|
||||
repository_dir_path: repository_dir_path.to_owned(),
|
||||
common_dir_path: common_dir_path.to_owned(),
|
||||
checkpoints: Arc::default(),
|
||||
}) as _
|
||||
},
|
||||
)
|
||||
|
|
|
@ -12,7 +12,7 @@ workspace = true
|
|||
path = "src/git.rs"
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
test-support = ["rand"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
|
@ -26,6 +26,7 @@ http_client.workspace = true
|
|||
log.workspace = true
|
||||
parking_lot.workspace = true
|
||||
regex.workspace = true
|
||||
rand = { workspace = true, optional = true }
|
||||
rope.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
|
@ -47,3 +48,4 @@ text = { workspace = true, features = ["test-support"] }
|
|||
unindent.workspace = true
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
tempfile.workspace = true
|
||||
rand.workspace = true
|
||||
|
|
|
@ -119,6 +119,13 @@ impl Oid {
|
|||
Ok(Self(oid))
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn random(rng: &mut impl rand::Rng) -> Self {
|
||||
let mut bytes = [0; 20];
|
||||
rng.fill(&mut bytes);
|
||||
Self::from_bytes(&bytes).unwrap()
|
||||
}
|
||||
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
self.0.as_bytes()
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ use collections::HashMap;
|
|||
use futures::future::BoxFuture;
|
||||
use futures::{AsyncWriteExt, FutureExt as _, select_biased};
|
||||
use git2::BranchType;
|
||||
use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString};
|
||||
use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task};
|
||||
use parking_lot::Mutex;
|
||||
use rope::Rope;
|
||||
use schemars::JsonSchema;
|
||||
|
@ -338,7 +338,7 @@ pub trait GitRepository: Send + Sync {
|
|||
|
||||
fn merge_message(&self) -> BoxFuture<'_, Option<String>>;
|
||||
|
||||
fn status(&self, path_prefixes: &[RepoPath]) -> BoxFuture<'_, Result<GitStatus>>;
|
||||
fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>>;
|
||||
|
||||
fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>>;
|
||||
|
||||
|
@ -953,25 +953,27 @@ impl GitRepository for RealGitRepository {
|
|||
.boxed()
|
||||
}
|
||||
|
||||
fn status(&self, path_prefixes: &[RepoPath]) -> BoxFuture<'_, Result<GitStatus>> {
|
||||
fn status(&self, path_prefixes: &[RepoPath]) -> Task<Result<GitStatus>> {
|
||||
let git_binary_path = self.git_binary_path.clone();
|
||||
let working_directory = self.working_directory();
|
||||
let path_prefixes = path_prefixes.to_owned();
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let output = new_std_command(&git_binary_path)
|
||||
.current_dir(working_directory?)
|
||||
.args(git_status_args(&path_prefixes))
|
||||
.output()?;
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout.parse()
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("git status failed: {stderr}");
|
||||
}
|
||||
})
|
||||
.boxed()
|
||||
let working_directory = match self.working_directory() {
|
||||
Ok(working_directory) => working_directory,
|
||||
Err(e) => return Task::ready(Err(e)),
|
||||
};
|
||||
let args = git_status_args(&path_prefixes);
|
||||
log::debug!("Checking for git status in {path_prefixes:?}");
|
||||
self.executor.spawn(async move {
|
||||
let output = new_std_command(&git_binary_path)
|
||||
.current_dir(working_directory)
|
||||
.args(args)
|
||||
.output()?;
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout.parse()
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("git status failed: {stderr}");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn branches(&self) -> BoxFuture<'_, Result<Vec<Branch>>> {
|
||||
|
|
|
@ -209,7 +209,7 @@ xkbcommon = { version = "0.8.0", features = [
|
|||
"wayland",
|
||||
"x11",
|
||||
], optional = true }
|
||||
xim = { git = "https://github.com/XDeme1/xim-rs", rev = "d50d461764c2213655cd9cf65a0ea94c70d3c4fd", features = [
|
||||
xim = { git = "https://github.com/zed-industries/xim-rs", rev = "c0a70c1bd2ce197364216e5e818a2cb3adb99a8d" , features = [
|
||||
"x11rb-xcb",
|
||||
"x11rb-client",
|
||||
], optional = true }
|
||||
|
@ -305,3 +305,7 @@ path = "examples/uniform_list.rs"
|
|||
[[example]]
|
||||
name = "window_shadow"
|
||||
path = "examples/window_shadow.rs"
|
||||
|
||||
[[example]]
|
||||
name = "grid_layout"
|
||||
path = "examples/grid_layout.rs"
|
||||
|
|
80
crates/gpui/examples/grid_layout.rs
Normal file
80
crates/gpui/examples/grid_layout.rs
Normal file
|
@ -0,0 +1,80 @@
|
|||
use gpui::{
|
||||
App, Application, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, div, prelude::*,
|
||||
px, rgb, size,
|
||||
};
|
||||
|
||||
// https://en.wikipedia.org/wiki/Holy_grail_(web_design)
|
||||
struct HolyGrailExample {}
|
||||
|
||||
impl Render for HolyGrailExample {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let block = |color: Hsla| {
|
||||
div()
|
||||
.size_full()
|
||||
.bg(color)
|
||||
.border_1()
|
||||
.border_dashed()
|
||||
.rounded_md()
|
||||
.border_color(gpui::white())
|
||||
.items_center()
|
||||
};
|
||||
|
||||
div()
|
||||
.gap_1()
|
||||
.grid()
|
||||
.bg(rgb(0x505050))
|
||||
.size(px(500.0))
|
||||
.shadow_lg()
|
||||
.border_1()
|
||||
.size_full()
|
||||
.grid_cols(5)
|
||||
.grid_rows(5)
|
||||
.child(
|
||||
block(gpui::white())
|
||||
.row_span(1)
|
||||
.col_span_full()
|
||||
.child("Header"),
|
||||
)
|
||||
.child(
|
||||
block(gpui::red())
|
||||
.col_span(1)
|
||||
.h_56()
|
||||
.child("Table of contents"),
|
||||
)
|
||||
.child(
|
||||
block(gpui::green())
|
||||
.col_span(3)
|
||||
.row_span(3)
|
||||
.child("Content"),
|
||||
)
|
||||
.child(
|
||||
block(gpui::blue())
|
||||
.col_span(1)
|
||||
.row_span(3)
|
||||
.child("AD :(")
|
||||
.text_color(gpui::white()),
|
||||
)
|
||||
.child(
|
||||
block(gpui::black())
|
||||
.row_span(1)
|
||||
.col_span_full()
|
||||
.text_color(gpui::white())
|
||||
.child("Footer"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
Application::new().run(|cx: &mut App| {
|
||||
let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx);
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|_, cx| cx.new(|_| HolyGrailExample {}),
|
||||
)
|
||||
.unwrap();
|
||||
cx.activate(true);
|
||||
});
|
||||
}
|
|
@ -816,8 +816,9 @@ impl App {
|
|||
pub fn prompt_for_new_path(
|
||||
&self,
|
||||
directory: &Path,
|
||||
suggested_name: Option<&str>,
|
||||
) -> oneshot::Receiver<Result<Option<PathBuf>>> {
|
||||
self.platform.prompt_for_new_path(directory)
|
||||
self.platform.prompt_for_new_path(directory, suggested_name)
|
||||
}
|
||||
|
||||
/// Reveals the specified path at the platform level, such as in Finder on macOS.
|
||||
|
|
|
@ -585,7 +585,7 @@ impl<V: 'static> Entity<V> {
|
|||
cx.executor().advance_clock(advance_clock_by);
|
||||
|
||||
async move {
|
||||
let notification = crate::util::timeout(duration, rx.recv())
|
||||
let notification = crate::util::smol_timeout(duration, rx.recv())
|
||||
.await
|
||||
.expect("next notification timed out");
|
||||
drop(subscription);
|
||||
|
@ -629,7 +629,7 @@ impl<V> Entity<V> {
|
|||
let handle = self.downgrade();
|
||||
|
||||
async move {
|
||||
crate::util::timeout(Duration::from_secs(1), async move {
|
||||
crate::util::smol_timeout(Duration::from_secs(1), async move {
|
||||
loop {
|
||||
{
|
||||
let cx = cx.borrow();
|
||||
|
|
|
@ -9,12 +9,14 @@ use refineable::Refineable;
|
|||
use schemars::{JsonSchema, json_schema};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Range;
|
||||
use std::{
|
||||
cmp::{self, PartialOrd},
|
||||
fmt::{self, Display},
|
||||
hash::Hash,
|
||||
ops::{Add, Div, Mul, MulAssign, Neg, Sub},
|
||||
};
|
||||
use taffy::prelude::{TaffyGridLine, TaffyGridSpan};
|
||||
|
||||
use crate::{App, DisplayId};
|
||||
|
||||
|
@ -3608,6 +3610,37 @@ impl From<()> for Length {
|
|||
}
|
||||
}
|
||||
|
||||
/// A location in a grid layout.
|
||||
#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, JsonSchema, Default)]
|
||||
pub struct GridLocation {
|
||||
/// The rows this item uses within the grid.
|
||||
pub row: Range<GridPlacement>,
|
||||
/// The columns this item uses within the grid.
|
||||
pub column: Range<GridPlacement>,
|
||||
}
|
||||
|
||||
/// The placement of an item within a grid layout's column or row.
|
||||
#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize, JsonSchema, Default)]
|
||||
pub enum GridPlacement {
|
||||
/// The grid line index to place this item.
|
||||
Line(i16),
|
||||
/// The number of grid lines to span.
|
||||
Span(u16),
|
||||
/// Automatically determine the placement, equivalent to Span(1)
|
||||
#[default]
|
||||
Auto,
|
||||
}
|
||||
|
||||
impl From<GridPlacement> for taffy::GridPlacement {
|
||||
fn from(placement: GridPlacement) -> Self {
|
||||
match placement {
|
||||
GridPlacement::Line(index) => taffy::GridPlacement::from_line_index(index),
|
||||
GridPlacement::Span(span) => taffy::GridPlacement::from_span(span),
|
||||
GridPlacement::Auto => taffy::GridPlacement::Auto,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides a trait for types that can calculate half of their value.
|
||||
///
|
||||
/// The `Half` trait is used for types that can be evenly divided, returning a new instance of the same type
|
||||
|
|
|
@ -157,7 +157,7 @@ pub use taffy::{AvailableSpace, LayoutId};
|
|||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub use test::*;
|
||||
pub use text_system::*;
|
||||
pub use util::arc_cow::ArcCow;
|
||||
pub use util::{FutureExt, Timeout, arc_cow::ArcCow};
|
||||
pub use view::*;
|
||||
pub use window::*;
|
||||
|
||||
|
|
|
@ -220,7 +220,11 @@ pub(crate) trait Platform: 'static {
|
|||
&self,
|
||||
options: PathPromptOptions,
|
||||
) -> oneshot::Receiver<Result<Option<Vec<PathBuf>>>>;
|
||||
fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver<Result<Option<PathBuf>>>;
|
||||
fn prompt_for_new_path(
|
||||
&self,
|
||||
directory: &Path,
|
||||
suggested_name: Option<&str>,
|
||||
) -> oneshot::Receiver<Result<Option<PathBuf>>>;
|
||||
fn can_select_mixed_files_and_dirs(&self) -> bool;
|
||||
fn reveal_path(&self, path: &Path);
|
||||
fn open_with_system(&self, path: &Path);
|
||||
|
|
|
@ -327,26 +327,35 @@ impl<P: LinuxClient + 'static> Platform for P {
|
|||
done_rx
|
||||
}
|
||||
|
||||
fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver<Result<Option<PathBuf>>> {
|
||||
fn prompt_for_new_path(
|
||||
&self,
|
||||
directory: &Path,
|
||||
suggested_name: Option<&str>,
|
||||
) -> oneshot::Receiver<Result<Option<PathBuf>>> {
|
||||
let (done_tx, done_rx) = oneshot::channel();
|
||||
|
||||
#[cfg(not(any(feature = "wayland", feature = "x11")))]
|
||||
let _ = (done_tx.send(Ok(None)), directory);
|
||||
let _ = (done_tx.send(Ok(None)), directory, suggested_name);
|
||||
|
||||
#[cfg(any(feature = "wayland", feature = "x11"))]
|
||||
self.foreground_executor()
|
||||
.spawn({
|
||||
let directory = directory.to_owned();
|
||||
let suggested_name = suggested_name.map(|s| s.to_owned());
|
||||
|
||||
async move {
|
||||
let request = match ashpd::desktop::file_chooser::SaveFileRequest::default()
|
||||
.modal(true)
|
||||
.title("Save File")
|
||||
.current_folder(directory)
|
||||
.expect("pathbuf should not be nul terminated")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let mut request_builder =
|
||||
ashpd::desktop::file_chooser::SaveFileRequest::default()
|
||||
.modal(true)
|
||||
.title("Save File")
|
||||
.current_folder(directory)
|
||||
.expect("pathbuf should not be nul terminated");
|
||||
|
||||
if let Some(suggested_name) = suggested_name {
|
||||
request_builder = request_builder.current_name(suggested_name.as_str());
|
||||
}
|
||||
|
||||
let request = match request_builder.send().await {
|
||||
Ok(request) => request,
|
||||
Err(err) => {
|
||||
let result = match err {
|
||||
|
|
|
@ -213,11 +213,7 @@ impl CosmicTextSystemState {
|
|||
features: &FontFeatures,
|
||||
) -> Result<SmallVec<[FontId; 4]>> {
|
||||
// TODO: Determine the proper system UI font.
|
||||
let name = if name == ".SystemUIFont" {
|
||||
"Zed Plex Sans"
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let name = crate::text_system::font_name_with_fallbacks(name, "IBM Plex Sans");
|
||||
|
||||
let families = self
|
||||
.font_system
|
||||
|
|
|
@ -642,13 +642,7 @@ impl X11Client {
|
|||
let xim_connected = xim_handler.connected;
|
||||
drop(state);
|
||||
|
||||
let xim_filtered = match ximc.filter_event(&event, &mut xim_handler) {
|
||||
Ok(handled) => handled,
|
||||
Err(err) => {
|
||||
log::error!("XIMClientError: {}", err);
|
||||
false
|
||||
}
|
||||
};
|
||||
let xim_filtered = ximc.filter_event(&event, &mut xim_handler);
|
||||
let xim_callback_event = xim_handler.last_callback_event.take();
|
||||
|
||||
let mut state = self.0.borrow_mut();
|
||||
|
@ -659,14 +653,28 @@ impl X11Client {
|
|||
self.handle_xim_callback_event(event);
|
||||
}
|
||||
|
||||
if xim_filtered {
|
||||
continue;
|
||||
}
|
||||
|
||||
if xim_connected {
|
||||
self.xim_handle_event(event);
|
||||
} else {
|
||||
self.handle_event(event);
|
||||
match xim_filtered {
|
||||
Ok(handled) => {
|
||||
if handled {
|
||||
continue;
|
||||
}
|
||||
if xim_connected {
|
||||
self.xim_handle_event(event);
|
||||
} else {
|
||||
self.handle_event(event);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// this might happen when xim server crashes on one of the events
|
||||
// we do lose 1-2 keys when crash happens since there is no reliable way to get that info
|
||||
// luckily, x11 sends us window not found error when xim server crashes upon further key press
|
||||
// hence we fall back to handle_event
|
||||
log::error!("XIMClientError: {}", err);
|
||||
let mut state = self.0.borrow_mut();
|
||||
state.take_xim();
|
||||
drop(state);
|
||||
self.handle_event(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -737,8 +737,13 @@ impl Platform for MacPlatform {
|
|||
done_rx
|
||||
}
|
||||
|
||||
fn prompt_for_new_path(&self, directory: &Path) -> oneshot::Receiver<Result<Option<PathBuf>>> {
|
||||
fn prompt_for_new_path(
|
||||
&self,
|
||||
directory: &Path,
|
||||
suggested_name: Option<&str>,
|
||||
) -> oneshot::Receiver<Result<Option<PathBuf>>> {
|
||||
let directory = directory.to_owned();
|
||||
let suggested_name = suggested_name.map(|s| s.to_owned());
|
||||
let (done_tx, done_rx) = oneshot::channel();
|
||||
self.foreground_executor()
|
||||
.spawn(async move {
|
||||
|
@ -748,6 +753,11 @@ impl Platform for MacPlatform {
|
|||
let url = NSURL::fileURLWithPath_isDirectory_(nil, path, true.to_objc());
|
||||
panel.setDirectoryURL(url);
|
||||
|
||||
if let Some(suggested_name) = suggested_name {
|
||||
let name_string = ns_string(&suggested_name);
|
||||
let _: () = msg_send![panel, setNameFieldStringValue: name_string];
|
||||
}
|
||||
|
||||
let done_tx = Cell::new(Some(done_tx));
|
||||
let block = ConcreteBlock::new(move |response: NSModalResponse| {
|
||||
let mut result = None;
|
||||
|
|
|
@ -211,11 +211,7 @@ impl MacTextSystemState {
|
|||
features: &FontFeatures,
|
||||
fallbacks: Option<&FontFallbacks>,
|
||||
) -> Result<SmallVec<[FontId; 4]>> {
|
||||
let name = if name == ".SystemUIFont" {
|
||||
".AppleSystemUIFont"
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let name = crate::text_system::font_name_with_fallbacks(name, ".AppleSystemUIFont");
|
||||
|
||||
let mut font_ids = SmallVec::new();
|
||||
let family = self
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue