mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
Compare commits
21 commits
ccea312ac4
...
8375259503
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8375259503 | ||
|
|
5c967da418 | ||
|
|
09165c15dc | ||
|
|
e2e7a6769e | ||
|
|
5d3b9e467e | ||
|
|
06826ef10f | ||
|
|
7f4a99aa95 | ||
|
|
122619624d | ||
|
|
2ea99a81f1 | ||
|
|
c30d18b10d | ||
|
|
18051ab399 | ||
|
|
654a864b3a | ||
|
|
5fba9b0cba | ||
|
|
906bff792c | ||
|
|
c029cc4354 | ||
|
|
0bafd1938c | ||
|
|
b7b1d1a2c7 | ||
|
|
a1d2ef6514 | ||
|
|
81f818aa86 | ||
|
|
f49be143f3 | ||
|
|
f8da686987 |
51 changed files with 2769 additions and 1033 deletions
64
Cargo.lock
generated
64
Cargo.lock
generated
|
|
@ -109,7 +109,6 @@ dependencies = [
|
|||
"parking_lot",
|
||||
"portable-pty",
|
||||
"project",
|
||||
"prompt_store",
|
||||
"rand 0.9.4",
|
||||
"sandbox",
|
||||
"serde",
|
||||
|
|
@ -406,7 +405,7 @@ dependencies = [
|
|||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
"collections",
|
||||
"convert_case 0.8.0",
|
||||
"convert_case 0.11.0",
|
||||
"fs",
|
||||
"futures 0.3.32",
|
||||
"gpui",
|
||||
|
|
@ -2163,7 +2162,7 @@ dependencies = [
|
|||
"bitflags 2.10.0",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.11.0",
|
||||
"itertools 0.10.5",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
|
|
@ -2183,7 +2182,7 @@ dependencies = [
|
|||
"bitflags 2.10.0",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.11.0",
|
||||
"itertools 0.10.5",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
|
|
@ -5313,7 +5312,7 @@ dependencies = [
|
|||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5783,7 +5782,7 @@ dependencies = [
|
|||
"client",
|
||||
"clock",
|
||||
"collections",
|
||||
"convert_case 0.8.0",
|
||||
"convert_case 0.11.0",
|
||||
"criterion",
|
||||
"ctor",
|
||||
"dap",
|
||||
|
|
@ -6148,7 +6147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -7604,7 +7603,7 @@ dependencies = [
|
|||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -9065,7 +9064,7 @@ dependencies = [
|
|||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
|
|
@ -9083,7 +9082,7 @@ dependencies = [
|
|||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
"windows-core 0.56.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -9337,7 +9336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.15.5",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
|
@ -10147,7 +10146,7 @@ dependencies = [
|
|||
"cloud_api_types",
|
||||
"collections",
|
||||
"component",
|
||||
"convert_case 0.8.0",
|
||||
"convert_case 0.11.0",
|
||||
"copilot",
|
||||
"copilot_chat",
|
||||
"copilot_ui",
|
||||
|
|
@ -10480,7 +10479,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "libwebrtc"
|
||||
version = "0.3.26"
|
||||
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1"
|
||||
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f"
|
||||
dependencies = [
|
||||
"cxx",
|
||||
"glib",
|
||||
|
|
@ -10590,7 +10589,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
|||
[[package]]
|
||||
name = "livekit"
|
||||
version = "0.7.32"
|
||||
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1"
|
||||
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bmrng",
|
||||
|
|
@ -10616,7 +10615,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "livekit-api"
|
||||
version = "0.4.14"
|
||||
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1"
|
||||
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"futures-util",
|
||||
|
|
@ -10643,7 +10642,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "livekit-protocol"
|
||||
version = "0.7.1"
|
||||
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1"
|
||||
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"livekit-runtime",
|
||||
|
|
@ -10659,7 +10658,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "livekit-runtime"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1"
|
||||
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f"
|
||||
dependencies = [
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
|
|
@ -11365,7 +11364,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"collections",
|
||||
"convert_case 0.8.0",
|
||||
"convert_case 0.11.0",
|
||||
"log",
|
||||
"pretty_assertions",
|
||||
"serde_json",
|
||||
|
|
@ -11951,7 +11950,7 @@ version = "0.50.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -14483,7 +14482,6 @@ dependencies = [
|
|||
"db",
|
||||
"fs",
|
||||
"futures 0.3.32",
|
||||
"fuzzy",
|
||||
"gpui",
|
||||
"handlebars 4.5.0",
|
||||
"heed",
|
||||
|
|
@ -14491,7 +14489,6 @@ dependencies = [
|
|||
"log",
|
||||
"parking_lot",
|
||||
"paths",
|
||||
"rope",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"strum 0.27.2",
|
||||
|
|
@ -14590,7 +14587,7 @@ checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4"
|
|||
dependencies = [
|
||||
"bytes 1.11.1",
|
||||
"heck 0.5.0",
|
||||
"itertools 0.11.0",
|
||||
"itertools 0.10.5",
|
||||
"log",
|
||||
"multimap",
|
||||
"once_cell",
|
||||
|
|
@ -14623,7 +14620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.11.0",
|
||||
"itertools 0.10.5",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
|
|
@ -14885,7 +14882,7 @@ dependencies = [
|
|||
"quinn-udp",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls 0.23.40",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
|
@ -14922,9 +14919,9 @@ dependencies = [
|
|||
"cfg_aliases 0.2.1",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -16149,7 +16146,7 @@ dependencies = [
|
|||
"errno 0.3.14",
|
||||
"libc",
|
||||
"linux-raw-sys 0.11.0",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -18220,6 +18217,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"file_icons",
|
||||
"gpui",
|
||||
"image",
|
||||
"language",
|
||||
"multi_buffer",
|
||||
"ui",
|
||||
|
|
@ -18771,7 +18769,7 @@ dependencies = [
|
|||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.2",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -19488,7 +19486,7 @@ name = "toolchain_selector"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.8.0",
|
||||
"convert_case 0.11.0",
|
||||
"editor",
|
||||
"futures 0.3.32",
|
||||
"fuzzy",
|
||||
|
|
@ -19696,7 +19694,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "2fb391ac70462b3097a755618fbf9c8f95ecc1eb379a414f7b46f202ed10db1f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -21489,7 +21487,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "webrtc-sys"
|
||||
version = "0.3.23"
|
||||
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1"
|
||||
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cxx",
|
||||
|
|
@ -21503,7 +21501,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "webrtc-sys-build"
|
||||
version = "0.3.13"
|
||||
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1#147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1"
|
||||
source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=c3a55bbc207008f1ca3474b6037fdd3c443cad0f#c3a55bbc207008f1ca3474b6037fdd3c443cad0f"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fs2",
|
||||
|
|
@ -21801,7 +21799,7 @@ version = "0.1.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -559,7 +559,7 @@ clap = { version = "4.4", features = ["derive", "wrap_help"] }
|
|||
cocoa = "=0.26.0"
|
||||
cocoa-foundation = "=0.2.0"
|
||||
const_format = "0.2"
|
||||
convert_case = "0.8.0"
|
||||
convert_case = "0.11.0"
|
||||
core-foundation = "=0.10.0"
|
||||
core-foundation-sys = "0.8.6"
|
||||
core-video = { version = "0.5.2", features = ["metal"] }
|
||||
|
|
@ -891,9 +891,9 @@ notify = { git = "https://github.com/zed-industries/notify.git", rev = "ce58c24c
|
|||
notify-types = { git = "https://github.com/zed-industries/notify.git", rev = "ce58c24cad542c28e04ced02e20325a4ec28a31d" }
|
||||
windows-capture = { git = "https://github.com/zed-industries/windows-capture.git", rev = "f0d6c1b6691db75461b732f6d5ff56eed002eeb9" }
|
||||
calloop = { git = "https://github.com/zed-industries/calloop" }
|
||||
livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" }
|
||||
libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" }
|
||||
webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "147fbca3d4b592d96d33f5e6a84b59fc0b5d9bf1" }
|
||||
livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" }
|
||||
libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" }
|
||||
webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" }
|
||||
|
||||
[profile.dev]
|
||||
split-debuginfo = "unpacked"
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ parking_lot = { workspace = true, optional = true }
|
|||
image.workspace = true
|
||||
portable-pty.workspace = true
|
||||
project.workspace = true
|
||||
prompt_store.workspace = true
|
||||
sandbox.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
use agent_client_protocol::schema as acp;
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use file_icons::FileIcons;
|
||||
use prompt_store::{PromptId, UserPromptId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
|
|
@ -37,10 +36,6 @@ pub enum MentionUri {
|
|||
id: acp::SessionId,
|
||||
name: String,
|
||||
},
|
||||
Rule {
|
||||
id: PromptId,
|
||||
name: String,
|
||||
},
|
||||
Diagnostics {
|
||||
#[serde(default = "default_include_errors")]
|
||||
include_errors: bool,
|
||||
|
|
@ -205,13 +200,6 @@ impl MentionUri {
|
|||
id: acp::SessionId::new(thread_id),
|
||||
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 if path == "/agent/diagnostics" {
|
||||
let mut include_errors = default_include_errors();
|
||||
let mut include_warnings = false;
|
||||
|
|
@ -342,7 +330,6 @@ impl MentionUri {
|
|||
MentionUri::PastedImage { name } => name.clone(),
|
||||
MentionUri::Symbol { name, .. } => name.clone(),
|
||||
MentionUri::Thread { name, .. } => name.clone(),
|
||||
MentionUri::Rule { name, .. } => name.clone(),
|
||||
MentionUri::Diagnostics { .. } => "Diagnostics".to_string(),
|
||||
MentionUri::TerminalSelection { line_count } => {
|
||||
if *line_count == 1 {
|
||||
|
|
@ -443,7 +430,6 @@ impl MentionUri {
|
|||
.unwrap_or_else(|| IconName::Folder.path().into()),
|
||||
MentionUri::Symbol { .. } => IconName::Code.path().into(),
|
||||
MentionUri::Thread { .. } => IconName::Thread.path().into(),
|
||||
MentionUri::Rule { .. } => IconName::Reader.path().into(),
|
||||
MentionUri::Diagnostics { .. } => IconName::Warning.path().into(),
|
||||
MentionUri::TerminalSelection { .. } => IconName::Terminal.path().into(),
|
||||
MentionUri::Selection { .. } => IconName::Reader.path().into(),
|
||||
|
|
@ -526,12 +512,6 @@ impl MentionUri {
|
|||
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::Diagnostics {
|
||||
include_errors,
|
||||
include_warnings,
|
||||
|
|
@ -811,20 +791,6 @@ mod tests {
|
|||
assert_eq!(parsed.to_uri().to_string(), thread_uri);
|
||||
}
|
||||
|
||||
#[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, PathStyle::local()).unwrap();
|
||||
match &parsed {
|
||||
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().to_string(), rule_uri);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_skill_uri_round_trip() {
|
||||
let skill_uri = MentionUri::Skill {
|
||||
|
|
|
|||
|
|
@ -316,17 +316,6 @@ impl UserMessage {
|
|||
MentionUri::Thread { .. } => {
|
||||
write!(&mut thread_context, "\n{}\n", content).ok();
|
||||
}
|
||||
MentionUri::Rule { .. } => {
|
||||
write!(
|
||||
&mut rules_context,
|
||||
"\n{}",
|
||||
MarkdownCodeBlock {
|
||||
tag: "",
|
||||
text: content
|
||||
}
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
MentionUri::Fetch { url } => {
|
||||
write!(&mut fetch_context, "\nFetch: {}\n\n{}", url, content).ok();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,6 @@ use gpui::{
|
|||
use language::LanguageRegistry;
|
||||
use language_model::LanguageModelRegistry;
|
||||
use project::{Project, ProjectPath, Worktree};
|
||||
use prompt_store::PromptStore;
|
||||
use settings::TerminalDockPosition;
|
||||
use settings::{NotifyWhenAgentWaiting, Settings, update_settings_file};
|
||||
use skill_creator::{SkillCreatorOpenMode, is_supported_skill_url, open_skill_creator};
|
||||
|
|
@ -1049,7 +1048,6 @@ pub struct AgentPanel {
|
|||
fs: Arc<dyn Fs>,
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
thread_store: Entity<ThreadStore>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
connection_store: Entity<AgentConnectionStore>,
|
||||
context_server_registry: Entity<ContextServerRegistry>,
|
||||
configuration: Option<Entity<AgentConfiguration>>,
|
||||
|
|
@ -1170,13 +1168,8 @@ impl AgentPanel {
|
|||
workspace: WeakEntity<Workspace>,
|
||||
mut cx: AsyncWindowContext,
|
||||
) -> Task<Result<Entity<Self>>> {
|
||||
let prompt_store = cx.update(|_window, cx| PromptStore::global(cx));
|
||||
let kvp = cx.update(|_window, cx| KeyValueStore::global(cx)).ok();
|
||||
cx.spawn(async move |cx| {
|
||||
let prompt_store = match prompt_store {
|
||||
Ok(prompt_store) => prompt_store.await.ok(),
|
||||
Err(_) => None,
|
||||
};
|
||||
let workspace_id = workspace
|
||||
.read_with(cx, |workspace, _| workspace.database_id())
|
||||
.ok()
|
||||
|
|
@ -1301,7 +1294,7 @@ impl AgentPanel {
|
|||
};
|
||||
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| Self::new(workspace, prompt_store, window, cx));
|
||||
let panel = cx.new(|cx| Self::new(workspace, window, cx));
|
||||
|
||||
panel.update(cx, |panel, cx| {
|
||||
let is_via_collab = panel.project.read(cx).is_via_collab();
|
||||
|
|
@ -1381,12 +1374,7 @@ impl AgentPanel {
|
|||
})
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
workspace: &Workspace,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
pub(crate) fn new(workspace: &Workspace, _window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let fs = workspace.app_state().fs.clone();
|
||||
let user_store = workspace.app_state().user_store.clone();
|
||||
let project = workspace.project();
|
||||
|
|
@ -1468,7 +1456,6 @@ impl AgentPanel {
|
|||
project: project.clone(),
|
||||
fs: fs.clone(),
|
||||
language_registry,
|
||||
prompt_store,
|
||||
connection_store,
|
||||
configuration: None,
|
||||
configuration_subscription: None,
|
||||
|
|
@ -1547,10 +1534,6 @@ impl AgentPanel {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_store(&self) -> &Option<Entity<PromptStore>> {
|
||||
&self.prompt_store
|
||||
}
|
||||
|
||||
pub fn thread_store(&self) -> &Entity<ThreadStore> {
|
||||
&self.thread_store
|
||||
}
|
||||
|
|
@ -4395,7 +4378,6 @@ impl AgentPanel {
|
|||
workspace.clone(),
|
||||
project,
|
||||
thread_store,
|
||||
self.prompt_store.clone(),
|
||||
source,
|
||||
window,
|
||||
cx,
|
||||
|
|
@ -6087,7 +6069,7 @@ impl Dismissable for TrialEndUpsell {
|
|||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl AgentPanel {
|
||||
pub fn test_new(workspace: &Workspace, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self::new(workspace, None, window, cx)
|
||||
Self::new(workspace, window, cx)
|
||||
}
|
||||
|
||||
/// Drops a thread's `ConversationView` from `retained_threads` without
|
||||
|
|
@ -6594,7 +6576,7 @@ mod tests {
|
|||
|
||||
// Set up workspace A: with an active thread.
|
||||
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
|
||||
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
|
||||
cx.new(|cx| AgentPanel::new(workspace, window, cx))
|
||||
});
|
||||
|
||||
panel_a.update_in(cx, |panel, window, cx| {
|
||||
|
|
@ -6620,7 +6602,7 @@ mod tests {
|
|||
|
||||
// Set up workspace B: ClaudeCode, no active thread.
|
||||
let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
|
||||
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
|
||||
cx.new(|cx| AgentPanel::new(workspace, window, cx))
|
||||
});
|
||||
|
||||
panel_b.update(cx, |panel, _cx| {
|
||||
|
|
@ -6723,7 +6705,7 @@ mod tests {
|
|||
|
||||
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
|
||||
cx.new(|cx| AgentPanel::new(workspace, window, cx))
|
||||
});
|
||||
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
|
|
@ -6918,7 +6900,7 @@ mod tests {
|
|||
|
||||
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
|
||||
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
|
||||
cx.new(|cx| AgentPanel::new(workspace, window, cx))
|
||||
});
|
||||
panel_a
|
||||
.update_in(cx, |panel, window, cx| {
|
||||
|
|
@ -6995,7 +6977,7 @@ mod tests {
|
|||
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
|
||||
cx.new(|cx| AgentPanel::new(workspace, window, cx))
|
||||
});
|
||||
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
|
|
@ -7087,7 +7069,7 @@ mod tests {
|
|||
});
|
||||
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
|
||||
cx.new(|cx| AgentPanel::new(workspace, window, cx))
|
||||
});
|
||||
|
||||
// Open a restored thread using a flaky server so the initial connect
|
||||
|
|
@ -7186,7 +7168,7 @@ mod tests {
|
|||
.unwrap();
|
||||
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -7286,12 +7268,12 @@ mod tests {
|
|||
|
||||
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -7666,7 +7648,7 @@ mod tests {
|
|||
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -7853,7 +7835,7 @@ mod tests {
|
|||
|
||||
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -8082,7 +8064,7 @@ mod tests {
|
|||
|
||||
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -8168,7 +8150,7 @@ mod tests {
|
|||
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -8258,7 +8240,7 @@ mod tests {
|
|||
let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
|
||||
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
|
||||
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
|
||||
cx.new(|cx| AgentPanel::new(workspace, window, cx))
|
||||
});
|
||||
|
||||
(panel, cx)
|
||||
|
|
@ -8305,7 +8287,7 @@ mod tests {
|
|||
register_test_sidebar(threads_list_active, &mut cx);
|
||||
|
||||
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
workspace.focus_panel::<AgentPanel>(window, cx);
|
||||
panel
|
||||
|
|
@ -8435,7 +8417,7 @@ mod tests {
|
|||
let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
|
||||
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -8548,7 +8530,7 @@ mod tests {
|
|||
let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
|
||||
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -9791,7 +9773,7 @@ mod tests {
|
|||
|
||||
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -10383,7 +10365,7 @@ mod tests {
|
|||
let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
|
||||
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
|
||||
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
|
||||
cx.new(|cx| AgentPanel::new(workspace, window, cx))
|
||||
});
|
||||
|
||||
// Open thread A and send a message. With empty next_prompt_updates it
|
||||
|
|
@ -10652,7 +10634,7 @@ mod tests {
|
|||
|
||||
// Set up workspace A with agent_a
|
||||
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
|
||||
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
|
||||
cx.new(|cx| AgentPanel::new(workspace, window, cx))
|
||||
});
|
||||
panel_a.update(cx, |panel, _cx| {
|
||||
panel.selected_agent = agent_a.clone();
|
||||
|
|
@ -10660,7 +10642,7 @@ mod tests {
|
|||
|
||||
// Set up workspace B with agent_b
|
||||
let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
|
||||
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
|
||||
cx.new(|cx| AgentPanel::new(workspace, window, cx))
|
||||
});
|
||||
panel_b.update(cx, |panel, _cx| {
|
||||
panel.selected_agent = agent_b.clone();
|
||||
|
|
@ -10731,7 +10713,7 @@ mod tests {
|
|||
};
|
||||
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -10788,7 +10770,7 @@ mod tests {
|
|||
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -10878,7 +10860,7 @@ mod tests {
|
|||
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -10966,7 +10948,7 @@ mod tests {
|
|||
workspace.update(cx, |workspace, _cx| workspace.set_random_database_id());
|
||||
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -11076,7 +11058,7 @@ mod tests {
|
|||
workspace.update(cx, |workspace, _cx| workspace.set_random_database_id());
|
||||
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -11182,7 +11164,7 @@ mod tests {
|
|||
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -11681,7 +11663,7 @@ mod tests {
|
|||
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
|
||||
cx.new(|cx| AgentPanel::new(workspace, window, cx))
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
|
@ -11782,7 +11764,7 @@ mod tests {
|
|||
|
||||
// Create the agent panel and add it to the workspace.
|
||||
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -11992,7 +11974,7 @@ mod tests {
|
|||
let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
|
||||
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -12229,7 +12211,7 @@ mod tests {
|
|||
|
||||
// Set up panel_a with an active thread and type draft text.
|
||||
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -12253,7 +12235,7 @@ mod tests {
|
|||
|
||||
// Set up panel_b on workspace_b — starts as a fresh, empty panel.
|
||||
let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -12323,7 +12305,7 @@ mod tests {
|
|||
|
||||
// Set up panel_a with draft text.
|
||||
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -12347,7 +12329,7 @@ mod tests {
|
|||
|
||||
// Set up panel_b with its OWN content — this is a non-fresh panel.
|
||||
let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
|
|
@ -12391,42 +12373,4 @@ mod tests {
|
|||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Regression test: NewThread must produce a connected thread even when
|
||||
/// the PromptStore fails to initialize (e.g. LMDB permission error).
|
||||
/// Before the fix, `NativeAgentServer::connect` propagated the
|
||||
/// PromptStore error with `?`, which put every new ConversationView
|
||||
/// into LoadError and made it impossible to start any native-agent
|
||||
/// thread.
|
||||
#[gpui::test]
|
||||
async fn test_new_thread_with_prompt_store_error(cx: &mut TestAppContext) {
|
||||
let (panel, mut cx) = setup_panel(cx).await;
|
||||
|
||||
// NativeAgentServer::connect needs a global Fs.
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
cx.update(|_, cx| {
|
||||
<dyn fs::Fs>::set_global(fs.clone(), cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// Dispatch NewThread, which goes through the real NativeAgentServer
|
||||
// path. In tests the PromptStore LMDB open fails with
|
||||
// "Permission denied"; the fix (.log_err() instead of ?) lets
|
||||
// the connection succeed anyway.
|
||||
panel.update_in(&mut cx, |panel, window, cx| {
|
||||
panel.new_thread(&NewThread, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
panel.read_with(&cx, |panel, cx| {
|
||||
assert!(
|
||||
panel.active_conversation_view().is_some(),
|
||||
"panel should have a conversation view after NewThread"
|
||||
);
|
||||
assert!(
|
||||
panel.active_agent_thread(cx).is_some(),
|
||||
"panel should have an active, connected agent thread"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ use markdown::{
|
|||
};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use project::{AgentId, AgentServerStore, Project, ProjectEntryId, ProjectPath};
|
||||
use prompt_store::{PromptId, PromptStore};
|
||||
|
||||
use crate::message_editor::SessionCapabilities;
|
||||
use crate::{AgentThreadSource, DEFAULT_THREAD_TITLE, resolve_agent_image};
|
||||
|
|
@ -75,7 +74,6 @@ use workspace::{
|
|||
path_link::sanitize_path_text,
|
||||
};
|
||||
use zed_actions::agent::{Chat, ToggleModelSelector};
|
||||
use zed_actions::assistant::OpenRulesLibrary;
|
||||
|
||||
use super::config_options::ConfigOptionsView;
|
||||
use super::entry_view_state::EntryViewState;
|
||||
|
|
@ -531,7 +529,6 @@ pub struct ConversationView {
|
|||
workspace: WeakEntity<Workspace>,
|
||||
project: Entity<Project>,
|
||||
thread_store: Option<Entity<ThreadStore>>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
pub(crate) thread_id: ThreadId,
|
||||
pub(crate) root_session_id: Option<acp::SessionId>,
|
||||
server_state: ServerState,
|
||||
|
|
@ -738,7 +735,6 @@ impl ConversationView {
|
|||
workspace: WeakEntity<Workspace>,
|
||||
project: Entity<Project>,
|
||||
thread_store: Option<Entity<ThreadStore>>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
source: AgentThreadSource,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
|
|
@ -795,7 +791,6 @@ impl ConversationView {
|
|||
workspace,
|
||||
project: project.clone(),
|
||||
thread_store,
|
||||
prompt_store,
|
||||
thread_id,
|
||||
root_session_id: resume_session_id.clone(),
|
||||
server_state: Self::initial_state(
|
||||
|
|
@ -1104,7 +1099,6 @@ impl ConversationView {
|
|||
self.workspace.clone(),
|
||||
self.project.downgrade(),
|
||||
self.thread_store.clone(),
|
||||
self.prompt_store.clone(),
|
||||
session_capabilities.clone(),
|
||||
self.agent.agent_id(),
|
||||
)
|
||||
|
|
@ -1273,7 +1267,6 @@ impl ConversationView {
|
|||
self.project.downgrade(),
|
||||
self.code_span_resolver.clone(),
|
||||
self.thread_store.clone(),
|
||||
self.prompt_store.clone(),
|
||||
initial_content,
|
||||
subscriptions,
|
||||
window,
|
||||
|
|
@ -2492,7 +2485,6 @@ impl ConversationView {
|
|||
workspace.clone(),
|
||||
project.clone(),
|
||||
None,
|
||||
None,
|
||||
session_capabilities.clone(),
|
||||
agent_name.clone(),
|
||||
"",
|
||||
|
|
@ -3721,7 +3713,6 @@ pub(crate) mod tests {
|
|||
workspace.downgrade(),
|
||||
project,
|
||||
Some(thread_store),
|
||||
None,
|
||||
AgentThreadSource::AgentPanel,
|
||||
window,
|
||||
cx,
|
||||
|
|
@ -3858,7 +3849,6 @@ pub(crate) mod tests {
|
|||
workspace.downgrade(),
|
||||
project,
|
||||
Some(thread_store),
|
||||
None,
|
||||
AgentThreadSource::AgentPanel,
|
||||
window,
|
||||
cx,
|
||||
|
|
@ -3940,7 +3930,6 @@ pub(crate) mod tests {
|
|||
workspace.downgrade(),
|
||||
project,
|
||||
Some(thread_store),
|
||||
None,
|
||||
AgentThreadSource::AgentPanel,
|
||||
window,
|
||||
cx,
|
||||
|
|
@ -4079,7 +4068,6 @@ pub(crate) mod tests {
|
|||
workspace.downgrade(),
|
||||
project.clone(),
|
||||
Some(thread_store),
|
||||
None,
|
||||
AgentThreadSource::AgentPanel,
|
||||
window,
|
||||
cx,
|
||||
|
|
@ -4364,7 +4352,7 @@ pub(crate) mod tests {
|
|||
let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
|
||||
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| crate::AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
workspace.focus_panel::<crate::AgentPanel>(window, cx);
|
||||
panel
|
||||
|
|
@ -4405,7 +4393,6 @@ pub(crate) mod tests {
|
|||
workspace.downgrade(),
|
||||
project.clone(),
|
||||
Some(thread_store),
|
||||
None,
|
||||
AgentThreadSource::AgentPanel,
|
||||
window,
|
||||
cx,
|
||||
|
|
@ -4504,7 +4491,6 @@ pub(crate) mod tests {
|
|||
workspace.downgrade(),
|
||||
project.clone(),
|
||||
Some(thread_store),
|
||||
None,
|
||||
AgentThreadSource::AgentPanel,
|
||||
window,
|
||||
cx,
|
||||
|
|
@ -4580,7 +4566,6 @@ pub(crate) mod tests {
|
|||
workspace.downgrade(),
|
||||
project.clone(),
|
||||
Some(thread_store),
|
||||
None,
|
||||
AgentThreadSource::AgentPanel,
|
||||
window,
|
||||
cx,
|
||||
|
|
@ -4648,7 +4633,6 @@ pub(crate) mod tests {
|
|||
workspace.downgrade(),
|
||||
project.clone(),
|
||||
Some(thread_store),
|
||||
None,
|
||||
AgentThreadSource::AgentPanel,
|
||||
window,
|
||||
cx,
|
||||
|
|
@ -4724,7 +4708,7 @@ pub(crate) mod tests {
|
|||
let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
|
||||
|
||||
let panel = workspace1.update_in(cx, |workspace, window, cx| {
|
||||
let panel = cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx));
|
||||
let panel = cx.new(|cx| crate::AgentPanel::new(workspace, window, cx));
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
|
||||
// Open the dock and activate the agent panel so it's visible
|
||||
|
|
@ -4770,7 +4754,6 @@ pub(crate) mod tests {
|
|||
workspace1.downgrade(),
|
||||
project1.clone(),
|
||||
Some(thread_store),
|
||||
None,
|
||||
AgentThreadSource::AgentPanel,
|
||||
window,
|
||||
cx,
|
||||
|
|
@ -4992,7 +4975,6 @@ pub(crate) mod tests {
|
|||
workspace.downgrade(),
|
||||
project,
|
||||
Some(thread_store),
|
||||
None,
|
||||
AgentThreadSource::AgentPanel,
|
||||
window,
|
||||
cx,
|
||||
|
|
@ -5651,7 +5633,6 @@ pub(crate) mod tests {
|
|||
workspace.downgrade(),
|
||||
project.clone(),
|
||||
Some(thread_store.clone()),
|
||||
None,
|
||||
AgentThreadSource::AgentPanel,
|
||||
window,
|
||||
cx,
|
||||
|
|
@ -8113,9 +8094,17 @@ pub(crate) mod tests {
|
|||
async fn test_permission_row_hidden_when_inline_bounds_unavailable(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let (_view, thread_view, _entry_ix, cx) =
|
||||
let (_view, thread_view, entry_ix, cx) =
|
||||
setup_pending_permission_thread("perm-no-bounds", cx).await;
|
||||
|
||||
// Pin the scroll top to the entry so it isn't treated as above the
|
||||
// viewport, forcing the unmeasured-bounds path we want to exercise.
|
||||
thread_view.read_with(cx, |view, _cx| {
|
||||
view.list_state.scroll_to(ListOffset {
|
||||
item_ix: entry_ix,
|
||||
offset_in_item: px(0.0),
|
||||
});
|
||||
});
|
||||
thread_view.update_in(cx, |view, window, cx| {
|
||||
assert!(
|
||||
view.render_main_agent_awaiting_permission(window, cx)
|
||||
|
|
@ -8176,8 +8165,8 @@ pub(crate) mod tests {
|
|||
let (_view, thread_view, entry_ix, cx) =
|
||||
setup_pending_permission_thread("perm-scroll", cx).await;
|
||||
|
||||
// Start off-screen below the viewport — row visible because the item
|
||||
// has bounds that do not intersect the viewport.
|
||||
// Start off-screen below the viewport. The row is visible because the
|
||||
// item has bounds that do not intersect the viewport.
|
||||
draw_thread_list_at(
|
||||
&thread_view,
|
||||
ListOffset {
|
||||
|
|
@ -8221,6 +8210,69 @@ pub(crate) mod tests {
|
|||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_permission_row_shown_when_inline_prompt_is_above_viewport(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
init_test(cx);
|
||||
|
||||
let (_view, thread_view, entry_ix, cx) =
|
||||
setup_pending_permission_thread("perm-above", cx).await;
|
||||
|
||||
let thread = thread_view.read_with(cx, |view, _cx| view.thread.clone());
|
||||
thread.update(cx, |thread, cx| {
|
||||
let result = thread.handle_session_update(
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
|
||||
"More content".into(),
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"following assistant message should be accepted"
|
||||
);
|
||||
});
|
||||
|
||||
draw_thread_list_at(
|
||||
&thread_view,
|
||||
ListOffset {
|
||||
item_ix: entry_ix + 1,
|
||||
offset_in_item: px(0.0),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
thread_view.read_with(cx, |view, _cx| {
|
||||
assert!(
|
||||
entry_ix < view.list_state.logical_scroll_top().item_ix,
|
||||
"The tool call entry should be above the logical scroll top"
|
||||
);
|
||||
});
|
||||
thread_view.update_in(cx, |view, window, cx| {
|
||||
assert!(
|
||||
view.render_main_agent_awaiting_permission(window, cx)
|
||||
.is_some(),
|
||||
"Floating row should be visible when the inline prompt is above the viewport"
|
||||
);
|
||||
});
|
||||
|
||||
// Scrolling up to the entry brings it back into view.
|
||||
draw_thread_list_at(
|
||||
&thread_view,
|
||||
ListOffset {
|
||||
item_ix: entry_ix,
|
||||
offset_in_item: px(0.0),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
thread_view.update_in(cx, |view, window, cx| {
|
||||
assert!(
|
||||
view.render_main_agent_awaiting_permission(window, cx)
|
||||
.is_none(),
|
||||
"Floating row should disappear after scrolling brings the inline prompt into view"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_permission_row_disappears_when_authorized(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
|
@ -8556,7 +8608,6 @@ pub(crate) mod tests {
|
|||
workspace.downgrade(),
|
||||
project,
|
||||
Some(thread_store),
|
||||
None,
|
||||
AgentThreadSource::AgentPanel,
|
||||
window,
|
||||
cx,
|
||||
|
|
|
|||
|
|
@ -683,7 +683,6 @@ impl ThreadView {
|
|||
project: WeakEntity<Project>,
|
||||
code_span_resolver: AgentCodeSpanResolver,
|
||||
thread_store: Option<Entity<ThreadStore>>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
initial_content: Option<AgentInitialContent>,
|
||||
mut subscriptions: Vec<Subscription>,
|
||||
window: &mut Window,
|
||||
|
|
@ -703,7 +702,6 @@ impl ThreadView {
|
|||
workspace.clone(),
|
||||
project.clone(),
|
||||
thread_store,
|
||||
prompt_store,
|
||||
session_capabilities.clone(),
|
||||
agent_id.clone(),
|
||||
&placeholder,
|
||||
|
|
@ -3047,15 +3045,6 @@ impl ThreadView {
|
|||
)
|
||||
}
|
||||
|
||||
/// Returns true when the entry has been measured and sits entirely below
|
||||
/// the current viewport.
|
||||
fn entry_is_below_viewport(&self, entry_ix: usize) -> bool {
|
||||
let viewport_bounds = self.list_state.viewport_bounds();
|
||||
self.list_state
|
||||
.bounds_for_item(entry_ix)
|
||||
.is_some_and(|entry_bounds| entry_bounds.top() >= viewport_bounds.bottom())
|
||||
}
|
||||
|
||||
pub(crate) fn render_main_agent_awaiting_permission(
|
||||
&self,
|
||||
window: &Window,
|
||||
|
|
@ -3073,9 +3062,13 @@ impl ThreadView {
|
|||
let thread = self.thread.read(cx);
|
||||
let (entry_ix, tool_call) = thread.tool_call(&tool_call_id)?;
|
||||
|
||||
if !self.entry_is_below_viewport(entry_ix) {
|
||||
let scroll_icon = if self.list_state.item_is_above_viewport(entry_ix)? {
|
||||
IconName::ArrowUp
|
||||
} else if self.list_state.item_is_below_viewport(entry_ix)? {
|
||||
IconName::ArrowDown
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let focus_handle = self.focus_handle(cx);
|
||||
|
||||
|
|
@ -3118,7 +3111,7 @@ impl ThreadView {
|
|||
Button::new("main-agent-permission-scroll-to", "Scroll")
|
||||
.label_size(LabelSize::Small)
|
||||
.end_icon(
|
||||
Icon::new(IconName::ArrowDown)
|
||||
Icon::new(scroll_icon)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Default),
|
||||
)
|
||||
|
|
@ -10014,17 +10007,6 @@ pub(crate) fn open_link(
|
|||
});
|
||||
}
|
||||
}
|
||||
MentionUri::Rule { id, .. } => {
|
||||
let PromptId::User { uuid } = id else {
|
||||
return;
|
||||
};
|
||||
window.dispatch_action(
|
||||
Box::new(OpenRulesLibrary {
|
||||
prompt_to_select: Some(uuid.0),
|
||||
}),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
MentionUri::Fetch { url } => {
|
||||
cx.open_url(url.as_str());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ use gpui::{
|
|||
ScrollHandle, TextStyleRefinement, WeakEntity, Window,
|
||||
};
|
||||
use language::language_settings::SoftWrap;
|
||||
use project::{AgentId, Project};
|
||||
use prompt_store::PromptStore;
|
||||
use project::{AgentId, Project, project_settings::DiagnosticSeverity};
|
||||
use rope::Point;
|
||||
use settings::Settings as _;
|
||||
use terminal_view::TerminalView;
|
||||
|
|
@ -25,7 +24,6 @@ pub struct EntryViewState {
|
|||
workspace: WeakEntity<Workspace>,
|
||||
project: WeakEntity<Project>,
|
||||
thread_store: Option<Entity<ThreadStore>>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
entries: Vec<Entry>,
|
||||
session_capabilities: SharedSessionCapabilities,
|
||||
agent_id: AgentId,
|
||||
|
|
@ -36,7 +34,6 @@ impl EntryViewState {
|
|||
workspace: WeakEntity<Workspace>,
|
||||
project: WeakEntity<Project>,
|
||||
thread_store: Option<Entity<ThreadStore>>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
session_capabilities: SharedSessionCapabilities,
|
||||
agent_id: AgentId,
|
||||
) -> Self {
|
||||
|
|
@ -44,7 +41,6 @@ impl EntryViewState {
|
|||
workspace,
|
||||
project,
|
||||
thread_store,
|
||||
prompt_store,
|
||||
entries: Vec::new(),
|
||||
session_capabilities,
|
||||
agent_id,
|
||||
|
|
@ -86,7 +82,6 @@ impl EntryViewState {
|
|||
self.workspace.clone(),
|
||||
self.project.clone(),
|
||||
self.thread_store.clone(),
|
||||
self.prompt_store.clone(),
|
||||
self.session_capabilities.clone(),
|
||||
self.agent_id.clone(),
|
||||
"Edit message - @ to include context",
|
||||
|
|
@ -444,7 +439,8 @@ fn create_editor_diff(
|
|||
cx,
|
||||
);
|
||||
editor.set_show_gutter(false, cx);
|
||||
editor.disable_inline_diagnostics();
|
||||
editor.disable_diagnostics(cx);
|
||||
editor.set_max_diagnostics_severity(DiagnosticSeverity::Off, cx);
|
||||
editor.disable_expand_excerpt_buttons(cx);
|
||||
editor.set_show_vertical_scrollbar(false, cx);
|
||||
editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
|
||||
|
|
@ -545,7 +541,6 @@ mod tests {
|
|||
workspace.downgrade(),
|
||||
project.downgrade(),
|
||||
thread_store,
|
||||
None,
|
||||
Arc::new(RwLock::new(SessionCapabilities::default())),
|
||||
"Test Agent".into(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ use language_model::{ConfigurationError, ConfiguredModel, LanguageModelRegistry}
|
|||
use multi_buffer::MultiBufferRow;
|
||||
use parking_lot::Mutex;
|
||||
use project::{DisableAiSettings, Project};
|
||||
use prompt_store::{PromptBuilder, PromptStore};
|
||||
use prompt_store::PromptBuilder;
|
||||
use settings::{Settings, SettingsStore};
|
||||
|
||||
use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
|
||||
|
|
@ -228,7 +228,6 @@ impl InlineAssistant {
|
|||
};
|
||||
let agent_panel = agent_panel.read(cx);
|
||||
|
||||
let prompt_store = agent_panel.prompt_store().as_ref().cloned();
|
||||
let thread_store = agent_panel.thread_store().clone();
|
||||
|
||||
let handle_assist =
|
||||
|
|
@ -240,7 +239,6 @@ impl InlineAssistant {
|
|||
cx.entity().downgrade(),
|
||||
workspace.project().downgrade(),
|
||||
thread_store,
|
||||
prompt_store,
|
||||
action.prompt.clone(),
|
||||
window,
|
||||
cx,
|
||||
|
|
@ -254,7 +252,6 @@ impl InlineAssistant {
|
|||
cx.entity().downgrade(),
|
||||
workspace.project().downgrade(),
|
||||
thread_store,
|
||||
prompt_store,
|
||||
action.prompt.clone(),
|
||||
window,
|
||||
cx,
|
||||
|
|
@ -437,7 +434,6 @@ impl InlineAssistant {
|
|||
workspace: WeakEntity<Workspace>,
|
||||
project: WeakEntity<Project>,
|
||||
thread_store: Entity<ThreadStore>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
initial_prompt: Option<String>,
|
||||
window: &mut Window,
|
||||
codegen_ranges: &[Range<Anchor>],
|
||||
|
|
@ -483,7 +479,6 @@ impl InlineAssistant {
|
|||
session_id,
|
||||
self.fs.clone(),
|
||||
thread_store.clone(),
|
||||
prompt_store.clone(),
|
||||
project.clone(),
|
||||
workspace.clone(),
|
||||
window,
|
||||
|
|
@ -574,7 +569,6 @@ impl InlineAssistant {
|
|||
workspace: WeakEntity<Workspace>,
|
||||
project: WeakEntity<Project>,
|
||||
thread_store: Entity<ThreadStore>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
initial_prompt: Option<String>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
|
|
@ -592,7 +586,6 @@ impl InlineAssistant {
|
|||
workspace,
|
||||
project,
|
||||
thread_store,
|
||||
prompt_store,
|
||||
initial_prompt,
|
||||
window,
|
||||
&codegen_ranges,
|
||||
|
|
@ -1915,7 +1908,6 @@ pub mod evals {
|
|||
workspace.downgrade(),
|
||||
project.downgrade(),
|
||||
thread_store,
|
||||
None,
|
||||
Some(prompt),
|
||||
window,
|
||||
cx,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ use language_model::{LanguageModel, LanguageModelRegistry};
|
|||
use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
|
||||
use parking_lot::Mutex;
|
||||
use project::Project;
|
||||
use prompt_store::PromptStore;
|
||||
use settings::Settings;
|
||||
use std::cmp;
|
||||
use std::ops::Range;
|
||||
|
|
@ -1237,7 +1236,6 @@ impl PromptEditor<BufferCodegen> {
|
|||
session_id: Uuid,
|
||||
fs: Arc<dyn Fs>,
|
||||
thread_store: Entity<ThreadStore>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
project: WeakEntity<Project>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
window: &mut Window,
|
||||
|
|
@ -1276,8 +1274,7 @@ impl PromptEditor<BufferCodegen> {
|
|||
editor
|
||||
});
|
||||
|
||||
let mention_set = cx
|
||||
.new(|_cx| MentionSet::new(project, Some(thread_store.clone()), prompt_store.clone()));
|
||||
let mention_set = cx.new(|_cx| MentionSet::new(project, Some(thread_store.clone())));
|
||||
|
||||
let model_selector_menu_handle = PopoverMenuHandle::default();
|
||||
|
||||
|
|
@ -1393,7 +1390,6 @@ impl PromptEditor<TerminalCodegen> {
|
|||
session_id: Uuid,
|
||||
fs: Arc<dyn Fs>,
|
||||
thread_store: Entity<ThreadStore>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
project: WeakEntity<Project>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
window: &mut Window,
|
||||
|
|
@ -1427,8 +1423,7 @@ impl PromptEditor<TerminalCodegen> {
|
|||
editor
|
||||
});
|
||||
|
||||
let mention_set = cx
|
||||
.new(|_cx| MentionSet::new(project, Some(thread_store.clone()), prompt_store.clone()));
|
||||
let mention_set = cx.new(|_cx| MentionSet::new(project, Some(thread_store.clone())));
|
||||
|
||||
let model_selector_menu_handle = PopoverMenuHandle::default();
|
||||
|
||||
|
|
@ -1705,7 +1700,6 @@ mod tests {
|
|||
session_id,
|
||||
fs,
|
||||
thread_store,
|
||||
None,
|
||||
project,
|
||||
workspace.downgrade(),
|
||||
window,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ use language_model::{LanguageModelImage, LanguageModelImageExt};
|
|||
use multi_buffer::MultiBufferRow;
|
||||
use postage::stream::Stream as _;
|
||||
use project::{Project, ProjectItem, ProjectPath, Worktree};
|
||||
use prompt_store::{PromptId, PromptStore};
|
||||
use rope::Point;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
|
|
@ -61,21 +60,15 @@ pub struct MentionImage {
|
|||
pub struct MentionSet {
|
||||
project: WeakEntity<Project>,
|
||||
thread_store: Option<Entity<ThreadStore>>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
mentions: HashMap<CreaseId, (MentionUri, MentionTask)>,
|
||||
crease_entities: HashMap<CreaseId, Entity<LoadingContext>>,
|
||||
}
|
||||
|
||||
impl MentionSet {
|
||||
pub fn new(
|
||||
project: WeakEntity<Project>,
|
||||
thread_store: Option<Entity<ThreadStore>>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
) -> Self {
|
||||
pub fn new(project: WeakEntity<Project>, thread_store: Option<Entity<ThreadStore>>) -> Self {
|
||||
Self {
|
||||
project,
|
||||
thread_store,
|
||||
prompt_store,
|
||||
mentions: HashMap::default(),
|
||||
crease_entities: HashMap::default(),
|
||||
}
|
||||
|
|
@ -153,7 +146,6 @@ impl MentionSet {
|
|||
line_range,
|
||||
..
|
||||
} => self.confirm_mention_for_symbol(abs_path, line_range, cx),
|
||||
MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx),
|
||||
MentionUri::Skill {
|
||||
skill_file_path, ..
|
||||
} => self.confirm_mention_for_skill(skill_file_path, cx),
|
||||
|
|
@ -327,7 +319,6 @@ impl MentionSet {
|
|||
line_range,
|
||||
..
|
||||
} => self.confirm_mention_for_symbol(abs_path, line_range, cx),
|
||||
MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx),
|
||||
MentionUri::Skill {
|
||||
skill_file_path, ..
|
||||
} => self.confirm_mention_for_skill(skill_file_path, cx),
|
||||
|
|
@ -515,24 +506,6 @@ impl MentionSet {
|
|||
})
|
||||
}
|
||||
|
||||
fn confirm_mention_for_rule(
|
||||
&mut self,
|
||||
id: PromptId,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Mention>> {
|
||||
let Some(prompt_store) = self.prompt_store.as_ref() else {
|
||||
return Task::ready(Err(anyhow!("Missing prompt store")));
|
||||
};
|
||||
let prompt = prompt_store.read(cx).load(id, cx);
|
||||
cx.spawn(async move |_, _| {
|
||||
let prompt = prompt.await?;
|
||||
Ok(Mention::Text {
|
||||
content: prompt,
|
||||
tracked_buffers: Vec::new(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn confirm_mention_for_selection(
|
||||
&mut self,
|
||||
source_range: Range<text::Anchor>,
|
||||
|
|
@ -773,7 +746,7 @@ mod tests {
|
|||
fs.insert_tree("/project", json!({"file": ""})).await;
|
||||
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
|
||||
let thread_store = None;
|
||||
let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), thread_store, None));
|
||||
let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), thread_store));
|
||||
|
||||
let task = mention_set.update(cx, |mention_set, cx| {
|
||||
mention_set.confirm_mention_for_thread(acp::SessionId::new("thread-1"), cx)
|
||||
|
|
@ -799,7 +772,7 @@ mod tests {
|
|||
)
|
||||
.await;
|
||||
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
|
||||
let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), None, None));
|
||||
let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), None));
|
||||
|
||||
let mention_task = mention_set.update(cx, |mention_set, cx| {
|
||||
let http_client = project.read(cx).client().http_client();
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ use project::AgentId;
|
|||
use project::{
|
||||
CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, ProjectPath, Worktree,
|
||||
};
|
||||
use prompt_store::PromptStore;
|
||||
use rope::Point;
|
||||
use settings::Settings;
|
||||
use std::{cmp::min, fmt::Write, ops::Range, rc::Rc, sync::Arc};
|
||||
|
|
@ -453,7 +452,6 @@ impl MessageEditor {
|
|||
workspace: WeakEntity<Workspace>,
|
||||
project: WeakEntity<Project>,
|
||||
thread_store: Option<Entity<ThreadStore>>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
session_capabilities: SharedSessionCapabilities,
|
||||
agent_id: AgentId,
|
||||
placeholder: &str,
|
||||
|
|
@ -506,8 +504,7 @@ impl MessageEditor {
|
|||
|
||||
editor
|
||||
});
|
||||
let mention_set =
|
||||
cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone()));
|
||||
let mention_set = cx.new(|_cx| MentionSet::new(project, thread_store.clone()));
|
||||
let completion_provider = Rc::new(PromptCompletionProvider::new(
|
||||
MessageEditorCompletionDelegate {
|
||||
session_capabilities: session_capabilities.clone(),
|
||||
|
|
@ -2475,7 +2472,6 @@ mod tests {
|
|||
workspace.downgrade(),
|
||||
project.downgrade(),
|
||||
thread_store.clone(),
|
||||
None,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -2576,7 +2572,6 @@ mod tests {
|
|||
workspace_handle.clone(),
|
||||
project.downgrade(),
|
||||
thread_store.clone(),
|
||||
None,
|
||||
session_capabilities.clone(),
|
||||
"Claude Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -2742,7 +2737,6 @@ mod tests {
|
|||
workspace_handle,
|
||||
project.downgrade(),
|
||||
thread_store.clone(),
|
||||
None,
|
||||
session_capabilities.clone(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -2915,7 +2909,6 @@ mod tests {
|
|||
workspace_handle,
|
||||
project.downgrade(),
|
||||
None,
|
||||
None,
|
||||
session_capabilities.clone(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -3064,7 +3057,6 @@ mod tests {
|
|||
workspace_handle,
|
||||
project.downgrade(),
|
||||
Some(thread_store),
|
||||
None,
|
||||
session_capabilities.clone(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -3556,7 +3548,6 @@ mod tests {
|
|||
workspace.downgrade(),
|
||||
project.downgrade(),
|
||||
thread_store.clone(),
|
||||
None,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -3657,7 +3648,6 @@ mod tests {
|
|||
workspace.downgrade(),
|
||||
project.downgrade(),
|
||||
thread_store.clone(),
|
||||
None,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -3726,7 +3716,6 @@ mod tests {
|
|||
workspace.downgrade(),
|
||||
project.downgrade(),
|
||||
thread_store.clone(),
|
||||
None,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -3779,7 +3768,6 @@ mod tests {
|
|||
workspace.downgrade(),
|
||||
project.downgrade(),
|
||||
thread_store.clone(),
|
||||
None,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -3836,7 +3824,6 @@ mod tests {
|
|||
workspace.downgrade(),
|
||||
project.downgrade(),
|
||||
thread_store.clone(),
|
||||
None,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -3894,7 +3881,6 @@ mod tests {
|
|||
workspace.downgrade(),
|
||||
project.downgrade(),
|
||||
thread_store.clone(),
|
||||
None,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -3956,7 +3942,6 @@ mod tests {
|
|||
workspace_handle,
|
||||
project.downgrade(),
|
||||
thread_store.clone(),
|
||||
None,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -4116,7 +4101,6 @@ mod tests {
|
|||
workspace_handle,
|
||||
project.downgrade(),
|
||||
thread_store.clone(),
|
||||
None,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -4236,7 +4220,6 @@ mod tests {
|
|||
workspace_handle,
|
||||
project.downgrade(),
|
||||
Some(thread_store.clone()),
|
||||
None,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -4315,7 +4298,6 @@ mod tests {
|
|||
workspace_handle,
|
||||
project.downgrade(),
|
||||
Some(thread_store),
|
||||
None,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -4493,7 +4475,6 @@ mod tests {
|
|||
workspace_handle,
|
||||
project.downgrade(),
|
||||
Some(thread_store),
|
||||
None,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -4905,7 +4886,6 @@ mod tests {
|
|||
workspace_handle,
|
||||
project.downgrade(),
|
||||
Some(thread_store),
|
||||
None,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -5160,7 +5140,6 @@ mod tests {
|
|||
workspace_handle,
|
||||
project.downgrade(),
|
||||
Some(thread_store),
|
||||
None,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -5253,7 +5232,6 @@ mod tests {
|
|||
workspace.downgrade(),
|
||||
project.downgrade(),
|
||||
None,
|
||||
None,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
@ -5402,7 +5380,6 @@ mod tests {
|
|||
workspace.downgrade(),
|
||||
project.downgrade(),
|
||||
None,
|
||||
None,
|
||||
Default::default(),
|
||||
"Test Agent".into(),
|
||||
"Test",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ use language_models::provider::anthropic::telemetry::{
|
|||
AnthropicCompletionType, AnthropicEventData, AnthropicEventType, report_anthropic_event,
|
||||
};
|
||||
use project::Project;
|
||||
use prompt_store::{PromptBuilder, PromptStore};
|
||||
use prompt_store::PromptBuilder;
|
||||
use std::sync::Arc;
|
||||
use terminal_view::TerminalView;
|
||||
use ui::prelude::*;
|
||||
|
|
@ -64,7 +64,6 @@ impl TerminalInlineAssistant {
|
|||
workspace: WeakEntity<Workspace>,
|
||||
project: WeakEntity<Project>,
|
||||
thread_store: Entity<ThreadStore>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
initial_prompt: Option<String>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
|
|
@ -89,7 +88,6 @@ impl TerminalInlineAssistant {
|
|||
session_id,
|
||||
self.fs.clone(),
|
||||
thread_store.clone(),
|
||||
prompt_store.clone(),
|
||||
project.clone(),
|
||||
workspace.clone(),
|
||||
window,
|
||||
|
|
|
|||
|
|
@ -1888,7 +1888,7 @@ mod tests {
|
|||
.unwrap();
|
||||
let mut vcx = VisualTestContext::from_window(multi_workspace.into(), cx);
|
||||
let panel = workspace_entity.update_in(&mut vcx, |workspace, window, cx| {
|
||||
cx.new(|cx| crate::AgentPanel::new(workspace, None, window, cx))
|
||||
cx.new(|cx| crate::AgentPanel::new(workspace, window, cx))
|
||||
});
|
||||
(panel, vcx)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ use gpui::{
|
|||
pulsating_between,
|
||||
};
|
||||
use language::Buffer;
|
||||
use prompt_store::PromptId;
|
||||
use rope::Point;
|
||||
use settings::Settings;
|
||||
use theme_settings::ThemeSettings;
|
||||
|
|
@ -195,9 +194,6 @@ fn open_mention_uri(
|
|||
MentionUri::Thread { id, name } => {
|
||||
open_thread(workspace, id, name, window, cx);
|
||||
}
|
||||
MentionUri::Rule { id, .. } => {
|
||||
open_rule(workspace, id, window, cx);
|
||||
}
|
||||
MentionUri::Skill {
|
||||
skill_file_path, ..
|
||||
} => {
|
||||
|
|
@ -360,23 +356,3 @@ fn open_thread(
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn open_rule(
|
||||
_workspace: &mut Workspace,
|
||||
id: PromptId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
use zed_actions::assistant::OpenRulesLibrary;
|
||||
|
||||
let PromptId::User { uuid } = id else {
|
||||
return;
|
||||
};
|
||||
|
||||
window.dispatch_action(
|
||||
Box::new(OpenRulesLibrary {
|
||||
prompt_to_select: Some(uuid.0),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,12 @@ mod real_implementation {
|
|||
|
||||
impl Default for EchoCanceller {
|
||||
fn default() -> Self {
|
||||
Self(Arc::new(Mutex::new(apm::AudioProcessingModule::new(
|
||||
true, false, false, false,
|
||||
))))
|
||||
// Sound-effect playback only feeds this APM through `process_reverse_stream`
|
||||
// for AEC reference; gain/HPF/NS would be no-ops here, so we keep the
|
||||
// original (echo only) configuration via the legacy flag form.
|
||||
Self(Arc::new(Mutex::new(
|
||||
apm::AudioProcessingModule::from_flags(true, false, false, false),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ pub enum ChannelEvent {
|
|||
|
||||
impl EventEmitter<ChannelEvent> for ChannelStore {}
|
||||
|
||||
enum OpenEntityHandle<E> {
|
||||
enum OpenEntityHandle<E: 'static> {
|
||||
Open(WeakEntity<E>),
|
||||
Loading(Shared<Task<Result<Entity<E>, Arc<anyhow::Error>>>>),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,10 +164,12 @@ pub fn lsp_tasks(
|
|||
},
|
||||
));
|
||||
}
|
||||
lsp_tasks
|
||||
.entry(source_kind)
|
||||
.or_insert_with(Vec::new)
|
||||
.append(&mut new_lsp_tasks);
|
||||
if !new_lsp_tasks.is_empty() {
|
||||
lsp_tasks
|
||||
.entry(source_kind)
|
||||
.or_insert_with(Vec::new)
|
||||
.append(&mut new_lsp_tasks);
|
||||
}
|
||||
}
|
||||
}
|
||||
lsp_tasks.into_iter().collect()
|
||||
|
|
|
|||
|
|
@ -35,6 +35,18 @@ impl FeatureFlag for AgentSharingFeatureFlag {
|
|||
}
|
||||
register_feature_flag!(AgentSharingFeatureFlag);
|
||||
|
||||
pub struct HandoffFeatureFlag;
|
||||
|
||||
impl FeatureFlag for HandoffFeatureFlag {
|
||||
const NAME: &'static str = "handoff";
|
||||
type Value = PresenceFlag;
|
||||
|
||||
fn enabled_for_staff() -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
register_feature_flag!(HandoffFeatureFlag);
|
||||
|
||||
pub struct DiffReviewFeatureFlag;
|
||||
|
||||
impl FeatureFlag for DiffReviewFeatureFlag {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use crate::commit_view::CommitView;
|
|||
use crate::git_panel_settings::GitPanelScrollbarAccessor;
|
||||
use crate::project_diff::{self, BranchDiff, Diff, ProjectDiff};
|
||||
use crate::remote_output::{self, RemoteAction, SuccessMessage};
|
||||
use crate::solo_diff_view::SoloDiffView;
|
||||
use crate::{branch_picker, picker_prompt, render_remote_button};
|
||||
use crate::{
|
||||
git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
|
||||
|
|
@ -15,10 +16,7 @@ use anyhow::Context as _;
|
|||
use askpass::AskPassDelegate;
|
||||
use collections::{BTreeMap, HashMap, HashSet};
|
||||
use db::kvp::KeyValueStore;
|
||||
use editor::{
|
||||
Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, SizingBehavior,
|
||||
actions::ExpandAllDiffHunks,
|
||||
};
|
||||
use editor::{Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, SizingBehavior};
|
||||
use editor::{EditorStyle, RewrapOptions};
|
||||
use file_icons::FileIcons;
|
||||
use futures::StreamExt as _;
|
||||
|
|
@ -62,7 +60,7 @@ use project::{
|
|||
},
|
||||
project_settings::{GitPathStyle, ProjectSettings},
|
||||
};
|
||||
use prompt_store::{BuiltInPrompt, PromptId, PromptStore, RULES_FILE_NAMES};
|
||||
use prompt_store::RULES_FILE_NAMES;
|
||||
use proto::RpcError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{Settings, SettingsStore, StatusStyle, update_settings_file};
|
||||
|
|
@ -85,7 +83,7 @@ use workspace::SERIALIZATION_THROTTLE_TIME;
|
|||
use workspace::{
|
||||
Item, Workspace,
|
||||
dock::{DockPosition, Panel, PanelEvent},
|
||||
notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyResultExt},
|
||||
notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyTaskExt},
|
||||
};
|
||||
use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize};
|
||||
|
||||
|
|
@ -1385,63 +1383,22 @@ impl GitPanel {
|
|||
});
|
||||
}
|
||||
|
||||
fn open_file(
|
||||
fn open_solo_diff(
|
||||
&mut self,
|
||||
_: &menu::SecondaryConfirm,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
maybe!({
|
||||
let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
|
||||
let active_repo = self.active_repository.as_ref()?;
|
||||
let path = active_repo
|
||||
.read(cx)
|
||||
.repo_path_to_project_path(&entry.repo_path, cx)?;
|
||||
if entry.status.is_deleted() {
|
||||
return None;
|
||||
}
|
||||
let entry = self
|
||||
.entries
|
||||
.get(self.selected_entry?)?
|
||||
.status_entry()?
|
||||
.clone();
|
||||
let repository = self.active_repository.clone()?;
|
||||
|
||||
let open_task = self
|
||||
.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.open_path_preview(path, None, false, false, true, window, cx)
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
let workspace = self.workspace.clone();
|
||||
cx.spawn_in(window, async move |_, mut cx| {
|
||||
let item = open_task
|
||||
.await
|
||||
.notify_workspace_async_err(workspace, &mut cx)
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to open file"))?;
|
||||
if let Some(active_editor) = item.downcast::<Editor>() {
|
||||
if let Some(diff_task) =
|
||||
active_editor.update(cx, |editor, _cx| editor.wait_for_diff_to_load())
|
||||
{
|
||||
diff_task.await;
|
||||
}
|
||||
|
||||
cx.update(|window, cx| {
|
||||
active_editor.update(cx, |editor, cx| {
|
||||
editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx);
|
||||
|
||||
let snapshot = editor.snapshot(window, cx);
|
||||
editor.go_to_hunk_before_or_after_position(
|
||||
&snapshot,
|
||||
language::Point::new(0, 0),
|
||||
Direction::Next,
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach();
|
||||
SoloDiffView::open_or_focus(entry, repository, self.workspace.clone(), window, cx)
|
||||
.detach_and_notify_err(self.workspace.clone(), window, cx);
|
||||
|
||||
Some(())
|
||||
});
|
||||
|
|
@ -2685,20 +2642,6 @@ impl GitPanel {
|
|||
}
|
||||
}
|
||||
|
||||
async fn load_commit_message_prompt(cx: &mut AsyncApp) -> String {
|
||||
let load = async {
|
||||
let store = cx.update(|cx| PromptStore::global(cx)).await.ok()?;
|
||||
store
|
||||
.update(cx, |s, cx| {
|
||||
s.load(PromptId::BuiltIn(BuiltInPrompt::CommitMessage), cx)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
};
|
||||
load.await
|
||||
.unwrap_or_else(|| BuiltInPrompt::CommitMessage.default_content().to_string())
|
||||
}
|
||||
|
||||
fn build_commit_message_prompt(
|
||||
prompt: &str,
|
||||
user_agents_md: Option<&str>,
|
||||
|
|
@ -2803,7 +2746,7 @@ impl GitPanel {
|
|||
.and_then(|user_agents_md| user_agents_md.content().cloned())
|
||||
});
|
||||
|
||||
let prompt = Self::load_commit_message_prompt(&mut cx).await;
|
||||
let prompt = include_str!("../src/commit_message_prompt.txt");
|
||||
|
||||
let subject = this.update(cx, |this, cx| {
|
||||
this.commit_editor
|
||||
|
|
@ -5984,7 +5927,7 @@ impl GitPanel {
|
|||
)
|
||||
.separator()
|
||||
.action("Open Diff", menu::Confirm.boxed_clone())
|
||||
.action("Open File", menu::SecondaryConfirm.boxed_clone())
|
||||
.action("Open Diff (File)", menu::SecondaryConfirm.boxed_clone())
|
||||
.when(!is_created, |context_menu| {
|
||||
context_menu
|
||||
.separator()
|
||||
|
|
@ -6263,7 +6206,7 @@ impl GitPanel {
|
|||
this.selected_entry = Some(ix);
|
||||
cx.notify();
|
||||
if event.click_count() > 1 || event.modifiers().secondary() {
|
||||
this.open_file(&Default::default(), window, cx)
|
||||
this.open_solo_diff(&Default::default(), window, cx)
|
||||
} else {
|
||||
this.open_diff(&Default::default(), window, cx);
|
||||
this.focus_handle.focus(window, cx);
|
||||
|
|
@ -6713,7 +6656,7 @@ impl Render for GitPanel {
|
|||
.on_action(cx.listener(Self::last_entry))
|
||||
.on_action(cx.listener(Self::close_panel))
|
||||
.on_action(cx.listener(Self::open_diff))
|
||||
.on_action(cx.listener(Self::open_file))
|
||||
.on_action(cx.listener(Self::open_solo_diff))
|
||||
.on_action(cx.listener(Self::focus_changes_list))
|
||||
.on_action(cx.listener(Self::focus_editor))
|
||||
.on_action(cx.listener(Self::expand_commit_editor))
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ pub mod picker_prompt;
|
|||
pub mod project_diff;
|
||||
pub(crate) mod remote_output;
|
||||
pub mod repository_selector;
|
||||
pub mod solo_diff_view;
|
||||
pub mod stash_picker;
|
||||
pub mod text_diff_view;
|
||||
pub mod worktree_names;
|
||||
|
|
|
|||
787
crates/git_ui/src/solo_diff_view.rs
Normal file
787
crates/git_ui/src/solo_diff_view.rs
Normal file
|
|
@ -0,0 +1,787 @@
|
|||
use crate::{git_panel::GitStatusEntry, git_status_icon};
|
||||
use anyhow::{Context as _, Result};
|
||||
use buffer_diff::DiffHunkSecondaryStatus;
|
||||
use editor::{
|
||||
Direction, Editor, EditorEvent, EditorSettings, SplittableEditor, ToggleSplitDiff,
|
||||
actions::{GoToHunk, GoToPreviousHunk},
|
||||
};
|
||||
use fs::Fs;
|
||||
use git::{
|
||||
Commit, Restore, StageAndNext, StageFile, ToggleStaged, UnstageAndNext, UnstageFile,
|
||||
repository::RepoPath, status::StageStatus,
|
||||
};
|
||||
use gpui::{
|
||||
Action, AnyElement, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, IntoElement, Render, Subscription, Task, WeakEntity, Window,
|
||||
};
|
||||
use language::{Buffer, HighlightedText};
|
||||
use multi_buffer::MultiBuffer;
|
||||
use project::{
|
||||
Project,
|
||||
git_store::{Repository, RepositoryId},
|
||||
};
|
||||
use settings::{DiffViewStyle, Settings, SettingsStore, update_settings_file};
|
||||
use std::{
|
||||
any::{Any, TypeId},
|
||||
sync::Arc,
|
||||
};
|
||||
use ui::{
|
||||
Color, DiffStat, Divider, Icon, IconButton, IconButtonShape, IconName, Label, LabelCommon as _,
|
||||
SharedString, Tooltip, prelude::*, vertical_divider,
|
||||
};
|
||||
use util::paths::{PathExt as _, PathStyle};
|
||||
use workspace::{
|
||||
Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
|
||||
Workspace,
|
||||
item::{ItemEvent, SaveOptions, TabContentParams},
|
||||
notifications::NotifyTaskExt,
|
||||
searchable::SearchableItemHandle,
|
||||
};
|
||||
|
||||
pub struct SoloDiffView {
|
||||
repository: Entity<Repository>,
|
||||
repository_id: RepositoryId,
|
||||
repo_path: RepoPath,
|
||||
buffer: Entity<Buffer>,
|
||||
editor: Entity<SplittableEditor>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
_settings_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl SoloDiffView {
|
||||
pub fn open_or_focus(
|
||||
entry: GitStatusEntry,
|
||||
repository: Entity<Repository>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Entity<Self>>> {
|
||||
let Some(workspace_entity) = workspace.upgrade() else {
|
||||
return Task::ready(Err(anyhow::anyhow!("workspace was dropped")));
|
||||
};
|
||||
|
||||
let existing = workspace_entity
|
||||
.read(cx)
|
||||
.items_of_type::<SoloDiffView>(cx)
|
||||
.find(|item| item.read(cx).matches(&repository, &entry.repo_path, cx));
|
||||
if let Some(existing) = existing {
|
||||
workspace_entity.update(cx, |workspace, cx| {
|
||||
workspace.activate_item(&existing, true, true, window, cx);
|
||||
});
|
||||
existing.focus_handle(cx).focus(window, cx);
|
||||
return Task::ready(Ok(existing));
|
||||
}
|
||||
|
||||
let Some(project_path) = repository
|
||||
.read(cx)
|
||||
.repo_path_to_project_path(&entry.repo_path, cx)
|
||||
else {
|
||||
return Task::ready(Err(anyhow::anyhow!(
|
||||
"could not resolve repository path {:?}",
|
||||
entry.repo_path
|
||||
)));
|
||||
};
|
||||
|
||||
let project = workspace_entity.read(cx).project().clone();
|
||||
let repo_path = entry.repo_path;
|
||||
window.spawn(cx, async move |cx| {
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_buffer(project_path.clone(), cx)
|
||||
})
|
||||
.await?;
|
||||
let diff = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_uncommitted_diff(buffer.clone(), cx)
|
||||
})
|
||||
.await?;
|
||||
|
||||
workspace_entity.update_in(cx, |workspace, window, cx| {
|
||||
let workspace_handle = cx.entity();
|
||||
let view = cx.new(|cx| {
|
||||
Self::new(
|
||||
project,
|
||||
repository,
|
||||
repo_path,
|
||||
buffer,
|
||||
diff,
|
||||
workspace_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
workspace.add_item_to_active_pane(Box::new(view.clone()), None, true, window, cx);
|
||||
view
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn new(
|
||||
project: Entity<Project>,
|
||||
repository: Entity<Repository>,
|
||||
repo_path: RepoPath,
|
||||
buffer: Entity<Buffer>,
|
||||
diff: Entity<buffer_diff::BufferDiff>,
|
||||
workspace: Entity<Workspace>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let repository_id = repository.read(cx).id;
|
||||
let multibuffer = cx.new(|cx| {
|
||||
let mut multibuffer = MultiBuffer::singleton(buffer.clone(), cx);
|
||||
multibuffer.add_diff(diff, cx);
|
||||
multibuffer.set_all_diff_hunks_expanded(cx);
|
||||
multibuffer
|
||||
});
|
||||
let editor = cx.new(|cx| {
|
||||
let editor = SplittableEditor::new(
|
||||
EditorSettings::get_global(cx).diff_view_style,
|
||||
multibuffer,
|
||||
project.clone(),
|
||||
workspace.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
editor.rhs_editor().update(cx, |editor, cx| {
|
||||
editor.set_should_serialize(false, cx);
|
||||
let snapshot = editor.snapshot(window, cx);
|
||||
editor.go_to_hunk_before_or_after_position(
|
||||
&snapshot,
|
||||
language::Point::new(0, 0),
|
||||
Direction::Next,
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
editor
|
||||
});
|
||||
|
||||
let mut previous_diff_view_style = EditorSettings::get_global(cx).diff_view_style;
|
||||
let settings_subscription =
|
||||
cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
|
||||
let diff_view_style = EditorSettings::get_global(cx).diff_view_style;
|
||||
if diff_view_style != previous_diff_view_style {
|
||||
this.editor.update(cx, |editor, cx| {
|
||||
if editor.diff_view_style() != diff_view_style {
|
||||
editor.toggle_split(&ToggleSplitDiff, window, cx);
|
||||
}
|
||||
});
|
||||
previous_diff_view_style = diff_view_style;
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
repository,
|
||||
repository_id,
|
||||
repo_path,
|
||||
buffer,
|
||||
editor,
|
||||
workspace: workspace.downgrade(),
|
||||
_settings_subscription: settings_subscription,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(&self, repository: &Entity<Repository>, repo_path: &RepoPath, cx: &App) -> bool {
|
||||
self.repository_id == repository.read(cx).id && &self.repo_path == repo_path
|
||||
}
|
||||
|
||||
fn button_states(&self, cx: &App) -> SoloDiffButtonStates {
|
||||
let editor = self.editor.read(cx).rhs_editor().read(cx);
|
||||
let multibuffer = editor.buffer().read(cx);
|
||||
let snapshot = multibuffer.snapshot(cx);
|
||||
let prev_next = snapshot.diff_hunks().nth(1).is_some();
|
||||
let mut selection = true;
|
||||
|
||||
let mut ranges = editor
|
||||
.selections
|
||||
.disjoint_anchor_ranges()
|
||||
.collect::<Vec<_>>();
|
||||
if !ranges.iter().any(|range| range.start != range.end) {
|
||||
selection = false;
|
||||
let anchor = editor.selections.newest_anchor().head();
|
||||
if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor)
|
||||
&& let Some(range) = snapshot
|
||||
.anchor_in_buffer(excerpt_range.context.start)
|
||||
.zip(snapshot.anchor_in_buffer(excerpt_range.context.end))
|
||||
.map(|(start, end)| start..end)
|
||||
{
|
||||
ranges = vec![range];
|
||||
} else {
|
||||
ranges = Vec::new();
|
||||
}
|
||||
}
|
||||
|
||||
let mut stage = false;
|
||||
let mut unstage = false;
|
||||
for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
|
||||
match hunk.status.secondary {
|
||||
DiffHunkSecondaryStatus::HasSecondaryHunk
|
||||
| DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => {
|
||||
stage = true;
|
||||
}
|
||||
DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
|
||||
stage = true;
|
||||
unstage = true;
|
||||
}
|
||||
DiffHunkSecondaryStatus::NoSecondaryHunk
|
||||
| DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
|
||||
unstage = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stage_status = self
|
||||
.repository
|
||||
.read(cx)
|
||||
.status_for_path(&self.repo_path)
|
||||
.map(|entry| entry.status.staging())
|
||||
.unwrap_or(StageStatus::Unstaged);
|
||||
|
||||
SoloDiffButtonStates {
|
||||
stage,
|
||||
unstage,
|
||||
restore: stage || unstage,
|
||||
prev_next,
|
||||
selection,
|
||||
stage_file: stage_status.has_unstaged(),
|
||||
unstage_file: stage_status.has_staged(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut App) {
|
||||
self.focus_handle(cx).focus(window, cx);
|
||||
let action = action.boxed_clone();
|
||||
cx.defer(move |cx| {
|
||||
cx.dispatch_action(action.as_ref());
|
||||
});
|
||||
}
|
||||
|
||||
fn change_file_stage(&self, stage: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let repository = self.repository.clone();
|
||||
let repo_path = self.repo_path.clone();
|
||||
let workspace = self.workspace.clone();
|
||||
let task = cx.spawn(async move |_, cx| {
|
||||
repository
|
||||
.update(cx, |repository, cx| {
|
||||
if stage {
|
||||
repository.stage_entries(vec![repo_path], cx)
|
||||
} else {
|
||||
repository.unstage_entries(vec![repo_path], cx)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.with_context(|| {
|
||||
if stage {
|
||||
"failed to stage file"
|
||||
} else {
|
||||
"failed to unstage file"
|
||||
}
|
||||
})
|
||||
});
|
||||
task.detach_and_notify_err(workspace, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<EditorEvent> for SoloDiffView {}
|
||||
|
||||
impl Focusable for SoloDiffView {
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
self.editor.focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Item for SoloDiffView {
|
||||
type Event = EditorEvent;
|
||||
|
||||
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
|
||||
Some(Icon::new(IconName::Diff).color(Color::Muted))
|
||||
}
|
||||
|
||||
fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
|
||||
Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
|
||||
.color(if params.selected {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
|
||||
self.buffer
|
||||
.read(cx)
|
||||
.file()
|
||||
.and_then(|file| {
|
||||
Some(
|
||||
file.full_path(cx)
|
||||
.file_name()?
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
self.repo_path
|
||||
.as_ref()
|
||||
.display(PathStyle::local())
|
||||
.into_owned()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
|
||||
Some(
|
||||
self.buffer
|
||||
.read(cx)
|
||||
.file()
|
||||
.map(|file| file.full_path(cx).compact().to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| {
|
||||
self.repo_path
|
||||
.as_ref()
|
||||
.display(PathStyle::local())
|
||||
.into_owned()
|
||||
})
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
|
||||
Editor::to_item_events(event, f)
|
||||
}
|
||||
|
||||
fn telemetry_event_text(&self) -> Option<&'static str> {
|
||||
Some("Solo Diff View Opened")
|
||||
}
|
||||
|
||||
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.editor.deactivated(window, cx);
|
||||
}
|
||||
|
||||
fn act_as_type<'a>(
|
||||
&'a self,
|
||||
type_id: TypeId,
|
||||
self_handle: &'a Entity<Self>,
|
||||
cx: &'a App,
|
||||
) -> Option<gpui::AnyEntity> {
|
||||
if type_id == TypeId::of::<Self>() {
|
||||
Some(self_handle.clone().into())
|
||||
} else {
|
||||
self.editor.act_as_type(type_id, cx)
|
||||
}
|
||||
}
|
||||
|
||||
fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
|
||||
Some(Box::new(self.editor.clone()))
|
||||
}
|
||||
|
||||
fn for_each_project_item(
|
||||
&self,
|
||||
cx: &App,
|
||||
f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
|
||||
) {
|
||||
self.editor.for_each_project_item(cx, f)
|
||||
}
|
||||
|
||||
fn set_nav_history(
|
||||
&mut self,
|
||||
nav_history: ItemNavHistory,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.rhs_editor().update(cx, |editor, _| {
|
||||
editor.set_nav_history(Some(nav_history));
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn navigate(
|
||||
&mut self,
|
||||
data: Arc<dyn Any + Send>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor
|
||||
.rhs_editor()
|
||||
.update(cx, |editor, cx| editor.navigate(data, window, cx))
|
||||
})
|
||||
}
|
||||
|
||||
fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
|
||||
ToolbarItemLocation::PrimaryLeft
|
||||
}
|
||||
|
||||
fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<gpui::Font>)> {
|
||||
self.editor.breadcrumbs(cx)
|
||||
}
|
||||
|
||||
fn added_to_workspace(
|
||||
&mut self,
|
||||
workspace: &mut Workspace,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.rhs_editor().update(cx, |editor, cx| {
|
||||
editor.added_to_workspace(workspace, window, cx)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn can_save(&self, cx: &App) -> bool {
|
||||
self.editor.read(cx).rhs_editor().read(cx).can_save(cx)
|
||||
}
|
||||
|
||||
fn save(
|
||||
&mut self,
|
||||
options: SaveOptions,
|
||||
project: Entity<Project>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.editor.save(options, project, window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SoloDiffView {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
self.editor.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SoloDiffStyleToolbar {
|
||||
solo_diff: Option<WeakEntity<SoloDiffView>>,
|
||||
}
|
||||
|
||||
pub struct SoloDiffGitToolbar {
|
||||
solo_diff: Option<WeakEntity<SoloDiffView>>,
|
||||
}
|
||||
|
||||
impl SoloDiffStyleToolbar {
|
||||
pub fn new(_: &mut Context<Self>) -> Self {
|
||||
Self { solo_diff: None }
|
||||
}
|
||||
|
||||
fn solo_diff(&self) -> Option<Entity<SoloDiffView>> {
|
||||
self.solo_diff.as_ref()?.upgrade()
|
||||
}
|
||||
|
||||
fn set_diff_view_style(
|
||||
&mut self,
|
||||
diff_view_style: DiffViewStyle,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(solo_diff) = self.solo_diff() else {
|
||||
return;
|
||||
};
|
||||
let workspace = solo_diff.read(cx).workspace.clone();
|
||||
|
||||
update_settings_file(<dyn Fs>::global(cx), cx, move |settings, _| {
|
||||
settings.editor.diff_view_style = Some(diff_view_style);
|
||||
});
|
||||
|
||||
if let Some(workspace) = workspace.upgrade() {
|
||||
let splittable_editors = {
|
||||
workspace
|
||||
.read(cx)
|
||||
.items(cx)
|
||||
.filter_map(|item| item.act_as_type(TypeId::of::<SplittableEditor>(), cx))
|
||||
.filter_map(|item| item.downcast::<SplittableEditor>().ok())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
for editor in splittable_editors {
|
||||
editor.update(cx, |editor, cx| {
|
||||
if editor.diff_view_style() != diff_view_style {
|
||||
editor.toggle_split(&ToggleSplitDiff, window, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ToolbarItemEvent> for SoloDiffStyleToolbar {}
|
||||
|
||||
impl ToolbarItemView for SoloDiffStyleToolbar {
|
||||
fn set_active_pane_item(
|
||||
&mut self,
|
||||
active_pane_item: Option<&dyn ItemHandle>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> ToolbarItemLocation {
|
||||
self.solo_diff = active_pane_item
|
||||
.and_then(|item| item.act_as::<SoloDiffView>(cx))
|
||||
.map(|entity| entity.downgrade());
|
||||
if self.solo_diff.is_some() {
|
||||
ToolbarItemLocation::PrimaryLeft
|
||||
} else {
|
||||
ToolbarItemLocation::Hidden
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SoloDiffStyleToolbar {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(solo_diff) = self.solo_diff() else {
|
||||
return div();
|
||||
};
|
||||
let editor_entity = solo_diff.read(cx).editor.clone();
|
||||
let editor = editor_entity.read(cx);
|
||||
let diff_view_style = editor.diff_view_style();
|
||||
let is_split_set = diff_view_style == DiffViewStyle::Split;
|
||||
let split_icon = if is_split_set && !editor.is_split() {
|
||||
IconName::DiffSplitAuto
|
||||
} else {
|
||||
IconName::DiffSplit
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.h_8()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
IconButton::new("solo-diff-unified", IconName::DiffUnified)
|
||||
.icon_size(IconSize::Small)
|
||||
.toggle_state(diff_view_style == DiffViewStyle::Unified)
|
||||
.tooltip(Tooltip::text("Unified"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.set_diff_view_style(DiffViewStyle::Unified, window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("solo-diff-split", split_icon)
|
||||
.icon_size(IconSize::Small)
|
||||
.toggle_state(diff_view_style == DiffViewStyle::Split)
|
||||
.tooltip(Tooltip::text("Split"))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.set_diff_view_style(DiffViewStyle::Split, window, cx);
|
||||
})),
|
||||
)
|
||||
.child(vertical_divider())
|
||||
.child(div().w_1())
|
||||
}
|
||||
}
|
||||
|
||||
impl SoloDiffGitToolbar {
|
||||
pub fn new(_: &mut Context<Self>) -> Self {
|
||||
Self { solo_diff: None }
|
||||
}
|
||||
|
||||
fn solo_diff(&self) -> Option<Entity<SoloDiffView>> {
|
||||
self.solo_diff.as_ref()?.upgrade()
|
||||
}
|
||||
|
||||
fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(solo_diff) = self.solo_diff() {
|
||||
solo_diff.update(cx, |solo_diff, cx| {
|
||||
solo_diff.dispatch_action(action, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn stage_file(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(solo_diff) = self.solo_diff() {
|
||||
solo_diff.update(cx, |solo_diff, cx| {
|
||||
solo_diff.change_file_stage(true, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn unstage_file(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(solo_diff) = self.solo_diff() {
|
||||
solo_diff.update(cx, |solo_diff, cx| {
|
||||
solo_diff.change_file_stage(false, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ToolbarItemEvent> for SoloDiffGitToolbar {}
|
||||
|
||||
impl ToolbarItemView for SoloDiffGitToolbar {
|
||||
fn set_active_pane_item(
|
||||
&mut self,
|
||||
active_pane_item: Option<&dyn ItemHandle>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> ToolbarItemLocation {
|
||||
self.solo_diff = active_pane_item
|
||||
.and_then(|item| item.act_as::<SoloDiffView>(cx))
|
||||
.map(|entity| entity.downgrade());
|
||||
if self.solo_diff.is_some() {
|
||||
ToolbarItemLocation::PrimaryRight
|
||||
} else {
|
||||
ToolbarItemLocation::Hidden
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SoloDiffButtonStates {
|
||||
stage: bool,
|
||||
unstage: bool,
|
||||
restore: bool,
|
||||
prev_next: bool,
|
||||
selection: bool,
|
||||
stage_file: bool,
|
||||
unstage_file: bool,
|
||||
}
|
||||
|
||||
impl Render for SoloDiffGitToolbar {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(solo_diff) = self.solo_diff() else {
|
||||
return div();
|
||||
};
|
||||
let focus_handle = solo_diff.focus_handle(cx);
|
||||
let solo_diff = solo_diff.read(cx);
|
||||
let button_states = solo_diff.button_states(cx);
|
||||
let status_entry = solo_diff
|
||||
.repository
|
||||
.read(cx)
|
||||
.status_for_path(&solo_diff.repo_path);
|
||||
let status = status_entry.as_ref().map(|entry| entry.status);
|
||||
let diff_stat = status_entry.and_then(|entry| entry.diff_stat);
|
||||
|
||||
h_group_xl()
|
||||
.my_neg_1()
|
||||
.py_1()
|
||||
.items_center()
|
||||
.flex_wrap()
|
||||
.justify_between()
|
||||
.children(status.map(|status| git_status_icon(status).into_any_element()))
|
||||
.children(diff_stat.map(|stat| {
|
||||
DiffStat::new("solo-diff-stat", stat.added as usize, stat.deleted as usize)
|
||||
.into_any_element()
|
||||
}))
|
||||
.child(
|
||||
h_group_sm()
|
||||
.when(button_states.selection, |el| {
|
||||
el.child(
|
||||
Button::new("stage", "Toggle Staged")
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Toggle Staged",
|
||||
&ToggleStaged,
|
||||
&focus_handle,
|
||||
))
|
||||
.disabled(!button_states.stage && !button_states.unstage)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dispatch_action(&ToggleStaged, window, cx)
|
||||
})),
|
||||
)
|
||||
})
|
||||
.when(!button_states.selection, |el| {
|
||||
el.child(
|
||||
Button::new("stage", "Stage")
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Stage and go to next hunk",
|
||||
&StageAndNext,
|
||||
&focus_handle,
|
||||
))
|
||||
.disabled(!button_states.stage)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dispatch_action(&StageAndNext, window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("unstage", "Unstage")
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Unstage and go to next hunk",
|
||||
&UnstageAndNext,
|
||||
&focus_handle,
|
||||
))
|
||||
.disabled(!button_states.unstage)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dispatch_action(&UnstageAndNext, window, cx)
|
||||
})),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new("restore", "Restore")
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Restore selected hunk",
|
||||
&Restore,
|
||||
&focus_handle,
|
||||
))
|
||||
.disabled(!button_states.restore)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dispatch_action(&Restore, window, cx)
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_group_sm()
|
||||
.child(
|
||||
IconButton::new("up", IconName::ArrowUp)
|
||||
.shape(IconButtonShape::Square)
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Go to previous hunk",
|
||||
&GoToPreviousHunk,
|
||||
&focus_handle,
|
||||
))
|
||||
.disabled(!button_states.prev_next)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dispatch_action(&GoToPreviousHunk, window, cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("down", IconName::ArrowDown)
|
||||
.shape(IconButtonShape::Square)
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Go to next hunk",
|
||||
&GoToHunk,
|
||||
&focus_handle,
|
||||
))
|
||||
.disabled(!button_states.prev_next)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dispatch_action(&GoToHunk, window, cx)
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(vertical_divider())
|
||||
.child(
|
||||
h_group_sm()
|
||||
.child(
|
||||
Button::new("stage-file", "Stage File")
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Stage file",
|
||||
&StageFile,
|
||||
&focus_handle,
|
||||
))
|
||||
.disabled(!button_states.stage_file)
|
||||
.on_click(
|
||||
cx.listener(|this, _, window, cx| this.stage_file(window, cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new("unstage-file", "Unstage File")
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Unstage file",
|
||||
&UnstageFile,
|
||||
&focus_handle,
|
||||
))
|
||||
.disabled(!button_states.unstage_file)
|
||||
.on_click(
|
||||
cx.listener(|this, _, window, cx| this.unstage_file(window, cx)),
|
||||
),
|
||||
)
|
||||
.child(Divider::vertical())
|
||||
.child(
|
||||
Button::new("commit", "Commit")
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Commit",
|
||||
&Commit,
|
||||
&focus_handle,
|
||||
))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dispatch_action(&Commit, window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -336,6 +336,20 @@ impl TestAppContext {
|
|||
self.test_platform.simulate_new_path_selection(select_path);
|
||||
}
|
||||
|
||||
/// Simulates responding to a `prompt_for_paths` ("Open") dialog.
|
||||
pub fn simulate_path_prompt_response(
|
||||
&self,
|
||||
select_paths: impl FnOnce(&crate::PathPromptOptions) -> Option<Vec<std::path::PathBuf>>,
|
||||
) {
|
||||
self.test_platform
|
||||
.simulate_path_prompt_response(select_paths);
|
||||
}
|
||||
|
||||
/// Returns true if there's a path selection dialog pending.
|
||||
pub fn did_prompt_for_paths(&self) -> bool {
|
||||
self.test_platform.did_prompt_for_paths()
|
||||
}
|
||||
|
||||
/// Simulates clicking a button in an platform-level alert dialog.
|
||||
#[track_caller]
|
||||
pub fn simulate_prompt_answer(&self, button: &str) {
|
||||
|
|
@ -1098,3 +1112,54 @@ impl AnyWindowHandle {
|
|||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{PathPromptOptions, TestAppContext};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_simulate_path_prompt_response(cx: &mut TestAppContext) {
|
||||
assert!(!cx.did_prompt_for_paths());
|
||||
|
||||
let receiver = cx.update(|cx| {
|
||||
cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: true,
|
||||
prompt: None,
|
||||
})
|
||||
});
|
||||
assert!(cx.did_prompt_for_paths());
|
||||
|
||||
let selected = vec![PathBuf::from("/a"), PathBuf::from("/b")];
|
||||
cx.simulate_path_prompt_response({
|
||||
let selected = selected.clone();
|
||||
move |options| {
|
||||
assert!(options.multiple);
|
||||
Some(selected)
|
||||
}
|
||||
});
|
||||
assert!(!cx.did_prompt_for_paths());
|
||||
|
||||
let response = receiver.await.unwrap().unwrap();
|
||||
assert_eq!(response, Some(selected));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_simulate_path_prompt_cancellation(cx: &mut TestAppContext) {
|
||||
let receiver = cx.update(|cx| {
|
||||
cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: None,
|
||||
})
|
||||
});
|
||||
|
||||
cx.simulate_path_prompt_response(|_options| None);
|
||||
|
||||
let response = receiver.await.unwrap().unwrap();
|
||||
assert_eq!(response, None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -741,6 +741,44 @@ impl ListState {
|
|||
pub fn viewport_bounds(&self) -> Bounds<Pixels> {
|
||||
self.0.borrow().last_layout_bounds.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns whether the item is entirely above the viewport, or `None` if
|
||||
/// the list has not measured enough layout to know.
|
||||
pub fn item_is_above_viewport(&self, ix: usize) -> Option<bool> {
|
||||
let viewport_bounds = self.viewport_bounds();
|
||||
if viewport_bounds.size.height == px(0.0) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let scroll_top = self.logical_scroll_top();
|
||||
if ix < scroll_top.item_ix {
|
||||
// Rows before the logical scroll top have no item bounds, but
|
||||
// their position relative to the viewport is known from scroll state.
|
||||
return Some(true);
|
||||
}
|
||||
|
||||
let item_bounds = self.bounds_for_item(ix)?;
|
||||
Some(item_bounds.bottom() <= viewport_bounds.top())
|
||||
}
|
||||
|
||||
/// Returns whether the item is entirely below the viewport, or `None` if
|
||||
/// the list has not measured enough layout to know.
|
||||
pub fn item_is_below_viewport(&self, ix: usize) -> Option<bool> {
|
||||
let viewport_bounds = self.viewport_bounds();
|
||||
if viewport_bounds.size.height == px(0.0) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let scroll_top = self.logical_scroll_top();
|
||||
if ix < scroll_top.item_ix {
|
||||
// Rows before the logical scroll top have no item bounds, but
|
||||
// their position relative to the viewport is known from scroll state.
|
||||
return Some(false);
|
||||
}
|
||||
|
||||
let item_bounds = self.bounds_for_item(ix)?;
|
||||
Some(item_bounds.top() >= viewport_bounds.bottom())
|
||||
}
|
||||
}
|
||||
|
||||
impl StateInner {
|
||||
|
|
@ -1644,6 +1682,114 @@ mod test {
|
|||
assert_eq!(offset.offset_in_item, px(0.));
|
||||
}
|
||||
|
||||
struct TestListView(ListState);
|
||||
impl Render for TestListView {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
list(self.0.clone(), |_, _, _| {
|
||||
div().h(px(20.)).w_full().into_any()
|
||||
})
|
||||
.w_full()
|
||||
.h_full()
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_item_viewport_queries_return_none_before_layout(_cx: &mut TestAppContext) {
|
||||
let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
|
||||
|
||||
assert_eq!(state.item_is_above_viewport(0), None);
|
||||
assert_eq!(state.item_is_below_viewport(0), None);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_item_viewport_queries_before_logical_scroll_top(cx: &mut TestAppContext) {
|
||||
let cx = cx.add_empty_window();
|
||||
|
||||
let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
|
||||
|
||||
state.scroll_to(gpui::ListOffset {
|
||||
item_ix: 2,
|
||||
offset_in_item: px(0.),
|
||||
});
|
||||
cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
|
||||
cx.new(|_| TestListView(state.clone())).into_any_element()
|
||||
});
|
||||
|
||||
assert_eq!(state.item_is_above_viewport(1), Some(true));
|
||||
assert_eq!(state.item_is_below_viewport(1), Some(false));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_item_viewport_queries_measured_item_inside_viewport(cx: &mut TestAppContext) {
|
||||
let cx = cx.add_empty_window();
|
||||
|
||||
let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
|
||||
|
||||
state.scroll_to(gpui::ListOffset {
|
||||
item_ix: 2,
|
||||
offset_in_item: px(0.),
|
||||
});
|
||||
cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
|
||||
cx.new(|_| TestListView(state.clone())).into_any_element()
|
||||
});
|
||||
|
||||
assert_eq!(state.item_is_above_viewport(2), Some(false));
|
||||
assert_eq!(state.item_is_below_viewport(2), Some(false));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_item_viewport_queries_measured_item_above_viewport(cx: &mut TestAppContext) {
|
||||
let cx = cx.add_empty_window();
|
||||
|
||||
let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
|
||||
|
||||
state.scroll_to(gpui::ListOffset {
|
||||
item_ix: 2,
|
||||
offset_in_item: px(20.),
|
||||
});
|
||||
cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
|
||||
cx.new(|_| TestListView(state.clone())).into_any_element()
|
||||
});
|
||||
|
||||
assert_eq!(state.item_is_above_viewport(2), Some(true));
|
||||
assert_eq!(state.item_is_below_viewport(2), Some(false));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_item_viewport_queries_measured_item_below_viewport(cx: &mut TestAppContext) {
|
||||
let cx = cx.add_empty_window();
|
||||
|
||||
let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
|
||||
|
||||
state.scroll_to(gpui::ListOffset {
|
||||
item_ix: 2,
|
||||
offset_in_item: px(0.),
|
||||
});
|
||||
cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
|
||||
cx.new(|_| TestListView(state.clone())).into_any_element()
|
||||
});
|
||||
|
||||
assert_eq!(state.item_is_above_viewport(3), Some(false));
|
||||
assert_eq!(state.item_is_below_viewport(3), Some(true));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_item_viewport_queries_after_scroll_to_end_before_layout(cx: &mut TestAppContext) {
|
||||
let cx = cx.add_empty_window();
|
||||
|
||||
let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all();
|
||||
|
||||
cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| {
|
||||
cx.new(|_| TestListView(state.clone())).into_any_element()
|
||||
});
|
||||
|
||||
state.scroll_to_end();
|
||||
|
||||
assert_eq!(state.logical_scroll_top().item_ix, state.item_count());
|
||||
assert_eq!(state.item_is_above_viewport(0), Some(true));
|
||||
assert_eq!(state.item_is_below_viewport(0), Some(false));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_measure_all_after_width_change(cx: &mut TestAppContext) {
|
||||
let cx = cx.add_empty_window();
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ use scheduler::Instant;
|
|||
use scheduler::Scheduler;
|
||||
use std::{future::Future, marker::PhantomData, mem, pin::Pin, rc::Rc, sync::Arc, time::Duration};
|
||||
|
||||
pub use scheduler::{
|
||||
FallibleTask, ForegroundExecutor as SchedulerForegroundExecutor, Priority, Task,
|
||||
};
|
||||
pub use scheduler::{FallibleTask, LocalExecutor as SchedulerLocalExecutor, Priority, Task};
|
||||
|
||||
/// A pointer to the executor that is currently running,
|
||||
/// for spawning background tasks.
|
||||
|
|
@ -22,7 +20,7 @@ pub struct BackgroundExecutor {
|
|||
/// for spawning tasks on the main thread.
|
||||
#[derive(Clone)]
|
||||
pub struct ForegroundExecutor {
|
||||
inner: scheduler::ForegroundExecutor,
|
||||
inner: scheduler::LocalExecutor,
|
||||
dispatcher: Arc<dyn PlatformDispatcher>,
|
||||
not_send: PhantomData<Rc<()>>,
|
||||
}
|
||||
|
|
@ -280,18 +278,29 @@ impl ForegroundExecutor {
|
|||
)
|
||||
} else {
|
||||
let platform_scheduler = Arc::new(PlatformScheduler::new(dispatcher.clone()));
|
||||
let session_id = platform_scheduler.allocate_session_id();
|
||||
(platform_scheduler, session_id)
|
||||
let inner = platform_scheduler.foreground_executor();
|
||||
return Self {
|
||||
inner,
|
||||
dispatcher,
|
||||
not_send: PhantomData,
|
||||
};
|
||||
};
|
||||
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
let (scheduler, session_id): (Arc<dyn Scheduler>, _) = {
|
||||
let inner = {
|
||||
let platform_scheduler = Arc::new(PlatformScheduler::new(dispatcher.clone()));
|
||||
let session_id = platform_scheduler.allocate_session_id();
|
||||
(platform_scheduler, session_id)
|
||||
platform_scheduler.foreground_executor()
|
||||
};
|
||||
|
||||
let inner = scheduler::ForegroundExecutor::new(session_id, scheduler);
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let inner = {
|
||||
let scheduler_for_dispatch = Arc::downgrade(&scheduler);
|
||||
scheduler::LocalExecutor::new(session_id, scheduler, move |runnable| {
|
||||
if let Some(scheduler) = scheduler_for_dispatch.upgrade() {
|
||||
scheduler.schedule_local(session_id, runnable);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
Self {
|
||||
inner,
|
||||
|
|
@ -366,7 +375,7 @@ impl ForegroundExecutor {
|
|||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn scheduler_executor(&self) -> SchedulerForegroundExecutor {
|
||||
pub fn scheduler_executor(&self) -> SchedulerLocalExecutor {
|
||||
self.inner.clone()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,8 +139,7 @@ impl PlatformDispatcher for TestDispatcher {
|
|||
}
|
||||
|
||||
fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) {
|
||||
self.scheduler
|
||||
.schedule_foreground(self.session_id, runnable);
|
||||
self.scheduler.schedule_local(self.session_id, runnable);
|
||||
}
|
||||
|
||||
fn dispatch_after(&self, _duration: Duration, _runnable: RunnableVariant) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
use crate::{
|
||||
AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels,
|
||||
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay,
|
||||
PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
|
||||
PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata,
|
||||
Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size,
|
||||
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, PathPromptOptions, Platform,
|
||||
PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper,
|
||||
PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream,
|
||||
SourceMetadata, Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams,
|
||||
size,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use collections::VecDeque;
|
||||
|
|
@ -85,6 +86,10 @@ struct TestPrompt {
|
|||
pub(crate) struct TestPrompts {
|
||||
multiple_choice: VecDeque<TestPrompt>,
|
||||
new_path: VecDeque<(PathBuf, oneshot::Sender<Result<Option<PathBuf>>>)>,
|
||||
paths: VecDeque<(
|
||||
PathPromptOptions,
|
||||
oneshot::Sender<Result<Option<Vec<PathBuf>>>>,
|
||||
)>,
|
||||
}
|
||||
|
||||
impl TestPlatform {
|
||||
|
|
@ -147,6 +152,33 @@ impl TestPlatform {
|
|||
tx.send(Ok(select_path(&path))).ok();
|
||||
}
|
||||
|
||||
pub(crate) fn simulate_path_prompt_response(
|
||||
&self,
|
||||
select_paths: impl FnOnce(&PathPromptOptions) -> Option<Vec<std::path::PathBuf>>,
|
||||
) {
|
||||
let (options, tx) = self
|
||||
.prompts
|
||||
.borrow_mut()
|
||||
.paths
|
||||
.pop_front()
|
||||
.expect("no pending paths prompt");
|
||||
let selection = select_paths(&options);
|
||||
if let Some(paths) = &selection
|
||||
&& !options.multiple
|
||||
&& paths.len() > 1
|
||||
{
|
||||
panic!(
|
||||
"selected {} paths for a prompt that does not allow multiple selection",
|
||||
paths.len()
|
||||
);
|
||||
}
|
||||
tx.send(Ok(selection)).ok();
|
||||
}
|
||||
|
||||
pub(crate) fn did_prompt_for_paths(&self) -> bool {
|
||||
!self.prompts.borrow().paths.is_empty()
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub(crate) fn simulate_prompt_answer(&self, response: &str) {
|
||||
let prompt = self
|
||||
|
|
@ -348,9 +380,11 @@ impl Platform for TestPlatform {
|
|||
|
||||
fn prompt_for_paths(
|
||||
&self,
|
||||
_options: crate::PathPromptOptions,
|
||||
options: crate::PathPromptOptions,
|
||||
) -> oneshot::Receiver<Result<Option<Vec<std::path::PathBuf>>>> {
|
||||
unimplemented!()
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.prompts.borrow_mut().paths.push_back((options, tx));
|
||||
rx
|
||||
}
|
||||
|
||||
fn prompt_for_new_path(
|
||||
|
|
|
|||
|
|
@ -3,10 +3,14 @@ use async_task::Runnable;
|
|||
use chrono::{DateTime, Utc};
|
||||
use futures::channel::oneshot;
|
||||
use scheduler::Instant;
|
||||
use scheduler::{Clock, Priority, Scheduler, SessionId, TestScheduler, Timer};
|
||||
use scheduler::{
|
||||
Clock, LocalExecutor, Priority, Scheduler, SessionId, Task, TestScheduler, Timer,
|
||||
spawn_dedicated_thread,
|
||||
};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::task::{Context, Poll};
|
||||
use std::{
|
||||
any::Any,
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::{
|
||||
|
|
@ -35,7 +39,17 @@ impl PlatformScheduler {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn allocate_session_id(&self) -> SessionId {
|
||||
pub fn foreground_executor(self: &Arc<Self>) -> LocalExecutor {
|
||||
let session_id = self.next_session_id();
|
||||
let scheduler = Arc::downgrade(self);
|
||||
LocalExecutor::new(session_id, self.clone(), move |runnable| {
|
||||
if let Some(scheduler) = scheduler.upgrade() {
|
||||
scheduler.schedule_local(session_id, runnable);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn next_session_id(&self) -> SessionId {
|
||||
SessionId::new(self.next_session_id.fetch_add(1, Ordering::SeqCst))
|
||||
}
|
||||
}
|
||||
|
|
@ -90,7 +104,7 @@ impl Scheduler for PlatformScheduler {
|
|||
}
|
||||
}
|
||||
|
||||
fn schedule_foreground(&self, _session_id: SessionId, runnable: Runnable<RunnableMeta>) {
|
||||
fn schedule_local(&self, _session_id: SessionId, runnable: Runnable<RunnableMeta>) {
|
||||
self.dispatcher
|
||||
.dispatch_on_main_thread(runnable, Priority::default());
|
||||
}
|
||||
|
|
@ -133,6 +147,21 @@ impl Scheduler for PlatformScheduler {
|
|||
self.clock.clone()
|
||||
}
|
||||
|
||||
fn spawn_dedicated(
|
||||
self: Arc<Self>,
|
||||
f: Box<
|
||||
dyn FnOnce(
|
||||
LocalExecutor,
|
||||
)
|
||||
-> Pin<Box<dyn Future<Output = Box<dyn Any + Send + Sync>> + 'static>>
|
||||
+ Send
|
||||
+ 'static,
|
||||
>,
|
||||
) -> Task<Box<dyn Any + Send + Sync>> {
|
||||
let session_id = self.next_session_id();
|
||||
spawn_dedicated_thread(session_id, self, move |executor| f(executor))
|
||||
}
|
||||
|
||||
fn as_test(&self) -> Option<&TestScheduler> {
|
||||
None
|
||||
}
|
||||
|
|
@ -152,3 +181,261 @@ impl Clock for PlatformClock {
|
|||
self.dispatcher.now()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_family = "wasm")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{RunnableVariant, ThreadTaskTimings};
|
||||
use scheduler::BackgroundExecutor;
|
||||
use std::time::Instant as StdInstant;
|
||||
|
||||
// `spawn_dedicated` shouldn't touch the platform dispatcher at all;
|
||||
// panicking on every method ensures the test catches it if it does.
|
||||
struct SmokeDispatcher;
|
||||
|
||||
impl PlatformDispatcher for SmokeDispatcher {
|
||||
fn get_all_timings(&self) -> Vec<ThreadTaskTimings> {
|
||||
Vec::new()
|
||||
}
|
||||
fn get_current_thread_timings(&self) -> ThreadTaskTimings {
|
||||
ThreadTaskTimings {
|
||||
thread_name: None,
|
||||
thread_id: std::thread::current().id(),
|
||||
timings: Vec::new(),
|
||||
total_pushed: 0,
|
||||
}
|
||||
}
|
||||
fn is_main_thread(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn dispatch(&self, _runnable: RunnableVariant, _priority: Priority) {
|
||||
panic!("SmokeDispatcher should not be asked to dispatch in this test");
|
||||
}
|
||||
fn dispatch_on_main_thread(&self, _runnable: RunnableVariant, _priority: Priority) {
|
||||
panic!("SmokeDispatcher does not implement a main thread");
|
||||
}
|
||||
fn dispatch_after(&self, _duration: Duration, _runnable: RunnableVariant) {
|
||||
panic!("SmokeDispatcher does not implement timers");
|
||||
}
|
||||
fn spawn_realtime(&self, _f: Box<dyn FnOnce() + Send>) {
|
||||
panic!("SmokeDispatcher does not implement realtime");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_dedicated_runs_on_a_real_separate_thread() {
|
||||
let background =
|
||||
BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher))));
|
||||
let started = StdInstant::now();
|
||||
let task = background.spawn_dedicated(|_executor| async move {
|
||||
// A genuine blocking syscall on the dedicated thread. If
|
||||
// `spawn_dedicated` were running the future on any shared
|
||||
// executor, this would stall that executor.
|
||||
let thread_id_before = std::thread::current().id();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let thread_id_after = std::thread::current().id();
|
||||
assert_eq!(thread_id_before, thread_id_after);
|
||||
(thread_id_before, "slept")
|
||||
});
|
||||
let (dedicated_thread_id, message) = futures::executor::block_on(task);
|
||||
let elapsed = started.elapsed();
|
||||
assert_eq!(message, "slept");
|
||||
assert_ne!(
|
||||
dedicated_thread_id,
|
||||
std::thread::current().id(),
|
||||
"dedicated future ran on the test thread"
|
||||
);
|
||||
assert!(
|
||||
elapsed >= Duration::from_millis(40),
|
||||
"expected the dedicated thread to genuinely sleep, elapsed = {:?}",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_dedicated_returns_not_send_future_output() {
|
||||
// The whole point of `spawn_dedicated` is that the future can be
|
||||
// `!Send`. Constructing one with `Rc<RefCell<_>>` ensures the
|
||||
// signature actually permits it.
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
let background =
|
||||
BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher))));
|
||||
let task = background.spawn_dedicated(|_executor| async move {
|
||||
let state = Rc::new(RefCell::new(0_i32));
|
||||
for _ in 0..3 {
|
||||
*state.borrow_mut() += 1;
|
||||
}
|
||||
*state.borrow()
|
||||
});
|
||||
let output = futures::executor::block_on(task);
|
||||
assert_eq!(output, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_dedicated_dropping_task_cancels_future() {
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::mpsc;
|
||||
|
||||
let background =
|
||||
BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher))));
|
||||
|
||||
let (started_tx, started_rx) = mpsc::channel::<()>();
|
||||
let (after_park_tx, after_park_rx) = mpsc::channel::<()>();
|
||||
let observed_post_await_write = Arc::new(Mutex::new(false));
|
||||
|
||||
let task = {
|
||||
let observed_post_await_write = observed_post_await_write.clone();
|
||||
background.spawn_dedicated(move |_executor| async move {
|
||||
// Announce that the future is live on the dedicated thread.
|
||||
started_tx
|
||||
.send(())
|
||||
.expect("started signal must be received");
|
||||
// Park forever. Dropping the `Task` must cancel us here so
|
||||
// the code below this `await` never runs.
|
||||
futures::future::pending::<()>().await;
|
||||
*observed_post_await_write.lock() = true;
|
||||
after_park_tx
|
||||
.send(())
|
||||
.expect("after-park signal must be received");
|
||||
})
|
||||
};
|
||||
|
||||
// Wait until the dedicated future is actually parked at the await.
|
||||
started_rx
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("dedicated future failed to start");
|
||||
|
||||
// Drop the root Task: this must cancel the future.
|
||||
drop(task);
|
||||
|
||||
// If cancellation works, the future never advances past `pending`,
|
||||
// so this recv must time out.
|
||||
assert!(
|
||||
after_park_rx
|
||||
.recv_timeout(Duration::from_millis(100))
|
||||
.is_err(),
|
||||
"dedicated future advanced past the await after its Task was dropped"
|
||||
);
|
||||
assert!(
|
||||
!*observed_post_await_write.lock(),
|
||||
"dedicated future ran code past the cancellation point"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_dedicated_thread_tears_down_after_work_completes() {
|
||||
use std::sync::mpsc;
|
||||
|
||||
// Fires from `Drop` so we observe teardown of the dedicated future's
|
||||
// captured state on whichever thread runs its destructor.
|
||||
struct DropSignal {
|
||||
tx: Option<mpsc::Sender<std::thread::ThreadId>>,
|
||||
}
|
||||
impl Drop for DropSignal {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tx) = self.tx.take() {
|
||||
let _ = tx.send(std::thread::current().id());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let background =
|
||||
BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher))));
|
||||
let (started_tx, started_rx) = mpsc::channel::<std::thread::ThreadId>();
|
||||
let (drop_tx, drop_rx) = mpsc::channel::<std::thread::ThreadId>();
|
||||
|
||||
let task = background.spawn_dedicated(move |_executor| async move {
|
||||
// Captured by the future's state. When the future completes and
|
||||
// its state is dropped on the dedicated thread, this guard's
|
||||
// `Drop` fires and reports the thread id it ran on.
|
||||
let _guard = DropSignal { tx: Some(drop_tx) };
|
||||
started_tx
|
||||
.send(std::thread::current().id())
|
||||
.expect("started signal must be received");
|
||||
// Future returns immediately. The dedicated thread should then
|
||||
// drop the future (firing _guard), exit the recv loop, and exit.
|
||||
});
|
||||
|
||||
let dedicated_thread_id = started_rx
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("dedicated future failed to start");
|
||||
assert_ne!(
|
||||
dedicated_thread_id,
|
||||
std::thread::current().id(),
|
||||
"dedicated future ran on the test thread"
|
||||
);
|
||||
|
||||
// Drive the root task to completion so its body finishes.
|
||||
futures::executor::block_on(task);
|
||||
|
||||
// The guard's drop runs from the dedicated thread as it tears down
|
||||
// the future's captured state. If the executor/recv-loop were
|
||||
// keeping the future alive past task completion, this would hang.
|
||||
let drop_thread_id = drop_rx
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("dedicated future's captured state was not dropped after task completion");
|
||||
assert_eq!(
|
||||
drop_thread_id, dedicated_thread_id,
|
||||
"dedicated future's captured state must be dropped on the dedicated thread, not elsewhere"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_dedicated_detached_child_outlives_root() {
|
||||
use std::sync::mpsc;
|
||||
|
||||
let background =
|
||||
BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher))));
|
||||
|
||||
// `gate_rx` lets the detached child park until the test explicitly
|
||||
// releases it — after we've already observed the root completing.
|
||||
let (gate_tx, gate_rx) = mpsc::channel::<()>();
|
||||
let (child_done_tx, child_done_rx) = mpsc::channel::<std::thread::ThreadId>();
|
||||
|
||||
let task = background.spawn_dedicated(move |executor| async move {
|
||||
executor
|
||||
.spawn(async move {
|
||||
// Blocking on `recv` is normally wrong inside an
|
||||
// executor, but the dedicated thread is exclusive to
|
||||
// this session, so blocking the only future on it is
|
||||
// fine — this is the property `spawn_dedicated` is
|
||||
// designed to provide.
|
||||
gate_rx
|
||||
.recv()
|
||||
.expect("gate sender dropped before child resumed");
|
||||
child_done_tx
|
||||
.send(std::thread::current().id())
|
||||
.expect("child_done receiver dropped");
|
||||
})
|
||||
.detach();
|
||||
// Root finishes here. The detached child must keep the
|
||||
// dedicated thread alive until it completes.
|
||||
});
|
||||
|
||||
futures::executor::block_on(task);
|
||||
|
||||
// Negative assertion: the child has not finished, because the gate
|
||||
// hasn't been released yet.
|
||||
assert!(
|
||||
child_done_rx
|
||||
.recv_timeout(Duration::from_millis(50))
|
||||
.is_err(),
|
||||
"detached child finished before being released"
|
||||
);
|
||||
|
||||
// Release the gate. The detached child should now complete on the
|
||||
// dedicated thread.
|
||||
gate_tx.send(()).expect("gate receiver dropped");
|
||||
|
||||
let child_thread_id = child_done_rx
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("detached child failed to complete after gate was released");
|
||||
assert_ne!(
|
||||
child_thread_id,
|
||||
std::thread::current().id(),
|
||||
"detached child ran on the test thread instead of the dedicated thread"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt, Window};
|
|||
use http_client::HttpClient;
|
||||
use language_model::{
|
||||
ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError,
|
||||
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
|
||||
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
|
||||
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
|
||||
env_var,
|
||||
LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName,
|
||||
LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
|
||||
LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
|
||||
LanguageModelToolSchemaFormat, RateLimiter, env_var,
|
||||
};
|
||||
use open_ai::ResponseStreamEvent;
|
||||
pub use settings::XaiAvailableModel as AvailableModel;
|
||||
|
|
@ -255,6 +255,75 @@ impl XAiLanguageModel {
|
|||
}
|
||||
}
|
||||
|
||||
fn x_ai_reasoning_efforts(model: &x_ai::Model) -> &'static [open_ai::ReasoningEffort] {
|
||||
if model.supports_reasoning_effort() {
|
||||
&[
|
||||
open_ai::ReasoningEffort::None,
|
||||
open_ai::ReasoningEffort::Low,
|
||||
open_ai::ReasoningEffort::Medium,
|
||||
open_ai::ReasoningEffort::High,
|
||||
]
|
||||
} else {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
fn default_thinking_reasoning_effort(model: &x_ai::Model) -> Option<open_ai::ReasoningEffort> {
|
||||
if model.supports_reasoning_effort() {
|
||||
Some(open_ai::ReasoningEffort::Low)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn reasoning_effort_for_request(
|
||||
request: &LanguageModelRequest,
|
||||
model: &x_ai::Model,
|
||||
) -> Option<open_ai::ReasoningEffort> {
|
||||
let supported_efforts = x_ai_reasoning_efforts(model);
|
||||
if supported_efforts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if request.thinking_allowed {
|
||||
request
|
||||
.thinking_effort
|
||||
.as_deref()
|
||||
.and_then(|effort| effort.parse::<open_ai::ReasoningEffort>().ok())
|
||||
.filter(|effort| supported_efforts.contains(effort))
|
||||
.filter(|effort| *effort != open_ai::ReasoningEffort::None)
|
||||
.or_else(|| default_thinking_reasoning_effort(model))
|
||||
} else if supported_efforts.contains(&open_ai::ReasoningEffort::None) {
|
||||
Some(open_ai::ReasoningEffort::None)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn supported_thinking_effort_levels(model: &x_ai::Model) -> Vec<LanguageModelEffortLevel> {
|
||||
let default_effort = default_thinking_reasoning_effort(model);
|
||||
x_ai_reasoning_efforts(model)
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(|effort| {
|
||||
let (name, value) = match effort {
|
||||
open_ai::ReasoningEffort::None => return None,
|
||||
open_ai::ReasoningEffort::Minimal => ("Minimal", "minimal"),
|
||||
open_ai::ReasoningEffort::Low => ("Low", "low"),
|
||||
open_ai::ReasoningEffort::Medium => ("Medium", "medium"),
|
||||
open_ai::ReasoningEffort::High => ("High", "high"),
|
||||
open_ai::ReasoningEffort::XHigh => ("Extra High", "xhigh"),
|
||||
};
|
||||
|
||||
Some(LanguageModelEffortLevel {
|
||||
name: name.into(),
|
||||
value: value.into(),
|
||||
is_default: Some(effort) == default_effort,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl LanguageModel for XAiLanguageModel {
|
||||
fn id(&self) -> LanguageModelId {
|
||||
self.id.clone()
|
||||
|
|
@ -291,6 +360,15 @@ impl LanguageModel for XAiLanguageModel {
|
|||
| LanguageModelToolChoice::None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_thinking(&self) -> bool {
|
||||
self.model.supports_reasoning_effort()
|
||||
}
|
||||
|
||||
fn supported_effort_levels(&self) -> Vec<LanguageModelEffortLevel> {
|
||||
supported_thinking_effort_levels(&self.model)
|
||||
}
|
||||
|
||||
fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
|
||||
if self.model.requires_json_schema_subset() {
|
||||
LanguageModelToolSchemaFormat::JsonSchemaSubset
|
||||
|
|
@ -329,13 +407,14 @@ impl LanguageModel for XAiLanguageModel {
|
|||
LanguageModelCompletionError,
|
||||
>,
|
||||
> {
|
||||
let reasoning_effort = reasoning_effort_for_request(&request, &self.model);
|
||||
let request = crate::provider::open_ai::into_open_ai(
|
||||
request,
|
||||
self.model.id(),
|
||||
self.model.supports_parallel_tool_calls(),
|
||||
self.model.supports_prompt_cache_key(),
|
||||
self.max_output_tokens(),
|
||||
None,
|
||||
reasoning_effort,
|
||||
false,
|
||||
);
|
||||
let completions = self.stream_completion(request, cx);
|
||||
|
|
@ -428,6 +507,56 @@ impl ConfigurationView {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grok_43_supports_selectable_thinking_effort_levels() {
|
||||
let effort_levels = supported_thinking_effort_levels(&x_ai::Model::Grok43);
|
||||
let values = effort_levels
|
||||
.iter()
|
||||
.map(|level| level.value.as_ref())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(values, ["low", "medium", "high"]);
|
||||
assert_eq!(
|
||||
effort_levels
|
||||
.iter()
|
||||
.find(|level| level.is_default)
|
||||
.map(|level| level.value.as_ref()),
|
||||
Some("low")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_43_request_uses_selected_reasoning_effort() {
|
||||
let request = LanguageModelRequest {
|
||||
thinking_allowed: true,
|
||||
thinking_effort: Some("high".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
reasoning_effort_for_request(&request, &x_ai::Model::Grok43),
|
||||
Some(open_ai::ReasoningEffort::High)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_43_request_uses_none_when_thinking_is_disabled() {
|
||||
let request = LanguageModelRequest {
|
||||
thinking_allowed: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
reasoning_effort_for_request(&request, &x_ai::Model::Grok43),
|
||||
Some(open_ai::ReasoningEffort::None)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ConfigurationView {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let env_var_set = self.state.read(cx).api_key_state.is_from_env_var();
|
||||
|
|
|
|||
|
|
@ -49,8 +49,27 @@ pub(crate) struct AudioStack {
|
|||
|
||||
impl AudioStack {
|
||||
pub(crate) fn new(executor: BackgroundExecutor) -> Self {
|
||||
// AGC2's `adaptive_digital` is what actually levels speech toward a target;
|
||||
// the `gain_controller2.enabled` master switch alone leaves it off, which
|
||||
// historically meant capture was effectively unleveled. Defaults match
|
||||
// what Chrome/Meet ship with -- in particular `max_gain_db = 50` paired
|
||||
// with `max_output_noise_level_dbfs = -50`, which lets the AGC reach
|
||||
// very quiet talkers while the noise-level estimator backs off before
|
||||
// boosting amplifies the noise floor.
|
||||
let apm = Arc::new(Mutex::new(apm::AudioProcessingModule::new(
|
||||
true, true, true, true,
|
||||
apm::AudioProcessingConfig {
|
||||
echo_canceller_enabled: true,
|
||||
gain_controller2: apm::GainController2Config {
|
||||
enabled: true,
|
||||
adaptive_digital: apm::AdaptiveDigitalConfig {
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
high_pass_filter_enabled: true,
|
||||
noise_suppression_enabled: true,
|
||||
},
|
||||
)));
|
||||
let mixer = Arc::new(Mutex::new(audio_mixer::AudioMixer::new()));
|
||||
Self {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ db.workspace = true
|
|||
|
||||
fs.workspace = true
|
||||
futures.workspace = true
|
||||
fuzzy.workspace = true
|
||||
gpui.workspace = true
|
||||
handlebars.workspace = true
|
||||
heed.workspace = true
|
||||
|
|
@ -29,7 +28,6 @@ language.workspace = true
|
|||
log.workspace = true
|
||||
parking_lot.workspace = true
|
||||
paths.workspace = true
|
||||
rope.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
strum.workspace = true
|
||||
|
|
|
|||
|
|
@ -6,24 +6,17 @@ use chrono::{DateTime, Utc};
|
|||
use collections::HashMap;
|
||||
use futures::FutureExt as _;
|
||||
use futures::future::Shared;
|
||||
use fuzzy::StringMatchCandidate;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, Global, ReadGlobal, SharedString, Task,
|
||||
};
|
||||
|
||||
use gpui::{App, AppContext, Entity, Global, ReadGlobal, SharedString, Task};
|
||||
use heed::{
|
||||
Database, RoTxn,
|
||||
types::{SerdeBincode, SerdeJson, Str},
|
||||
};
|
||||
use parking_lot::RwLock;
|
||||
pub use prompts::*;
|
||||
use rope::Rope;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
cmp::Reverse,
|
||||
future::Future,
|
||||
path::PathBuf,
|
||||
sync::{Arc, atomic::AtomicBool},
|
||||
};
|
||||
use std::{future::Future, path::PathBuf, sync::Arc};
|
||||
use strum::{EnumIter, IntoEnumIterator as _};
|
||||
use text::LineEnding;
|
||||
use util::ResultExt;
|
||||
|
|
@ -122,15 +115,6 @@ impl PromptId {
|
|||
pub fn is_built_in(&self) -> bool {
|
||||
matches!(self, Self::BuiltIn { .. })
|
||||
}
|
||||
|
||||
pub fn can_edit(&self) -> bool {
|
||||
match self {
|
||||
Self::User { .. } => true,
|
||||
Self::BuiltIn(builtin) => match builtin {
|
||||
BuiltInPrompt::CommitMessage => true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BuiltInPrompt> for PromptId {
|
||||
|
|
@ -173,14 +157,9 @@ impl std::fmt::Display for PromptId {
|
|||
pub struct PromptStore {
|
||||
env: heed::Env,
|
||||
metadata_cache: RwLock<MetadataCache>,
|
||||
metadata: Database<SerdeJson<PromptId>, SerdeJson<PromptMetadata>>,
|
||||
bodies: Database<SerdeJson<PromptId>, Str>,
|
||||
}
|
||||
|
||||
pub struct PromptsUpdatedEvent;
|
||||
|
||||
impl EventEmitter<PromptsUpdatedEvent> for PromptStore {}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MetadataCache {
|
||||
metadata: Vec<PromptMetadata>,
|
||||
|
|
@ -220,21 +199,6 @@ impl MetadataCache {
|
|||
Ok(cache)
|
||||
}
|
||||
|
||||
fn insert(&mut self, metadata: PromptMetadata) {
|
||||
self.metadata_by_id.insert(metadata.id, metadata.clone());
|
||||
if let Some(old_metadata) = self.metadata.iter_mut().find(|m| m.id == metadata.id) {
|
||||
*old_metadata = metadata;
|
||||
} else {
|
||||
self.metadata.push(metadata);
|
||||
}
|
||||
self.sort();
|
||||
}
|
||||
|
||||
fn remove(&mut self, id: PromptId) {
|
||||
self.metadata.retain(|metadata| metadata.id != id);
|
||||
self.metadata_by_id.remove(&id);
|
||||
}
|
||||
|
||||
fn sort(&mut self) {
|
||||
self.metadata.sort_unstable_by(|a, b| {
|
||||
a.title
|
||||
|
|
@ -275,7 +239,6 @@ impl PromptStore {
|
|||
Ok(PromptStore {
|
||||
env: db_env,
|
||||
metadata_cache: RwLock::new(metadata_cache),
|
||||
metadata,
|
||||
bodies,
|
||||
})
|
||||
})
|
||||
|
|
@ -363,219 +326,6 @@ impl PromptStore {
|
|||
pub fn all_prompt_metadata(&self) -> Vec<PromptMetadata> {
|
||||
self.metadata_cache.read().metadata.clone()
|
||||
}
|
||||
|
||||
pub fn default_prompt_metadata(&self) -> Vec<PromptMetadata> {
|
||||
return self
|
||||
.metadata_cache
|
||||
.read()
|
||||
.metadata
|
||||
.iter()
|
||||
.filter(|metadata| metadata.default)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
}
|
||||
|
||||
pub fn delete(&self, id: PromptId, cx: &Context<Self>) -> Task<Result<()>> {
|
||||
self.metadata_cache.write().remove(id);
|
||||
|
||||
let db_connection = self.env.clone();
|
||||
let bodies = self.bodies;
|
||||
let metadata = self.metadata;
|
||||
|
||||
let task = cx.background_spawn(async move {
|
||||
let mut txn = db_connection.write_txn()?;
|
||||
|
||||
metadata.delete(&mut txn, &id)?;
|
||||
bodies.delete(&mut txn, &id)?;
|
||||
|
||||
if let PromptId::User { uuid } = id {
|
||||
let prompt_id_v1 = PromptIdV1::from(uuid);
|
||||
|
||||
if let Some(metadata_v1_db) = db_connection
|
||||
.open_database::<SerdeBincode<PromptIdV1>, SerdeBincode<()>>(
|
||||
&txn,
|
||||
Some("metadata"),
|
||||
)?
|
||||
{
|
||||
metadata_v1_db.delete(&mut txn, &prompt_id_v1)?;
|
||||
}
|
||||
|
||||
if let Some(bodies_v1_db) = db_connection
|
||||
.open_database::<SerdeBincode<PromptIdV1>, SerdeBincode<()>>(
|
||||
&txn,
|
||||
Some("bodies"),
|
||||
)?
|
||||
{
|
||||
bodies_v1_db.delete(&mut txn, &prompt_id_v1)?;
|
||||
}
|
||||
}
|
||||
|
||||
txn.commit()?;
|
||||
anyhow::Ok(())
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
task.await?;
|
||||
this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok();
|
||||
anyhow::Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn metadata(&self, id: PromptId) -> Option<PromptMetadata> {
|
||||
self.metadata_cache.read().metadata_by_id.get(&id).cloned()
|
||||
}
|
||||
|
||||
pub fn first(&self) -> Option<PromptMetadata> {
|
||||
self.metadata_cache.read().metadata.first().cloned()
|
||||
}
|
||||
|
||||
pub fn id_for_title(&self, title: &str) -> Option<PromptId> {
|
||||
let metadata_cache = self.metadata_cache.read();
|
||||
let metadata = metadata_cache
|
||||
.metadata
|
||||
.iter()
|
||||
.find(|metadata| metadata.title.as_deref() == Some(title))?;
|
||||
Some(metadata.id)
|
||||
}
|
||||
|
||||
pub fn search(
|
||||
&self,
|
||||
query: String,
|
||||
cancellation_flag: Arc<AtomicBool>,
|
||||
cx: &App,
|
||||
) -> Task<Vec<PromptMetadata>> {
|
||||
let cached_metadata = self.metadata_cache.read().metadata.clone();
|
||||
let executor = cx.background_executor().clone();
|
||||
cx.background_spawn(async move {
|
||||
let mut matches = if query.is_empty() {
|
||||
cached_metadata
|
||||
} else {
|
||||
let candidates = cached_metadata
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(ix, metadata)| {
|
||||
Some(StringMatchCandidate::new(ix, metadata.title.as_ref()?))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let matches = fuzzy::match_strings(
|
||||
&candidates,
|
||||
&query,
|
||||
false,
|
||||
true,
|
||||
100,
|
||||
&cancellation_flag,
|
||||
executor,
|
||||
)
|
||||
.await;
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|mat| cached_metadata[mat.candidate_id].clone())
|
||||
.collect()
|
||||
};
|
||||
matches.sort_by_key(|metadata| Reverse(metadata.default));
|
||||
matches
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save(
|
||||
&self,
|
||||
id: PromptId,
|
||||
title: Option<SharedString>,
|
||||
default: bool,
|
||||
body: Rope,
|
||||
cx: &Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
if !id.can_edit() {
|
||||
return Task::ready(Err(anyhow!("this prompt cannot be edited")));
|
||||
}
|
||||
|
||||
let body = body.to_string();
|
||||
let is_default_content = id
|
||||
.as_built_in()
|
||||
.is_some_and(|builtin| body.trim() == builtin.default_content().trim());
|
||||
|
||||
let metadata = if let Some(builtin) = id.as_built_in() {
|
||||
PromptMetadata::builtin(builtin)
|
||||
} else {
|
||||
PromptMetadata {
|
||||
id,
|
||||
title,
|
||||
default,
|
||||
saved_at: Utc::now(),
|
||||
}
|
||||
};
|
||||
|
||||
self.metadata_cache.write().insert(metadata.clone());
|
||||
|
||||
let db_connection = self.env.clone();
|
||||
let bodies = self.bodies;
|
||||
let metadata_db = self.metadata;
|
||||
|
||||
let task = cx.background_spawn(async move {
|
||||
let mut txn = db_connection.write_txn()?;
|
||||
|
||||
if is_default_content {
|
||||
metadata_db.delete(&mut txn, &id)?;
|
||||
bodies.delete(&mut txn, &id)?;
|
||||
} else {
|
||||
metadata_db.put(&mut txn, &id, &metadata)?;
|
||||
bodies.put(&mut txn, &id, &body)?;
|
||||
}
|
||||
|
||||
txn.commit()?;
|
||||
|
||||
anyhow::Ok(())
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
task.await?;
|
||||
this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok();
|
||||
anyhow::Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_metadata(
|
||||
&self,
|
||||
id: PromptId,
|
||||
mut title: Option<SharedString>,
|
||||
default: bool,
|
||||
cx: &Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let mut cache = self.metadata_cache.write();
|
||||
|
||||
if !id.can_edit() {
|
||||
title = cache
|
||||
.metadata_by_id
|
||||
.get(&id)
|
||||
.and_then(|metadata| metadata.title.clone());
|
||||
}
|
||||
|
||||
let prompt_metadata = PromptMetadata {
|
||||
id,
|
||||
title,
|
||||
default,
|
||||
saved_at: Utc::now(),
|
||||
};
|
||||
|
||||
cache.insert(prompt_metadata.clone());
|
||||
|
||||
let db_connection = self.env.clone();
|
||||
let metadata = self.metadata;
|
||||
|
||||
let task = cx.background_spawn(async move {
|
||||
let mut txn = db_connection.write_txn()?;
|
||||
metadata.put(&mut txn, &id, &prompt_metadata)?;
|
||||
txn.commit()?;
|
||||
|
||||
anyhow::Ok(())
|
||||
});
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
task.await?;
|
||||
this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok();
|
||||
anyhow::Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Deprecated: Legacy V1 prompt ID format, used only for migrating data from old databases. Use `PromptId` instead.
|
||||
|
|
@ -608,7 +358,7 @@ mod tests {
|
|||
use gpui::TestAppContext;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_built_in_prompt_load_save(cx: &mut TestAppContext) {
|
||||
async fn test_built_in_prompt_load(cx: &mut TestAppContext) {
|
||||
cx.executor().allow_parking();
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
|
@ -632,265 +382,14 @@ mod tests {
|
|||
"Loading a built-in prompt not in DB should return default content"
|
||||
);
|
||||
|
||||
let metadata = store.read_with(cx, |store, _| store.metadata(commit_message_id));
|
||||
assert!(
|
||||
metadata.is_some(),
|
||||
"Built-in prompt should always have metadata"
|
||||
);
|
||||
assert!(
|
||||
store.read_with(cx, |store, _| {
|
||||
store
|
||||
.metadata_cache
|
||||
.read()
|
||||
.metadata_by_id
|
||||
.contains_key(&commit_message_id)
|
||||
.all_prompt_metadata()
|
||||
.iter()
|
||||
.any(|metadata| metadata.id == commit_message_id)
|
||||
}),
|
||||
"Built-in prompt should always be in cache"
|
||||
);
|
||||
|
||||
let custom_content = "Custom commit message prompt";
|
||||
store
|
||||
.update(cx, |store, cx| {
|
||||
store.save(
|
||||
commit_message_id,
|
||||
Some("Commit message".into()),
|
||||
false,
|
||||
Rope::from(custom_content),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let loaded_custom = store
|
||||
.update(cx, |store, cx| store.load(commit_message_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
loaded_custom.trim(),
|
||||
custom_content.trim(),
|
||||
"Custom content should be loaded after saving"
|
||||
);
|
||||
|
||||
assert!(
|
||||
store
|
||||
.read_with(cx, |store, _| store.metadata(commit_message_id))
|
||||
.is_some(),
|
||||
"Built-in prompt should have metadata after customization"
|
||||
);
|
||||
|
||||
store
|
||||
.update(cx, |store, cx| {
|
||||
store.save(
|
||||
commit_message_id,
|
||||
Some("Commit message".into()),
|
||||
false,
|
||||
Rope::from(BuiltInPrompt::CommitMessage.default_content()),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let metadata_after_reset =
|
||||
store.read_with(cx, |store, _| store.metadata(commit_message_id));
|
||||
assert!(
|
||||
metadata_after_reset.is_some(),
|
||||
"Built-in prompt should still have metadata after reset"
|
||||
);
|
||||
assert_eq!(
|
||||
metadata_after_reset
|
||||
.as_ref()
|
||||
.and_then(|m| m.title.as_ref().map(|t| t.as_ref())),
|
||||
Some("Commit message"),
|
||||
"Built-in prompt should have default title after reset"
|
||||
);
|
||||
|
||||
let loaded_after_reset = store
|
||||
.update(cx, |store, cx| store.load(commit_message_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut expected_content_after_reset =
|
||||
BuiltInPrompt::CommitMessage.default_content().to_string();
|
||||
LineEnding::normalize(&mut expected_content_after_reset);
|
||||
assert_eq!(
|
||||
loaded_after_reset.trim(),
|
||||
expected_content_after_reset.trim(),
|
||||
"Content should be back to default after saving default content"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that the prompt store initializes successfully even when the database
|
||||
/// contains records with incompatible/undecodable PromptId keys (e.g., from
|
||||
/// a different branch that used a different serialization format).
|
||||
///
|
||||
/// This is a regression test for the "fail-open" behavior: we should skip
|
||||
/// bad records rather than failing the entire store initialization.
|
||||
#[gpui::test]
|
||||
async fn test_prompt_store_handles_incompatible_db_records(cx: &mut TestAppContext) {
|
||||
cx.executor().allow_parking();
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("prompts-db-with-bad-records");
|
||||
std::fs::create_dir_all(&db_path).unwrap();
|
||||
|
||||
// First, create the DB and write an incompatible record directly.
|
||||
// We simulate a record written by a different branch that used
|
||||
// `{"kind":"CommitMessage"}` instead of `{"kind":"BuiltIn", ...}`.
|
||||
{
|
||||
let db_env = unsafe {
|
||||
heed::EnvOpenOptions::new()
|
||||
.map_size(1024 * 1024 * 1024)
|
||||
.max_dbs(4)
|
||||
.open(&db_path)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let mut txn = db_env.write_txn().unwrap();
|
||||
// Create the metadata.v2 database with raw bytes so we can write
|
||||
// an incompatible key format.
|
||||
let metadata_db: Database<heed::types::Bytes, heed::types::Bytes> = db_env
|
||||
.create_database(&mut txn, Some("metadata.v2"))
|
||||
.unwrap();
|
||||
|
||||
// Write an incompatible PromptId key: `{"kind":"CommitMessage"}`
|
||||
// This is the old/branch format that current code can't decode.
|
||||
let bad_key = br#"{"kind":"CommitMessage"}"#;
|
||||
let dummy_metadata = br#"{"id":{"kind":"CommitMessage"},"title":"Bad Record","default":false,"saved_at":"2024-01-01T00:00:00Z"}"#;
|
||||
metadata_db.put(&mut txn, bad_key, dummy_metadata).unwrap();
|
||||
|
||||
// Also write a valid record to ensure we can still read good data.
|
||||
let good_key = br#"{"kind":"User","uuid":"550e8400-e29b-41d4-a716-446655440000"}"#;
|
||||
let good_metadata = br#"{"id":{"kind":"User","uuid":"550e8400-e29b-41d4-a716-446655440000"},"title":"Good Record","default":false,"saved_at":"2024-01-01T00:00:00Z"}"#;
|
||||
metadata_db.put(&mut txn, good_key, good_metadata).unwrap();
|
||||
|
||||
txn.commit().unwrap();
|
||||
}
|
||||
|
||||
// Now try to create a PromptStore from this DB.
|
||||
// With fail-open behavior, this should succeed and skip the bad record.
|
||||
// Without fail-open, this would return an error.
|
||||
let store_result = cx.update(|cx| PromptStore::new(db_path, cx)).await;
|
||||
|
||||
assert!(
|
||||
store_result.is_ok(),
|
||||
"PromptStore should initialize successfully even with incompatible DB records. \
|
||||
Got error: {:?}",
|
||||
store_result.err()
|
||||
);
|
||||
|
||||
let store = cx.new(|_cx| store_result.unwrap());
|
||||
|
||||
// Verify the good record was loaded.
|
||||
let good_id = PromptId::User {
|
||||
uuid: UserPromptId("550e8400-e29b-41d4-a716-446655440000".parse().unwrap()),
|
||||
};
|
||||
let metadata = store.read_with(cx, |store, _| store.metadata(good_id));
|
||||
assert!(
|
||||
metadata.is_some(),
|
||||
"Valid records should still be loaded after skipping bad ones"
|
||||
);
|
||||
assert_eq!(
|
||||
metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.title.as_ref().map(|t| t.as_ref())),
|
||||
Some("Good Record"),
|
||||
"Valid record should have correct title"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_deleted_prompt_does_not_reappear_after_migration(cx: &mut TestAppContext) {
|
||||
cx.executor().allow_parking();
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let db_path = temp_dir.path().join("prompts-db-v1-migration");
|
||||
std::fs::create_dir_all(&db_path).unwrap();
|
||||
|
||||
let prompt_uuid: Uuid = "550e8400-e29b-41d4-a716-446655440001".parse().unwrap();
|
||||
let prompt_id_v1 = PromptIdV1(prompt_uuid);
|
||||
let prompt_id_v2 = PromptId::User {
|
||||
uuid: UserPromptId(prompt_uuid),
|
||||
};
|
||||
|
||||
// Create V1 database with a prompt
|
||||
{
|
||||
let db_env = unsafe {
|
||||
heed::EnvOpenOptions::new()
|
||||
.map_size(1024 * 1024 * 1024)
|
||||
.max_dbs(4)
|
||||
.open(&db_path)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let mut txn = db_env.write_txn().unwrap();
|
||||
|
||||
let metadata_v1_db: Database<SerdeBincode<PromptIdV1>, SerdeBincode<PromptMetadataV1>> =
|
||||
db_env.create_database(&mut txn, Some("metadata")).unwrap();
|
||||
|
||||
let bodies_v1_db: Database<SerdeBincode<PromptIdV1>, SerdeBincode<String>> =
|
||||
db_env.create_database(&mut txn, Some("bodies")).unwrap();
|
||||
|
||||
let metadata_v1 = PromptMetadataV1 {
|
||||
id: prompt_id_v1.clone(),
|
||||
title: Some("V1 Prompt".into()),
|
||||
default: false,
|
||||
saved_at: Utc::now(),
|
||||
};
|
||||
|
||||
metadata_v1_db
|
||||
.put(&mut txn, &prompt_id_v1, &metadata_v1)
|
||||
.unwrap();
|
||||
bodies_v1_db
|
||||
.put(&mut txn, &prompt_id_v1, &"V1 prompt body".to_string())
|
||||
.unwrap();
|
||||
|
||||
txn.commit().unwrap();
|
||||
}
|
||||
|
||||
// Migrate V1 to V2 by creating PromptStore
|
||||
let store = cx
|
||||
.update(|cx| PromptStore::new(db_path.clone(), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let store = cx.new(|_cx| store);
|
||||
|
||||
// Verify the prompt was migrated
|
||||
let metadata = store.read_with(cx, |store, _| store.metadata(prompt_id_v2));
|
||||
assert!(metadata.is_some(), "V1 prompt should be migrated to V2");
|
||||
assert_eq!(
|
||||
metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.title.as_ref().map(|t| t.as_ref())),
|
||||
Some("V1 Prompt"),
|
||||
"Migrated prompt should have correct title"
|
||||
);
|
||||
|
||||
// Delete the prompt
|
||||
store
|
||||
.update(cx, |store, cx| store.delete(prompt_id_v2, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify prompt is deleted
|
||||
let metadata_after_delete = store.read_with(cx, |store, _| store.metadata(prompt_id_v2));
|
||||
assert!(
|
||||
metadata_after_delete.is_none(),
|
||||
"Prompt should be deleted from V2"
|
||||
);
|
||||
|
||||
drop(store);
|
||||
|
||||
// "Restart" by creating a new PromptStore from the same path
|
||||
let store_after_restart = cx.update(|cx| PromptStore::new(db_path, cx)).await.unwrap();
|
||||
let store_after_restart = cx.new(|_cx| store_after_restart);
|
||||
|
||||
// Test the prompt does not reappear
|
||||
let metadata_after_restart =
|
||||
store_after_restart.read_with(cx, |store, _| store.metadata(prompt_id_v2));
|
||||
assert!(
|
||||
metadata_after_restart.is_none(),
|
||||
"Deleted prompt should NOT reappear after restart/migration"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1555,7 +1555,7 @@ type ResponseChannels = Mutex<HashMap<MessageId, oneshot::Sender<(Envelope, ones
|
|||
type StreamResponseChannels =
|
||||
Arc<Mutex<HashMap<MessageId, UnboundedSender<(Result<Envelope>, oneshot::Sender<()>)>>>>;
|
||||
|
||||
struct Signal<T> {
|
||||
struct Signal<T: 'static> {
|
||||
tx: Mutex<Option<oneshot::Sender<T>>>,
|
||||
rx: Shared<Task<Option<T>>>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use crate::{Instant, Priority, RunnableMeta, Scheduler, SessionId, Timer};
|
||||
use async_task::Runnable;
|
||||
use std::{
|
||||
any::Any,
|
||||
future::Future,
|
||||
marker::PhantomData,
|
||||
mem::ManuallyDrop,
|
||||
|
|
@ -12,18 +14,39 @@ use std::{
|
|||
time::Duration,
|
||||
};
|
||||
|
||||
/// A `!Send` executor pinned to a single session. Tasks spawned on it run in
|
||||
/// order on whichever thread drains the dispatch destination supplied at
|
||||
/// construction time — typically the main thread for the default session, or
|
||||
/// a dedicated OS thread for sessions created by `spawn_dedicated_thread`.
|
||||
#[derive(Clone)]
|
||||
pub struct ForegroundExecutor {
|
||||
pub struct LocalExecutor {
|
||||
session_id: SessionId,
|
||||
scheduler: Arc<dyn Scheduler>,
|
||||
// Spawned tasks' schedule callbacks each hold an `Arc` clone of this
|
||||
// closure, so the destination it captures stays alive as long as work
|
||||
// could still land on it.
|
||||
dispatch: Arc<dyn Fn(Runnable<RunnableMeta>) + Send + Sync>,
|
||||
not_send: PhantomData<Rc<()>>,
|
||||
}
|
||||
|
||||
impl ForegroundExecutor {
|
||||
pub fn new(session_id: SessionId, scheduler: Arc<dyn Scheduler>) -> Self {
|
||||
impl LocalExecutor {
|
||||
/// Constructs a local executor that runs spawned tasks by sending their
|
||||
/// runnables through `dispatch`. The `scheduler` is retained for access to
|
||||
/// clocks, timers, and other scheduler-level services.
|
||||
///
|
||||
/// For the common case of routing runnables through
|
||||
/// `Scheduler::schedule_local`, callers pass a closure that does exactly
|
||||
/// that. `spawn_dedicated_thread` instead passes a closure that sends to
|
||||
/// the dedicated thread's channel.
|
||||
pub fn new(
|
||||
session_id: SessionId,
|
||||
scheduler: Arc<dyn Scheduler>,
|
||||
dispatch: impl Fn(Runnable<RunnableMeta>) + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
session_id,
|
||||
scheduler,
|
||||
dispatch: Arc::new(dispatch),
|
||||
not_send: PhantomData,
|
||||
}
|
||||
}
|
||||
|
|
@ -42,16 +65,11 @@ impl ForegroundExecutor {
|
|||
F: Future + 'static,
|
||||
F::Output: 'static,
|
||||
{
|
||||
let session_id = self.session_id;
|
||||
let scheduler = Arc::downgrade(&self.scheduler);
|
||||
let dispatch = self.dispatch.clone();
|
||||
let location = Location::caller();
|
||||
let (runnable, task) = spawn_local_with_source_location(
|
||||
future,
|
||||
move |runnable| {
|
||||
if let Some(scheduler) = scheduler.upgrade() {
|
||||
scheduler.schedule_foreground(session_id, runnable);
|
||||
}
|
||||
},
|
||||
move |runnable| dispatch(runnable),
|
||||
RunnableMeta { location },
|
||||
);
|
||||
runnable.schedule();
|
||||
|
|
@ -110,6 +128,48 @@ impl ForegroundExecutor {
|
|||
pub fn now(&self) -> Instant {
|
||||
self.scheduler.clock().now()
|
||||
}
|
||||
|
||||
/// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`].
|
||||
/// The closure runs on a new OS thread under `PlatformScheduler`, or on
|
||||
/// the test scheduler's loop under `TestScheduler`.
|
||||
///
|
||||
/// The returned `Task` represents the dedicated work: dropping it cancels
|
||||
/// the dedicated closure, `.await`ing it yields the closure's return
|
||||
/// value, `.detach()`ing it lets the dedicated work run independently of
|
||||
/// the caller.
|
||||
#[track_caller]
|
||||
pub fn spawn_dedicated<F, Fut>(&self, f: F) -> Task<Fut::Output>
|
||||
where
|
||||
F: FnOnce(LocalExecutor) -> Fut + Send + 'static,
|
||||
Fut: Future + 'static,
|
||||
Fut::Output: Send + Sync + 'static,
|
||||
{
|
||||
self.scheduler
|
||||
.clone()
|
||||
.spawn_dedicated(box_dedicated(f))
|
||||
.downcast::<Fut::Output>()
|
||||
}
|
||||
}
|
||||
|
||||
/// Boxes the user-supplied dedicated closure into the type-erased shape
|
||||
/// expected by [`Scheduler::spawn_dedicated`]. The user's `Fut::Output` is
|
||||
/// boxed as `Box<dyn Any + Send + Sync>` on the dedicated side and downcast
|
||||
/// back to `Fut::Output` by [`Task::downcast`] in the wrapper.
|
||||
fn box_dedicated<F, Fut>(
|
||||
f: F,
|
||||
) -> Box<
|
||||
dyn FnOnce(LocalExecutor) -> Pin<Box<dyn Future<Output = Box<dyn Any + Send + Sync>> + 'static>>
|
||||
+ Send
|
||||
+ 'static,
|
||||
>
|
||||
where
|
||||
F: FnOnce(LocalExecutor) -> Fut + Send + 'static,
|
||||
Fut: Future + 'static,
|
||||
Fut::Output: Send + Sync + 'static,
|
||||
{
|
||||
Box::new(move |executor| {
|
||||
Box::pin(async move { Box::new(f(executor).await) as Box<dyn Any + Send + Sync> })
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -193,6 +253,27 @@ impl BackgroundExecutor {
|
|||
pub fn scheduler(&self) -> &Arc<dyn Scheduler> {
|
||||
&self.scheduler
|
||||
}
|
||||
|
||||
/// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`].
|
||||
/// The closure runs on a new OS thread under `PlatformScheduler`, or on
|
||||
/// the test scheduler's loop under `TestScheduler`.
|
||||
///
|
||||
/// The returned `Task` represents the dedicated work: dropping it cancels
|
||||
/// the dedicated closure, `.await`ing it yields the closure's return
|
||||
/// value, `.detach()`ing it lets the dedicated work run independently of
|
||||
/// the caller.
|
||||
#[track_caller]
|
||||
pub fn spawn_dedicated<F, Fut>(&self, f: F) -> Task<Fut::Output>
|
||||
where
|
||||
F: FnOnce(LocalExecutor) -> Fut + Send + 'static,
|
||||
Fut: Future + 'static,
|
||||
Fut::Output: Send + Sync + 'static,
|
||||
{
|
||||
self.scheduler
|
||||
.clone()
|
||||
.spawn_dedicated(box_dedicated(f))
|
||||
.downcast::<Fut::Output>()
|
||||
}
|
||||
}
|
||||
|
||||
/// Task is a primitive that allows work to happen in the background.
|
||||
|
|
@ -202,16 +283,22 @@ impl BackgroundExecutor {
|
|||
/// If you drop a task it will be cancelled immediately. Calling [`Task::detach`] allows
|
||||
/// the task to continue running, but with no way to return a value.
|
||||
#[must_use]
|
||||
#[derive(Debug)]
|
||||
pub struct Task<T>(TaskState<T>);
|
||||
|
||||
#[derive(Debug)]
|
||||
enum TaskState<T> {
|
||||
/// A task that is ready to return a value
|
||||
Ready(Option<T>),
|
||||
|
||||
/// A task that is currently running.
|
||||
Spawned(async_task::Task<T, RunnableMeta>),
|
||||
|
||||
/// A typed view of a [`Task<Box<dyn Any + Send + Sync>>`] obtained via
|
||||
/// [`Task::downcast`]. The inner task drives the actual work; the
|
||||
/// downcast layer just unwraps the `Box<dyn Any + Send + Sync>` on poll.
|
||||
Downcast {
|
||||
inner: Box<Task<Box<dyn Any + Send + Sync>>>,
|
||||
marker: PhantomData<fn() -> T>,
|
||||
},
|
||||
}
|
||||
|
||||
impl<T> Task<T> {
|
||||
|
|
@ -229,6 +316,7 @@ impl<T> Task<T> {
|
|||
match &self.0 {
|
||||
TaskState::Ready(_) => true,
|
||||
TaskState::Spawned(task) => task.is_finished(),
|
||||
TaskState::Downcast { inner, .. } => inner.is_ready(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -237,6 +325,7 @@ impl<T> Task<T> {
|
|||
match self {
|
||||
Task(TaskState::Ready(_)) => {}
|
||||
Task(TaskState::Spawned(task)) => task.detach(),
|
||||
Task(TaskState::Downcast { inner, .. }) => inner.detach(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -245,10 +334,43 @@ impl<T> Task<T> {
|
|||
FallibleTask(match self.0 {
|
||||
TaskState::Ready(val) => FallibleTaskState::Ready(val),
|
||||
TaskState::Spawned(task) => FallibleTaskState::Spawned(task.fallible()),
|
||||
TaskState::Downcast { inner, .. } => FallibleTaskState::Downcast {
|
||||
inner: Box::new(inner.fallible()),
|
||||
marker: PhantomData,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Task<Box<dyn Any + Send + Sync>> {
|
||||
/// Reinterprets the boxed output as a concrete `T` via downcast on
|
||||
/// completion. Used by [`LocalExecutor::spawn_dedicated`] and
|
||||
/// [`BackgroundExecutor::spawn_dedicated`] to recover the user closure's
|
||||
/// `Fut::Output` from the dyn-safe [`Scheduler::spawn_dedicated`].
|
||||
///
|
||||
/// Panics on poll if the inner output is not in fact a `T` -- a logic
|
||||
/// error in whatever produced the inner task, since the downcast type is
|
||||
/// chosen by the caller of `downcast`.
|
||||
pub fn downcast<T: Send + Sync + 'static>(self) -> Task<T> {
|
||||
Task(TaskState::Downcast {
|
||||
inner: Box::new(self),
|
||||
marker: PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::fmt::Debug for Task<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match &self.0 {
|
||||
TaskState::Ready(_) => f.debug_tuple("Task::Ready").finish(),
|
||||
TaskState::Spawned(task) => f.debug_tuple("Task::Spawned").field(task).finish(),
|
||||
TaskState::Downcast { inner, .. } => {
|
||||
f.debug_tuple("Task::Downcast").field(inner).finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A task that returns `Option<T>` instead of panicking when cancelled.
|
||||
#[must_use]
|
||||
pub struct FallibleTask<T>(FallibleTaskState<T>);
|
||||
|
|
@ -259,6 +381,12 @@ enum FallibleTaskState<T> {
|
|||
|
||||
/// A task that is currently running (wraps async_task::FallibleTask).
|
||||
Spawned(async_task::FallibleTask<T, RunnableMeta>),
|
||||
|
||||
/// Mirror of [`TaskState::Downcast`] for fallible tasks.
|
||||
Downcast {
|
||||
inner: Box<FallibleTask<Box<dyn Any + Send + Sync>>>,
|
||||
marker: PhantomData<fn() -> T>,
|
||||
},
|
||||
}
|
||||
|
||||
impl<T> FallibleTask<T> {
|
||||
|
|
@ -272,17 +400,29 @@ impl<T> FallibleTask<T> {
|
|||
match self.0 {
|
||||
FallibleTaskState::Ready(_) => {}
|
||||
FallibleTaskState::Spawned(task) => task.detach(),
|
||||
FallibleTaskState::Downcast { inner, .. } => inner.detach(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Future for FallibleTask<T> {
|
||||
impl<T: 'static> Future for FallibleTask<T> {
|
||||
type Output = Option<T>;
|
||||
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
|
||||
match unsafe { self.get_unchecked_mut() } {
|
||||
FallibleTask(FallibleTaskState::Ready(val)) => Poll::Ready(val.take()),
|
||||
FallibleTask(FallibleTaskState::Spawned(task)) => Pin::new(task).poll(cx),
|
||||
FallibleTask(FallibleTaskState::Downcast { inner, .. }) => {
|
||||
match Pin::new(inner.as_mut()).poll(cx) {
|
||||
Poll::Ready(Some(boxed_any)) => Poll::Ready(Some(
|
||||
*boxed_any
|
||||
.downcast::<T>()
|
||||
.expect("FallibleTask::poll: downcast type mismatch"),
|
||||
)),
|
||||
Poll::Ready(None) => Poll::Ready(None),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -294,17 +434,29 @@ impl<T> std::fmt::Debug for FallibleTask<T> {
|
|||
FallibleTaskState::Spawned(task) => {
|
||||
f.debug_tuple("FallibleTask::Spawned").field(task).finish()
|
||||
}
|
||||
FallibleTaskState::Downcast { inner, .. } => f
|
||||
.debug_tuple("FallibleTask::Downcast")
|
||||
.field(inner)
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Future for Task<T> {
|
||||
impl<T: 'static> Future for Task<T> {
|
||||
type Output = T;
|
||||
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
|
||||
match unsafe { self.get_unchecked_mut() } {
|
||||
Task(TaskState::Ready(val)) => Poll::Ready(val.take().unwrap()),
|
||||
Task(TaskState::Spawned(task)) => Pin::new(task).poll(cx),
|
||||
Task(TaskState::Downcast { inner, .. }) => match Pin::new(inner.as_mut()).poll(cx) {
|
||||
Poll::Ready(boxed_any) => Poll::Ready(
|
||||
*boxed_any
|
||||
.downcast::<T>()
|
||||
.expect("Task::poll: downcast type mismatch"),
|
||||
),
|
||||
Poll::Pending => Poll::Pending,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ pub use test_scheduler::*;
|
|||
use async_task::Runnable;
|
||||
use futures::channel::oneshot;
|
||||
use std::{
|
||||
any::Any,
|
||||
future::Future,
|
||||
panic::Location,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
|
|
@ -82,7 +84,11 @@ pub trait Scheduler: Send + Sync {
|
|||
timeout: Option<Duration>,
|
||||
) -> bool;
|
||||
|
||||
fn schedule_foreground(&self, session_id: SessionId, runnable: Runnable<RunnableMeta>);
|
||||
/// Schedule a runnable on the local (session-pinned) queue for `session_id`.
|
||||
/// Runnables scheduled here run in order on whichever thread drains the
|
||||
/// session — the main thread for ordinary sessions, or a dedicated OS
|
||||
/// thread for sessions created via `spawn_dedicated_thread`.
|
||||
fn schedule_local(&self, session_id: SessionId, runnable: Runnable<RunnableMeta>);
|
||||
|
||||
/// Schedule a background task with the given priority.
|
||||
fn schedule_background_with_priority(
|
||||
|
|
@ -103,11 +109,87 @@ pub trait Scheduler: Send + Sync {
|
|||
fn timer(&self, timeout: Duration) -> Timer;
|
||||
fn clock(&self) -> Arc<dyn Clock>;
|
||||
|
||||
/// Spawn a closure on a fresh session pinned to its own [`LocalExecutor`].
|
||||
///
|
||||
/// `PlatformScheduler` runs the closure on a new OS thread (see
|
||||
/// [`spawn_dedicated_thread`]). `TestScheduler` runs it on the test
|
||||
/// scheduler's loop alongside everything else so determinism under
|
||||
/// `TestScheduler::many` is preserved.
|
||||
///
|
||||
/// This is the dyn-safe entry point: the closure's output is type-erased
|
||||
/// as `Box<dyn Any + Send + Sync>` so the trait stays object-safe.
|
||||
/// Callers typically reach for the type-safe wrappers on
|
||||
/// [`LocalExecutor::spawn_dedicated`] and
|
||||
/// [`BackgroundExecutor::spawn_dedicated`], which compose this method
|
||||
/// with [`Task::downcast`] to recover the closure's concrete return type.
|
||||
fn spawn_dedicated(
|
||||
self: Arc<Self>,
|
||||
f: Box<
|
||||
dyn FnOnce(
|
||||
LocalExecutor,
|
||||
)
|
||||
-> Pin<Box<dyn Future<Output = Box<dyn Any + Send + Sync>> + 'static>>
|
||||
+ Send
|
||||
+ 'static,
|
||||
>,
|
||||
) -> Task<Box<dyn Any + Send + Sync>>;
|
||||
|
||||
fn as_test(&self) -> Option<&TestScheduler> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn work on a fresh OS thread that's exclusive to the returned task and
|
||||
/// anything spawned on the executor it provides. Blocking syscalls inside that
|
||||
/// work don't disturb any other executor in the process.
|
||||
///
|
||||
/// `f` is called on the dedicated thread with a [`LocalExecutor`] pinned
|
||||
/// to it. The future `f` returns may freely be `!Send`. The returned `Task` is
|
||||
/// that future's task: dropping it cancels the root, but detached children
|
||||
/// keep running until they finish. The thread shuts down once the executor and
|
||||
/// every task on it are gone.
|
||||
///
|
||||
/// The caller is responsible for supplying a `session_id` that's distinct from
|
||||
/// every other live session on `scheduler`. Concrete schedulers typically wrap
|
||||
/// this in an inherent method that allocates the id from their own counter.
|
||||
pub fn spawn_dedicated_thread<F, Fut>(
|
||||
session_id: SessionId,
|
||||
scheduler: Arc<dyn Scheduler>,
|
||||
f: F,
|
||||
) -> Task<Fut::Output>
|
||||
where
|
||||
F: FnOnce(LocalExecutor) -> Fut + Send + 'static,
|
||||
Fut: Future + 'static,
|
||||
Fut::Output: Send + 'static,
|
||||
{
|
||||
let (runnable_sender, runnable_receiver) = flume::unbounded::<Runnable<RunnableMeta>>();
|
||||
let (task_sender, task_receiver) = flume::bounded::<Task<Fut::Output>>(1);
|
||||
|
||||
thread::Builder::new()
|
||||
.name(format!("spawn_dedicated session {:?}", session_id))
|
||||
.spawn(move || {
|
||||
let dispatch = move |runnable: Runnable<RunnableMeta>| {
|
||||
let _ = runnable_sender.send(runnable);
|
||||
};
|
||||
let executor = LocalExecutor::new(session_id, scheduler, dispatch);
|
||||
let root_task = executor.spawn(f(executor.clone()));
|
||||
let _ = task_sender.send(root_task);
|
||||
// After this drop, every strong reference to the runnable sender
|
||||
// lives inside a spawned task or a user-held executor clone. The
|
||||
// recv loop exits once all of those are gone.
|
||||
drop(executor);
|
||||
|
||||
while let Ok(runnable) = runnable_receiver.recv() {
|
||||
runnable.run();
|
||||
}
|
||||
})
|
||||
.expect("failed to spawn dedicated thread");
|
||||
|
||||
task_receiver
|
||||
.recv()
|
||||
.expect("dedicated thread failed to produce root task")
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
|
||||
pub struct SessionId(u16);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use crate::{
|
||||
BackgroundExecutor, Clock, ForegroundExecutor, Instant, Priority, RunnableMeta, Scheduler,
|
||||
SessionId, TestClock, Timer,
|
||||
BackgroundExecutor, Clock, Instant, LocalExecutor, Priority, RunnableMeta, Scheduler,
|
||||
SessionId, Task, TestClock, Timer,
|
||||
};
|
||||
use async_task::Runnable;
|
||||
use backtrace::{Backtrace, BacktraceFrame};
|
||||
|
|
@ -10,6 +10,7 @@ use rand::{
|
|||
distr::{StandardUniform, uniform::SampleRange, uniform::SampleUniform},
|
||||
prelude::*,
|
||||
};
|
||||
use std::any::Any;
|
||||
use std::{
|
||||
any::type_name_of_val,
|
||||
collections::{BTreeMap, HashSet, VecDeque},
|
||||
|
|
@ -152,18 +153,21 @@ impl TestScheduler {
|
|||
self.state.lock().is_main_thread
|
||||
}
|
||||
|
||||
/// Allocate a new session ID for foreground task scheduling.
|
||||
/// This is used by GPUI's TestDispatcher to map dispatcher instances to sessions.
|
||||
pub fn allocate_session_id(&self) -> SessionId {
|
||||
let mut state = self.state.lock();
|
||||
state.next_session_id.0 += 1;
|
||||
state.next_session_id
|
||||
}
|
||||
|
||||
/// Create a foreground executor for this scheduler
|
||||
pub fn foreground(self: &Arc<Self>) -> ForegroundExecutor {
|
||||
/// Create a local executor for this scheduler.
|
||||
pub fn foreground(self: &Arc<Self>) -> LocalExecutor {
|
||||
let session_id = self.allocate_session_id();
|
||||
ForegroundExecutor::new(session_id, self.clone())
|
||||
let scheduler = Arc::downgrade(self);
|
||||
LocalExecutor::new(session_id, self.clone(), move |runnable| {
|
||||
if let Some(scheduler) = scheduler.upgrade() {
|
||||
scheduler.schedule_local(session_id, runnable);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a background executor for this scheduler
|
||||
|
|
@ -585,7 +589,7 @@ impl Scheduler for TestScheduler {
|
|||
completed
|
||||
}
|
||||
|
||||
fn schedule_foreground(&self, session_id: SessionId, runnable: Runnable<RunnableMeta>) {
|
||||
fn schedule_local(&self, session_id: SessionId, runnable: Runnable<RunnableMeta>) {
|
||||
assert_correct_thread(&self.thread, &self.state);
|
||||
let mut state = self.state.lock();
|
||||
let ix = if state.randomize_order {
|
||||
|
|
@ -660,6 +664,31 @@ impl Scheduler for TestScheduler {
|
|||
self.clock.clone()
|
||||
}
|
||||
|
||||
/// In the test world, dedicated work is just a fresh local session driven
|
||||
/// by the test scheduler's run loop alongside everything else. No real
|
||||
/// thread is spawned, so determinism under `TestScheduler::many` is
|
||||
/// preserved.
|
||||
fn spawn_dedicated(
|
||||
self: Arc<Self>,
|
||||
f: Box<
|
||||
dyn FnOnce(
|
||||
LocalExecutor,
|
||||
)
|
||||
-> Pin<Box<dyn Future<Output = Box<dyn Any + Send + Sync>> + 'static>>
|
||||
+ Send
|
||||
+ 'static,
|
||||
>,
|
||||
) -> Task<Box<dyn Any + Send + Sync>> {
|
||||
let session_id = self.allocate_session_id();
|
||||
let scheduler = Arc::downgrade(&self);
|
||||
let executor = LocalExecutor::new(session_id, self, move |runnable| {
|
||||
if let Some(scheduler) = scheduler.upgrade() {
|
||||
scheduler.schedule_local(session_id, runnable);
|
||||
}
|
||||
});
|
||||
executor.spawn(f(executor.clone()))
|
||||
}
|
||||
|
||||
fn as_test(&self) -> Option<&TestScheduler> {
|
||||
Some(self)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -728,3 +728,234 @@ fn test_background_priority_scheduling() {
|
|||
iterations
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spawn_dedicated_basic_round_trip() {
|
||||
let result = TestScheduler::once(async |scheduler| {
|
||||
scheduler
|
||||
.background()
|
||||
.spawn_dedicated(|_executor| async { 42 })
|
||||
.await
|
||||
});
|
||||
assert_eq!(result, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spawn_dedicated_not_send_future() {
|
||||
let result = TestScheduler::once(async |scheduler| {
|
||||
scheduler
|
||||
.background()
|
||||
.spawn_dedicated(|_executor| async move {
|
||||
// `Rc<RefCell<_>>` is `!Send`. If `spawn_dedicated` required
|
||||
// the returned future to be `Send`, this wouldn't compile.
|
||||
let state = Rc::new(RefCell::new(0_i32));
|
||||
for _ in 0..5 {
|
||||
*state.borrow_mut() += 1;
|
||||
}
|
||||
*state.borrow()
|
||||
})
|
||||
.await
|
||||
});
|
||||
assert_eq!(result, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spawn_dedicated_send_closure_captures() {
|
||||
use parking_lot::Mutex;
|
||||
|
||||
let observed = TestScheduler::once(async |scheduler| {
|
||||
let shared = Arc::new(Mutex::new(0_i32));
|
||||
let shared_for_closure = shared.clone();
|
||||
let returned = scheduler
|
||||
.background()
|
||||
.spawn_dedicated(move |_executor| {
|
||||
// `shared_for_closure` crossed the `Send` boundary of the
|
||||
// closure; we then mutate it from inside the !Send future.
|
||||
let local = shared_for_closure;
|
||||
async move {
|
||||
*local.lock() = 7;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let _: () = returned;
|
||||
*shared.lock()
|
||||
});
|
||||
assert_eq!(observed, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spawn_dedicated_inner_spawn_local() {
|
||||
let result = TestScheduler::once(async |scheduler| {
|
||||
scheduler
|
||||
.background()
|
||||
.spawn_dedicated(|executor| async move {
|
||||
// The provided executor can spawn additional `!Send` work
|
||||
// onto the same dedicated session.
|
||||
let inner = Rc::new(RefCell::new(0_i32));
|
||||
let inner_for_child = inner.clone();
|
||||
let child = executor.spawn(async move {
|
||||
*inner_for_child.borrow_mut() = 99;
|
||||
*inner_for_child.borrow()
|
||||
});
|
||||
child.await
|
||||
})
|
||||
.await
|
||||
});
|
||||
assert_eq!(result, 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spawn_dedicated_determinism_under_many() {
|
||||
use parking_lot::Mutex;
|
||||
|
||||
let outcomes = TestScheduler::many(if cfg!(miri) { 4 } else { 20 }, async |scheduler| {
|
||||
let trace = Arc::new(Mutex::new(Vec::<u32>::new()));
|
||||
|
||||
let background = scheduler.background();
|
||||
let mut tasks = Vec::new();
|
||||
for id in 0..4_u32 {
|
||||
let trace = trace.clone();
|
||||
let task = background.spawn_dedicated(move |executor| async move {
|
||||
for step in 0..3 {
|
||||
trace.lock().push(id * 100 + step);
|
||||
executor.spawn(async {}).await;
|
||||
}
|
||||
id
|
||||
});
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
let mut outputs = Vec::new();
|
||||
for task in tasks {
|
||||
outputs.push(task.await);
|
||||
}
|
||||
|
||||
(trace.lock().clone(), outputs)
|
||||
});
|
||||
|
||||
// Re-running with the same seed should produce the same trace. Run a
|
||||
// second pass with identical seeds and compare to the first.
|
||||
let outcomes_replay = TestScheduler::many(if cfg!(miri) { 4 } else { 20 }, async |scheduler| {
|
||||
let trace = Arc::new(Mutex::new(Vec::<u32>::new()));
|
||||
|
||||
let background = scheduler.background();
|
||||
let mut tasks = Vec::new();
|
||||
for id in 0..4_u32 {
|
||||
let trace = trace.clone();
|
||||
let task = background.spawn_dedicated(move |executor| async move {
|
||||
for step in 0..3 {
|
||||
trace.lock().push(id * 100 + step);
|
||||
executor.spawn(async {}).await;
|
||||
}
|
||||
id
|
||||
});
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
let mut outputs = Vec::new();
|
||||
for task in tasks {
|
||||
outputs.push(task.await);
|
||||
}
|
||||
|
||||
(trace.lock().clone(), outputs)
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
outcomes, outcomes_replay,
|
||||
"per-seed outcomes should be reproducible"
|
||||
);
|
||||
|
||||
// Sanity: at least one seed produced a non-monotonic trace,
|
||||
// demonstrating that dedicated tasks really do interleave under the
|
||||
// scheduler's randomization.
|
||||
let any_interleaved = outcomes.iter().any(|(trace, _)| {
|
||||
trace
|
||||
.windows(2)
|
||||
.any(|window| window[0] / 100 != window[1] / 100)
|
||||
});
|
||||
assert!(
|
||||
any_interleaved,
|
||||
"expected at least one seed to interleave dedicated tasks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spawn_dedicated_dropping_task_cancels_future() {
|
||||
use parking_lot::Mutex;
|
||||
|
||||
let counter_after = TestScheduler::once(async |scheduler| {
|
||||
let counter = Arc::new(Mutex::new(0_u32));
|
||||
let (resume_tx, resume_rx) = oneshot::channel::<()>();
|
||||
|
||||
let task = {
|
||||
let counter = counter.clone();
|
||||
scheduler
|
||||
.background()
|
||||
.spawn_dedicated(move |_executor| async move {
|
||||
*counter.lock() = 1;
|
||||
// Park here until the test resumes us. If the task is
|
||||
// dropped before this resolves, the second assignment
|
||||
// below must never happen.
|
||||
let _ = resume_rx.await;
|
||||
*counter.lock() = 2;
|
||||
})
|
||||
};
|
||||
|
||||
// Let the dedicated future make its first observable step.
|
||||
scheduler.run();
|
||||
assert_eq!(*counter.lock(), 1);
|
||||
|
||||
// Cancel by dropping the root task, then unblock the parked oneshot.
|
||||
// The future must not advance past the await: counter stays at 1.
|
||||
drop(task);
|
||||
let _ = resume_tx.send(());
|
||||
scheduler.run();
|
||||
|
||||
*counter.lock()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
counter_after, 1,
|
||||
"dropping the dedicated task must cancel the root future before its second write"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spawn_dedicated_detached_child_runs_after_root_completes() {
|
||||
use parking_lot::Mutex;
|
||||
|
||||
let child_ran = TestScheduler::once(async |scheduler| {
|
||||
let child_ran = Arc::new(Mutex::new(false));
|
||||
|
||||
let task = {
|
||||
let child_ran = child_ran.clone();
|
||||
scheduler
|
||||
.background()
|
||||
.spawn_dedicated(move |executor| async move {
|
||||
executor
|
||||
.spawn(async move {
|
||||
*child_ran.lock() = true;
|
||||
})
|
||||
.detach();
|
||||
// Root returns immediately, before the child has had a
|
||||
// chance to run.
|
||||
})
|
||||
};
|
||||
|
||||
task.await;
|
||||
|
||||
// Drain the dedicated session. The detached child must run.
|
||||
scheduler.run();
|
||||
|
||||
*child_ran.lock()
|
||||
});
|
||||
|
||||
assert!(
|
||||
child_ran,
|
||||
"detached child must complete after the root, not be cancelled with it"
|
||||
);
|
||||
}
|
||||
|
||||
// The production smoke test for `spawn_dedicated` lives in the `gpui` crate
|
||||
// alongside `PlatformScheduler`, which is the real production implementation
|
||||
// of the `Scheduler` trait. See `crates/gpui/src/platform_scheduler.rs`.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ path = "src/svg_preview.rs"
|
|||
multi_buffer.workspace = true
|
||||
file_icons.workspace = true
|
||||
gpui.workspace = true
|
||||
image.workspace = true
|
||||
language.workspace = true
|
||||
ui.workspace = true
|
||||
workspace.workspace = true
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use workspace::Workspace;
|
|||
|
||||
pub mod svg_preview_view;
|
||||
|
||||
pub use zed_actions::preview::svg::{OpenPreview, OpenPreviewToTheSide};
|
||||
pub use zed_actions::preview::svg::{CopyAsImage, OpenPreview, OpenPreviewToTheSide};
|
||||
|
||||
actions!(
|
||||
svg,
|
||||
|
|
|
|||
|
|
@ -1,23 +1,29 @@
|
|||
use std::mem;
|
||||
use std::sync::Arc;
|
||||
use std::{io::Cursor, mem, sync::Arc};
|
||||
|
||||
use file_icons::FileIcons;
|
||||
use gpui::{
|
||||
App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render,
|
||||
RenderImage, Styled, Subscription, Task, WeakEntity, Window, div, img,
|
||||
App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Image, ImageFormat,
|
||||
IntoElement, ParentElement, Render, RenderImage, Styled, Subscription, Task, WeakEntity,
|
||||
Window, div, img,
|
||||
};
|
||||
use language::{Buffer, BufferEvent};
|
||||
use multi_buffer::MultiBuffer;
|
||||
use ui::prelude::*;
|
||||
use ui::{ContextMenu, prelude::*, right_click_menu};
|
||||
use workspace::item::Item;
|
||||
use workspace::{Pane, Workspace};
|
||||
|
||||
use crate::{OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide};
|
||||
use crate::{CopyAsImage, OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RenderedSvg {
|
||||
preview_image: Arc<RenderImage>,
|
||||
clipboard_image: Arc<Image>,
|
||||
}
|
||||
|
||||
pub struct SvgPreviewView {
|
||||
focus_handle: FocusHandle,
|
||||
buffer: Option<Entity<Buffer>>,
|
||||
current_svg: Option<Result<Arc<RenderImage>, SharedString>>,
|
||||
current_svg: Option<Result<RenderedSvg, SharedString>>,
|
||||
_refresh: Task<()>,
|
||||
_buffer_subscription: Option<Subscription>,
|
||||
_workspace_subscription: Option<Subscription>,
|
||||
|
|
@ -110,15 +116,22 @@ impl SvgPreviewView {
|
|||
let renderer = cx.svg_renderer();
|
||||
let content = buffer.read(cx).snapshot();
|
||||
let background_task = cx.background_spawn(async move {
|
||||
renderer.render_single_frame(content.text().as_bytes(), SCALE_FACTOR)
|
||||
let preview_image = renderer
|
||||
.render_single_frame(content.text().as_bytes(), SCALE_FACTOR)
|
||||
.map_err(|error| SharedString::from(error.to_string()))?;
|
||||
let clipboard_image = Arc::new(Self::clipboard_image_for_render_image(&preview_image)?);
|
||||
|
||||
Ok(RenderedSvg {
|
||||
preview_image,
|
||||
clipboard_image,
|
||||
})
|
||||
});
|
||||
|
||||
self._refresh = cx.spawn_in(window, async move |this, cx| {
|
||||
let result = background_task.await;
|
||||
|
||||
this.update_in(cx, |view, window, cx| {
|
||||
let current = result.map_err(|e| e.to_string().into());
|
||||
view.set_current(Some(current), window, cx);
|
||||
view.set_current(Some(result), window, cx);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
|
|
@ -126,16 +139,56 @@ impl SvgPreviewView {
|
|||
|
||||
fn set_current(
|
||||
&mut self,
|
||||
image: Option<Result<Arc<RenderImage>, SharedString>>,
|
||||
image: Option<Result<RenderedSvg, SharedString>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(Ok(image)) = mem::replace(&mut self.current_svg, image) {
|
||||
window.drop_image(image).ok();
|
||||
if let Some(Ok(rendered_svg)) = mem::replace(&mut self.current_svg, image) {
|
||||
window.drop_image(rendered_svg.preview_image).ok();
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn clipboard_image_for_render_image(render_image: &RenderImage) -> Result<Image, SharedString> {
|
||||
let size = render_image.size(0);
|
||||
let width = u32::try_from(size.width.0)
|
||||
.map_err(|_| SharedString::from("Failed to render SVG image"))?;
|
||||
let height = u32::try_from(size.height.0)
|
||||
.map_err(|_| SharedString::from("Failed to render SVG image"))?;
|
||||
|
||||
if width == 0 || height == 0 {
|
||||
return Err("Failed to render SVG image".into());
|
||||
}
|
||||
|
||||
let mut rgba_bytes = render_image
|
||||
.as_bytes(0)
|
||||
.ok_or_else(|| SharedString::from("Failed to render SVG image"))?
|
||||
.to_vec();
|
||||
|
||||
for pixel in rgba_bytes.chunks_exact_mut(4) {
|
||||
pixel.swap(0, 2);
|
||||
}
|
||||
|
||||
let rgba_image = image::RgbaImage::from_raw(width, height, rgba_bytes)
|
||||
.ok_or_else(|| SharedString::from("Failed to encode SVG image"))?;
|
||||
let dynamic_image = image::DynamicImage::ImageRgba8(rgba_image);
|
||||
let mut png_bytes = Vec::new();
|
||||
let mut cursor = Cursor::new(&mut png_bytes);
|
||||
dynamic_image
|
||||
.write_to(&mut cursor, image::ImageFormat::Png)
|
||||
.map_err(|error| SharedString::from(format!("Failed to encode SVG image: {error}")))?;
|
||||
|
||||
Ok(Image::from_bytes(ImageFormat::Png, png_bytes))
|
||||
}
|
||||
|
||||
fn copy_as_image(&mut self, _: &CopyAsImage, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(Ok(rendered_svg)) = self.current_svg.as_ref() {
|
||||
cx.write_to_clipboard(ClipboardItem::new_image(
|
||||
rendered_svg.clipboard_image.as_ref(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn find_existing_preview_item_idx(
|
||||
pane: &Pane,
|
||||
buffer: &Entity<MultiBuffer>,
|
||||
|
|
@ -279,25 +332,46 @@ impl SvgPreviewView {
|
|||
|
||||
impl Render for SvgPreviewView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let focus_handle = self.focus_handle(cx);
|
||||
|
||||
v_flex()
|
||||
.id("SvgPreview")
|
||||
.key_context("SvgPreview")
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.on_action(cx.listener(Self::copy_as_image))
|
||||
.size_full()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.map(|this| match self.current_svg.clone() {
|
||||
Some(Ok(image)) => {
|
||||
this.child(img(image).max_w_full().max_h_full().with_fallback(|| {
|
||||
h_flex()
|
||||
.p_4()
|
||||
.gap_2()
|
||||
.child(Icon::new(IconName::Warning))
|
||||
.child("Failed to load SVG image")
|
||||
.into_any_element()
|
||||
}))
|
||||
Some(Ok(rendered_svg)) => {
|
||||
let menu_focus_handle = focus_handle.clone();
|
||||
|
||||
this.child(
|
||||
right_click_menu("svg-preview-context-menu")
|
||||
.trigger(move |_, _, _| {
|
||||
img(rendered_svg.preview_image)
|
||||
.max_w_full()
|
||||
.max_h_full()
|
||||
.with_fallback(|| {
|
||||
h_flex()
|
||||
.p_4()
|
||||
.gap_2()
|
||||
.child(Icon::new(IconName::Warning))
|
||||
.child("Failed to load SVG image")
|
||||
.into_any_element()
|
||||
})
|
||||
})
|
||||
.menu(move |window, cx| {
|
||||
let menu_focus_handle = menu_focus_handle.clone();
|
||||
|
||||
ContextMenu::build(window, cx, move |menu, _, _| {
|
||||
menu.context(menu_focus_handle)
|
||||
.action("Copy Image", Box::new(CopyAsImage))
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
Some(Err(e)) => this.child(div().p_4().child(e).into_any_element()),
|
||||
None => this.child(div().p_4().child("No SVG file selected")),
|
||||
|
|
@ -339,3 +413,27 @@ impl Item for SvgPreviewView {
|
|||
|
||||
fn to_item_events(_event: &Self::Event, _f: &mut dyn FnMut(workspace::item::ItemEvent)) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clipboard_image_for_render_image_encodes_png() {
|
||||
let render_image = RenderImage::new(vec![image::Frame::new(
|
||||
image::RgbaImage::from_raw(1, 1, vec![3, 2, 1, 4]).unwrap(),
|
||||
)]);
|
||||
|
||||
let clipboard_image =
|
||||
SvgPreviewView::clipboard_image_for_render_image(&render_image).unwrap();
|
||||
|
||||
assert_eq!(clipboard_image.format, ImageFormat::Png);
|
||||
|
||||
let decoded =
|
||||
image::load_from_memory_with_format(&clipboard_image.bytes, image::ImageFormat::Png)
|
||||
.unwrap()
|
||||
.into_rgba8();
|
||||
|
||||
assert_eq!(decoded.get_pixel(0, 0).0, [1, 2, 3, 4]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -734,11 +734,14 @@ mod tests {
|
|||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use editor::{Editor, SelectionEffects};
|
||||
use gpui::{TestAppContext, VisualTestContext};
|
||||
use language::{Language, LanguageConfig, LanguageMatcher, Point};
|
||||
use gpui::{App, Entity, Task, TestAppContext, VisualTestContext};
|
||||
use language::{
|
||||
Buffer, ContextProvider, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher,
|
||||
LanguageServerName, Point,
|
||||
};
|
||||
use project::{ContextProviderWithTasks, FakeFs, Project};
|
||||
use serde_json::json;
|
||||
use task::TaskTemplates;
|
||||
use task::{TaskTemplate, TaskTemplates};
|
||||
use util::path;
|
||||
use workspace::{CloseInactiveTabsAndPanes, MultiWorkspace, OpenOptions, OpenVisible};
|
||||
|
||||
|
|
@ -1033,6 +1036,80 @@ mod tests {
|
|||
cx.executor().run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_empty_lsp_task_response_keeps_language_tasks_in_modal(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(path!("/dir"), json!({ "main.test": "test" }))
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
|
||||
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
|
||||
language_registry.add(Arc::new(
|
||||
Language::new(
|
||||
LanguageConfig {
|
||||
name: "Test".into(),
|
||||
matcher: LanguageMatcher {
|
||||
path_suffixes: vec!["test".to_string()],
|
||||
..LanguageMatcher::default()
|
||||
},
|
||||
..LanguageConfig::default()
|
||||
},
|
||||
None,
|
||||
)
|
||||
.with_context_provider(Some(Arc::new(
|
||||
ContextProviderWithLspTaskSource::new(ContextProviderWithTasks::new(
|
||||
TaskTemplates(vec![TaskTemplate {
|
||||
label: "Run language task".to_string(),
|
||||
command: "echo".to_string(),
|
||||
args: vec!["language task".to_string()],
|
||||
..TaskTemplate::default()
|
||||
}]),
|
||||
)),
|
||||
))),
|
||||
));
|
||||
let mut fake_servers = language_registry.register_fake_lsp(
|
||||
"Test",
|
||||
FakeLspAdapter {
|
||||
name: TEST_LSP_NAME,
|
||||
..FakeLspAdapter::default()
|
||||
},
|
||||
);
|
||||
|
||||
let (multi_workspace, cx) =
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
|
||||
let workspace =
|
||||
multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
|
||||
let _item = workspace
|
||||
.update_in(cx, |workspace, window, cx| {
|
||||
workspace.open_abs_path(
|
||||
PathBuf::from(path!("/dir/main.test")),
|
||||
OpenOptions {
|
||||
visible: Some(OpenVisible::All),
|
||||
..Default::default()
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx.executor().run_until_parked();
|
||||
let fake_server = fake_servers
|
||||
.try_recv()
|
||||
.expect("fake LSP server should have started");
|
||||
use project::lsp_store::lsp_ext_command::Runnables;
|
||||
fake_server
|
||||
.set_request_handler::<Runnables, _, _>(move |_, _| async move { Ok(Vec::new()) });
|
||||
|
||||
let tasks_picker = open_spawn_tasks(&workspace, cx);
|
||||
assert_eq!(
|
||||
task_names(&tasks_picker, cx),
|
||||
vec!["Run language task"],
|
||||
"An empty LSP task response should not suppress language tasks in the modal"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_language_task_filtering(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
|
@ -1238,6 +1315,32 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
const TEST_LSP_NAME: &str = "test-lsp";
|
||||
|
||||
struct ContextProviderWithLspTaskSource {
|
||||
tasks: ContextProviderWithTasks,
|
||||
}
|
||||
|
||||
impl ContextProviderWithLspTaskSource {
|
||||
fn new(tasks: ContextProviderWithTasks) -> Self {
|
||||
Self { tasks }
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextProvider for ContextProviderWithLspTaskSource {
|
||||
fn associated_tasks(
|
||||
&self,
|
||||
buffer: Option<Entity<Buffer>>,
|
||||
cx: &App,
|
||||
) -> Task<Option<TaskTemplates>> {
|
||||
self.tasks.associated_tasks(buffer, cx)
|
||||
}
|
||||
|
||||
fn lsp_task_source(&self) -> Option<LanguageServerName> {
|
||||
Some(LanguageServerName::new_static(TEST_LSP_NAME))
|
||||
}
|
||||
}
|
||||
|
||||
fn emulate_task_schedule(
|
||||
tasks_picker: Entity<Picker<TasksModalDelegate>>,
|
||||
project: &Entity<Project>,
|
||||
|
|
|
|||
|
|
@ -122,4 +122,11 @@ impl Model {
|
|||
Self::Custom { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn supports_reasoning_effort(&self) -> bool {
|
||||
match self {
|
||||
Self::Grok43 => true,
|
||||
Self::Grok420Reasoning | Self::Grok420NonReasoning | Self::Custom { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ use futures::{StreamExt, channel::mpsc, select_biased};
|
|||
use git_ui::commit_view::CommitViewToolbar;
|
||||
use git_ui::git_panel::GitPanel;
|
||||
use git_ui::project_diff::{BranchDiffToolbar, ProjectDiffToolbar};
|
||||
use git_ui::solo_diff_view::{SoloDiffGitToolbar, SoloDiffStyleToolbar};
|
||||
use gpui::{
|
||||
Action, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent,
|
||||
Element, Entity, FocusHandle, Focusable, Image, ImageFormat, KeyBinding, ParentElement,
|
||||
|
|
@ -1305,6 +1306,8 @@ fn initialize_pane(
|
|||
pane.toolbar().update(cx, |toolbar, cx| {
|
||||
let multibuffer_hint = cx.new(|_| MultibufferHint::new());
|
||||
toolbar.add_item(multibuffer_hint, window, cx);
|
||||
let solo_diff_style_toolbar = cx.new(SoloDiffStyleToolbar::new);
|
||||
toolbar.add_item(solo_diff_style_toolbar, window, cx);
|
||||
let breadcrumbs = cx.new(|_| Breadcrumbs::new());
|
||||
toolbar.add_item(breadcrumbs, window, cx);
|
||||
let buffer_search_bar = cx.new(|cx| {
|
||||
|
|
@ -1343,6 +1346,8 @@ fn initialize_pane(
|
|||
toolbar.add_item(project_diff_toolbar, window, cx);
|
||||
let branch_diff_toolbar = cx.new(BranchDiffToolbar::new);
|
||||
toolbar.add_item(branch_diff_toolbar, window, cx);
|
||||
let solo_diff_git_toolbar = cx.new(SoloDiffGitToolbar::new);
|
||||
toolbar.add_item(solo_diff_git_toolbar, window, cx);
|
||||
let commit_view_toolbar = cx.new(|_| CommitViewToolbar::new());
|
||||
toolbar.add_item(commit_view_toolbar, window, cx);
|
||||
let agent_diff_toolbar = cx.new(AgentDiffToolbar::new);
|
||||
|
|
|
|||
|
|
@ -826,6 +826,8 @@ pub mod preview {
|
|||
OpenPreview,
|
||||
/// Opens an SVG preview in a split pane.
|
||||
OpenPreviewToTheSide,
|
||||
/// Copies the SVG preview as a PNG image.
|
||||
CopyAsImage,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
libxfixes,
|
||||
libxkbcommon,
|
||||
libxrandr,
|
||||
lld,
|
||||
libx11,
|
||||
libxcb,
|
||||
nodejs_22,
|
||||
|
|
@ -137,6 +138,8 @@ let
|
|||
]
|
||||
++ lib.optionals stdenv'.hostPlatform.isLinux [ makeWrapper ]
|
||||
++ lib.optionals stdenv'.hostPlatform.isDarwin [
|
||||
# Provides `ld64.lld` for clang's `-fuse-ld=lld`.
|
||||
lld
|
||||
(cargo-bundle.overrideAttrs (
|
||||
new: old: {
|
||||
version = "0.6.1-zed";
|
||||
|
|
@ -246,6 +249,11 @@ let
|
|||
}";
|
||||
|
||||
NIX_OUTPATH_USED_AS_RANDOM_SEED = "norebuilds";
|
||||
}
|
||||
// lib.optionalAttrs stdenv'.hostPlatform.isDarwin {
|
||||
# Link with lld on Darwin. nixpkgs' classic open-source ld64 fails to insert
|
||||
# ARM64 branch thunks for this binary, producing `b(l) ARM64 branch out of range`.
|
||||
NIX_CFLAGS_LINK = "-fuse-ld=lld";
|
||||
};
|
||||
|
||||
# prevent nix from removing the "unused" wayland/gpu-lib rpaths
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
},
|
||||
{
|
||||
"name": "Markdown Preview",
|
||||
"labels": ["area:preview/markdown", "area:preview/mermaid"]
|
||||
"labels": ["area:preview/markdown", "area:preview/mermaid", "area:preview/csv"]
|
||||
},
|
||||
{
|
||||
"name": "NixOS",
|
||||
|
|
@ -88,9 +88,11 @@
|
|||
"labels": [
|
||||
"area:command palette",
|
||||
"area:file finder",
|
||||
"area:fs",
|
||||
"area:navigation",
|
||||
"area:outline",
|
||||
"area:project panel",
|
||||
"area:scanning",
|
||||
"area:workspace"
|
||||
]
|
||||
},
|
||||
|
|
@ -100,6 +102,7 @@
|
|||
"area:code folding",
|
||||
"area:editor",
|
||||
"area:editor/brackets",
|
||||
"area:editor/bookmarks",
|
||||
"area:editor/linked edits",
|
||||
"area:multi-buffer",
|
||||
"area:multi-cursor",
|
||||
|
|
@ -135,6 +138,7 @@
|
|||
"area:ai",
|
||||
"area:ai/acp",
|
||||
"area:ai/agent thread",
|
||||
"area:ai/agent thread/skills",
|
||||
"area:ai/anthropic",
|
||||
"area:ai/assistant",
|
||||
"area:ai/bedrock",
|
||||
|
|
@ -154,6 +158,7 @@
|
|||
"area:ai/opencode",
|
||||
"area:ai/openrouter",
|
||||
"area:ai/qwen",
|
||||
"area:ai/terminal threads",
|
||||
"area:ai/text thread"
|
||||
]
|
||||
},
|
||||
|
|
@ -222,6 +227,7 @@
|
|||
"name": "Performance & Catch-all",
|
||||
"labels": [
|
||||
"area:cli",
|
||||
"area:crashes",
|
||||
"area:discoverability",
|
||||
"area:installer-updater",
|
||||
"area:internationalization",
|
||||
|
|
@ -236,6 +242,7 @@
|
|||
"area:performance",
|
||||
"area:performance/memory leak",
|
||||
"area:release notes",
|
||||
"area:scripts",
|
||||
"area:security & privacy",
|
||||
"area:security & privacy/workspace trust",
|
||||
"area:serialization",
|
||||
|
|
|
|||
|
|
@ -146,11 +146,15 @@ No action needed. A maintainer will review this shortly.
|
|||
]
|
||||
parts.append("**Possibly related open issues:**\n\n" + "\n".join(lines))
|
||||
if related_closed_issues:
|
||||
# state_reason is shown only for "duplicate" (the close type is otherwise
|
||||
# already visible from GitHub's icon next to the issue number on render).
|
||||
lines = [
|
||||
f"- #{m['number']} (closed as {m['state_reason']}) — {m['explanation']}"
|
||||
f"- #{m['number']}"
|
||||
f"{' (closed as duplicate)' if m['state_reason'] == 'duplicate' else ''}"
|
||||
f" — {m['explanation']}"
|
||||
for m in related_closed_issues
|
||||
]
|
||||
parts.append("**Recently closed, possibly related:**\n\n" + "\n".join(lines))
|
||||
parts.append("**Recently closed, possibly the same bug:**\n\n" + "\n".join(lines))
|
||||
body = "\n\n".join(parts)
|
||||
sections.append(f"""<details>
|
||||
<summary>Additional recent context for triagers</summary>
|
||||
|
|
@ -280,6 +284,12 @@ def detect_areas(anthropic_key, issue, area_labels):
|
|||
|
||||
system_prompt = """You analyze GitHub issues to identify which area labels apply.
|
||||
|
||||
Decide the area from the user's stated symptom and reproduction steps. Issue bodies routinely
|
||||
contain pasted log output, crash dumps, stack traces, settings files, and template headers like
|
||||
"Attach Zed log file" or "Relevant Zed settings" — these are evidence about the symptom and
|
||||
should not push you toward labels like "logging" or "settings" unless the bug itself is about
|
||||
how that subsystem works.
|
||||
|
||||
Respond with ONLY a comma-separated list of matching area names. No prose, no explanation,
|
||||
no markdown, no preamble — just the names.
|
||||
|
||||
|
|
@ -500,8 +510,14 @@ def analyze_duplicates(anthropic_key, issue, magnets, search_results):
|
|||
return [], [], []
|
||||
|
||||
log("Analyzing candidates with Claude")
|
||||
log(f" Candidate pool: {len(top_magnets)} magnets, {len(open_results)} open search results, "
|
||||
f"{len(closed_results)} closed search results (will pass {min(len(closed_results), 5)} closed)")
|
||||
enrich_magnets(top_magnets)
|
||||
|
||||
closed_candidates_for_claude = closed_results[:5]
|
||||
if closed_candidates_for_claude:
|
||||
log(f" Closed candidates given to proposer: {[r['number'] for r in closed_candidates_for_claude]}")
|
||||
|
||||
candidates = [
|
||||
{"number": m["number"], "title": m["title"], "body_preview": m["body_preview"],
|
||||
"state": "open", "state_reason": None, "source": "known_duplicate_magnet"}
|
||||
|
|
@ -509,7 +525,7 @@ def analyze_duplicates(anthropic_key, issue, magnets, search_results):
|
|||
] + [
|
||||
{"number": r["number"], "title": r["title"], "body_preview": r["body_preview"],
|
||||
"state": r["state"], "state_reason": r["state_reason"], "source": "search_result"}
|
||||
for r in open_results[:10] + closed_results[:5]
|
||||
for r in open_results[:10] + closed_candidates_for_claude
|
||||
]
|
||||
|
||||
system_prompt = """You analyze GitHub issues to (a) identify duplicates among OPEN candidates
|
||||
|
|
@ -548,17 +564,63 @@ Examples of things that are NOT duplicates:
|
|||
For OPEN duplicates (either bucket), false positives are MUCH worse than false negatives — they
|
||||
waste the time of both the issue author and the maintainers. When in doubt, omit.
|
||||
|
||||
# (b) Related closed issues — CLOSED candidates only
|
||||
# (b) Closed candidates that may be the same bug — CLOSED candidates only
|
||||
|
||||
The goal is to give triagers extra context, NOT to claim a duplicate. The bar is lower than for
|
||||
duplicates: include a closed candidate if a triager would plausibly want to see it when reviewing
|
||||
the new issue. Examples worth surfacing:
|
||||
- A recently fixed (state_reason "completed") issue describing the same symptom — triager may ask
|
||||
the reporter to retest on the latest build.
|
||||
- A cluster of similar issues closed as "not_planned" — signals a known limitation or design choice.
|
||||
- A previously triaged duplicate (state_reason "duplicate") in the same code area.
|
||||
The goal is NOT a "related reading" list. The goal is to surface closed issues where the
|
||||
new issue is plausibly the SAME bug — a duplicate that just happens to be filed against a
|
||||
closed predecessor instead of an open one. Empty is preferable to weak filler — triagers
|
||||
lose trust in this section quickly if it's stretched. The same false-positives-are-worse
|
||||
asymmetry as for duplicates applies here.
|
||||
|
||||
Include at most 5 closed candidates, prioritized by relevance.
|
||||
The bar: a triager reading this should be able to act — ask the reporter to retest a fix,
|
||||
point at a known design decision that already declined this request, or point at the
|
||||
canonical bug this is a duplicate of. "Useful context" or "shared area" is NOT a reason
|
||||
to include.
|
||||
|
||||
Omit a candidate if ANY of these apply (in observed practice, almost everything does):
|
||||
|
||||
1. Self-contradiction. If you find yourself writing "while focused on X rather than Y",
|
||||
"although this is about A, the new issue is about B", "this issue focuses on... rather
|
||||
than...", or any acknowledgment that the candidate isn't on the same topic — STOP.
|
||||
You've already made the case for omitting it.
|
||||
|
||||
2. Fabricated specifics. Every concrete claim about the candidate (its trigger, its scope,
|
||||
its conditions) must be visible in the candidate's title or body preview. Specifics
|
||||
like "when X happens", "under Y conditions", "specifically affecting Z" that aren't
|
||||
supported by the candidate's actual text mean you're inventing details to fit the new
|
||||
issue. Omit.
|
||||
|
||||
3. Weasel phrases. Paraphrases of these all indicate you don't have a real claim:
|
||||
"may indicate similar...", "could provide context for...", "shows / demonstrates recent
|
||||
attention to...", "indicates the team has considered...", "demonstrates a pattern
|
||||
of...", "may provide useful context...". STOP and omit.
|
||||
|
||||
4. Retest by default. The "reporter may need to retest on the latest build" framing only
|
||||
applies when the candidate's symptom is literally the same as the new issue's. It is
|
||||
NOT a default justification for "this was a recent fix in roughly the same area."
|
||||
|
||||
5. Same area / feature, different mechanism. Examples to omit:
|
||||
- "ARM compile failure" alongside "ARM runtime perf" — same area, different mechanism.
|
||||
- "Worktree path bug" alongside "worktree display label confusion" — same feature,
|
||||
unrelated.
|
||||
|
||||
6. Vague catch-all candidate. A closed issue like "Zed is slow" / "performance" / "agent
|
||||
panel UX" that could be cited next to almost any new bug is filler. If you'd reuse the
|
||||
same closed issue across many unrelated new issues, omit.
|
||||
|
||||
7. Label or single-keyword overlap. A closed issue whose only connection is a shared
|
||||
area:* label or one shared keyword is not relevant.
|
||||
|
||||
Worth surfacing — strict examples:
|
||||
- A recently fixed ("completed") issue with the SAME specific trigger as the new issue —
|
||||
triager can ask the reporter to retest on the latest build.
|
||||
- A cluster of "not_planned" closures about the EXACT same request — known design choice
|
||||
the triager can point to.
|
||||
- A previously triaged "duplicate" pointing at the same canonical issue, or sharing the
|
||||
same specific mechanism.
|
||||
|
||||
Count: typically 0 or 1. Never more than 2 unless there's an obvious cluster of identical
|
||||
"not_planned" reports. 0 is a normal outcome.
|
||||
|
||||
# Output format
|
||||
|
||||
|
|
@ -614,10 +676,164 @@ Return empty arrays where nothing relevant is found."""
|
|||
likely = data.get("likely_duplicates", [])
|
||||
possible = data.get("possible_duplicates", [])
|
||||
closed = data.get("related_closed_issues", [])
|
||||
|
||||
# Claude occasionally places a closed candidate in the duplicate buckets, or vice
|
||||
# versa. Enforce that each match lives in the bucket consistent with the canonical
|
||||
# state of the candidate we passed in.
|
||||
candidate_states = {c["number"]: c["state"] for c in candidates}
|
||||
|
||||
def filter_by_state(items, expected_state, label):
|
||||
kept, wrong = [], []
|
||||
for m in items:
|
||||
(kept if candidate_states.get(m["number"]) == expected_state else wrong).append(m)
|
||||
if wrong:
|
||||
log(f" Dropped {len(wrong)} from {label} with wrong/unknown state: {[m['number'] for m in wrong]}")
|
||||
return kept
|
||||
|
||||
likely = filter_by_state(likely, "open", "likely_duplicates")
|
||||
possible = filter_by_state(possible, "open", "possible_duplicates")
|
||||
closed = filter_by_state(closed, "closed", "related_closed_issues")
|
||||
|
||||
# Avoid showing the same issue in both the user-facing alert and the triage section.
|
||||
likely_numbers = {m["number"] for m in likely}
|
||||
overlap = [m["number"] for m in possible if m["number"] in likely_numbers]
|
||||
if overlap:
|
||||
log(f" Dropped {len(overlap)} from possible_duplicates already in likely_duplicates: {overlap}")
|
||||
possible = [m for m in possible if m["number"] not in likely_numbers]
|
||||
|
||||
log(f" Found {len(likely) + len(possible) + len(closed)} potential matches")
|
||||
return likely, possible, closed
|
||||
|
||||
|
||||
CRITIQUE_SYSTEM_PROMPT = """You are evaluating ONE recently closed GitHub issue to decide whether a triager looking
|
||||
at a brand-new bug report would find it useful to be told about that closed issue.
|
||||
|
||||
There is no slate to fill. There is no quota. You will be shown exactly one candidate.
|
||||
The default verdict is OMIT. Zero is the expected outcome for most candidates.
|
||||
|
||||
A candidate is worth surfacing ONLY if the new issue is plausibly the SAME BUG as the
|
||||
closed one — a duplicate that happens to be filed against a closed predecessor. Concretely,
|
||||
the legitimate cases are exactly three:
|
||||
|
||||
- The candidate was closed as "completed" (a fix shipped) AND the new issue has the same
|
||||
specific trigger / symptom. The triager will ask the reporter to retest.
|
||||
- The candidate was closed as "not_planned" AND the new issue is the EXACT same request
|
||||
(a feature decision the team already declined). The triager will point at it.
|
||||
- The candidate was closed as "duplicate" AND it pointed at the same canonical bug the new
|
||||
issue describes, or it shares the same specific mechanism.
|
||||
|
||||
"Same broad area", "similar-sounding symptom", or "recent attention to this subsystem" are
|
||||
NOT reasons to include. Omit them.
|
||||
|
||||
Return "omit" if ANY of the following apply (in observed practice, almost everything does):
|
||||
|
||||
1. Self-contradiction. If your reasoning includes "while focused on X rather than Y",
|
||||
"although this is about A, the new issue is about B", "this issue focuses on... rather
|
||||
than...", or any acknowledgment the candidate is on a different topic — you've already
|
||||
decided to omit.
|
||||
2. Fabricated specifics. Every concrete claim about the candidate (its trigger, scope,
|
||||
conditions) must be visible in the candidate's title or body preview. If you find
|
||||
yourself describing the candidate using details that aren't in its text, you're
|
||||
inventing details to fit the new issue. Omit.
|
||||
3. Weasel phrases. Paraphrases of "may indicate similar...", "could provide context
|
||||
for...", "shows / demonstrates recent attention to...", "indicates the team has
|
||||
considered...", "demonstrates a pattern of...", "may provide useful context..." —
|
||||
these mean you don't have a real claim. Omit.
|
||||
4. Retest by default. The "reporter may need to retest on the latest build" framing only
|
||||
applies when the closed issue's symptom is LITERALLY the same as the new issue's. "This
|
||||
was a recent fix in roughly the same area" is not enough.
|
||||
5. Same area / feature, different mechanism. Same area label but different bug, different
|
||||
code path, different trigger. Omit.
|
||||
6. Vague catch-all candidate. A closed issue like "Zed is slow" / "performance" / "agent
|
||||
panel UX" that you could cite next to many unrelated new bugs. Omit.
|
||||
7. Label or single-keyword overlap. Only connection is a shared area:* label or one shared
|
||||
keyword. Omit.
|
||||
|
||||
Output only valid JSON (no markdown code blocks):
|
||||
{
|
||||
"verdict": "include" | "omit",
|
||||
"rule_violated": null | 1 | 2 | 3 | 4 | 5 | 6 | 7,
|
||||
"rationale": "one concise sentence explaining the verdict"
|
||||
}
|
||||
|
||||
When "verdict" is "include", "rule_violated" must be null.
|
||||
When "verdict" is "omit", "rule_violated" should be the most relevant rule number, or null
|
||||
if the candidate is simply too unrelated for any rule to specifically apply."""
|
||||
|
||||
|
||||
def critique_closed_candidates(anthropic_key, issue, proposed, search_results):
|
||||
"""Run a strict per-candidate critique pass over the proposer's closed candidates.
|
||||
|
||||
For each proposed match, call Claude with only the new issue and that single candidate
|
||||
(blind to the proposer's rationale) and ask for a yes/no verdict. Default is omit.
|
||||
Returns the subset of `proposed` that passes critique.
|
||||
"""
|
||||
if not proposed:
|
||||
log(" Critique: proposer surfaced 0 closed candidates; skipping")
|
||||
return []
|
||||
|
||||
log(f" Critique: proposer surfaced {len(proposed)} closed candidate(s): "
|
||||
f"{[m['number'] for m in proposed]}")
|
||||
|
||||
results_by_number = {r["number"]: r for r in search_results}
|
||||
kept = []
|
||||
for match in proposed:
|
||||
number = match["number"]
|
||||
candidate = results_by_number.get(number)
|
||||
if candidate is None:
|
||||
# Should not happen — analyze_duplicates only emits numbers from candidates it
|
||||
# was given — but be defensive rather than crash the bot.
|
||||
log(f" Critique: dropping #{number} — candidate context not found")
|
||||
continue
|
||||
|
||||
state_reason = candidate.get("state_reason") or "unknown"
|
||||
user_content = f"""## New Issue #{issue['number']}
|
||||
**Title:** {issue['title']}
|
||||
|
||||
**Body:**
|
||||
{issue['body'][:3000]}
|
||||
|
||||
## Closed Candidate #{number}
|
||||
**Title:** {candidate.get('title', '')}
|
||||
**State reason:** {state_reason}
|
||||
|
||||
**Body preview:**
|
||||
{candidate.get('body_preview', '')}"""
|
||||
|
||||
log(f" Critique: evaluating #{number}")
|
||||
try:
|
||||
response = call_claude(anthropic_key, CRITIQUE_SYSTEM_PROMPT, user_content, max_tokens=300)
|
||||
except requests.RequestException as e:
|
||||
# If the critique call fails, prefer omitting the candidate over posting noise.
|
||||
log(f" Critique: API call failed for #{number} ({e}); omitting candidate")
|
||||
continue
|
||||
|
||||
fence = re.match(r"^\s*```(?:json)?\s*\n?(.*?)\n?```\s*$", response, re.DOTALL)
|
||||
if fence:
|
||||
response = fence.group(1)
|
||||
|
||||
try:
|
||||
verdict_data = json.loads(response)
|
||||
except json.JSONDecodeError as e:
|
||||
log(f" Critique: failed to parse verdict for #{number} ({e}); omitting candidate")
|
||||
log(f" Raw response: {response}")
|
||||
continue
|
||||
|
||||
verdict = verdict_data.get("verdict")
|
||||
rule = verdict_data.get("rule_violated")
|
||||
rationale = verdict_data.get("rationale", "")
|
||||
|
||||
if verdict == "include":
|
||||
log(f" Critique: keeping #{number} — {rationale}")
|
||||
kept.append(match)
|
||||
else:
|
||||
rule_str = f"rule {rule}" if rule else "no specific rule"
|
||||
log(f" Critique: omitting #{number} ({rule_str}) — {rationale}")
|
||||
|
||||
log(f" Critique: kept {len(kept)} of {len(proposed)} closed candidates")
|
||||
return kept
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Identify potential duplicate issues")
|
||||
parser.add_argument("issue_number", type=int, help="Issue number to analyze")
|
||||
|
|
@ -658,6 +874,13 @@ if __name__ == "__main__":
|
|||
anthropic_key, issue, relevant_magnets, search_results
|
||||
)
|
||||
|
||||
# second-pass critique: prompt iteration on the proposer hit a ceiling around 30% noise.
|
||||
# Re-evaluate each proposed closed candidate in isolation with a stricter prompt that
|
||||
# has no slate to fill and is blind to the proposer's rationale.
|
||||
related_closed_issues = critique_closed_candidates(
|
||||
anthropic_key, issue, related_closed_issues, search_results
|
||||
)
|
||||
|
||||
# resolve close reason from our search results (the source of truth) so we don't depend
|
||||
# on Claude to faithfully echo it back
|
||||
results_by_number = {r["number"]: r for r in search_results}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import functools
|
|||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
|
|
@ -47,6 +48,8 @@ BOT_START_DATE = "2026-02-18"
|
|||
NEEDS_TRIAGE_LABEL = "state:needs triage"
|
||||
DEFAULT_PROJECT_NUMBER = 76
|
||||
VALID_CLOSED_AS_VALUES = {"duplicate", "not_planned", "completed"}
|
||||
# HTTP statuses we'll retry on for GET requests
|
||||
TRANSIENT_HTTP_STATUSES = {429, 500, 502, 503, 504}
|
||||
# Add a new tuple when you deploy a new version of the bot that you want to
|
||||
# keep track of (e.g. the prompt gets a rewrite or the model gets swapped).
|
||||
# Newest first, please. The datetime is for the deployment time (merge to main).
|
||||
|
|
@ -67,10 +70,22 @@ def bot_version_for_time(date_string):
|
|||
|
||||
|
||||
def github_api_get(path, params=None):
|
||||
"""Fetch JSON from the GitHub REST API, retrying transient failures. Raises on non-2xx status."""
|
||||
url = f"{GITHUB_API}/{path.lstrip('/')}"
|
||||
response = requests.get(url, headers=GITHUB_HEADERS, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
for attempt in range(3):
|
||||
try:
|
||||
response = requests.get(url, headers=GITHUB_HEADERS, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
transient = isinstance(e, (requests.ConnectionError, requests.Timeout)) or (
|
||||
isinstance(e, requests.HTTPError) and e.response.status_code in TRANSIENT_HTTP_STATUSES
|
||||
)
|
||||
if not transient or attempt == 2:
|
||||
raise
|
||||
wait = 2 ** attempt
|
||||
print(f" Transient GitHub API error ({e}); retrying in {wait}s")
|
||||
time.sleep(wait)
|
||||
|
||||
|
||||
def github_search_issues(query):
|
||||
|
|
@ -161,9 +176,11 @@ def find_canonical_among(duplicate_number, candidates):
|
|||
if not candidates:
|
||||
return None
|
||||
|
||||
# candidate issue numbers are baked into the query body via field aliases
|
||||
# (GraphQL doesn't let you parametrize alias names), so $numbers isn't needed.
|
||||
data = github_api_graphql(
|
||||
"""
|
||||
query($owner: String!, $repo: String!, $numbers: [Int!]!) {
|
||||
query($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
PLACEHOLDER
|
||||
}
|
||||
|
|
@ -174,7 +191,7 @@ def find_canonical_among(duplicate_number, candidates):
|
|||
f' nodes {{ ... on MarkedAsDuplicateEvent {{ duplicate {{ ... on Issue {{ number }} }} }} }} }} }}'
|
||||
for number in candidates
|
||||
)),
|
||||
{"owner": REPO_OWNER, "repo": REPO_NAME, "numbers": list(candidates)},
|
||||
{"owner": REPO_OWNER, "repo": REPO_NAME},
|
||||
partial_errors_ok=True,
|
||||
)
|
||||
|
||||
|
|
@ -409,11 +426,10 @@ def classify_as_assist(issue, bot_comment):
|
|||
bot_comment_time=bot_comment["created_at"])
|
||||
return
|
||||
|
||||
original = None
|
||||
try:
|
||||
original = find_canonical_among(issue["number"], suggested)
|
||||
except (requests.RequestException, RuntimeError) as error:
|
||||
print(f" Warning: failed to query candidate timelines: {error}")
|
||||
# Let exceptions from find_canonical_among propagate — a query failure here is
|
||||
# not the same as "no canonical match" and shouldn't be silently downgraded to
|
||||
# a Needs review entry. Failing the workflow surfaces the problem immediately.
|
||||
original = find_canonical_among(issue["number"], suggested)
|
||||
|
||||
if original:
|
||||
status = "Auto-classified"
|
||||
|
|
@ -483,6 +499,8 @@ def classify_open():
|
|||
errors += 1
|
||||
|
||||
print(f" Done: added {added}, skipped {skipped}, errors {errors}")
|
||||
if errors > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ dependencies = [
|
|||
"typer>=0.15.1",
|
||||
"types-pytz>=2025.1.0.20250204",
|
||||
"types-requests>=2.32.0",
|
||||
"urllib3>=2.7.0",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.3"
|
||||
version = "2.34.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
|
|
@ -142,9 +142,9 @@ dependencies = [
|
|||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -252,6 +252,7 @@ dependencies = [
|
|||
{ name = "typer" },
|
||||
{ name = "types-pytz" },
|
||||
{ name = "types-requests" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
|
@ -263,13 +264,14 @@ requires-dist = [
|
|||
{ name = "typer", specifier = ">=0.15.1" },
|
||||
{ name = "types-pytz", specifier = ">=2025.1.0.20250204" },
|
||||
{ name = "types-requests", specifier = ">=2.32.0" },
|
||||
{ name = "urllib3", specifier = ">=2.7.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.2.3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677, upload-time = "2024-09-12T10:52:18.401Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338, upload-time = "2024-09-12T10:52:16.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue