diff --git a/.github/CODEOWNERS.hold b/.github/CODEOWNERS.hold index 410d473c1f7..0e6ab04228d 100644 --- a/.github/CODEOWNERS.hold +++ b/.github/CODEOWNERS.hold @@ -55,7 +55,6 @@ /crates/open_ai/ @zed-industries/ai-team /crates/open_router/ @zed-industries/ai-team /crates/prompt_store/ @zed-industries/ai-team -/crates/rules_library/ @zed-industries/ai-team # SUGGESTED: Review needed - based on Richard Feldman (2 commits) /crates/shell_command_parser/ @zed-industries/ai-team /crates/vercel/ @zed-industries/ai-team diff --git a/Cargo.lock b/Cargo.lock index 34657ef738a..58d81f58bd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,7 +109,6 @@ dependencies = [ "parking_lot", "portable-pty", "project", - "prompt_store", "rand 0.9.4", "sandbox", "serde", @@ -234,6 +233,7 @@ dependencies = [ "agent_settings", "agent_skills", "anyhow", + "assets", "async-channel 2.5.0", "async-io", "chrono", @@ -290,6 +290,7 @@ dependencies = [ "tempfile", "text", "theme", + "theme_settings", "thiserror 2.0.17", "ui", "unindent", @@ -404,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", @@ -514,7 +515,6 @@ dependencies = [ "remote_server", "reqwest_client", "rope", - "rules_library", "schemars 1.0.4", "search", "semver", @@ -2162,7 +2162,7 @@ dependencies = [ "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.10.5", "log", "prettyplease", "proc-macro2", @@ -2182,7 +2182,7 @@ dependencies = [ "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.10.5", "proc-macro2", "quote", "regex", @@ -5312,7 +5312,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5782,7 +5782,7 @@ dependencies = [ "client", "clock", "collections", - "convert_case 0.8.0", + "convert_case 0.11.0", "criterion", "ctor", "dap", @@ -6147,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]] @@ -7603,7 +7603,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9064,7 +9064,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -9082,7 +9082,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.56.0", ] [[package]] @@ -9336,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", ] @@ -10146,7 +10146,7 @@ dependencies = [ "cloud_api_types", "collections", "component", - "convert_case 0.8.0", + "convert_case 0.11.0", "copilot", "copilot_chat", "copilot_ui", @@ -10479,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", @@ -10589,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", @@ -10615,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", @@ -10642,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", @@ -10658,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", @@ -11364,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", @@ -11950,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]] @@ -14482,7 +14482,6 @@ dependencies = [ "db", "fs", "futures 0.3.32", - "fuzzy", "gpui", "handlebars 4.5.0", "heed", @@ -14490,7 +14489,6 @@ dependencies = [ "log", "parking_lot", "paths", - "rope", "serde", "serde_json", "strum 0.27.2", @@ -14589,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", @@ -14622,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", @@ -14884,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", @@ -14921,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]] @@ -16007,33 +16005,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad8388ea1a9e0ea807e442e8263a699e7edcb320ecbcd21b4fa8ff859acce3ba" -[[package]] -name = "rules_library" -version = "0.1.0" -dependencies = [ - "anyhow", - "collections", - "editor", - "gpui", - "language", - "language_model", - "log", - "menu", - "picker", - "platform_title_bar", - "prompt_store", - "release_channel", - "rope", - "serde", - "settings", - "theme_settings", - "ui", - "ui_input", - "util", - "workspace", - "zed_actions", -] - [[package]] name = "runtimelib" version = "1.4.0" @@ -16175,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]] @@ -18797,7 +18768,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -19514,7 +19485,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", @@ -19722,7 +19693,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]] @@ -21515,7 +21486,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", @@ -21529,7 +21500,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", @@ -21827,7 +21798,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]] diff --git a/Cargo.toml b/Cargo.toml index 57088d9e56d..883e0e04486 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -171,7 +171,6 @@ members = [ "crates/reqwest_client", "crates/rope", "crates/rpc", - "crates/rules_library", "crates/sandbox", "crates/skill_creator", "crates/scheduler", @@ -432,7 +431,6 @@ reqwest_client = { path = "crates/reqwest_client" } rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a", features = ["wav", "playback", "wav_output", "recording"] } rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } -rules_library = { path = "crates/rules_library" } skill_creator = { path = "crates/skill_creator" } scheduler = { path = "crates/scheduler" } sandbox = { path = "crates/sandbox" } @@ -561,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"] } @@ -893,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" diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index ba1ed2fcd8e..47bd6f05aca 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -380,15 +380,7 @@ "shift-backspace": "agent::ArchiveSelectedThread", }, }, - { - "context": "RulesLibrary", - "bindings": { - "new": "rules_library::NewRule", - "ctrl-n": "rules_library::NewRule", - "ctrl-shift-s": "rules_library::ToggleDefaultRule", - "ctrl-w": "workspace::CloseWindow", - }, - }, + { "context": "BufferSearchBar", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index efb2ecd01ea..dbd6d64a719 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -427,15 +427,6 @@ "backspace": "agent::ArchiveSelectedThread", }, }, - { - "context": "RulesLibrary", - "use_key_equivalents": true, - "bindings": { - "cmd-n": "rules_library::NewRule", - "cmd-shift-s": "rules_library::ToggleDefaultRule", - "cmd-w": "workspace::CloseWindow", - }, - }, { "context": "BufferSearchBar", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index ce8b775229b..1996fe19f66 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -383,15 +383,6 @@ "shift-backspace": "agent::ArchiveSelectedThread", }, }, - { - "context": "RulesLibrary", - "use_key_equivalents": true, - "bindings": { - "ctrl-n": "rules_library::NewRule", - "ctrl-shift-s": "rules_library::ToggleDefaultRule", - "ctrl-w": "workspace::CloseWindow", - }, - }, { "context": "BufferSearchBar", "use_key_equivalents": true, diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index d115b29b1f6..c22259f94b1 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -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 diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index cb96de34813..f2423858523 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -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 { diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 8c36cff81d0..d1f9877af3e 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -78,6 +78,7 @@ zed_env_vars.workspace = true zstd.workspace = true [dev-dependencies] +assets.workspace = true async-io.workspace = true agent_servers = { workspace = true, "features" = ["test-support"] } client = { workspace = true, "features" = ["test-support"] } @@ -103,6 +104,7 @@ reqwest_client.workspace = true settings = { workspace = true, "features" = ["test-support"] } theme = { workspace = true, "features" = ["test-support"] } +theme_settings.workspace = true unindent = { workspace = true } diff --git a/crates/agent/benches/edit_file_tool.rs b/crates/agent/benches/edit_file_tool.rs index 5a26d0a3d4d..7080b01200e 100644 --- a/crates/agent/benches/edit_file_tool.rs +++ b/crates/agent/benches/edit_file_tool.rs @@ -1,4 +1,5 @@ use std::{ + any::Any, future::Future, path::Path, sync::Arc, @@ -14,26 +15,40 @@ use agent_settings::{AgentSettings, ToolRules}; use criterion::{ BatchSize, BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main, }; -use futures::{pin_mut, task::noop_waker}; -use gpui::{AppContext as _, BackgroundExecutor, Entity, TestAppContext, UpdateGlobal as _}; +use editor::{Editor, EditorStyle}; +use futures::{StreamExt as _, pin_mut, task::noop_waker}; +use gpui::{ + AnyWindowHandle, AppContext as _, BackgroundExecutor, Entity, Focusable as _, TestAppContext, + UpdateGlobal as _, +}; +use language::{FakeLspAdapter, rust_lang}; use language_model::fake_provider::FakeLanguageModel; use project::{FakeFs, Project}; use prompt_store::ProjectContext; use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; use serde_json::{Value, json}; use settings::{Settings as _, SettingsStore}; +use ui::IntoElement as _; const SEED: u64 = 0x5EED_5EED; const OLD_TEXT_CHUNK_SIZE: usize = 512; const NEW_TEXT_CHUNK_SIZE: usize = 512; +const FILE_PROJECT_PATH: &str = "root/src/workspace_snapshot.rs"; +const FILE_ABS_PATH: &str = "/root/src/workspace_snapshot.rs"; + +#[derive(Clone)] +struct EditOp { + old_text: String, + new_text: String, +} + #[derive(Clone)] struct EditFixture { name: &'static str, old_file_text: String, expected_file_text: String, - old_text: String, - new_text: String, + edits: Vec, } struct BenchmarkHarness { @@ -43,6 +58,12 @@ struct BenchmarkHarness { partial_payloads: Vec, final_payload: Value, expected_file_text: String, + editor: Option>, + window: Option, + // Keeps the LSP buffer-registration handle and the fake language server alive + // for the lifetime of the benchmark so `didChange`/diagnostics keep flowing + // while edits are applied. + keep_alive: Vec>, } impl Drop for BenchmarkHarness { @@ -50,19 +71,18 @@ impl Drop for BenchmarkHarness { // Release our handles to the entities first. self.edit_tool.take(); self.thread.take(); + self.editor.take(); + self.keep_alive.clear(); - if let Some(cx) = self.cx.take() { - // `ActionLog` holds buffers strongly via `tracked_buffers`, and spawns a background - // diff-maintenance task that also captures a strong `Entity`. Releasing the - // last handle to the action log only marks its entity for deferred release; the - // entity's value (and the buffer handles inside) is not actually dropped until - // `flush_effects` runs `release_dropped_entities`. Even then, the cancelled task's - // captured handle does not drop until the executor pumps the cancellation through. - // - // Without this two-step teardown, GPUI's test leak detector panics on - // `TestAppContext` drop because the buffer still appears alive. See - // `ActionLog::track_buffer_internal` and `LeakDetector::drop` in - // `crates/gpui/src/app/entity_map.rs`. + if let Some(mut cx) = self.cx.take() { + // Close the editor window so the editor entity and the buffer handles + // it holds are released, then pump the executor so cancelled editor / + // action-log background tasks drop their captured handles before the + // leak detector runs on `TestAppContext` drop. + if let Some(window) = self.window.take() { + cx.update_window(window, |_, window, _| window.remove_window()) + .ok(); + } cx.update(|_| {}); cx.executor().run_until_parked(); cx.quit(); @@ -76,9 +96,10 @@ fn edit_file_tool_streaming(c: &mut Criterion) { group.sample_size(10); for fixture in fixtures { - group.throughput(Throughput::Bytes(fixture.new_text.len() as u64)); + let new_bytes: usize = fixture.edits.iter().map(|edit| edit.new_text.len()).sum(); + group.throughput(Throughput::Bytes(new_bytes as u64)); group.bench_with_input( - BenchmarkId::new(fixture.name, fixture.old_text.len()), + BenchmarkId::new(fixture.name, fixture.old_file_text.len()), &fixture, |bench, fixture| { bench.iter_batched( @@ -107,26 +128,168 @@ fn edit_file_tool_streaming(c: &mut Criterion) { fn setup_harness(fixture: EditFixture) -> BenchmarkHarness { let mut cx = init_context(); let executor = cx.executor(); - let (edit_tool, thread) = block_on_executor( + let parts = block_on_executor( &executor, - setup_edit_tool(&mut cx, fixture.old_file_text.clone()), + setup_editor_and_tool(&mut cx, fixture.old_file_text.clone()), ); - let partial_payloads = streamed_partial_payloads(&fixture.old_text, &fixture.new_text); + // Let the LSP handshake, initial parse, and first layout settle before timing. + cx.executor().run_until_parked(); + + let partial_payloads = streamed_partial_payloads(&fixture.edits); let final_payload = json!({ - "path": "root/src/workspace_snapshot.rs", - "edits": [{ - "old_text": fixture.old_text, - "new_text": fixture.new_text, - }], + "path": FILE_PROJECT_PATH, + "edits": fixture + .edits + .iter() + .map(|edit| json!({ "old_text": edit.old_text, "new_text": edit.new_text })) + .collect::>(), }); BenchmarkHarness { cx: Some(cx), - edit_tool: Some(edit_tool), - thread: Some(thread), + edit_tool: Some(parts.edit_tool), + thread: Some(parts.thread), partial_payloads, final_payload, expected_file_text: fixture.expected_file_text, + editor: Some(parts.editor), + window: Some(parts.window), + keep_alive: parts.keep_alive, + } +} + +struct HarnessParts { + edit_tool: Arc, + thread: Entity, + editor: Entity, + window: AnyWindowHandle, + keep_alive: Vec>, +} + +/// Builds a project + edit tool, opens the target buffer in an editor view inside +/// a window, and attaches a fake Rust language server. This mirrors the real app: +/// the edited file is open in a pane with a language server, so each buffer edit +/// drives the editor's observer cascade (matching brackets, code actions, outline, +/// bracket colorization), a tree-sitter reparse, and an LSP `didChange` + +/// diagnostics round-trip — the costs that dominate a real agent edit. +async fn setup_editor_and_tool(cx: &mut TestAppContext, file_text: String) -> HarnessParts { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "src": { + "workspace_snapshot.rs": file_text, + }, + }), + ) + .await; + + let project = Project::test(fs, [Path::new("/root")], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + text_document_sync: Some(lsp::TextDocumentSyncCapability::Kind( + lsp::TextDocumentSyncKind::INCREMENTAL, + )), + ..Default::default() + }, + ..Default::default() + }, + ); + + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let action_log: Entity = + thread.read_with(cx, |thread, _cx| thread.action_log().clone()); + let edit_tool = Arc::new(EditFileTool::new( + project.clone(), + thread.downgrade(), + action_log, + language_registry, + )); + + // Open the same buffer the tool will edit and register it with the language + // servers so edits produce `didChange` notifications. + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(FILE_ABS_PATH, cx) + }) + .await + .expect("failed to open buffer"); + let lsp_handle = project.update(cx, |project, cx| { + project.register_buffer_with_language_servers(&buffer, cx) + }); + + let fake_server = fake_servers + .next() + .await + .expect("fake language server should start"); + // Publish diagnostics on every edit, mirroring a real server reacting to + // `didChange`, so the editor's diagnostics path runs per edit. + let server = fake_server.clone(); + fake_server.handle_notification::( + move |params, _cx| { + server.notify::(lsp::PublishDiagnosticsParams { + uri: params.text_document.uri.clone(), + version: Some(params.text_document.version), + diagnostics: vec![lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)), + severity: Some(lsp::DiagnosticSeverity::WARNING), + message: "bench diagnostic".to_string(), + ..Default::default() + }], + }); + }, + ); + + // Attach an editor view in a window and lay it out once so the viewport-gated + // observers (bracket colorization, selection highlights) have a visible range. + let window = cx.add_window(|window, cx| { + let mut editor = Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx); + editor.set_style(EditorStyle::default(), window, cx); + window.focus(&editor.focus_handle(cx), cx); + editor + }); + let editor = window.root(cx).expect("window should have an editor root"); + let window: AnyWindowHandle = window.into(); + // Lay out and paint a real frame so the editor establishes a viewport (this + // is what makes the viewport-gated observers like bracket colorization run). + { + let mut visual_cx = gpui::VisualTestContext::from_window(window, &*cx); + visual_cx.draw( + gpui::point(gpui::px(0.0), gpui::px(0.0)), + gpui::size(gpui::px(1024.0), gpui::px(768.0)), + |_, _| editor.clone().into_any_element(), + ); + } + + let keep_alive: Vec> = vec![ + Box::new(lsp_handle), + Box::new(fake_server), + Box::new(fake_servers), + Box::new(buffer), + ]; + + HarnessParts { + edit_tool, + thread, + editor, + window, + keep_alive, } } @@ -135,6 +298,9 @@ fn init_context() -> TestAppContext { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); + assets::Assets.load_test_fonts(cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { store.update_user_settings(cx, |settings| { settings @@ -142,6 +308,7 @@ fn init_context() -> TestAppContext { .all_languages .defaults .ensure_final_newline_on_save = Some(false); + settings.project.all_languages.defaults.colorize_brackets = Some(true); }); }); @@ -161,48 +328,6 @@ fn init_context() -> TestAppContext { cx } -async fn setup_edit_tool( - cx: &mut TestAppContext, - file_text: String, -) -> (Arc, Entity) { - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "src": { - "workspace_snapshot.rs": file_text, - }, - }), - ) - .await; - - let project = Project::test(fs, [Path::new("/root")], cx).await; - let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let action_log: Entity = - thread.read_with(cx, |thread, _cx| thread.action_log().clone()); - - let edit_tool = Arc::new(EditFileTool::new( - project, - thread.downgrade(), - action_log, - language_registry, - )); - (edit_tool, thread) -} - fn run_streamed_edit(harness: &mut BenchmarkHarness) -> EditFileToolOutput { let (mut sender, input): (_, ToolInput) = ToolInput::test(); for payload in &harness.partial_payloads { @@ -247,33 +372,36 @@ fn block_on_executor(executor: &BackgroundExecutor, future: impl Future Vec { - let path = "root/src/workspace_snapshot.rs"; - let mut payloads = Vec::new(); +/// Builds the streamed partial payloads for a (possibly multi-edit) session, +/// mirroring how the agent reveals one edit at a time: earlier edits stay +/// complete in the array while the current edit streams its `old_text` then its +/// `new_text` in chunks. +fn streamed_partial_payloads(edits: &[EditOp]) -> Vec { + let path = FILE_PROJECT_PATH; + let mut payloads = vec![json!({ "path": path }), json!({ "path": path })]; - payloads.push(json!({ "path": path })); - payloads.push(json!({ "path": path })); + for index in 0..edits.len() { + let completed: Vec = edits[..index] + .iter() + .map(|edit| json!({ "old_text": edit.old_text, "new_text": edit.new_text })) + .collect(); + let edit = &edits[index]; - for old_end in chunk_ends(old_text, OLD_TEXT_CHUNK_SIZE) { - payloads.push(json!({ - "path": path, - "edits": [{ "old_text": &old_text[..old_end] }], - })); - } + for old_end in chunk_ends(&edit.old_text, OLD_TEXT_CHUNK_SIZE) { + let mut arr = completed.clone(); + arr.push(json!({ "old_text": &edit.old_text[..old_end] })); + payloads.push(json!({ "path": path, "edits": arr })); + } - payloads.push(json!({ - "path": path, - "edits": [{ "old_text": old_text, "new_text": "" }], - })); + let mut arr = completed.clone(); + arr.push(json!({ "old_text": edit.old_text, "new_text": "" })); + payloads.push(json!({ "path": path, "edits": arr })); - for new_end in chunk_ends(new_text, NEW_TEXT_CHUNK_SIZE) { - payloads.push(json!({ - "path": path, - "edits": [{ - "old_text": old_text, - "new_text": &new_text[..new_end], - }], - })); + for new_end in chunk_ends(&edit.new_text, NEW_TEXT_CHUNK_SIZE) { + let mut arr = completed.clone(); + arr.push(json!({ "old_text": edit.old_text, "new_text": &edit.new_text[..new_end] })); + payloads.push(json!({ "path": path, "edits": arr })); + } } payloads @@ -326,6 +454,7 @@ fn fixtures() -> Vec { EditPattern::InsertHelperBlocks { every_nth_line: 9 }, SEED + 3, ), + make_large_multi_edit_fixture("large_multi_edit", 80, 16, SEED + 4), ] } @@ -375,11 +504,106 @@ fn make_fixture( name, old_file_text, expected_file_text, - old_text, - new_text, + edits: vec![EditOp { old_text, new_text }], } } +fn make_large_multi_edit_fixture( + name: &'static str, + function_count: usize, + edit_count: usize, + seed: u64, +) -> EditFixture { + const HEADER_LINES: usize = 10; + const FUNCTION_LINES: usize = 12; + const FUNCTION_BODY_LINES: usize = 11; + + let mut rng = StdRng::seed_from_u64(seed); + let old_lines = random_rust_module(&mut rng, function_count); + let old_file_text = old_lines.join("\n"); + + let step = (function_count / edit_count).max(1); + let mut picks: Vec = (0..edit_count) + .map(|k| (k * step).min(function_count - 1)) + .collect(); + picks.dedup(); + + let replacements: Vec<(usize, Vec)> = picks + .iter() + .map(|&function_index| { + ( + function_index, + large_function_lines(&mut rng, function_index), + ) + }) + .collect(); + + let edits = replacements + .iter() + .map(|(function_index, new_function)| { + let start = HEADER_LINES + function_index * FUNCTION_LINES; + let end = start + FUNCTION_BODY_LINES; + EditOp { + old_text: old_lines[start..end].join("\n"), + new_text: new_function.join("\n"), + } + }) + .collect(); + + let mut new_lines = old_lines; + for (function_index, new_function) in replacements.iter().rev() { + let start = HEADER_LINES + function_index * FUNCTION_LINES; + let end = start + FUNCTION_BODY_LINES; + new_lines.splice(start..end, new_function.iter().cloned()); + } + let expected_file_text = new_lines.join("\n"); + + EditFixture { + name, + old_file_text, + expected_file_text, + edits, + } +} + +fn large_function_lines(rng: &mut StdRng, index: usize) -> Vec { + let function_name = identifier(rng, index + 40_000); + let argument_name = identifier(rng, index + 41_000); + + let mut lines = vec![ + format!( + " pub fn {function_name}(&mut self, {argument_name}: usize) -> Result {{" + ), + format!(" let mut accumulator = {argument_name};"), + ]; + + let body_lines = rng.random_range(30..42); + for body_index in 0..body_lines { + let local_name = identifier(rng, index + 50_000 + body_index); + let multiplier = rng.random_range(2..19); + let offset = rng.random_range(1..256); + match body_index % 4 { + 0 => lines.push(format!( + " let {local_name} = accumulator.saturating_mul({multiplier}).saturating_add({offset});" + )), + 1 => lines.push(format!( + " accumulator = {local_name}.saturating_sub(self.version % {offset}.max(1));" + )), + 2 => lines.push(format!( + " if {local_name} % {multiplier} == 0 {{ accumulator = accumulator.saturating_add({local_name}); }}" + )), + _ => lines.push(format!( + " self.buffers.insert(\"{local_name}\".to_string(), accumulator);" + )), + } + } + + lines.push(" self.version = self.version.saturating_add(accumulator);".to_string()); + lines.push(" Ok(accumulator)".to_string()); + lines.push(" }".to_string()); + lines +} + fn edit_range(lines: &[String], pattern: &EditPattern) -> std::ops::Range { let mut range = match pattern { EditPattern::LocalizedRewrite { diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 72ce5227975..16faa56c786 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -4069,63 +4069,6 @@ async fn test_send_retry_on_error(cx: &mut TestAppContext) { }); } -#[gpui::test] -async fn test_send_retry_on_http_send_error(cx: &mut TestAppContext) { - let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; - let fake_model = model.as_fake(); - - let mut events = thread - .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Hello!"], cx) - }) - .expect("thread send should start"); - cx.run_until_parked(); - - fake_model.send_last_completion_stream_error(LanguageModelCompletionError::HttpSend { - provider: LanguageModelProviderName::new("OpenAI"), - error: anyhow::anyhow!("response headers timed out after 10s"), - }); - fake_model.end_last_completion_stream(); - - cx.executor().advance_clock(BASE_RETRY_DELAY); - cx.run_until_parked(); - - fake_model.send_last_completion_stream_text_chunk("Recovered!"); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - let mut retry_events = Vec::new(); - while let Some(Ok(event)) = events.next().await { - match event { - ThreadEvent::Retry(retry_status) => { - retry_events.push(retry_status); - } - ThreadEvent::Stop(..) => break, - _ => {} - } - } - - assert_eq!(retry_events.len(), 1); - assert!(matches!( - retry_events[0], - acp_thread::RetryStatus { attempt: 1, .. } - )); - thread.read_with(cx, |thread, _cx| { - assert_eq!( - thread.to_markdown(), - indoc! {" - ## User - - Hello! - - ## Assistant - - Recovered! - "} - ) - }); -} - #[gpui::test] async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 5eb2c170a12..4a5efc2e1cc 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -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(); } diff --git a/crates/agent/src/tools/edit_session.rs b/crates/agent/src/tools/edit_session.rs index fcf3a98f678..016058318bf 100644 --- a/crates/agent/src/tools/edit_session.rs +++ b/crates/agent/src/tools/edit_session.rs @@ -620,21 +620,14 @@ impl EditPipeline { log::debug!("new_text_chunk: done=true, final_text='{}'", final_text); - if !final_text.is_empty() { - let char_ops = streaming_diff.push_new(&final_text); - apply_char_operations( - &char_ops, - buffer, - &original_snapshot, - &mut edit_cursor, - &context.action_log, - cx, - ); - } - - let remaining_ops = streaming_diff.finish(); + let mut char_ops = if final_text.is_empty() { + Vec::new() + } else { + streaming_diff.push_new(&final_text) + }; + char_ops.extend(streaming_diff.finish()); apply_char_operations( - &remaining_ops, + &char_ops, buffer, &original_snapshot, &mut edit_cursor, @@ -902,16 +895,17 @@ fn apply_char_operations( action_log: &Entity, cx: &mut AsyncApp, ) { + let mut edits: Vec<_> = Vec::new(); for op in ops { match op { CharOperation::Insert { text } => { let anchor = snapshot.anchor_after(*edit_cursor); - agent_edit_buffer(&buffer, [(anchor..anchor, text.as_str())], action_log, cx); + edits.push((anchor..anchor, text.as_str().into())); } CharOperation::Delete { bytes } => { let delete_end = *edit_cursor + bytes; let anchor_range = snapshot.anchor_range_inside(*edit_cursor..delete_end); - agent_edit_buffer(&buffer, [(anchor_range, "")], action_log, cx); + edits.push((anchor_range, Arc::::from(""))); *edit_cursor = delete_end; } CharOperation::Keep { bytes } => { @@ -919,6 +913,9 @@ fn apply_char_operations( } } } + if !edits.is_empty() { + agent_edit_buffer(buffer, edits, action_log, cx); + } } fn extract_match( diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 68b0b5faa41..acabc22a95c 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -89,7 +89,6 @@ release_channel.workspace = true remote.workspace = true remote_connection.workspace = true rope.workspace = true -rules_library.workspace = true skill_creator.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 91b17a91a79..45aa0014b1a 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -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, language_registry: Arc, thread_store: Entity, - prompt_store: Option>, connection_store: Entity, context_server_registry: Entity, configuration: Option>, @@ -1170,13 +1168,8 @@ impl AgentPanel { workspace: WeakEntity, mut cx: AsyncWindowContext, ) -> Task>> { - 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>, - _window: &mut Window, - cx: &mut Context, - ) -> Self { + pub(crate) fn new(workspace: &Workspace, _window: &mut Window, cx: &mut Context) -> 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> { - &self.prompt_store - } - pub fn thread_store(&self) -> &Entity { &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::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::(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| { - ::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" - ); - }); - } } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index fc67e12904a..ecaef031bac 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -58,7 +58,7 @@ use language_model::{ ConfiguredModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, }; use project::{AgentId, DisableAiSettings}; -use prompt_store::{PromptBuilder, rules_to_skills_migration}; +use prompt_store::{self, PromptBuilder, rules_to_skills_migration}; use rope::Point; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -550,7 +550,7 @@ pub fn init( cx: &mut App, ) { agent::ThreadStore::init_global(cx); - rules_library::init(cx); + prompt_store::init(cx); skill_creator::init(cx); if !is_eval { // Initializing the language model from the user settings messes with the eval, so we only initialize them when diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 568b94c1240..755c9629b1f 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -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, project: Entity, thread_store: Option>, - prompt_store: Option>, pub(crate) thread_id: ThreadId, pub(crate) root_session_id: Option, server_state: ServerState, @@ -738,7 +735,6 @@ impl ConversationView { workspace: WeakEntity, project: Entity, thread_store: Option>, - prompt_store: Option>, source: AgentThreadSource, window: &mut Window, cx: &mut Context, @@ -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::(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, diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index a90fef49bd9..3eec4c93ff0 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -683,7 +683,6 @@ impl ThreadView { project: WeakEntity, code_span_resolver: AgentCodeSpanResolver, thread_store: Option>, - prompt_store: Option>, initial_content: Option, mut subscriptions: Vec, 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()); } diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index 15bd9e89b57..9d2b030638f 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -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, project: WeakEntity, thread_store: Option>, - prompt_store: Option>, entries: Vec, session_capabilities: SharedSessionCapabilities, agent_id: AgentId, @@ -36,7 +34,6 @@ impl EntryViewState { workspace: WeakEntity, project: WeakEntity, thread_store: Option>, - prompt_store: Option>, 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(), ) diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index b13a9b615b6..661feabb5f6 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -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, project: WeakEntity, thread_store: Entity, - prompt_store: Option>, initial_prompt: Option, window: &mut Window, codegen_ranges: &[Range], @@ -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, project: WeakEntity, thread_store: Entity, - prompt_store: Option>, initial_prompt: Option, 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, diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 31bdf83da16..4860b8b5765 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -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 { session_id: Uuid, fs: Arc, thread_store: Entity, - prompt_store: Option>, project: WeakEntity, workspace: WeakEntity, window: &mut Window, @@ -1276,8 +1274,7 @@ impl PromptEditor { 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 { session_id: Uuid, fs: Arc, thread_store: Entity, - prompt_store: Option>, project: WeakEntity, workspace: WeakEntity, window: &mut Window, @@ -1427,8 +1423,7 @@ impl PromptEditor { 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, diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index beecb840f08..cae882e8c9f 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -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, thread_store: Option>, - prompt_store: Option>, mentions: HashMap, crease_entities: HashMap>, } impl MentionSet { - pub fn new( - project: WeakEntity, - thread_store: Option>, - prompt_store: Option>, - ) -> Self { + pub fn new(project: WeakEntity, thread_store: Option>) -> 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, - ) -> Task> { - 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, @@ -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(); diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 5b6b8671413..c6e52939d60 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -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, project: WeakEntity, thread_store: Option>, - prompt_store: Option>, 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", diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index c4db6a088da..0524fb2bd29 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -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, project: WeakEntity, thread_store: Entity, - prompt_store: Option>, initial_prompt: Option, 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, diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 80bf512147f..5b4b90e4b70 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -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) } diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 6ce10698b5f..77cfc5512ed 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -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, -) { - 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, - ); -} diff --git a/crates/audio/src/audio_pipeline/echo_canceller.rs b/crates/audio/src/audio_pipeline/echo_canceller.rs index ec612b1b448..59f3063d9fc 100644 --- a/crates/audio/src/audio_pipeline/echo_canceller.rs +++ b/crates/audio/src/audio_pipeline/echo_canceller.rs @@ -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), + ))) } } diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index a1b4fb622e7..e73809e6047 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -143,7 +143,7 @@ pub enum ChannelEvent { impl EventEmitter for ChannelStore {} -enum OpenEntityHandle { +enum OpenEntityHandle { Open(WeakEntity), Loading(Shared, Arc>>>), } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 7e03201b278..026a3032a1c 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1,15 +1,19 @@ +mod header; +mod mouse; + +#[cfg(test)] +pub(crate) use header::StickyHeader; +pub(crate) use header::{header_jump_data, render_buffer_header}; + use crate::{ - BUFFER_HEADER_PADDING, BlockId, CURSORS_VISIBLE_FOR, ChunkRendererContext, ChunkReplacement, - CodeActionSource, ColumnarMode, ConflictsOurs, ConflictsOursMarker, ConflictsOuter, - ConflictsTheirs, ConflictsTheirsMarker, ContextMenuPlacement, CursorShape, CustomBlockId, - DisplayDiffHunk, DisplayPoint, DisplayRow, EditDisplayMode, EditPrediction, Editor, EditorMode, - EditorSettings, EditorSnapshot, EditorStyle, FILE_HEADER_HEIGHT, FocusedBlock, - GutterDimensions, GutterHoverButton, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor, - InlayHintRefreshReason, JumpData, LineDown, LineHighlight, LineUp, MAX_LINE_LEN, - MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, PageDown, PageUp, - PhantomDiffReviewIndicator, Point, RowExt, RowRangeExt, SelectPhase, Selection, - SelectionDragState, SelectionEffects, SizingBehavior, SoftWrap, StickyHeaderExcerpt, ToPoint, - ToggleFold, ToggleFoldAll, + BUFFER_HEADER_PADDING, BlockId, ChunkRendererContext, ChunkReplacement, CodeActionSource, + ConflictsOurs, ConflictsOursMarker, ConflictsOuter, ConflictsTheirs, ConflictsTheirsMarker, + ContextMenuPlacement, CursorShape, CustomBlockId, DisplayDiffHunk, DisplayPoint, DisplayRow, + EditDisplayMode, EditPrediction, Editor, EditorMode, EditorSettings, EditorSnapshot, + EditorStyle, FILE_HEADER_HEIGHT, FocusedBlock, GutterDimensions, HalfPageDown, HalfPageUp, + HandleInput, HoveredCursor, InlayHintRefreshReason, LineDown, LineHighlight, LineUp, + MAX_LINE_LEN, MINIMAP_FONT_SIZE, PageDown, PageUp, Point, RowExt, RowRangeExt, Selection, + SelectionDragState, SizingBehavior, SoftWrap, ToPoint, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, column_pixels, display_map::{ @@ -17,38 +21,34 @@ use crate::{ HighlightKey, HighlightedChunk, ToDisplayPoint, }, editor_settings::{ - CurrentLineHighlight, DocumentColorsRenderMode, DoubleClickInMultibuffer, Minimap, - MinimapThumb, MinimapThumbBorder, ScrollBeyondLastLine, ScrollbarAxes, - ScrollbarDiagnostics, ShowMinimap, + CurrentLineHighlight, DocumentColorsRenderMode, Minimap, MinimapThumb, MinimapThumbBorder, + ScrollBeyondLastLine, ScrollbarAxes, ScrollbarDiagnostics, ShowMinimap, }, git::blame::{BlameRenderer, GitBlame, GlobalBlameRenderer}, hover_popover::{ self, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT, - POPOVER_RIGHT_OFFSET, hover_at, + POPOVER_RIGHT_OFFSET, }, inlay_hint_settings, - mouse_context_menu::{self, MenuPosition}, scroll::{ - ActiveScrollbarState, Autoscroll, ScrollOffset, ScrollPixelOffset, ScrollbarThumbState, + ActiveScrollbarState, ScrollOffset, ScrollPixelOffset, ScrollbarThumbState, scroll_amount::ScrollAmount, }, }; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use collections::{BTreeMap, HashMap, HashSet}; use feature_flags::{DiffReviewFeatureFlag, FeatureFlagAppExt as _}; -use file_icons::FileIcons; -use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus}; +use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage}; use gpui::{ Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle, - Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corners, CursorStyle, DispatchPhase, - Edges, Element, ElementInputHandler, Entity, Focusable as _, Font, FontId, FontWeight, - GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, Length, - Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, - MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, PressureStage, ScrollDelta, - ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, - Style, Styled, StyledText, TaskExt, TextAlign, TextRun, TextStyleRefinement, WeakEntity, - Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, - pattern_slash, point, px, quad, relative, size, solid_background, transparent_black, + Bounds, ClipboardItem, ContentMask, Context, Corners, CursorStyle, DispatchPhase, Edges, + Element, ElementInputHandler, Entity, Focusable as _, Font, FontId, FontWeight, + GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, + ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, + ParentElement, Pixels, ScrollHandle, ShapedLine, SharedString, Size, + StatefulInteractiveElement, Style, Styled, StyledText, TaskExt, TextAlign, TextRun, + TextStyleRefinement, WeakEntity, Window, div, fill, outline, pattern_slash, point, px, quad, + relative, size, solid_background, transparent_black, }; use itertools::Itertools; use language::{ @@ -57,18 +57,16 @@ use language::{ }; use markdown::Markdown; use multi_buffer::{ - Anchor, ExcerptBoundaryInfo, ExpandExcerptDirection, ExpandInfo, MultiBufferPoint, - MultiBufferRow, RowInfo, + Anchor, ExpandExcerptDirection, ExpandInfo, MultiBufferPoint, MultiBufferRow, RowInfo, }; use project::{ - DisableAiSettings, Entry, debugger::breakpoint_store::{Breakpoint, BreakpointSessionState}, project_settings::ProjectSettings, }; use settings::{ GitGutterSetting, GitHunkStyleSetting, IndentGuideBackgroundColoring, IndentGuideColoring, - RelativeLineNumbers, Settings, + Settings, }; use smallvec::{SmallVec, smallvec}; use std::{ @@ -79,26 +77,20 @@ use std::{ fmt::{self, Write}, iter, mem, ops::{Deref, Range}, - path::{self, Path}, rc::Rc, sync::Arc, - time::{Duration, Instant}, + time::Duration, }; use sum_tree::Bias; -use text::{BufferId, SelectionGoal}; +use text::BufferId; use theme::{ActiveTheme, Appearance, PlayerColor}; use theme_settings::BufferLineHeight; use ui::utils::ensure_minimum_contrast; -use ui::{ - ButtonLike, ContextMenu, Indicator, KeyBinding, POPOVER_Y_PADDING, Tooltip, prelude::*, - right_click_menu, scrollbars::ShowScrollbar, text_for_keystroke, -}; +use ui::{ButtonLike, POPOVER_Y_PADDING, Tooltip, prelude::*, scrollbars::ShowScrollbar}; use unicode_segmentation::UnicodeSegmentation; -use util::post_inc; -use util::{RangeExt, ResultExt, debug_panic}; +use util::{ResultExt, debug_panic}; use workspace::{ - CollaboratorId, ItemHandle, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel, - Workspace, + CollaboratorId, ItemHandle, Workspace, item::{Item, ItemBufferKind}, }; @@ -222,10 +214,6 @@ impl EditorElement { self.split_side = Some(side); } - fn should_show_buffer_headers(&self) -> bool { - self.split_side.is_none() - } - fn register_actions(&self, window: &mut Window, cx: &mut App) { let editor = &self.editor; editor.update(cx, |editor, cx| { @@ -713,840 +701,6 @@ impl EditorElement { }); } - fn mouse_left_down( - editor: &mut Editor, - event: &MouseDownEvent, - position_map: &PositionMap, - line_numbers: &HashMap, - window: &mut Window, - cx: &mut Context, - ) { - if window.default_prevented() { - return; - } - - let text_hitbox = &position_map.text_hitbox; - let gutter_hitbox = &position_map.gutter_hitbox; - let point_for_position = position_map.point_for_position(event.position); - let mut click_count = event.click_count; - let mut modifiers = event.modifiers; - - if let Some(hovered_hunk) = - position_map - .display_hunks - .iter() - .find_map(|(hunk, hunk_hitbox)| match hunk { - DisplayDiffHunk::Folded { .. } => None, - DisplayDiffHunk::Unfolded { - multi_buffer_range, .. - } => hunk_hitbox - .as_ref() - .is_some_and(|hitbox| hitbox.is_hovered(window)) - .then(|| multi_buffer_range.clone()), - }) - { - editor.toggle_single_diff_hunk(hovered_hunk, cx); - cx.notify(); - return; - } else if gutter_hitbox.is_hovered(window) { - click_count = 3; // Simulate triple-click when clicking the gutter to select lines - } else if !text_hitbox.is_hovered(window) { - return; - } - - if EditorSettings::get_global(cx) - .drag_and_drop_selection - .enabled - && click_count == 1 - && !modifiers.shift - { - let newest_anchor = editor.selections.newest_anchor(); - let snapshot = editor.snapshot(window, cx); - let selection = newest_anchor.map(|anchor| anchor.to_display_point(&snapshot)); - if point_for_position.intersects_selection(&selection) { - editor.selection_drag_state = SelectionDragState::ReadyToDrag { - selection: newest_anchor.clone(), - click_position: event.position, - mouse_down_time: Instant::now(), - }; - cx.stop_propagation(); - return; - } - } - - let is_singleton = editor.buffer().read(cx).is_singleton(); - - if click_count == 2 && !is_singleton { - match EditorSettings::get_global(cx).double_click_in_multibuffer { - DoubleClickInMultibuffer::Select => { - // do nothing special on double click, all selection logic is below - } - DoubleClickInMultibuffer::Open => { - if modifiers.alt { - // if double click is made with alt, pretend it's a regular double click without opening and alt, - // and run the selection logic. - modifiers.alt = false; - } else { - let scroll_position_row = position_map.scroll_position.y; - let display_row = (((event.position - gutter_hitbox.bounds.origin).y - / position_map.line_height) - as f64 - + position_map.scroll_position.y) - as u32; - let multi_buffer_row = position_map - .snapshot - .display_point_to_point( - DisplayPoint::new(DisplayRow(display_row), 0), - Bias::Right, - ) - .row; - let line_offset_from_top = display_row - scroll_position_row as u32; - // if double click is made without alt, open the corresponding excerp - editor.open_excerpts_common( - Some(JumpData::MultiBufferRow { - row: MultiBufferRow(multi_buffer_row), - line_offset_from_top, - }), - false, - window, - cx, - ); - return; - } - } - } - } - - if !is_singleton { - let display_row = (ScrollPixelOffset::from( - (event.position - gutter_hitbox.bounds.origin).y / position_map.line_height, - ) + position_map.scroll_position.y) as u32; - let multi_buffer_row = position_map - .snapshot - .display_point_to_point(DisplayPoint::new(DisplayRow(display_row), 0), Bias::Right) - .row; - if line_numbers - .get(&MultiBufferRow(multi_buffer_row)) - .is_some_and(|line_layout| { - line_layout.segments.iter().any(|segment| { - segment - .hitbox - .as_ref() - .is_some_and(|hitbox| hitbox.contains(&event.position)) - }) - }) - { - let line_offset_from_top = display_row - position_map.scroll_position.y as u32; - - editor.open_excerpts_common( - Some(JumpData::MultiBufferRow { - row: MultiBufferRow(multi_buffer_row), - line_offset_from_top, - }), - modifiers.alt, - window, - cx, - ); - cx.stop_propagation(); - return; - } - } - - let position = point_for_position.nearest_valid; - if let Some(mode) = Editor::columnar_selection_mode(&modifiers, cx) { - editor.select( - SelectPhase::BeginColumnar { - position, - reset: match mode { - ColumnarMode::FromMouse => true, - ColumnarMode::FromSelection => false, - }, - mode, - goal_column: point_for_position.exact_unclipped.column(), - }, - window, - cx, - ); - } else if modifiers.shift && !modifiers.control && !modifiers.alt && !modifiers.secondary() - { - editor.select( - SelectPhase::Extend { - position, - click_count, - }, - window, - cx, - ); - } else { - editor.select( - SelectPhase::Begin { - position, - add: Editor::is_alt_pressed(&modifiers, cx), - click_count, - }, - window, - cx, - ); - } - cx.stop_propagation(); - } - - fn mouse_right_down( - editor: &mut Editor, - event: &MouseDownEvent, - position_map: &PositionMap, - window: &mut Window, - cx: &mut Context, - ) { - if position_map.gutter_hitbox.is_hovered(window) { - let gutter_right_padding = editor.gutter_dimensions.right_padding; - let hitbox = &position_map.gutter_hitbox; - - if event.position.x <= hitbox.bounds.right() - gutter_right_padding - // Don't show the gutter_context_menu in collab notes - && editor.project.is_some() - { - let point_for_position = position_map.point_for_position(event.position); - editor.set_gutter_context_menu( - point_for_position.nearest_valid.row(), - None, - event.position, - window, - cx, - ); - } - return; - } - - if !position_map.text_hitbox.is_hovered(window) { - return; - } - - let point_for_position = position_map.point_for_position(event.position); - mouse_context_menu::deploy_context_menu( - editor, - Some(event.position), - point_for_position.nearest_valid, - window, - cx, - ); - cx.stop_propagation(); - } - - fn mouse_middle_down( - editor: &mut Editor, - event: &MouseDownEvent, - position_map: &PositionMap, - window: &mut Window, - cx: &mut Context, - ) { - if !position_map.text_hitbox.is_hovered(window) || window.default_prevented() { - return; - } - - let point_for_position = position_map.point_for_position(event.position); - let position = point_for_position.nearest_valid; - - editor.select( - SelectPhase::BeginColumnar { - position, - reset: true, - mode: ColumnarMode::FromMouse, - goal_column: point_for_position.exact_unclipped.column(), - }, - window, - cx, - ); - } - - fn mouse_up( - editor: &mut Editor, - event: &MouseUpEvent, - position_map: &PositionMap, - window: &mut Window, - cx: &mut Context, - ) { - // Handle diff review drag completion - if editor.diff_review_drag_state.is_some() { - editor.end_diff_review_drag(window, cx); - cx.stop_propagation(); - return; - } - - let text_hitbox = &position_map.text_hitbox; - let end_selection = editor.has_pending_selection(); - let pending_nonempty_selections = editor.has_pending_nonempty_selection(); - let point_for_position = position_map.point_for_position(event.position); - - match editor.selection_drag_state { - SelectionDragState::ReadyToDrag { - selection: _, - ref click_position, - mouse_down_time: _, - } => { - if event.position == *click_position { - editor.select( - SelectPhase::Begin { - position: point_for_position.nearest_valid, - add: false, - click_count: 1, // ready to drag state only occurs on click count 1 - }, - window, - cx, - ); - editor.selection_drag_state = SelectionDragState::None; - cx.stop_propagation(); - return; - } else { - debug_panic!("drag state can never be in ready state after drag") - } - } - SelectionDragState::Dragging { ref selection, .. } => { - let snapshot = editor.snapshot(window, cx); - let selection_display = selection.map(|anchor| anchor.to_display_point(&snapshot)); - if !point_for_position.intersects_selection(&selection_display) - && text_hitbox.is_hovered(window) - { - let is_cut = !(cfg!(target_os = "macos") && event.modifiers.alt - || cfg!(not(target_os = "macos")) && event.modifiers.control); - editor.move_selection_on_drop( - &selection.clone(), - point_for_position.nearest_valid, - is_cut, - window, - cx, - ); - } - editor.selection_drag_state = SelectionDragState::None; - cx.stop_propagation(); - cx.notify(); - return; - } - _ => {} - } - - if end_selection { - editor.select(SelectPhase::End, window, cx); - } - - if end_selection && pending_nonempty_selections { - cx.stop_propagation(); - } else if cfg!(any(target_os = "linux", target_os = "freebsd")) - && event.button == MouseButton::Middle - { - #[allow( - clippy::collapsible_if, - clippy::needless_return, - reason = "The cfg-block below makes this a false positive" - )] - if !text_hitbox.is_hovered(window) || editor.read_only(cx) { - return; - } - - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - if EditorSettings::get_global(cx).middle_click_paste { - if let Some(text) = cx.read_from_primary().and_then(|item| item.text()) { - let point_for_position = position_map.point_for_position(event.position); - let position = point_for_position.nearest_valid; - - editor.select( - SelectPhase::Begin { - position, - add: false, - click_count: 1, - }, - window, - cx, - ); - editor.insert(&text, window, cx); - } - cx.stop_propagation() - } - } - } - - fn click( - editor: &mut Editor, - event: &ClickEvent, - position_map: &PositionMap, - window: &mut Window, - cx: &mut Context, - ) { - let text_hitbox = &position_map.text_hitbox; - let pending_nonempty_selections = editor.has_pending_nonempty_selection(); - - let hovered_link_modifier = Editor::is_cmd_or_ctrl_pressed(&event.modifiers(), cx); - let mouse_down_hovered_link_modifier = if let ClickEvent::Mouse(mouse_event) = event { - Editor::is_cmd_or_ctrl_pressed(&mouse_event.down.modifiers, cx) - } else { - true - }; - - if let Some(mouse_position) = event.mouse_position() - && !pending_nonempty_selections - && hovered_link_modifier - && mouse_down_hovered_link_modifier - && text_hitbox.is_hovered(window) - && !matches!( - editor.selection_drag_state, - SelectionDragState::Dragging { .. } - ) - { - let point = position_map.point_for_position(mouse_position); - editor.handle_click_hovered_link(point, event.modifiers(), window, cx); - editor.selection_drag_state = SelectionDragState::None; - - cx.stop_propagation(); - } - } - - fn pressure_click( - editor: &mut Editor, - event: &MousePressureEvent, - position_map: &PositionMap, - window: &mut Window, - cx: &mut Context, - ) { - let text_hitbox = &position_map.text_hitbox; - let force_click_possible = - matches!(editor.prev_pressure_stage, Some(PressureStage::Normal)) - && event.stage == PressureStage::Force; - - editor.prev_pressure_stage = Some(event.stage); - - if force_click_possible && text_hitbox.is_hovered(window) { - let point = position_map.point_for_position(event.position); - editor.handle_click_hovered_link(point, event.modifiers, window, cx); - editor.selection_drag_state = SelectionDragState::None; - cx.stop_propagation(); - } - } - - fn mouse_dragged( - editor: &mut Editor, - event: &MouseMoveEvent, - position_map: &PositionMap, - window: &mut Window, - cx: &mut Context, - ) { - if !editor.has_pending_selection() - && matches!(editor.selection_drag_state, SelectionDragState::None) - { - return; - } - - let point_for_position = position_map.point_for_position(event.position); - let text_hitbox = &position_map.text_hitbox; - - let scroll_delta = { - let text_bounds = text_hitbox.bounds; - let mut scroll_delta = gpui::Point::::default(); - let vertical_margin = position_map.line_height.min(text_bounds.size.height / 3.0); - let top = text_bounds.origin.y + vertical_margin; - let bottom = text_bounds.bottom_left().y - vertical_margin; - if event.position.y < top { - scroll_delta.y = -scale_vertical_mouse_autoscroll_delta(top - event.position.y); - } - if event.position.y > bottom { - scroll_delta.y = scale_vertical_mouse_autoscroll_delta(event.position.y - bottom); - } - - // We need horizontal width of text - let style = editor.style.clone().unwrap_or_default(); - let font_id = window.text_system().resolve_font(&style.text.font()); - let font_size = style.text.font_size.to_pixels(window.rem_size()); - let em_width = window.text_system().em_width(font_id, font_size).unwrap(); - - let scroll_margin_x = EditorSettings::get_global(cx).horizontal_scroll_margin; - - let scroll_space: Pixels = scroll_margin_x * em_width; - - let left = text_bounds.origin.x + scroll_space; - let right = text_bounds.top_right().x - scroll_space; - - if event.position.x < left { - scroll_delta.x = -scale_horizontal_mouse_autoscroll_delta(left - event.position.x); - } - if event.position.x > right { - scroll_delta.x = scale_horizontal_mouse_autoscroll_delta(event.position.x - right); - } - scroll_delta - }; - - if !editor.has_pending_selection() { - let drop_anchor = position_map - .snapshot - .display_point_to_anchor(point_for_position.nearest_valid, Bias::Left); - match editor.selection_drag_state { - SelectionDragState::Dragging { - ref mut drop_cursor, - ref mut hide_drop_cursor, - .. - } => { - drop_cursor.start = drop_anchor; - drop_cursor.end = drop_anchor; - *hide_drop_cursor = !text_hitbox.is_hovered(window); - editor.apply_scroll_delta(scroll_delta, window, cx); - cx.notify(); - } - SelectionDragState::ReadyToDrag { - ref selection, - ref click_position, - ref mouse_down_time, - } => { - let drag_and_drop_delay = Duration::from_millis( - EditorSettings::get_global(cx) - .drag_and_drop_selection - .delay - .0, - ); - if mouse_down_time.elapsed() >= drag_and_drop_delay { - let drop_cursor = Selection { - id: post_inc(&mut editor.selections.next_selection_id()), - start: drop_anchor, - end: drop_anchor, - reversed: false, - goal: SelectionGoal::None, - }; - editor.selection_drag_state = SelectionDragState::Dragging { - selection: selection.clone(), - drop_cursor, - hide_drop_cursor: false, - }; - editor.apply_scroll_delta(scroll_delta, window, cx); - cx.notify(); - } else { - let click_point = position_map.point_for_position(*click_position); - editor.selection_drag_state = SelectionDragState::None; - editor.select( - SelectPhase::Begin { - position: click_point.nearest_valid, - add: false, - click_count: 1, - }, - window, - cx, - ); - editor.select( - SelectPhase::Update { - position: point_for_position.nearest_valid, - goal_column: point_for_position.exact_unclipped.column(), - scroll_delta, - }, - window, - cx, - ); - } - } - _ => {} - } - } else { - editor.select( - SelectPhase::Update { - position: point_for_position.nearest_valid, - goal_column: point_for_position.exact_unclipped.column(), - scroll_delta, - }, - window, - cx, - ); - } - } - - pub(crate) fn mouse_moved( - editor: &mut Editor, - event: &MouseMoveEvent, - position_map: &PositionMap, - split_side: Option, - window: &mut Window, - cx: &mut Context, - ) { - let text_hitbox = &position_map.text_hitbox; - let gutter_hitbox = &position_map.gutter_hitbox; - let modifiers = event.modifiers; - let text_hovered = text_hitbox.is_hovered(window); - let gutter_hovered = gutter_hitbox.is_hovered(window); - editor.set_gutter_hovered(gutter_hovered, cx); - - let point_for_position = position_map.point_for_position(event.position); - let valid_point = point_for_position.nearest_valid; - - // Update diff review drag state if we're dragging - if editor.diff_review_drag_state.is_some() { - editor.update_diff_review_drag(valid_point.row(), window, cx); - } - - let hovered_diff_control = position_map - .diff_hunk_control_bounds - .iter() - .find(|(_, bounds)| bounds.contains(&event.position)) - .map(|(row, _)| *row); - - let hovered_diff_hunk_row = if let Some(control_row) = hovered_diff_control { - Some(control_row) - } else if text_hovered { - let current_row = valid_point.row(); - position_map.display_hunks.iter().find_map(|(hunk, _)| { - if let DisplayDiffHunk::Unfolded { - display_row_range, .. - } = hunk - { - if display_row_range.contains(¤t_row) { - Some(display_row_range.start) - } else { - None - } - } else { - None - } - }) - } else { - None - }; - - if hovered_diff_hunk_row != editor.hovered_diff_hunk_row { - editor.hovered_diff_hunk_row = hovered_diff_hunk_row; - cx.notify(); - } - - if text_hovered - && let Some((bounds, buffer_id, blame_entry)) = &position_map.inline_blame_bounds - { - let mouse_over_inline_blame = bounds.contains(&event.position); - let mouse_over_popover = editor - .inline_blame_popover - .as_ref() - .and_then(|state| state.popover_bounds) - .is_some_and(|bounds| bounds.contains(&event.position)); - let keyboard_grace = editor - .inline_blame_popover - .as_ref() - .is_some_and(|state| state.keyboard_grace); - - if mouse_over_inline_blame || mouse_over_popover { - editor.show_blame_popover(*buffer_id, blame_entry, event.position, false, cx); - } else if !keyboard_grace { - editor.hide_blame_popover(false, cx); - } - } else { - let keyboard_grace = editor - .inline_blame_popover - .as_ref() - .is_some_and(|state| state.keyboard_grace); - if !keyboard_grace { - editor.hide_blame_popover(false, cx); - } - } - - // Handle diff review indicator when gutter is hovered in diff mode with AI enabled - let show_diff_review = editor.show_diff_review_button() - && cx.has_flag::() - && !DisableAiSettings::is_ai_disabled_for_buffer( - editor.buffer.read(cx).as_singleton().as_ref(), - cx, - ); - - let diff_review_indicator = if gutter_hovered && show_diff_review { - let is_visible = editor - .gutter_diff_review_indicator - .0 - .is_some_and(|indicator| indicator.is_active); - - if !is_visible { - editor - .gutter_diff_review_indicator - .1 - .get_or_insert_with(|| { - cx.spawn(async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(200)) - .await; - - this.update(cx, |this, cx| { - if let Some(indicator) = - this.gutter_diff_review_indicator.0.as_mut() - { - indicator.is_active = true; - cx.notify(); - } - }) - .ok(); - }) - }); - } - - let anchor = position_map - .snapshot - .display_point_to_anchor(valid_point, Bias::Left); - Some(PhantomDiffReviewIndicator { - start: anchor, - end: anchor, - is_active: is_visible, - }) - } else { - editor.gutter_diff_review_indicator.1 = None; - None - }; - - if diff_review_indicator != editor.gutter_diff_review_indicator.0 { - editor.gutter_diff_review_indicator.0 = diff_review_indicator; - cx.notify(); - } - - // Don't show breakpoint indicator when diff review indicator is active on this row - let is_on_diff_review_button_row = diff_review_indicator.is_some_and(|indicator| { - let start_row = indicator - .start - .to_display_point(&position_map.snapshot.display_snapshot) - .row(); - indicator.is_active && start_row == valid_point.row() - }); - - let gutter_hover_button = if gutter_hovered - && !is_on_diff_review_button_row - && split_side != Some(SplitSide::Left) - { - let buffer_anchor = position_map - .snapshot - .display_point_to_anchor(valid_point, Bias::Left); - - if position_map - .snapshot - .buffer_snapshot() - .anchor_to_buffer_anchor(buffer_anchor) - .is_some() - { - let is_visible = editor - .gutter_hover_button - .0 - .is_some_and(|indicator| indicator.is_active); - - if !is_visible { - editor.gutter_hover_button.1.get_or_insert_with(|| { - cx.spawn(async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(200)) - .await; - - this.update(cx, |this, cx| { - if let Some(indicator) = this.gutter_hover_button.0.as_mut() { - indicator.is_active = true; - cx.notify(); - } - }) - .ok(); - }) - }); - } - - Some(GutterHoverButton { - display_row: valid_point.row(), - is_active: is_visible, - }) - } else { - editor.gutter_hover_button.1 = None; - None - } - } else if editor.has_mouse_context_menu() { - editor.gutter_hover_button.1 = None; - editor.gutter_hover_button.0 - } else { - editor.gutter_hover_button.1 = None; - None - }; - - if &gutter_hover_button != &editor.gutter_hover_button.0 { - editor.gutter_hover_button.0 = gutter_hover_button; - cx.notify(); - } - - // Don't trigger hover popover if mouse is hovering over context menu - if text_hovered { - editor.update_hovered_link( - point_for_position, - Some(event.position), - &position_map.snapshot, - modifiers, - window, - cx, - ); - - if let Some(point) = point_for_position.as_valid() { - let anchor = position_map - .snapshot - .buffer_snapshot() - .anchor_before(point.to_offset(&position_map.snapshot, Bias::Left)); - hover_at(editor, Some(anchor), Some(event.position), window, cx); - Self::update_visible_cursor(editor, point, position_map, window, cx); - } else { - editor.update_inlay_link_and_hover_points( - &position_map.snapshot, - point_for_position, - Some(event.position), - modifiers.secondary(), - modifiers.shift, - window, - cx, - ); - } - } else { - editor.hide_hovered_link(cx); - hover_at(editor, None, Some(event.position), window, cx); - } - } - - fn update_visible_cursor( - editor: &mut Editor, - point: DisplayPoint, - position_map: &PositionMap, - window: &mut Window, - cx: &mut Context, - ) { - let snapshot = &position_map.snapshot; - let Some(hub) = editor.collaboration_hub() else { - return; - }; - let start = snapshot.display_snapshot.clip_point( - DisplayPoint::new(point.row(), point.column().saturating_sub(1)), - Bias::Left, - ); - let end = snapshot.display_snapshot.clip_point( - DisplayPoint::new( - point.row(), - (point.column() + 1).min(snapshot.line_len(point.row())), - ), - Bias::Right, - ); - - let range = snapshot - .buffer_snapshot() - .anchor_before(start.to_point(&snapshot.display_snapshot)) - ..snapshot - .buffer_snapshot() - .anchor_after(end.to_point(&snapshot.display_snapshot)); - - let Some(selection) = snapshot.remote_selections_in_range(&range, hub, cx).next() else { - return; - }; - let key = crate::HoveredCursor { - replica_id: selection.replica_id, - selection_id: selection.selection.id, - }; - editor.hovered_cursors.insert( - key.clone(), - cx.spawn_in(window, async move |editor, cx| { - cx.background_executor().timer(CURSORS_VISIBLE_FOR).await; - editor - .update(cx, |editor, cx| { - editor.hovered_cursors.remove(&key); - cx.notify(); - }) - .ok(); - }), - ); - cx.notify() - } - fn layout_selections( &self, start_anchor: Anchor, @@ -4047,14 +3201,15 @@ impl EditorElement { if self.should_show_buffer_headers() { let selected = selected_buffer_ids.contains(&first_excerpt.buffer_id()); - let jump_data = header_jump_data( + let jump_data = header::header_jump_data( snapshot, block_row_start, *height, first_excerpt, latest_selection_anchors, ); - result = result.child(self.render_buffer_header( + result = result.child(header::render_buffer_header( + &self.editor, first_excerpt, true, selected, @@ -4093,7 +3248,7 @@ impl EditorElement { let mut result = v_flex().id(block_id).w_full(); if self.should_show_buffer_headers() { - let jump_data = header_jump_data( + let jump_data = header::header_jump_data( snapshot, block_row_start, *height, @@ -4105,8 +3260,15 @@ impl EditorElement { let selected = selected_buffer_ids.contains(&excerpt.buffer_id()); result = result.child(div().pr(editor_margins.right).child( - self.render_buffer_header( - excerpt, false, selected, false, jump_data, window, cx, + header::render_buffer_header( + &self.editor, + excerpt, + false, + selected, + false, + jump_data, + window, + cx, ), )); } else { @@ -4261,28 +3423,6 @@ impl EditorElement { .into_any() } - fn render_buffer_header( - &self, - for_excerpt: &ExcerptBoundaryInfo, - is_folded: bool, - is_selected: bool, - is_sticky: bool, - jump_data: JumpData, - window: &mut Window, - cx: &mut App, - ) -> impl IntoElement { - render_buffer_header( - &self.editor, - for_excerpt, - is_folded, - is_selected, - is_sticky, - jump_data, - window, - cx, - ) - } - fn render_blocks( &self, rows: Range, @@ -4569,233 +3709,6 @@ impl EditorElement { } } - fn layout_sticky_buffer_header( - &self, - StickyHeaderExcerpt { excerpt }: StickyHeaderExcerpt<'_>, - scroll_position: gpui::Point, - line_height: Pixels, - right_margin: Pixels, - snapshot: &EditorSnapshot, - hitbox: &Hitbox, - selected_buffer_ids: &Vec, - blocks: &[BlockLayout], - latest_selection_anchors: &HashMap, - window: &mut Window, - cx: &mut App, - ) -> AnyElement { - let jump_data = header_jump_data( - snapshot, - DisplayRow(scroll_position.y as u32), - FILE_HEADER_HEIGHT + MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, - excerpt, - latest_selection_anchors, - ); - - let editor_bg_color = cx.theme().colors().editor_background; - - let selected = selected_buffer_ids.contains(&excerpt.buffer_id()); - - let available_width = hitbox.bounds.size.width - right_margin; - - let mut header = v_flex() - .w_full() - .relative() - .child( - div() - .w(available_width) - .h(FILE_HEADER_HEIGHT as f32 * line_height) - .bg(linear_gradient( - 0., - linear_color_stop(editor_bg_color.opacity(0.), 0.), - linear_color_stop(editor_bg_color, 0.6), - )) - .absolute() - .top_0(), - ) - .child( - self.render_buffer_header(excerpt, false, selected, true, jump_data, window, cx) - .into_any_element(), - ) - .into_any_element(); - - let mut origin = hitbox.origin; - // Move floating header up to avoid colliding with the next buffer header. - for block in blocks.iter() { - if !block.is_buffer_header { - continue; - } - - let Some(display_row) = block.row.filter(|row| row.0 > scroll_position.y as u32) else { - continue; - }; - - let max_row = display_row.0.saturating_sub(FILE_HEADER_HEIGHT); - let offset = scroll_position.y - max_row as f64; - - if offset > 0.0 { - origin.y -= Pixels::from(offset * ScrollPixelOffset::from(line_height)); - } - break; - } - - let size = size( - AvailableSpace::Definite(available_width), - AvailableSpace::MinContent, - ); - - header.prepaint_as_root(origin, size, window, cx); - - header - } - - fn layout_sticky_headers( - &self, - snapshot: &EditorSnapshot, - editor_width: Pixels, - is_row_soft_wrapped: impl Copy + Fn(usize) -> bool, - line_height: Pixels, - scroll_pixel_position: gpui::Point, - content_origin: gpui::Point, - gutter_dimensions: &GutterDimensions, - gutter_hitbox: &Hitbox, - text_hitbox: &Hitbox, - relative_line_numbers: RelativeLineNumbers, - relative_to: Option, - window: &mut Window, - cx: &mut App, - ) -> Option { - let show_line_numbers = snapshot - .show_line_numbers - .unwrap_or_else(|| EditorSettings::get_global(cx).gutter.line_numbers); - - let rows = Self::sticky_headers(self.editor.read(cx), snapshot); - - let mut lines = Vec::::new(); - - for StickyHeader { - sticky_row, - start_point, - offset, - } in rows.into_iter().rev() - { - let line = layout_line( - sticky_row, - snapshot, - &self.style, - editor_width, - is_row_soft_wrapped, - window, - cx, - ); - - let line_number = show_line_numbers.then(|| { - let start_display_row = start_point.to_display_point(snapshot).row(); - let relative_number = relative_to - .filter(|_| relative_line_numbers != RelativeLineNumbers::Disabled) - .map(|base| { - snapshot.relative_line_delta( - base, - start_display_row, - relative_line_numbers == RelativeLineNumbers::Wrapped, - ) - }); - let number = relative_number - .filter(|&delta| delta != 0) - .map(|delta| delta.unsigned_abs() as u32) - .unwrap_or(start_point.row + 1); - let color = cx.theme().colors().editor_line_number; - self.shape_line_number(SharedString::from(number.to_string()), color, window) - }); - - lines.push(StickyHeaderLine::new( - sticky_row, - line_height * offset as f32, - line, - line_number, - line_height, - scroll_pixel_position, - content_origin, - gutter_hitbox, - text_hitbox, - window, - cx, - )); - } - - lines.reverse(); - if lines.is_empty() { - return None; - } - - Some(StickyHeaders { - lines, - gutter_background: cx.theme().colors().editor_gutter_background, - content_background: self.style.background, - gutter_right_padding: gutter_dimensions.right_padding, - }) - } - - pub(crate) fn sticky_headers(editor: &Editor, snapshot: &EditorSnapshot) -> Vec { - let scroll_top = snapshot.scroll_position().y; - - let mut end_rows = Vec::::new(); - let mut rows = Vec::::new(); - - for item in editor.sticky_headers.iter().flatten() { - let start_point = item - .source_range_for_text - .start - .to_point(snapshot.buffer_snapshot()); - let end_point = item.range.end.to_point(snapshot.buffer_snapshot()); - - let sticky_row = snapshot - .display_snapshot - .point_to_display_point(start_point, Bias::Left) - .row(); - if rows - .last() - .is_some_and(|last| last.sticky_row == sticky_row) - { - continue; - } - - let end_row = snapshot - .display_snapshot - .point_to_display_point(end_point, Bias::Left) - .row(); - let max_sticky_row = end_row.previous_row(); - if max_sticky_row <= sticky_row { - continue; - } - - while end_rows - .last() - .is_some_and(|&last_end| last_end <= sticky_row) - { - end_rows.pop(); - } - let depth = end_rows.len(); - let adjusted_scroll_top = scroll_top + depth as f64; - - if sticky_row.as_f64() >= adjusted_scroll_top || end_row.as_f64() <= adjusted_scroll_top - { - continue; - } - - let max_scroll_offset = max_sticky_row.as_f64() - scroll_top; - let offset = (depth as f64).min(max_scroll_offset); - - end_rows.push(end_row); - rows.push(StickyHeader { - sticky_row, - start_point, - offset, - }); - } - - rows - } - fn layout_cursor_popovers( &self, line_height: Pixels, @@ -5327,83 +4240,6 @@ impl EditorElement { } } - fn layout_mouse_context_menu( - &self, - editor_snapshot: &EditorSnapshot, - visible_range: Range, - content_origin: gpui::Point, - window: &mut Window, - cx: &mut App, - ) -> Option { - let position = self.editor.update(cx, |editor, cx| { - let visible_start_point = editor.display_to_pixel_point( - DisplayPoint::new(visible_range.start, 0), - editor_snapshot, - window, - cx, - )?; - let visible_end_point = editor.display_to_pixel_point( - DisplayPoint::new(visible_range.end, 0), - editor_snapshot, - window, - cx, - )?; - - let mouse_context_menu = editor.mouse_context_menu.as_ref()?; - let (source_display_point, position) = match mouse_context_menu.position { - MenuPosition::PinnedToScreen(point) => (None, point), - MenuPosition::PinnedToEditor { source, offset } => { - let source_display_point = source.to_display_point(editor_snapshot); - let source_point = - editor.to_pixel_point(source, editor_snapshot, window, cx)?; - let position = content_origin + source_point + offset; - (Some(source_display_point), position) - } - }; - - let source_included = source_display_point.is_none_or(|source_display_point| { - visible_range - .to_inclusive() - .contains(&source_display_point.row()) - }); - let position_included = - visible_start_point.y <= position.y && position.y <= visible_end_point.y; - if !source_included && !position_included { - None - } else { - Some(position) - } - })?; - - let text_style = TextStyleRefinement { - line_height: Some(DefiniteLength::Fraction( - BufferLineHeight::Comfortable.value(), - )), - ..Default::default() - }; - window.with_text_style(Some(text_style), |window| { - let mut element = self.editor.read_with(cx, |editor, _| { - let mouse_context_menu = editor.mouse_context_menu.as_ref()?; - let context_menu = mouse_context_menu.context_menu.clone(); - - Some( - deferred( - anchored() - .position(position) - .child(context_menu) - .anchor(gpui::Anchor::TopLeft) - .snap_to_window_with_margin(px(8.)), - ) - .with_priority(1) - .into_any(), - ) - })?; - - element.prepaint_as_root(position, AvailableSpace::min_size(), window, cx); - Some(element) - }) - } - fn layout_hover_popovers( &self, snapshot: &EditorSnapshot, @@ -6742,108 +5578,6 @@ impl EditorElement { } } - fn paint_sticky_headers( - &mut self, - layout: &mut EditorLayout, - window: &mut Window, - cx: &mut App, - ) { - let Some(mut sticky_headers) = layout.sticky_headers.take() else { - return; - }; - - if sticky_headers.lines.is_empty() { - layout.sticky_headers = Some(sticky_headers); - return; - } - - let whitespace_setting = self - .editor - .read(cx) - .buffer - .read(cx) - .language_settings(cx) - .show_whitespaces; - sticky_headers.paint(layout, whitespace_setting, window, cx); - - let sticky_header_hitboxes: Vec = sticky_headers - .lines - .iter() - .map(|line| line.hitbox.clone()) - .collect(); - let hovered_hitbox = sticky_header_hitboxes - .iter() - .find_map(|hitbox| hitbox.is_hovered(window).then_some(hitbox.id)); - - window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, _cx| { - if !phase.bubble() { - return; - } - - let current_hover = sticky_header_hitboxes - .iter() - .find_map(|hitbox| hitbox.is_hovered(window).then_some(hitbox.id)); - if hovered_hitbox != current_hover { - window.refresh(); - } - }); - - let position_map = layout.position_map.clone(); - - for (line_index, line) in sticky_headers.lines.iter().enumerate() { - let editor = self.editor.clone(); - let hitbox = line.hitbox.clone(); - let row = line.row; - let line_layout = line.line.clone(); - let position_map = position_map.clone(); - window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { - if !phase.bubble() { - return; - } - - if event.button == MouseButton::Left && hitbox.is_hovered(window) { - let point_for_position = - position_map.point_for_position_on_line(event.position, row, &line_layout); - - editor.update(cx, |editor, cx| { - let snapshot = editor.snapshot(window, cx); - let anchor = snapshot - .display_snapshot - .display_point_to_anchor(point_for_position.nearest_valid, Bias::Left); - editor.change_selections( - SelectionEffects::scroll(Autoscroll::top_relative( - line_index as ScrollOffset, - )), - window, - cx, - |selections| { - selections.clear_disjoint(); - selections.set_pending_anchor_range( - anchor..anchor, - crate::SelectMode::Character, - ); - }, - ); - cx.stop_propagation(); - }); - } - }); - } - - let text_bounds = layout.position_map.text_hitbox.bounds; - let border_top = text_bounds.top() - + sticky_headers.lines.last().unwrap().offset - + layout.position_map.line_height; - let separator_height = px(1.); - let border_bounds = window.pixel_snap_bounds(Bounds::from_corners( - point(layout.gutter_hitbox.bounds.left(), border_top), - point(text_bounds.right(), border_top + separator_height), - )); - window.paint_quad(fill(border_bounds, cx.theme().colors().border_variant)); - - layout.sticky_headers = Some(sticky_headers); - } - fn paint_lines_background( &mut self, layout: &mut EditorLayout, @@ -7738,237 +6472,6 @@ impl EditorElement { } } - fn paint_scroll_wheel_listener( - &mut self, - layout: &EditorLayout, - window: &mut Window, - cx: &mut App, - ) { - window.on_mouse_event({ - let position_map = layout.position_map.clone(); - let editor = self.editor.clone(); - let hitbox = layout.hitbox.clone(); - let mut delta = ScrollDelta::default(); - - // Set a minimum scroll_sensitivity of 0.01 to make sure the user doesn't - // accidentally turn off their scrolling. - let base_scroll_sensitivity = - EditorSettings::get_global(cx).scroll_sensitivity.max(0.01); - - // Use a minimum fast_scroll_sensitivity for same reason above - let fast_scroll_sensitivity = EditorSettings::get_global(cx) - .fast_scroll_sensitivity - .max(0.01); - - move |event: &ScrollWheelEvent, phase, window, cx| { - if phase == DispatchPhase::Bubble && hitbox.should_handle_scroll(window) { - delta = delta.coalesce(event.delta); - - if event.modifiers.secondary() - && editor.read(cx).enable_mouse_wheel_zoom - && EditorSettings::get_global(cx).mouse_wheel_zoom - { - let delta_y = match event.delta { - ScrollDelta::Pixels(pixels) => pixels.y.into(), - ScrollDelta::Lines(lines) => lines.y, - }; - - if delta_y > 0.0 { - theme_settings::increase_buffer_font_size(cx); - } else if delta_y < 0.0 { - theme_settings::decrease_buffer_font_size(cx); - } - - cx.stop_propagation(); - } else { - let scroll_sensitivity = { - if event.modifiers.alt { - fast_scroll_sensitivity - } else { - base_scroll_sensitivity - } - }; - - editor.update(cx, |editor, cx| { - let line_height = position_map.line_height; - let glyph_width = position_map.em_layout_width; - let (delta, axis) = match delta { - gpui::ScrollDelta::Pixels(mut pixels) => { - //Trackpad - let axis = - position_map.snapshot.ongoing_scroll.filter(&mut pixels); - (pixels, axis) - } - - gpui::ScrollDelta::Lines(lines) => { - //Not trackpad - let pixels = - point(lines.x * glyph_width, lines.y * line_height); - (pixels, None) - } - }; - - let current_scroll_position = position_map.snapshot.scroll_position(); - let x = (current_scroll_position.x - * ScrollPixelOffset::from(glyph_width) - - ScrollPixelOffset::from(delta.x * scroll_sensitivity)) - / ScrollPixelOffset::from(glyph_width); - let y = (current_scroll_position.y - * ScrollPixelOffset::from(line_height) - - ScrollPixelOffset::from(delta.y * scroll_sensitivity)) - / ScrollPixelOffset::from(line_height); - let mut scroll_position = - point(x, y).clamp(&point(0., 0.), &position_map.scroll_max); - let forbid_vertical_scroll = - editor.scroll_manager.forbid_vertical_scroll(); - if forbid_vertical_scroll { - scroll_position.y = current_scroll_position.y; - } - - if scroll_position != current_scroll_position { - editor.scroll(scroll_position, axis, window, cx); - cx.stop_propagation(); - } else if y < 0. { - // Due to clamping, we may fail to detect cases of overscroll to the top; - // We want the scroll manager to get an update in such cases and detect the change of direction - // on the next frame. - cx.notify(); - } - }); - } - } - } - }); - } - - fn paint_mouse_listeners(&mut self, layout: &EditorLayout, window: &mut Window, cx: &mut App) { - if layout.mode.is_minimap() { - return; - } - - self.paint_scroll_wheel_listener(layout, window, cx); - - window.on_mouse_event({ - let position_map = layout.position_map.clone(); - let editor = self.editor.clone(); - let line_numbers = layout.line_numbers.clone(); - - move |event: &MouseDownEvent, phase, window, cx| { - if phase == DispatchPhase::Bubble { - match event.button { - MouseButton::Left => editor.update(cx, |editor, cx| { - let pending_mouse_down = editor - .pending_mouse_down - .get_or_insert_with(Default::default) - .clone(); - - *pending_mouse_down.borrow_mut() = Some(event.clone()); - - Self::mouse_left_down( - editor, - event, - &position_map, - line_numbers.as_ref(), - window, - cx, - ); - }), - MouseButton::Right => editor.update(cx, |editor, cx| { - Self::mouse_right_down(editor, event, &position_map, window, cx); - }), - MouseButton::Middle => editor.update(cx, |editor, cx| { - Self::mouse_middle_down(editor, event, &position_map, window, cx); - }), - _ => {} - }; - } - } - }); - - window.on_mouse_event({ - let editor = self.editor.clone(); - let position_map = layout.position_map.clone(); - - move |event: &MouseUpEvent, phase, window, cx| { - if phase == DispatchPhase::Bubble { - editor.update(cx, |editor, cx| { - Self::mouse_up(editor, event, &position_map, window, cx) - }); - } - } - }); - - window.on_mouse_event({ - let editor = self.editor.clone(); - let position_map = layout.position_map.clone(); - let mut captured_mouse_down = None; - - move |event: &MouseUpEvent, phase, window, cx| match phase { - // Clear the pending mouse down during the capture phase, - // so that it happens even if another event handler stops - // propagation. - DispatchPhase::Capture => editor.update(cx, |editor, _cx| { - let pending_mouse_down = editor - .pending_mouse_down - .get_or_insert_with(Default::default) - .clone(); - - let mut pending_mouse_down = pending_mouse_down.borrow_mut(); - if pending_mouse_down.is_some() && position_map.text_hitbox.is_hovered(window) { - captured_mouse_down = pending_mouse_down.take(); - window.refresh(); - } - }), - // Fire click handlers during the bubble phase. - DispatchPhase::Bubble => editor.update(cx, |editor, cx| { - if let Some(mouse_down) = captured_mouse_down.take() { - let event = ClickEvent::Mouse(MouseClickEvent { - down: mouse_down, - up: event.clone(), - }); - Self::click(editor, &event, &position_map, window, cx); - } - }), - } - }); - - window.on_mouse_event({ - let position_map = layout.position_map.clone(); - let editor = self.editor.clone(); - - move |event: &MousePressureEvent, phase, window, cx| { - if phase == DispatchPhase::Bubble { - editor.update(cx, |editor, cx| { - Self::pressure_click(editor, &event, &position_map, window, cx); - }) - } - } - }); - - window.on_mouse_event({ - let position_map = layout.position_map.clone(); - let editor = self.editor.clone(); - let split_side = self.split_side; - - move |event: &MouseMoveEvent, phase, window, cx| { - if phase == DispatchPhase::Bubble { - editor.update(cx, |editor, cx| { - if editor.hover_state.focused(window, cx) { - return; - } - if event.pressed_button == Some(MouseButton::Left) - || event.pressed_button == Some(MouseButton::Middle) - { - Self::mouse_dragged(editor, event, &position_map, window, cx) - } - - Self::mouse_moved(editor, event, &position_map, split_side, window, cx) - }); - } - } - }); - } - fn shape_line_number( &self, text: SharedString, @@ -8341,511 +6844,6 @@ fn apply_dirty_filename_style( ) } -fn file_status_label_color(file_status: Option) -> Color { - file_status.map_or(Color::Default, |status| { - if status.is_conflicted() { - Color::Conflict - } else if status.is_modified() { - Color::Modified - } else if status.is_deleted() { - Color::Disabled - } else if status.is_created() { - Color::Created - } else { - Color::Default - } - }) -} - -pub(crate) fn header_jump_data( - editor_snapshot: &EditorSnapshot, - block_row_start: DisplayRow, - height: u32, - first_excerpt: &ExcerptBoundaryInfo, - latest_selection_anchors: &HashMap, -) -> JumpData { - let multibuffer_snapshot = editor_snapshot.buffer_snapshot(); - let buffer = first_excerpt.buffer(multibuffer_snapshot); - let (jump_anchor, jump_buffer, excerpt_start) = if let Some(anchor) = - latest_selection_anchors.get(&first_excerpt.buffer_id()) - && let Some((jump_anchor, selection_buffer)) = - multibuffer_snapshot.anchor_to_buffer_anchor(*anchor) - { - let jump_offset = text::ToOffset::to_offset(&jump_anchor, selection_buffer); - let selection_excerpt_start = multibuffer_snapshot - .excerpts_for_buffer(jump_anchor.buffer_id) - .find(|excerpt| { - let start = text::ToOffset::to_offset(&excerpt.context.start, selection_buffer); - let end = text::ToOffset::to_offset(&excerpt.context.end, selection_buffer); - start <= jump_offset && jump_offset <= end - }) - .map(|excerpt| excerpt.context.start) - .unwrap_or(first_excerpt.range.context.start); - (jump_anchor, selection_buffer, selection_excerpt_start) - } else { - ( - first_excerpt.range.primary.start, - buffer, - first_excerpt.range.context.start, - ) - }; - let jump_position = language::ToPoint::to_point(&jump_anchor, jump_buffer); - let rows_from_excerpt_start = if jump_anchor == excerpt_start { - 0 - } else { - let excerpt_start_point = language::ToPoint::to_point(&excerpt_start, jump_buffer); - jump_position.row.saturating_sub(excerpt_start_point.row) - }; - - let line_offset_from_top = (block_row_start.0 + height + rows_from_excerpt_start) - .saturating_sub( - editor_snapshot - .scroll_anchor - .scroll_position(&editor_snapshot.display_snapshot) - .y as u32, - ); - - JumpData::MultiBufferPoint { - anchor: jump_anchor, - position: jump_position, - line_offset_from_top, - } -} - -pub(crate) fn render_buffer_header( - editor: &Entity, - for_excerpt: &ExcerptBoundaryInfo, - is_folded: bool, - is_selected: bool, - is_sticky: bool, - jump_data: JumpData, - window: &mut Window, - cx: &mut App, -) -> impl IntoElement { - let editor_read = editor.read(cx); - let multi_buffer = editor_read.buffer.read(cx); - let is_read_only = editor_read.read_only(cx); - let editor_handle: &dyn ItemHandle = editor; - let multibuffer_snapshot = multi_buffer.snapshot(cx); - let buffer = for_excerpt.buffer(&multibuffer_snapshot); - - let breadcrumbs = if is_selected { - editor_read.breadcrumbs_inner(cx) - } else { - None - }; - - let buffer_id = for_excerpt.buffer_id(); - let file_status = multi_buffer - .all_diff_hunks_expanded() - .then(|| editor_read.status_for_buffer_id(buffer_id, cx)) - .flatten(); - let indicator = multi_buffer.buffer(buffer_id).and_then(|buffer| { - let buffer = buffer.read(cx); - let indicator_color = match (buffer.has_conflict(), buffer.is_dirty()) { - (true, _) => Some(Color::Warning), - (_, true) => Some(Color::Accent), - (false, false) => None, - }; - indicator_color.map(|indicator_color| Indicator::dot().color(indicator_color)) - }); - - let include_root = editor_read - .project - .as_ref() - .map(|project| project.read(cx).visible_worktrees(cx).count() > 1) - .unwrap_or_default(); - let file = buffer.file(); - let can_open_excerpts = file.is_none_or(|file| file.can_open()); - let path_style = file.map(|file| file.path_style(cx)); - let relative_path = buffer.resolve_file_path(include_root, cx); - let (parent_path, filename) = if let Some(path) = &relative_path { - if let Some(path_style) = path_style { - let (dir, file_name) = path_style.split(path); - (dir.map(|dir| dir.to_owned()), Some(file_name.to_owned())) - } else { - (None, Some(path.clone())) - } - } else { - (None, None) - }; - let focus_handle = editor_read.focus_handle(cx); - let colors = cx.theme().colors(); - - let header = div() - .id(("buffer-header", buffer_id.to_proto())) - .p(BUFFER_HEADER_PADDING) - .w_full() - .h(FILE_HEADER_HEIGHT as f32 * window.line_height()) - .child( - h_flex() - .group("buffer-header-group") - .size_full() - .flex_basis(Length::Definite(DefiniteLength::Fraction(0.667))) - .pl_1() - .pr_2() - .rounded_sm() - .gap_1p5() - .when(is_sticky, |el| el.shadow_md()) - .border_1() - .map(|border| { - let border_color = - if is_selected && is_folded && focus_handle.contains_focused(window, cx) { - colors.border_focused - } else { - colors.border - }; - border.border_color(border_color) - }) - .bg(colors.editor_subheader_background) - .hover(|style| style.bg(colors.element_hover)) - .map(|header| { - let editor = editor.clone(); - let buffer_id = for_excerpt.buffer_id(); - let toggle_chevron_icon = - FileIcons::get_chevron_icon(!is_folded, cx).map(Icon::from_path); - let button_size = rems_from_px(28.); - - header.child( - div() - .hover(|style| style.bg(colors.element_selected)) - .rounded_xs() - .child( - ButtonLike::new("toggle-buffer-fold") - .style(ButtonStyle::Transparent) - .height(button_size.into()) - .width(button_size) - .children(toggle_chevron_icon) - .tooltip({ - let focus_handle = focus_handle.clone(); - let is_folded_for_tooltip = is_folded; - move |_window, cx| { - Tooltip::with_meta_in( - if is_folded_for_tooltip { - "Unfold Excerpt" - } else { - "Fold Excerpt" - }, - Some(&ToggleFold), - format!( - "{} to toggle all", - text_for_keystroke( - &Modifiers::alt(), - "click", - cx - ) - ), - &focus_handle, - cx, - ) - } - }) - .on_click(move |event, window, cx| { - if event.modifiers().alt { - editor.update(cx, |editor, cx| { - editor.toggle_fold_all(&ToggleFoldAll, window, cx); - }); - } else { - if is_folded { - editor.update(cx, |editor, cx| { - editor.unfold_buffer(buffer_id, cx); - }); - } else { - editor.update(cx, |editor, cx| { - editor.fold_buffer(buffer_id, cx); - }); - } - } - }), - ), - ) - }) - .children( - editor_read - .addons - .values() - .filter_map(|addon| { - addon.render_buffer_header_controls(for_excerpt, buffer, window, cx) - }) - .take(1), - ) - .when(!is_read_only, |this| { - this.child( - h_flex() - .size_3() - .justify_center() - .flex_shrink_0() - .children(indicator), - ) - }) - .child( - h_flex() - .cursor_pointer() - .id("path_header_block") - .min_w_0() - .size_full() - .gap_1() - .justify_between() - .overflow_hidden() - .child(h_flex().min_w_0().flex_1().gap_0p5().overflow_hidden().map( - |path_header| { - let filename = filename - .map(SharedString::from) - .unwrap_or_else(|| "untitled".into()); - - let full_path = match parent_path.as_deref() { - Some(parent) if !parent.is_empty() => { - format!("{}{}", parent, filename.as_str()) - } - _ => filename.as_str().to_string(), - }; - - path_header - .child( - ButtonLike::new("filename-button") - .when(ItemSettings::get_global(cx).file_icons, |this| { - let path = path::Path::new(filename.as_str()); - let icon = FileIcons::get_icon(path, cx) - .unwrap_or_default(); - - this.child( - Icon::from_path(icon).color(Color::Muted), - ) - }) - .child( - Label::new(filename) - .single_line() - .color(file_status_label_color(file_status)) - .buffer_font(cx) - .when( - file_status.is_some_and(|s| s.is_deleted()), - |label| label.strikethrough(), - ), - ) - .tooltip(move |_, cx| { - Tooltip::with_meta( - "Open File", - None, - full_path.clone(), - cx, - ) - }) - .on_click(window.listener_for(editor, { - let jump_data = jump_data.clone(); - move |editor, e: &ClickEvent, window, cx| { - editor.open_excerpts_common( - Some(jump_data.clone()), - e.modifiers().secondary(), - window, - cx, - ); - } - })), - ) - .when_some(parent_path, |then, path| { - then.child( - Label::new(path) - .buffer_font(cx) - .truncate_start() - .color( - if file_status - .is_some_and(FileStatus::is_deleted) - { - Color::Custom(colors.text_disabled) - } else { - Color::Custom(colors.text_muted) - }, - ), - ) - }) - .when(!buffer.capability.editable(), |el| { - el.child(Icon::new(IconName::FileLock).color(Color::Muted)) - }) - .when_some(breadcrumbs, |then, breadcrumbs| { - let font = theme_settings::ThemeSettings::get_global(cx) - .buffer_font - .clone(); - then.child(render_breadcrumb_text( - breadcrumbs, - Some(font), - None, - editor_handle, - true, - window, - cx, - )) - }) - }, - )) - .when(can_open_excerpts && relative_path.is_some(), |this| { - this.child( - div() - .when(!is_selected, |this| { - this.visible_on_hover("buffer-header-group") - }) - .child( - Button::new("open-file-button", "Open File") - .style(ButtonStyle::OutlinedGhost) - .when(is_selected, |this| { - this.key_binding(KeyBinding::for_action_in( - &OpenExcerpts, - &focus_handle, - cx, - )) - }) - .on_click(window.listener_for(editor, { - let jump_data = jump_data.clone(); - move |editor, e: &ClickEvent, window, cx| { - editor.open_excerpts_common( - Some(jump_data.clone()), - e.modifiers().secondary(), - window, - cx, - ); - } - })), - ), - ) - }) - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(window.listener_for(editor, { - let buffer_id = for_excerpt.buffer_id(); - move |editor, e: &ClickEvent, window, cx| { - if e.modifiers().alt { - editor.open_excerpts_common( - Some(jump_data.clone()), - e.modifiers().secondary(), - window, - cx, - ); - return; - } - - if is_folded { - editor.unfold_buffer(buffer_id, cx); - } else { - editor.fold_buffer(buffer_id, cx); - } - } - })), - ), - ); - - let file = buffer.file().cloned(); - let editor = editor.clone(); - let buffer_snapshot = buffer.clone(); - - right_click_menu("buffer-header-context-menu") - .trigger(move |_, _, _| header) - .menu(move |window, cx| { - let menu_context = focus_handle.clone(); - let editor = editor.clone(); - let file = file.clone(); - let buffer_snapshot = buffer_snapshot.clone(); - ContextMenu::build(window, cx, move |mut menu, window, cx| { - if let Some(file) = file - && let Some(project) = editor.read(cx).project() - && let Some(worktree) = - project.read(cx).worktree_for_id(file.worktree_id(cx), cx) - { - let path_style = file.path_style(cx); - let worktree = worktree.read(cx); - let relative_path = file.path(); - let entry_for_path = worktree.entry_for_path(relative_path); - let abs_path = entry_for_path.map(|e| { - e.canonical_path - .as_deref() - .map_or_else(|| worktree.absolutize(relative_path), Path::to_path_buf) - }); - let has_relative_path = worktree.root_entry().is_some_and(Entry::is_dir); - - let parent_abs_path = abs_path - .as_ref() - .and_then(|abs_path| Some(abs_path.parent()?.to_path_buf())); - let relative_path = has_relative_path - .then_some(relative_path) - .map(ToOwned::to_owned); - - let visible_in_project_panel = relative_path.is_some() && worktree.is_visible(); - let reveal_in_project_panel = entry_for_path - .filter(|_| visible_in_project_panel) - .map(|entry| entry.id); - menu = menu - .when_some(abs_path, |menu, abs_path| { - menu.entry( - "Copy Path", - Some(Box::new(zed_actions::workspace::CopyPath)), - window.handler_for(&editor, move |_, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - abs_path.to_string_lossy().into_owned(), - )); - }), - ) - }) - .when_some(relative_path, |menu, relative_path| { - menu.entry( - "Copy Relative Path", - Some(Box::new(zed_actions::workspace::CopyRelativePath)), - window.handler_for(&editor, move |_, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - relative_path.display(path_style).to_string(), - )); - }), - ) - }) - .when( - reveal_in_project_panel.is_some() || parent_abs_path.is_some(), - |menu| menu.separator(), - ) - .when_some(reveal_in_project_panel, |menu, entry_id| { - menu.entry( - "Reveal In Project Panel", - Some(Box::new(RevealInProjectPanel::default())), - window.handler_for(&editor, move |editor, _, cx| { - if let Some(project) = &mut editor.project { - project.update(cx, |_, cx| { - cx.emit(project::Event::RevealInProjectPanel(entry_id)) - }); - } - }), - ) - }) - .when_some(parent_abs_path, |menu, parent_abs_path| { - menu.entry( - "Open in Terminal", - Some(Box::new(OpenInTerminal)), - window.handler_for(&editor, move |_, window, cx| { - window.dispatch_action( - OpenTerminal { - working_directory: parent_abs_path.clone(), - local: false, - } - .boxed_clone(), - cx, - ); - }), - ) - }); - } - - menu = editor.update(cx, |editor, cx| { - let mut menu = menu; - for addon in editor.addons.values() { - menu = addon.extend_buffer_header_context_menu( - menu, - &buffer_snapshot, - window, - cx, - ); - } - menu - }); - - menu.context(menu_context) - }) - }) -} - fn render_inline_blame_entry( blame_entry: BlameEntry, style: &EditorStyle, @@ -11529,181 +9527,18 @@ pub struct EditorLayout { tab_invisible: ShapedLine, space_invisible: ShapedLine, sticky_buffer_header: Option, - sticky_headers: Option, + sticky_headers: Option, document_colors: Option<(DocumentColorsRenderMode, Vec<(Range, Hsla)>)>, text_align: TextAlign, content_width: Pixels, } -struct StickyHeaders { - lines: Vec, - gutter_background: Hsla, - content_background: Hsla, - gutter_right_padding: Pixels, -} - -struct StickyHeaderLine { - row: DisplayRow, - offset: Pixels, - line: Rc, - line_number: Option, - elements: SmallVec<[AnyElement; 1]>, - available_text_width: Pixels, - hitbox: Hitbox, -} - impl EditorLayout { fn line_end_overshoot(&self) -> Pixels { 0.15 * self.position_map.line_height } } -impl StickyHeaders { - fn paint( - &mut self, - layout: &mut EditorLayout, - whitespace_setting: ShowWhitespaceSetting, - window: &mut Window, - cx: &mut App, - ) { - let line_height = layout.position_map.line_height; - - for line in self.lines.iter_mut().rev() { - window.paint_layer( - Bounds::new( - layout.gutter_hitbox.origin + point(Pixels::ZERO, line.offset), - size(line.hitbox.size.width, line_height), - ), - |window| { - let gutter_bounds = Bounds::new( - layout.gutter_hitbox.origin + point(Pixels::ZERO, line.offset), - size(layout.gutter_hitbox.size.width, line_height), - ); - window.paint_quad(fill(gutter_bounds, self.gutter_background)); - - let text_bounds = Bounds::new( - layout.position_map.text_hitbox.origin + point(Pixels::ZERO, line.offset), - size(line.available_text_width, line_height), - ); - window.paint_quad(fill(text_bounds, self.content_background)); - - if line.hitbox.is_hovered(window) { - let hover_overlay = cx.theme().colors().panel_overlay_hover; - window.paint_quad(fill(gutter_bounds, hover_overlay)); - window.paint_quad(fill(text_bounds, hover_overlay)); - } - - line.paint( - layout, - self.gutter_right_padding, - line.available_text_width, - layout.content_origin, - line_height, - whitespace_setting, - window, - cx, - ); - }, - ); - - window.set_cursor_style(CursorStyle::IBeam, &line.hitbox); - } - } -} - -impl StickyHeaderLine { - fn new( - row: DisplayRow, - offset: Pixels, - mut line: LineWithInvisibles, - line_number: Option, - line_height: Pixels, - scroll_pixel_position: gpui::Point, - content_origin: gpui::Point, - gutter_hitbox: &Hitbox, - text_hitbox: &Hitbox, - window: &mut Window, - cx: &mut App, - ) -> Self { - let mut elements = SmallVec::<[AnyElement; 1]>::new(); - line.prepaint_with_custom_offset( - line_height, - scroll_pixel_position, - content_origin, - offset, - &mut elements, - window, - cx, - ); - - let hitbox_bounds = Bounds::new( - gutter_hitbox.origin + point(Pixels::ZERO, offset), - size(text_hitbox.right() - gutter_hitbox.left(), line_height), - ); - let available_text_width = - (hitbox_bounds.size.width - gutter_hitbox.size.width).max(Pixels::ZERO); - - Self { - row, - offset, - line: Rc::new(line), - line_number, - elements, - available_text_width, - hitbox: window.insert_hitbox(hitbox_bounds, HitboxBehavior::BlockMouseExceptScroll), - } - } - - fn paint( - &mut self, - layout: &EditorLayout, - gutter_right_padding: Pixels, - available_text_width: Pixels, - content_origin: gpui::Point, - line_height: Pixels, - whitespace_setting: ShowWhitespaceSetting, - window: &mut Window, - cx: &mut App, - ) { - window.with_content_mask( - Some(ContentMask { - bounds: Bounds::new( - layout.position_map.text_hitbox.bounds.origin - + point(Pixels::ZERO, self.offset), - size(available_text_width, line_height), - ), - }), - |window| { - self.line.draw_with_custom_offset( - layout, - self.row, - content_origin, - self.offset, - whitespace_setting, - &[], - window, - cx, - ); - for element in &mut self.elements { - element.paint(window, cx); - } - }, - ); - - if let Some(line_number) = &self.line_number { - let gutter_origin = layout.gutter_hitbox.origin + point(Pixels::ZERO, self.offset); - let gutter_width = layout.gutter_hitbox.size.width; - let origin = point( - gutter_origin.x + gutter_width - gutter_right_padding - line_number.width, - gutter_origin.y, - ); - line_number - .paint(origin, line_height, TextAlign::Left, None, window, cx) - .log_err(); - } - } -} - #[derive(Debug)] struct LineNumberSegment { shaped_line: ShapedLine, @@ -12653,25 +10488,11 @@ impl HighlightedRange { } } -pub(crate) struct StickyHeader { - pub sticky_row: DisplayRow, - pub start_point: Point, - pub offset: ScrollOffset, -} - enum CursorPopoverType { CodeContextMenu, EditPrediction, } -pub fn scale_vertical_mouse_autoscroll_delta(delta: Pixels) -> f32 { - (delta.pow(1.2) / 100.0).min(px(3.0)).into() -} - -fn scale_horizontal_mouse_autoscroll_delta(delta: Pixels) -> f32 { - (delta.pow(1.2) / 300.0).into() -} - pub fn register_action( editor: &Entity, window: &mut Window, diff --git a/crates/editor/src/element/header.rs b/crates/editor/src/element/header.rs new file mode 100644 index 00000000000..02dfebdfc31 --- /dev/null +++ b/crates/editor/src/element/header.rs @@ -0,0 +1,1055 @@ +use std::path::Path; +use std::rc::Rc; + +use collections::HashMap; +use file_icons::FileIcons; +use git::status::FileStatus; +use gpui::{ + Action, AnyElement, App, AvailableSpace, Bounds, ClickEvent, ClipboardItem, ContentMask, + CursorStyle, DefiniteLength, Entity, Focusable as _, Hitbox, HitboxBehavior, Hsla, IntoElement, + Length, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, + ShapedLine, SharedString, Styled, TextAlign, Window, div, fill, linear_color_stop, + linear_gradient, point, px, size, +}; +use language::language_settings::ShowWhitespaceSetting; +use multi_buffer::{Anchor, ExcerptBoundaryInfo}; +use project::Entry; +use settings::{RelativeLineNumbers, Settings}; +use smallvec::SmallVec; +use sum_tree::Bias; +use text::BufferId; +use theme::ActiveTheme; +use ui::{ + ButtonLike, ContextMenu, Indicator, KeyBinding, Tooltip, prelude::*, right_click_menu, + text_for_keystroke, +}; +use util::ResultExt; +use workspace::{ItemHandle, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel}; + +use super::{ + BlockLayout, EditorElement, EditorLayout, LineWithInvisibles, layout_line, + render_breadcrumb_text, +}; +use crate::{ + BUFFER_HEADER_PADDING, DisplayRow, Editor, EditorSettings, EditorSnapshot, FILE_HEADER_HEIGHT, + GutterDimensions, JumpData, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, Point, RowExt, + SelectionEffects, StickyHeaderExcerpt, ToPoint, ToggleFold, ToggleFoldAll, + display_map::ToDisplayPoint, + scroll::{Autoscroll, ScrollOffset, ScrollPixelOffset}, +}; + +pub(crate) struct StickyHeader { + sticky_row: DisplayRow, + pub(crate) start_point: Point, + pub(crate) offset: ScrollOffset, +} + +pub(super) struct StickyHeaders { + pub(super) lines: Vec, + gutter_background: Hsla, + content_background: Hsla, + gutter_right_padding: Pixels, +} + +pub(super) struct StickyHeaderLine { + row: DisplayRow, + pub(super) offset: Pixels, + line: Rc, + line_number: Option, + elements: SmallVec<[AnyElement; 1]>, + available_text_width: Pixels, + hitbox: Hitbox, +} + +impl EditorElement { + pub(crate) fn sticky_headers(editor: &Editor, snapshot: &EditorSnapshot) -> Vec { + let scroll_top = snapshot.scroll_position().y; + + let mut end_rows = Vec::::new(); + let mut rows = Vec::::new(); + + for item in editor.sticky_headers.iter().flatten() { + let start_point = item + .source_range_for_text + .start + .to_point(snapshot.buffer_snapshot()); + let end_point = item.range.end.to_point(snapshot.buffer_snapshot()); + + let sticky_row = snapshot + .display_snapshot + .point_to_display_point(start_point, Bias::Left) + .row(); + if rows + .last() + .is_some_and(|last| last.sticky_row == sticky_row) + { + continue; + } + + let end_row = snapshot + .display_snapshot + .point_to_display_point(end_point, Bias::Left) + .row(); + let max_sticky_row = end_row.previous_row(); + if max_sticky_row <= sticky_row { + continue; + } + + while end_rows + .last() + .is_some_and(|&last_end| last_end <= sticky_row) + { + end_rows.pop(); + } + let depth = end_rows.len(); + let adjusted_scroll_top = scroll_top + depth as f64; + + if sticky_row.as_f64() >= adjusted_scroll_top || end_row.as_f64() <= adjusted_scroll_top + { + continue; + } + + let max_scroll_offset = max_sticky_row.as_f64() - scroll_top; + let offset = (depth as f64).min(max_scroll_offset); + + end_rows.push(end_row); + rows.push(StickyHeader { + sticky_row, + start_point, + offset, + }); + } + + rows + } + + pub(super) fn should_show_buffer_headers(&self) -> bool { + self.split_side.is_none() + } + + pub(super) fn layout_sticky_buffer_header( + &self, + StickyHeaderExcerpt { excerpt }: StickyHeaderExcerpt<'_>, + scroll_position: gpui::Point, + line_height: Pixels, + right_margin: Pixels, + snapshot: &EditorSnapshot, + hitbox: &Hitbox, + selected_buffer_ids: &Vec, + blocks: &[BlockLayout], + latest_selection_anchors: &HashMap, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + let jump_data = header_jump_data( + snapshot, + DisplayRow(scroll_position.y as u32), + FILE_HEADER_HEIGHT + MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, + excerpt, + latest_selection_anchors, + ); + + let editor_bg_color = cx.theme().colors().editor_background; + + let selected = selected_buffer_ids.contains(&excerpt.buffer_id()); + + let available_width = hitbox.bounds.size.width - right_margin; + + let mut header = v_flex() + .w_full() + .relative() + .child( + div() + .w(available_width) + .h(FILE_HEADER_HEIGHT as f32 * line_height) + .bg(linear_gradient( + 0., + linear_color_stop(editor_bg_color.opacity(0.), 0.), + linear_color_stop(editor_bg_color, 0.6), + )) + .absolute() + .top_0(), + ) + .child( + render_buffer_header( + &self.editor, + excerpt, + false, + selected, + true, + jump_data, + window, + cx, + ) + .into_any_element(), + ) + .into_any_element(); + + let mut origin = hitbox.origin; + // Move floating header up to avoid colliding with the next buffer header. + for block in blocks.iter() { + if !block.is_buffer_header { + continue; + } + + let Some(display_row) = block.row.filter(|row| row.0 > scroll_position.y as u32) else { + continue; + }; + + let max_row = display_row.0.saturating_sub(FILE_HEADER_HEIGHT); + let offset = scroll_position.y - max_row as f64; + + if offset > 0.0 { + origin.y -= Pixels::from(offset * ScrollPixelOffset::from(line_height)); + } + break; + } + + let size = size( + AvailableSpace::Definite(available_width), + AvailableSpace::MinContent, + ); + + header.prepaint_as_root(origin, size, window, cx); + + header + } + + pub(super) fn layout_sticky_headers( + &self, + snapshot: &EditorSnapshot, + editor_width: Pixels, + is_row_soft_wrapped: impl Copy + Fn(usize) -> bool, + line_height: Pixels, + scroll_pixel_position: gpui::Point, + content_origin: gpui::Point, + gutter_dimensions: &GutterDimensions, + gutter_hitbox: &Hitbox, + text_hitbox: &Hitbox, + relative_line_numbers: RelativeLineNumbers, + relative_to: Option, + window: &mut Window, + cx: &mut App, + ) -> Option { + let show_line_numbers = snapshot + .show_line_numbers + .unwrap_or_else(|| EditorSettings::get_global(cx).gutter.line_numbers); + + let rows = Self::sticky_headers(self.editor.read(cx), snapshot); + + let mut lines = Vec::::new(); + + for StickyHeader { + sticky_row, + start_point, + offset, + } in rows.into_iter().rev() + { + let line = layout_line( + sticky_row, + snapshot, + &self.style, + editor_width, + is_row_soft_wrapped, + window, + cx, + ); + + let line_number = show_line_numbers.then(|| { + let start_display_row = start_point.to_display_point(snapshot).row(); + let relative_number = relative_to + .filter(|_| relative_line_numbers != RelativeLineNumbers::Disabled) + .map(|base| { + snapshot.relative_line_delta( + base, + start_display_row, + relative_line_numbers == RelativeLineNumbers::Wrapped, + ) + }); + let number = relative_number + .filter(|&delta| delta != 0) + .map(|delta| delta.unsigned_abs() as u32) + .unwrap_or(start_point.row + 1); + let color = cx.theme().colors().editor_line_number; + self.shape_line_number(SharedString::from(number.to_string()), color, window) + }); + + lines.push(StickyHeaderLine::new( + sticky_row, + line_height * offset as f32, + line, + line_number, + line_height, + scroll_pixel_position, + content_origin, + gutter_hitbox, + text_hitbox, + window, + cx, + )); + } + + lines.reverse(); + if lines.is_empty() { + return None; + } + + Some(StickyHeaders { + lines, + gutter_background: cx.theme().colors().editor_gutter_background, + content_background: self.style.background, + gutter_right_padding: gutter_dimensions.right_padding, + }) + } + + pub(super) fn paint_sticky_headers( + &mut self, + layout: &mut EditorLayout, + window: &mut Window, + cx: &mut App, + ) { + let Some(mut sticky_headers) = layout.sticky_headers.take() else { + return; + }; + + let Some(last_line_offset) = sticky_headers.lines.last().map(|line| line.offset) else { + layout.sticky_headers = Some(sticky_headers); + return; + }; + + let whitespace_setting = self + .editor + .read(cx) + .buffer + .read(cx) + .language_settings(cx) + .show_whitespaces; + sticky_headers.paint(layout, whitespace_setting, window, cx); + + let sticky_header_hitboxes: Vec = sticky_headers + .lines + .iter() + .map(|line| line.hitbox.clone()) + .collect(); + let hovered_hitbox = sticky_header_hitboxes + .iter() + .find_map(|hitbox| hitbox.is_hovered(window).then_some(hitbox.id)); + + window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, _cx| { + if !phase.bubble() { + return; + } + + let current_hover = sticky_header_hitboxes + .iter() + .find_map(|hitbox| hitbox.is_hovered(window).then_some(hitbox.id)); + if hovered_hitbox != current_hover { + window.refresh(); + } + }); + + let position_map = layout.position_map.clone(); + + for (line_index, line) in sticky_headers.lines.iter().enumerate() { + let editor = self.editor.clone(); + let hitbox = line.hitbox.clone(); + let row = line.row; + let line_layout = line.line.clone(); + let position_map = position_map.clone(); + window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| { + if !phase.bubble() { + return; + } + + if event.button == MouseButton::Left && hitbox.is_hovered(window) { + let point_for_position = + position_map.point_for_position_on_line(event.position, row, &line_layout); + + editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + let anchor = snapshot + .display_snapshot + .display_point_to_anchor(point_for_position.nearest_valid, Bias::Left); + editor.change_selections( + SelectionEffects::scroll(Autoscroll::top_relative( + line_index as ScrollOffset, + )), + window, + cx, + |selections| { + selections.clear_disjoint(); + selections.set_pending_anchor_range( + anchor..anchor, + crate::SelectMode::Character, + ); + }, + ); + cx.stop_propagation(); + }); + } + }); + } + + let text_bounds = layout.position_map.text_hitbox.bounds; + let border_top = text_bounds.top() + last_line_offset + layout.position_map.line_height; + let separator_height = px(1.); + let border_bounds = window.pixel_snap_bounds(Bounds::from_corners( + point(layout.gutter_hitbox.bounds.left(), border_top), + point(text_bounds.right(), border_top + separator_height), + )); + window.paint_quad(fill(border_bounds, cx.theme().colors().border_variant)); + + layout.sticky_headers = Some(sticky_headers); + } +} + +impl StickyHeaders { + fn paint( + &mut self, + layout: &mut EditorLayout, + whitespace_setting: ShowWhitespaceSetting, + window: &mut Window, + cx: &mut App, + ) { + let line_height = layout.position_map.line_height; + + for line in self.lines.iter_mut().rev() { + window.paint_layer( + Bounds::new( + layout.gutter_hitbox.origin + point(Pixels::ZERO, line.offset), + size(line.hitbox.size.width, line_height), + ), + |window| { + let gutter_bounds = Bounds::new( + layout.gutter_hitbox.origin + point(Pixels::ZERO, line.offset), + size(layout.gutter_hitbox.size.width, line_height), + ); + window.paint_quad(fill(gutter_bounds, self.gutter_background)); + + let text_bounds = Bounds::new( + layout.position_map.text_hitbox.origin + point(Pixels::ZERO, line.offset), + size(line.available_text_width, line_height), + ); + window.paint_quad(fill(text_bounds, self.content_background)); + + if line.hitbox.is_hovered(window) { + let hover_overlay = cx.theme().colors().panel_overlay_hover; + window.paint_quad(fill(gutter_bounds, hover_overlay)); + window.paint_quad(fill(text_bounds, hover_overlay)); + } + + line.paint( + layout, + self.gutter_right_padding, + line.available_text_width, + layout.content_origin, + line_height, + whitespace_setting, + window, + cx, + ); + }, + ); + + window.set_cursor_style(CursorStyle::IBeam, &line.hitbox); + } + } +} + +impl StickyHeaderLine { + fn new( + row: DisplayRow, + offset: Pixels, + mut line: LineWithInvisibles, + line_number: Option, + line_height: Pixels, + scroll_pixel_position: gpui::Point, + content_origin: gpui::Point, + gutter_hitbox: &Hitbox, + text_hitbox: &Hitbox, + window: &mut Window, + cx: &mut App, + ) -> Self { + let mut elements = SmallVec::<[AnyElement; 1]>::new(); + line.prepaint_with_custom_offset( + line_height, + scroll_pixel_position, + content_origin, + offset, + &mut elements, + window, + cx, + ); + + let hitbox_bounds = Bounds::new( + gutter_hitbox.origin + point(Pixels::ZERO, offset), + size(text_hitbox.right() - gutter_hitbox.left(), line_height), + ); + let available_text_width = + (hitbox_bounds.size.width - gutter_hitbox.size.width).max(Pixels::ZERO); + + Self { + row, + offset, + line: Rc::new(line), + line_number, + elements, + available_text_width, + hitbox: window.insert_hitbox(hitbox_bounds, HitboxBehavior::BlockMouseExceptScroll), + } + } + + fn paint( + &mut self, + layout: &EditorLayout, + gutter_right_padding: Pixels, + available_text_width: Pixels, + content_origin: gpui::Point, + line_height: Pixels, + whitespace_setting: ShowWhitespaceSetting, + window: &mut Window, + cx: &mut App, + ) { + window.with_content_mask( + Some(ContentMask { + bounds: Bounds::new( + layout.position_map.text_hitbox.bounds.origin + + point(Pixels::ZERO, self.offset), + size(available_text_width, line_height), + ), + }), + |window| { + self.line.draw_with_custom_offset( + layout, + self.row, + content_origin, + self.offset, + whitespace_setting, + &[], + window, + cx, + ); + for element in &mut self.elements { + element.paint(window, cx); + } + }, + ); + + if let Some(line_number) = &self.line_number { + let gutter_origin = layout.gutter_hitbox.origin + point(Pixels::ZERO, self.offset); + let gutter_width = layout.gutter_hitbox.size.width; + let origin = point( + gutter_origin.x + gutter_width - gutter_right_padding - line_number.width, + gutter_origin.y, + ); + line_number + .paint(origin, line_height, TextAlign::Left, None, window, cx) + .log_err(); + } + } +} + +pub(crate) fn header_jump_data( + editor_snapshot: &EditorSnapshot, + block_row_start: DisplayRow, + height: u32, + first_excerpt: &ExcerptBoundaryInfo, + latest_selection_anchors: &HashMap, +) -> JumpData { + let multibuffer_snapshot = editor_snapshot.buffer_snapshot(); + let buffer = first_excerpt.buffer(multibuffer_snapshot); + let (jump_anchor, jump_buffer, excerpt_start) = if let Some(anchor) = + latest_selection_anchors.get(&first_excerpt.buffer_id()) + && let Some((jump_anchor, selection_buffer)) = + multibuffer_snapshot.anchor_to_buffer_anchor(*anchor) + { + let jump_offset = text::ToOffset::to_offset(&jump_anchor, selection_buffer); + let selection_excerpt_start = multibuffer_snapshot + .excerpts_for_buffer(jump_anchor.buffer_id) + .find(|excerpt| { + let start = text::ToOffset::to_offset(&excerpt.context.start, selection_buffer); + let end = text::ToOffset::to_offset(&excerpt.context.end, selection_buffer); + start <= jump_offset && jump_offset <= end + }) + .map(|excerpt| excerpt.context.start) + .unwrap_or(first_excerpt.range.context.start); + (jump_anchor, selection_buffer, selection_excerpt_start) + } else { + ( + first_excerpt.range.primary.start, + buffer, + first_excerpt.range.context.start, + ) + }; + let jump_position = language::ToPoint::to_point(&jump_anchor, jump_buffer); + let rows_from_excerpt_start = if jump_anchor == excerpt_start { + 0 + } else { + let excerpt_start_point = language::ToPoint::to_point(&excerpt_start, jump_buffer); + jump_position.row.saturating_sub(excerpt_start_point.row) + }; + + let line_offset_from_top = (block_row_start.0 + height + rows_from_excerpt_start) + .saturating_sub( + editor_snapshot + .scroll_anchor + .scroll_position(&editor_snapshot.display_snapshot) + .y as u32, + ); + + JumpData::MultiBufferPoint { + anchor: jump_anchor, + position: jump_position, + line_offset_from_top, + } +} + +pub(crate) fn render_buffer_header( + editor: &Entity, + for_excerpt: &ExcerptBoundaryInfo, + is_folded: bool, + is_selected: bool, + is_sticky: bool, + jump_data: JumpData, + window: &mut Window, + cx: &mut App, +) -> impl IntoElement { + let editor_read = editor.read(cx); + let multi_buffer = editor_read.buffer.read(cx); + let is_read_only = editor_read.read_only(cx); + let editor_handle: &dyn ItemHandle = editor; + let multibuffer_snapshot = multi_buffer.snapshot(cx); + let buffer = for_excerpt.buffer(&multibuffer_snapshot); + + let breadcrumbs = if is_selected { + editor_read.breadcrumbs_inner(cx) + } else { + None + }; + + let buffer_id = for_excerpt.buffer_id(); + let file_status = multi_buffer + .all_diff_hunks_expanded() + .then(|| editor_read.status_for_buffer_id(buffer_id, cx)) + .flatten(); + let indicator = multi_buffer.buffer(buffer_id).and_then(|buffer| { + let buffer = buffer.read(cx); + let indicator_color = match (buffer.has_conflict(), buffer.is_dirty()) { + (true, _) => Some(Color::Warning), + (_, true) => Some(Color::Accent), + (false, false) => None, + }; + indicator_color.map(|indicator_color| Indicator::dot().color(indicator_color)) + }); + + let include_root = editor_read + .project + .as_ref() + .map(|project| project.read(cx).visible_worktrees(cx).count() > 1) + .unwrap_or_default(); + let file = buffer.file(); + let can_open_excerpts = file.is_none_or(|file| file.can_open()); + let path_style = file.map(|file| file.path_style(cx)); + let relative_path = buffer.resolve_file_path(include_root, cx); + let (parent_path, filename) = if let Some(path) = &relative_path { + if let Some(path_style) = path_style { + let (dir, file_name) = path_style.split(path); + (dir.map(|dir| dir.to_owned()), Some(file_name.to_owned())) + } else { + (None, Some(path.clone())) + } + } else { + (None, None) + }; + let focus_handle = editor_read.focus_handle(cx); + let colors = cx.theme().colors(); + + let header = div() + .id(("buffer-header", buffer_id.to_proto())) + .p(BUFFER_HEADER_PADDING) + .w_full() + .h(FILE_HEADER_HEIGHT as f32 * window.line_height()) + .child( + h_flex() + .group("buffer-header-group") + .size_full() + .flex_basis(Length::Definite(DefiniteLength::Fraction(0.667))) + .pl_1() + .pr_2() + .rounded_sm() + .gap_1p5() + .when(is_sticky, |el| el.shadow_md()) + .border_1() + .map(|border| { + let border_color = + if is_selected && is_folded && focus_handle.contains_focused(window, cx) { + colors.border_focused + } else { + colors.border + }; + border.border_color(border_color) + }) + .bg(colors.editor_subheader_background) + .hover(|style| style.bg(colors.element_hover)) + .map(|header| { + let editor = editor.clone(); + let buffer_id = for_excerpt.buffer_id(); + let toggle_chevron_icon = + FileIcons::get_chevron_icon(!is_folded, cx).map(Icon::from_path); + let button_size = rems_from_px(28.); + + header.child( + div() + .hover(|style| style.bg(colors.element_selected)) + .rounded_xs() + .child( + ButtonLike::new("toggle-buffer-fold") + .style(ButtonStyle::Transparent) + .height(button_size.into()) + .width(button_size) + .children(toggle_chevron_icon) + .tooltip({ + let focus_handle = focus_handle.clone(); + let is_folded_for_tooltip = is_folded; + move |_window, cx| { + Tooltip::with_meta_in( + if is_folded_for_tooltip { + "Unfold Excerpt" + } else { + "Fold Excerpt" + }, + Some(&ToggleFold), + format!( + "{} to toggle all", + text_for_keystroke( + &Modifiers::alt(), + "click", + cx + ) + ), + &focus_handle, + cx, + ) + } + }) + .on_click(move |event, window, cx| { + if event.modifiers().alt { + editor.update(cx, |editor, cx| { + editor.toggle_fold_all(&ToggleFoldAll, window, cx); + }); + } else { + if is_folded { + editor.update(cx, |editor, cx| { + editor.unfold_buffer(buffer_id, cx); + }); + } else { + editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_id, cx); + }); + } + } + }), + ), + ) + }) + .children( + editor_read + .addons + .values() + .filter_map(|addon| { + addon.render_buffer_header_controls(for_excerpt, buffer, window, cx) + }) + .take(1), + ) + .when(!is_read_only, |this| { + this.child( + h_flex() + .size_3() + .justify_center() + .flex_shrink_0() + .children(indicator), + ) + }) + .child( + h_flex() + .cursor_pointer() + .id("path_header_block") + .min_w_0() + .size_full() + .gap_1() + .justify_between() + .overflow_hidden() + .child(h_flex().min_w_0().flex_1().gap_0p5().overflow_hidden().map( + |path_header| { + let filename = filename + .map(SharedString::from) + .unwrap_or_else(|| "untitled".into()); + + let full_path = match parent_path.as_deref() { + Some(parent) if !parent.is_empty() => { + format!("{}{}", parent, filename.as_str()) + } + _ => filename.as_str().to_string(), + }; + + path_header + .child( + ButtonLike::new("filename-button") + .when(ItemSettings::get_global(cx).file_icons, |this| { + let path = std::path::Path::new(filename.as_str()); + let icon = FileIcons::get_icon(path, cx) + .unwrap_or_default(); + + this.child( + Icon::from_path(icon).color(Color::Muted), + ) + }) + .child( + Label::new(filename) + .single_line() + .color(file_status_label_color(file_status)) + .buffer_font(cx) + .when( + file_status.is_some_and(|s| s.is_deleted()), + |label| label.strikethrough(), + ), + ) + .tooltip(move |_, cx| { + Tooltip::with_meta( + "Open File", + None, + full_path.clone(), + cx, + ) + }) + .on_click(window.listener_for(editor, { + let jump_data = jump_data.clone(); + move |editor, e: &ClickEvent, window, cx| { + editor.open_excerpts_common( + Some(jump_data.clone()), + e.modifiers().secondary(), + window, + cx, + ); + } + })), + ) + .when_some(parent_path, |then, path| { + then.child( + Label::new(path) + .buffer_font(cx) + .truncate_start() + .color( + if file_status + .is_some_and(FileStatus::is_deleted) + { + Color::Custom(colors.text_disabled) + } else { + Color::Custom(colors.text_muted) + }, + ), + ) + }) + .when(!buffer.capability.editable(), |el| { + el.child(Icon::new(IconName::FileLock).color(Color::Muted)) + }) + .when_some(breadcrumbs, |then, breadcrumbs| { + let font = theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(); + then.child(render_breadcrumb_text( + breadcrumbs, + Some(font), + None, + editor_handle, + true, + window, + cx, + )) + }) + }, + )) + .when(can_open_excerpts && relative_path.is_some(), |this| { + this.child( + div() + .when(!is_selected, |this| { + this.visible_on_hover("buffer-header-group") + }) + .child( + Button::new("open-file-button", "Open File") + .style(ButtonStyle::OutlinedGhost) + .when(is_selected, |this| { + this.key_binding(KeyBinding::for_action_in( + &OpenExcerpts, + &focus_handle, + cx, + )) + }) + .on_click(window.listener_for(editor, { + let jump_data = jump_data.clone(); + move |editor, e: &ClickEvent, window, cx| { + editor.open_excerpts_common( + Some(jump_data.clone()), + e.modifiers().secondary(), + window, + cx, + ); + } + })), + ), + ) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(window.listener_for(editor, { + let buffer_id = for_excerpt.buffer_id(); + move |editor, e: &ClickEvent, window, cx| { + if e.modifiers().alt { + editor.open_excerpts_common( + Some(jump_data.clone()), + e.modifiers().secondary(), + window, + cx, + ); + return; + } + + if is_folded { + editor.unfold_buffer(buffer_id, cx); + } else { + editor.fold_buffer(buffer_id, cx); + } + } + })), + ), + ); + + let file = buffer.file().cloned(); + let editor = editor.clone(); + let buffer_snapshot = buffer.clone(); + + right_click_menu("buffer-header-context-menu") + .trigger(move |_, _, _| header) + .menu(move |window, cx| { + let menu_context = focus_handle.clone(); + let editor = editor.clone(); + let file = file.clone(); + let buffer_snapshot = buffer_snapshot.clone(); + ContextMenu::build(window, cx, move |mut menu, window, cx| { + if let Some(file) = file + && let Some(project) = editor.read(cx).project() + && let Some(worktree) = + project.read(cx).worktree_for_id(file.worktree_id(cx), cx) + { + let path_style = file.path_style(cx); + let worktree = worktree.read(cx); + let relative_path = file.path(); + let entry_for_path = worktree.entry_for_path(relative_path); + let abs_path = entry_for_path.map(|e| { + e.canonical_path + .as_deref() + .map_or_else(|| worktree.absolutize(relative_path), Path::to_path_buf) + }); + let has_relative_path = worktree.root_entry().is_some_and(Entry::is_dir); + + let parent_abs_path = abs_path + .as_ref() + .and_then(|abs_path| Some(abs_path.parent()?.to_path_buf())); + let relative_path = has_relative_path + .then_some(relative_path) + .map(ToOwned::to_owned); + + let visible_in_project_panel = relative_path.is_some() && worktree.is_visible(); + let reveal_in_project_panel = entry_for_path + .filter(|_| visible_in_project_panel) + .map(|entry| entry.id); + menu = menu + .when_some(abs_path, |menu, abs_path| { + menu.entry( + "Copy Path", + Some(Box::new(zed_actions::workspace::CopyPath)), + window.handler_for(&editor, move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + abs_path.to_string_lossy().into_owned(), + )); + }), + ) + }) + .when_some(relative_path, |menu, relative_path| { + menu.entry( + "Copy Relative Path", + Some(Box::new(zed_actions::workspace::CopyRelativePath)), + window.handler_for(&editor, move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + relative_path.display(path_style).to_string(), + )); + }), + ) + }) + .when( + reveal_in_project_panel.is_some() || parent_abs_path.is_some(), + |menu| menu.separator(), + ) + .when_some(reveal_in_project_panel, |menu, entry_id| { + menu.entry( + "Reveal In Project Panel", + Some(Box::new(RevealInProjectPanel::default())), + window.handler_for(&editor, move |editor, _, cx| { + if let Some(project) = &mut editor.project { + project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(entry_id)) + }); + } + }), + ) + }) + .when_some(parent_abs_path, |menu, parent_abs_path| { + menu.entry( + "Open in Terminal", + Some(Box::new(OpenInTerminal)), + window.handler_for(&editor, move |_, window, cx| { + window.dispatch_action( + OpenTerminal { + working_directory: parent_abs_path.clone(), + local: false, + } + .boxed_clone(), + cx, + ); + }), + ) + }); + } + + menu = editor.update(cx, |editor, cx| { + let mut menu = menu; + for addon in editor.addons.values() { + menu = addon.extend_buffer_header_context_menu( + menu, + &buffer_snapshot, + window, + cx, + ); + } + menu + }); + + menu.context(menu_context) + }) + }) +} + +fn file_status_label_color(file_status: Option) -> Color { + file_status.map_or(Color::Default, |status| { + if status.is_conflicted() { + Color::Conflict + } else if status.is_modified() { + Color::Modified + } else if status.is_deleted() { + Color::Disabled + } else if status.is_created() { + Color::Created + } else { + Color::Default + } + }) +} diff --git a/crates/editor/src/element/mouse.rs b/crates/editor/src/element/mouse.rs new file mode 100644 index 00000000000..b3fc095dbbc --- /dev/null +++ b/crates/editor/src/element/mouse.rs @@ -0,0 +1,1187 @@ +use std::ops::Range; +use std::time::{Duration, Instant}; + +use collections::HashMap; +use feature_flags::{DiffReviewFeatureFlag, FeatureFlagAppExt as _}; +use gpui::{ + AnyElement, App, AvailableSpace, ClickEvent, Context, DefiniteLength, DispatchPhase, Element, + MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, + ParentElement, Pixels, PressureStage, ScrollDelta, ScrollWheelEvent, TextStyleRefinement, + Window, anchored, deferred, point, px, +}; +use multi_buffer::MultiBufferRow; +use project::DisableAiSettings; +use settings::Settings; +use sum_tree::Bias; +use text::SelectionGoal; +use theme_settings::BufferLineHeight; +use util::{RangeExt, debug_panic, post_inc}; + +use super::{EditorElement, EditorLayout, LineNumberLayout, PositionMap, SplitSide}; +use crate::{ + CURSORS_VISIBLE_FOR, ColumnarMode, DisplayDiffHunk, DisplayPoint, DisplayRow, Editor, + EditorSettings, EditorSnapshot, GutterHoverButton, HoveredCursor, JumpData, + PhantomDiffReviewIndicator, SelectPhase, Selection, SelectionDragState, + display_map::ToDisplayPoint, editor_settings::DoubleClickInMultibuffer, + hover_popover::hover_at, mouse_context_menu, scroll::ScrollPixelOffset, +}; + +impl EditorElement { + pub(crate) fn mouse_moved( + editor: &mut Editor, + event: &MouseMoveEvent, + position_map: &PositionMap, + split_side: Option, + window: &mut Window, + cx: &mut Context, + ) { + let text_hitbox = &position_map.text_hitbox; + let gutter_hitbox = &position_map.gutter_hitbox; + let modifiers = event.modifiers; + let text_hovered = text_hitbox.is_hovered(window); + let gutter_hovered = gutter_hitbox.is_hovered(window); + editor.set_gutter_hovered(gutter_hovered, cx); + + let point_for_position = position_map.point_for_position(event.position); + let valid_point = point_for_position.nearest_valid; + + // Update diff review drag state if we're dragging + if editor.diff_review_drag_state.is_some() { + editor.update_diff_review_drag(valid_point.row(), window, cx); + } + + let hovered_diff_control = position_map + .diff_hunk_control_bounds + .iter() + .find(|(_, bounds)| bounds.contains(&event.position)) + .map(|(row, _)| *row); + + let hovered_diff_hunk_row = if let Some(control_row) = hovered_diff_control { + Some(control_row) + } else if text_hovered { + let current_row = valid_point.row(); + position_map.display_hunks.iter().find_map(|(hunk, _)| { + if let DisplayDiffHunk::Unfolded { + display_row_range, .. + } = hunk + { + if display_row_range.contains(¤t_row) { + Some(display_row_range.start) + } else { + None + } + } else { + None + } + }) + } else { + None + }; + + if hovered_diff_hunk_row != editor.hovered_diff_hunk_row { + editor.hovered_diff_hunk_row = hovered_diff_hunk_row; + cx.notify(); + } + + if text_hovered + && let Some((bounds, buffer_id, blame_entry)) = &position_map.inline_blame_bounds + { + let mouse_over_inline_blame = bounds.contains(&event.position); + let mouse_over_popover = editor + .inline_blame_popover + .as_ref() + .and_then(|state| state.popover_bounds) + .is_some_and(|bounds| bounds.contains(&event.position)); + let keyboard_grace = editor + .inline_blame_popover + .as_ref() + .is_some_and(|state| state.keyboard_grace); + + if mouse_over_inline_blame || mouse_over_popover { + editor.show_blame_popover(*buffer_id, blame_entry, event.position, false, cx); + } else if !keyboard_grace { + editor.hide_blame_popover(false, cx); + } + } else { + let keyboard_grace = editor + .inline_blame_popover + .as_ref() + .is_some_and(|state| state.keyboard_grace); + if !keyboard_grace { + editor.hide_blame_popover(false, cx); + } + } + + // Handle diff review indicator when gutter is hovered in diff mode with AI enabled + let show_diff_review = editor.show_diff_review_button() + && cx.has_flag::() + && !DisableAiSettings::is_ai_disabled_for_buffer( + editor.buffer.read(cx).as_singleton().as_ref(), + cx, + ); + + let diff_review_indicator = if gutter_hovered && show_diff_review { + let is_visible = editor + .gutter_diff_review_indicator + .0 + .is_some_and(|indicator| indicator.is_active); + + if !is_visible { + editor + .gutter_diff_review_indicator + .1 + .get_or_insert_with(|| { + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(200)) + .await; + + this.update(cx, |this, cx| { + if let Some(indicator) = + this.gutter_diff_review_indicator.0.as_mut() + { + indicator.is_active = true; + cx.notify(); + } + }) + .ok(); + }) + }); + } + + let anchor = position_map + .snapshot + .display_point_to_anchor(valid_point, Bias::Left); + Some(PhantomDiffReviewIndicator { + start: anchor, + end: anchor, + is_active: is_visible, + }) + } else { + editor.gutter_diff_review_indicator.1 = None; + None + }; + + if diff_review_indicator != editor.gutter_diff_review_indicator.0 { + editor.gutter_diff_review_indicator.0 = diff_review_indicator; + cx.notify(); + } + + // Don't show breakpoint indicator when diff review indicator is active on this row + let is_on_diff_review_button_row = diff_review_indicator.is_some_and(|indicator| { + let start_row = indicator + .start + .to_display_point(&position_map.snapshot.display_snapshot) + .row(); + indicator.is_active && start_row == valid_point.row() + }); + + let gutter_hover_button = if gutter_hovered + && !is_on_diff_review_button_row + && split_side != Some(SplitSide::Left) + { + let buffer_anchor = position_map + .snapshot + .display_point_to_anchor(valid_point, Bias::Left); + + if position_map + .snapshot + .buffer_snapshot() + .anchor_to_buffer_anchor(buffer_anchor) + .is_some() + { + let is_visible = editor + .gutter_hover_button + .0 + .is_some_and(|indicator| indicator.is_active); + + if !is_visible { + editor.gutter_hover_button.1.get_or_insert_with(|| { + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(200)) + .await; + + this.update(cx, |this, cx| { + if let Some(indicator) = this.gutter_hover_button.0.as_mut() { + indicator.is_active = true; + cx.notify(); + } + }) + .ok(); + }) + }); + } + + Some(GutterHoverButton { + display_row: valid_point.row(), + is_active: is_visible, + }) + } else { + editor.gutter_hover_button.1 = None; + None + } + } else if editor.has_mouse_context_menu() { + editor.gutter_hover_button.1 = None; + editor.gutter_hover_button.0 + } else { + editor.gutter_hover_button.1 = None; + None + }; + + if &gutter_hover_button != &editor.gutter_hover_button.0 { + editor.gutter_hover_button.0 = gutter_hover_button; + cx.notify(); + } + + // Don't trigger hover popover if mouse is hovering over context menu + if text_hovered { + editor.update_hovered_link( + point_for_position, + Some(event.position), + &position_map.snapshot, + modifiers, + window, + cx, + ); + + if let Some(point) = point_for_position.as_valid() { + let anchor = position_map + .snapshot + .buffer_snapshot() + .anchor_before(point.to_offset(&position_map.snapshot, Bias::Left)); + hover_at(editor, Some(anchor), Some(event.position), window, cx); + Self::update_visible_cursor(editor, point, position_map, window, cx); + } else { + editor.update_inlay_link_and_hover_points( + &position_map.snapshot, + point_for_position, + Some(event.position), + modifiers.secondary(), + modifiers.shift, + window, + cx, + ); + } + } else { + editor.hide_hovered_link(cx); + hover_at(editor, None, Some(event.position), window, cx); + } + } + + pub(super) fn layout_mouse_context_menu( + &self, + editor_snapshot: &EditorSnapshot, + visible_range: Range, + content_origin: gpui::Point, + window: &mut Window, + cx: &mut App, + ) -> Option { + let position = self.editor.update(cx, |editor, cx| { + let visible_start_point = editor.display_to_pixel_point( + DisplayPoint::new(visible_range.start, 0), + editor_snapshot, + window, + cx, + )?; + let visible_end_point = editor.display_to_pixel_point( + DisplayPoint::new(visible_range.end, 0), + editor_snapshot, + window, + cx, + )?; + + let mouse_context_menu = editor.mouse_context_menu.as_ref()?; + let (source_display_point, position) = match mouse_context_menu.position { + mouse_context_menu::MenuPosition::PinnedToScreen(point) => (None, point), + mouse_context_menu::MenuPosition::PinnedToEditor { source, offset } => { + let source_display_point = source.to_display_point(editor_snapshot); + let source_point = + editor.to_pixel_point(source, editor_snapshot, window, cx)?; + let position = content_origin + source_point + offset; + (Some(source_display_point), position) + } + }; + + let source_included = source_display_point.is_none_or(|source_display_point| { + visible_range + .to_inclusive() + .contains(&source_display_point.row()) + }); + let position_included = + visible_start_point.y <= position.y && position.y <= visible_end_point.y; + if !source_included && !position_included { + None + } else { + Some(position) + } + })?; + + let text_style = TextStyleRefinement { + line_height: Some(DefiniteLength::Fraction( + BufferLineHeight::Comfortable.value(), + )), + ..Default::default() + }; + window.with_text_style(Some(text_style), |window| { + let mut element = self.editor.read_with(cx, |editor, _| { + let mouse_context_menu = editor.mouse_context_menu.as_ref()?; + let context_menu = mouse_context_menu.context_menu.clone(); + + Some( + deferred( + anchored() + .position(position) + .child(context_menu) + .anchor(gpui::Anchor::TopLeft) + .snap_to_window_with_margin(px(8.)), + ) + .with_priority(1) + .into_any(), + ) + })?; + + element.prepaint_as_root(position, AvailableSpace::min_size(), window, cx); + Some(element) + }) + } + + pub(super) fn paint_mouse_listeners( + &mut self, + layout: &EditorLayout, + window: &mut Window, + cx: &mut App, + ) { + if layout.mode.is_minimap() { + return; + } + + self.paint_scroll_wheel_listener(layout, window, cx); + + window.on_mouse_event({ + let position_map = layout.position_map.clone(); + let editor = self.editor.clone(); + let line_numbers = layout.line_numbers.clone(); + + move |event: &MouseDownEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + match event.button { + MouseButton::Left => editor.update(cx, |editor, cx| { + let pending_mouse_down = editor + .pending_mouse_down + .get_or_insert_with(Default::default) + .clone(); + + *pending_mouse_down.borrow_mut() = Some(event.clone()); + + Self::mouse_left_down( + editor, + event, + &position_map, + line_numbers.as_ref(), + window, + cx, + ); + }), + MouseButton::Right => editor.update(cx, |editor, cx| { + Self::mouse_right_down(editor, event, &position_map, window, cx); + }), + MouseButton::Middle => editor.update(cx, |editor, cx| { + Self::mouse_middle_down(editor, event, &position_map, window, cx); + }), + _ => {} + }; + } + } + }); + + window.on_mouse_event({ + let editor = self.editor.clone(); + let position_map = layout.position_map.clone(); + + move |event: &MouseUpEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + editor.update(cx, |editor, cx| { + Self::mouse_up(editor, event, &position_map, window, cx) + }); + } + } + }); + + window.on_mouse_event({ + let editor = self.editor.clone(); + let position_map = layout.position_map.clone(); + let mut captured_mouse_down = None; + + move |event: &MouseUpEvent, phase, window, cx| match phase { + // Clear the pending mouse down during the capture phase, + // so that it happens even if another event handler stops + // propagation. + DispatchPhase::Capture => editor.update(cx, |editor, _cx| { + let pending_mouse_down = editor + .pending_mouse_down + .get_or_insert_with(Default::default) + .clone(); + + let mut pending_mouse_down = pending_mouse_down.borrow_mut(); + if pending_mouse_down.is_some() && position_map.text_hitbox.is_hovered(window) { + captured_mouse_down = pending_mouse_down.take(); + window.refresh(); + } + }), + // Fire click handlers during the bubble phase. + DispatchPhase::Bubble => editor.update(cx, |editor, cx| { + if let Some(mouse_down) = captured_mouse_down.take() { + let event = ClickEvent::Mouse(MouseClickEvent { + down: mouse_down, + up: event.clone(), + }); + Self::click(editor, &event, &position_map, window, cx); + } + }), + } + }); + + window.on_mouse_event({ + let position_map = layout.position_map.clone(); + let editor = self.editor.clone(); + + move |event: &MousePressureEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + editor.update(cx, |editor, cx| { + Self::pressure_click(editor, &event, &position_map, window, cx); + }) + } + } + }); + + window.on_mouse_event({ + let position_map = layout.position_map.clone(); + let editor = self.editor.clone(); + let split_side = self.split_side; + + move |event: &MouseMoveEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + editor.update(cx, |editor, cx| { + if editor.hover_state.focused(window, cx) { + return; + } + if event.pressed_button == Some(MouseButton::Left) + || event.pressed_button == Some(MouseButton::Middle) + { + Self::mouse_dragged(editor, event, &position_map, window, cx) + } + + Self::mouse_moved(editor, event, &position_map, split_side, window, cx) + }); + } + } + }); + } + + fn paint_scroll_wheel_listener( + &mut self, + layout: &EditorLayout, + window: &mut Window, + cx: &mut App, + ) { + window.on_mouse_event({ + let position_map = layout.position_map.clone(); + let editor = self.editor.clone(); + let hitbox = layout.hitbox.clone(); + let mut delta = ScrollDelta::default(); + + // Set a minimum scroll_sensitivity of 0.01 to make sure the user doesn't + // accidentally turn off their scrolling. + let base_scroll_sensitivity = + EditorSettings::get_global(cx).scroll_sensitivity.max(0.01); + + // Use a minimum fast_scroll_sensitivity for same reason above + let fast_scroll_sensitivity = EditorSettings::get_global(cx) + .fast_scroll_sensitivity + .max(0.01); + + move |event: &ScrollWheelEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble && hitbox.should_handle_scroll(window) { + delta = delta.coalesce(event.delta); + + if event.modifiers.secondary() + && editor.read(cx).enable_mouse_wheel_zoom + && EditorSettings::get_global(cx).mouse_wheel_zoom + { + let delta_y = match event.delta { + ScrollDelta::Pixels(pixels) => pixels.y.into(), + ScrollDelta::Lines(lines) => lines.y, + }; + + if delta_y > 0.0 { + theme_settings::increase_buffer_font_size(cx); + } else if delta_y < 0.0 { + theme_settings::decrease_buffer_font_size(cx); + } + + cx.stop_propagation(); + } else { + let scroll_sensitivity = { + if event.modifiers.alt { + fast_scroll_sensitivity + } else { + base_scroll_sensitivity + } + }; + + editor.update(cx, |editor, cx| { + let line_height = position_map.line_height; + let glyph_width = position_map.em_layout_width; + let (delta, axis) = match delta { + gpui::ScrollDelta::Pixels(mut pixels) => { + //Trackpad + let axis = + position_map.snapshot.ongoing_scroll.filter(&mut pixels); + (pixels, axis) + } + + gpui::ScrollDelta::Lines(lines) => { + //Not trackpad + let pixels = + point(lines.x * glyph_width, lines.y * line_height); + (pixels, None) + } + }; + + let current_scroll_position = position_map.snapshot.scroll_position(); + let x = (current_scroll_position.x + * ScrollPixelOffset::from(glyph_width) + - ScrollPixelOffset::from(delta.x * scroll_sensitivity)) + / ScrollPixelOffset::from(glyph_width); + let y = (current_scroll_position.y + * ScrollPixelOffset::from(line_height) + - ScrollPixelOffset::from(delta.y * scroll_sensitivity)) + / ScrollPixelOffset::from(line_height); + let mut scroll_position = + point(x, y).clamp(&point(0., 0.), &position_map.scroll_max); + let forbid_vertical_scroll = + editor.scroll_manager.forbid_vertical_scroll(); + if forbid_vertical_scroll { + scroll_position.y = current_scroll_position.y; + } + + if scroll_position != current_scroll_position { + editor.scroll(scroll_position, axis, window, cx); + cx.stop_propagation(); + } else if y < 0. { + // Due to clamping, we may fail to detect cases of overscroll to the top; + // We want the scroll manager to get an update in such cases and detect the change of direction + // on the next frame. + cx.notify(); + } + }); + } + } + } + }); + } + + fn mouse_left_down( + editor: &mut Editor, + event: &MouseDownEvent, + position_map: &PositionMap, + line_numbers: &HashMap, + window: &mut Window, + cx: &mut Context, + ) { + if window.default_prevented() { + return; + } + + let text_hitbox = &position_map.text_hitbox; + let gutter_hitbox = &position_map.gutter_hitbox; + let point_for_position = position_map.point_for_position(event.position); + let mut click_count = event.click_count; + let mut modifiers = event.modifiers; + + if let Some(hovered_hunk) = + position_map + .display_hunks + .iter() + .find_map(|(hunk, hunk_hitbox)| match hunk { + DisplayDiffHunk::Folded { .. } => None, + DisplayDiffHunk::Unfolded { + multi_buffer_range, .. + } => hunk_hitbox + .as_ref() + .is_some_and(|hitbox| hitbox.is_hovered(window)) + .then(|| multi_buffer_range.clone()), + }) + { + editor.toggle_single_diff_hunk(hovered_hunk, cx); + cx.notify(); + return; + } else if gutter_hitbox.is_hovered(window) { + click_count = 3; // Simulate triple-click when clicking the gutter to select lines + } else if !text_hitbox.is_hovered(window) { + return; + } + + if EditorSettings::get_global(cx) + .drag_and_drop_selection + .enabled + && click_count == 1 + && !modifiers.shift + { + let newest_anchor = editor.selections.newest_anchor(); + let snapshot = editor.snapshot(window, cx); + let selection = newest_anchor.map(|anchor| anchor.to_display_point(&snapshot)); + if point_for_position.intersects_selection(&selection) { + editor.selection_drag_state = SelectionDragState::ReadyToDrag { + selection: newest_anchor.clone(), + click_position: event.position, + mouse_down_time: Instant::now(), + }; + cx.stop_propagation(); + return; + } + } + + let is_singleton = editor.buffer().read(cx).is_singleton(); + + if click_count == 2 && !is_singleton { + match EditorSettings::get_global(cx).double_click_in_multibuffer { + DoubleClickInMultibuffer::Select => { + // do nothing special on double click, all selection logic is below + } + DoubleClickInMultibuffer::Open => { + if modifiers.alt { + // if double click is made with alt, pretend it's a regular double click without opening and alt, + // and run the selection logic. + modifiers.alt = false; + } else { + let scroll_position_row = position_map.scroll_position.y; + let display_row = (((event.position - gutter_hitbox.bounds.origin).y + / position_map.line_height) + as f64 + + position_map.scroll_position.y) + as u32; + let multi_buffer_row = position_map + .snapshot + .display_point_to_point( + DisplayPoint::new(DisplayRow(display_row), 0), + Bias::Right, + ) + .row; + let line_offset_from_top = display_row - scroll_position_row as u32; + // if double click is made without alt, open the corresponding excerp + editor.open_excerpts_common( + Some(JumpData::MultiBufferRow { + row: MultiBufferRow(multi_buffer_row), + line_offset_from_top, + }), + false, + window, + cx, + ); + return; + } + } + } + } + + if !is_singleton { + let display_row = (ScrollPixelOffset::from( + (event.position - gutter_hitbox.bounds.origin).y / position_map.line_height, + ) + position_map.scroll_position.y) as u32; + let multi_buffer_row = position_map + .snapshot + .display_point_to_point(DisplayPoint::new(DisplayRow(display_row), 0), Bias::Right) + .row; + if line_numbers + .get(&MultiBufferRow(multi_buffer_row)) + .is_some_and(|line_layout| { + line_layout.segments.iter().any(|segment| { + segment + .hitbox + .as_ref() + .is_some_and(|hitbox| hitbox.contains(&event.position)) + }) + }) + { + let line_offset_from_top = display_row - position_map.scroll_position.y as u32; + + editor.open_excerpts_common( + Some(JumpData::MultiBufferRow { + row: MultiBufferRow(multi_buffer_row), + line_offset_from_top, + }), + modifiers.alt, + window, + cx, + ); + cx.stop_propagation(); + return; + } + } + + let position = point_for_position.nearest_valid; + if let Some(mode) = Editor::columnar_selection_mode(&modifiers, cx) { + editor.select( + SelectPhase::BeginColumnar { + position, + reset: match mode { + ColumnarMode::FromMouse => true, + ColumnarMode::FromSelection => false, + }, + mode, + goal_column: point_for_position.exact_unclipped.column(), + }, + window, + cx, + ); + } else if modifiers.shift && !modifiers.control && !modifiers.alt && !modifiers.secondary() + { + editor.select( + SelectPhase::Extend { + position, + click_count, + }, + window, + cx, + ); + } else { + editor.select( + SelectPhase::Begin { + position, + add: Editor::is_alt_pressed(&modifiers, cx), + click_count, + }, + window, + cx, + ); + } + cx.stop_propagation(); + } + + fn mouse_right_down( + editor: &mut Editor, + event: &MouseDownEvent, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + if position_map.gutter_hitbox.is_hovered(window) { + let gutter_right_padding = editor.gutter_dimensions.right_padding; + let hitbox = &position_map.gutter_hitbox; + + if event.position.x <= hitbox.bounds.right() - gutter_right_padding + // Don't show the gutter_context_menu in collab notes + && editor.project.is_some() + { + let point_for_position = position_map.point_for_position(event.position); + editor.set_gutter_context_menu( + point_for_position.nearest_valid.row(), + None, + event.position, + window, + cx, + ); + } + return; + } + + if !position_map.text_hitbox.is_hovered(window) { + return; + } + + let point_for_position = position_map.point_for_position(event.position); + mouse_context_menu::deploy_context_menu( + editor, + Some(event.position), + point_for_position.nearest_valid, + window, + cx, + ); + cx.stop_propagation(); + } + + fn mouse_middle_down( + editor: &mut Editor, + event: &MouseDownEvent, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + if !position_map.text_hitbox.is_hovered(window) || window.default_prevented() { + return; + } + + let point_for_position = position_map.point_for_position(event.position); + let position = point_for_position.nearest_valid; + + editor.select( + SelectPhase::BeginColumnar { + position, + reset: true, + mode: ColumnarMode::FromMouse, + goal_column: point_for_position.exact_unclipped.column(), + }, + window, + cx, + ); + } + + fn mouse_up( + editor: &mut Editor, + event: &MouseUpEvent, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + // Handle diff review drag completion + if editor.diff_review_drag_state.is_some() { + editor.end_diff_review_drag(window, cx); + cx.stop_propagation(); + return; + } + + let text_hitbox = &position_map.text_hitbox; + let end_selection = editor.has_pending_selection(); + let pending_nonempty_selections = editor.has_pending_nonempty_selection(); + let point_for_position = position_map.point_for_position(event.position); + + match editor.selection_drag_state { + SelectionDragState::ReadyToDrag { + selection: _, + ref click_position, + mouse_down_time: _, + } => { + if event.position == *click_position { + editor.select( + SelectPhase::Begin { + position: point_for_position.nearest_valid, + add: false, + click_count: 1, // ready to drag state only occurs on click count 1 + }, + window, + cx, + ); + editor.selection_drag_state = SelectionDragState::None; + cx.stop_propagation(); + return; + } else { + debug_panic!("drag state can never be in ready state after drag") + } + } + SelectionDragState::Dragging { ref selection, .. } => { + let snapshot = editor.snapshot(window, cx); + let selection_display = selection.map(|anchor| anchor.to_display_point(&snapshot)); + if !point_for_position.intersects_selection(&selection_display) + && text_hitbox.is_hovered(window) + { + let is_cut = !(cfg!(target_os = "macos") && event.modifiers.alt + || cfg!(not(target_os = "macos")) && event.modifiers.control); + editor.move_selection_on_drop( + &selection.clone(), + point_for_position.nearest_valid, + is_cut, + window, + cx, + ); + } + editor.selection_drag_state = SelectionDragState::None; + cx.stop_propagation(); + cx.notify(); + return; + } + _ => {} + } + + if end_selection { + editor.select(SelectPhase::End, window, cx); + } + + if end_selection && pending_nonempty_selections { + cx.stop_propagation(); + } else if cfg!(any(target_os = "linux", target_os = "freebsd")) + && event.button == MouseButton::Middle + { + #[allow( + clippy::collapsible_if, + clippy::needless_return, + reason = "The cfg-block below makes this a false positive" + )] + if !text_hitbox.is_hovered(window) || editor.read_only(cx) { + return; + } + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + if EditorSettings::get_global(cx).middle_click_paste { + if let Some(text) = cx.read_from_primary().and_then(|item| item.text()) { + let point_for_position = position_map.point_for_position(event.position); + let position = point_for_position.nearest_valid; + + editor.select( + SelectPhase::Begin { + position, + add: false, + click_count: 1, + }, + window, + cx, + ); + editor.insert(&text, window, cx); + } + cx.stop_propagation() + } + } + } + + fn click( + editor: &mut Editor, + event: &ClickEvent, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + let text_hitbox = &position_map.text_hitbox; + let pending_nonempty_selections = editor.has_pending_nonempty_selection(); + + let hovered_link_modifier = Editor::is_cmd_or_ctrl_pressed(&event.modifiers(), cx); + let mouse_down_hovered_link_modifier = if let ClickEvent::Mouse(mouse_event) = event { + Editor::is_cmd_or_ctrl_pressed(&mouse_event.down.modifiers, cx) + } else { + true + }; + + if let Some(mouse_position) = event.mouse_position() + && !pending_nonempty_selections + && hovered_link_modifier + && mouse_down_hovered_link_modifier + && text_hitbox.is_hovered(window) + && !matches!( + editor.selection_drag_state, + SelectionDragState::Dragging { .. } + ) + { + let point = position_map.point_for_position(mouse_position); + editor.handle_click_hovered_link(point, event.modifiers(), window, cx); + editor.selection_drag_state = SelectionDragState::None; + + cx.stop_propagation(); + } + } + + fn pressure_click( + editor: &mut Editor, + event: &MousePressureEvent, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + let text_hitbox = &position_map.text_hitbox; + let force_click_possible = + matches!(editor.prev_pressure_stage, Some(PressureStage::Normal)) + && event.stage == PressureStage::Force; + + editor.prev_pressure_stage = Some(event.stage); + + if force_click_possible && text_hitbox.is_hovered(window) { + let point = position_map.point_for_position(event.position); + editor.handle_click_hovered_link(point, event.modifiers, window, cx); + editor.selection_drag_state = SelectionDragState::None; + cx.stop_propagation(); + } + } + + fn mouse_dragged( + editor: &mut Editor, + event: &MouseMoveEvent, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + if !editor.has_pending_selection() + && matches!(editor.selection_drag_state, SelectionDragState::None) + { + return; + } + + let point_for_position = position_map.point_for_position(event.position); + let text_hitbox = &position_map.text_hitbox; + + let scroll_delta = { + let text_bounds = text_hitbox.bounds; + let mut scroll_delta = gpui::Point::::default(); + let vertical_margin = position_map.line_height.min(text_bounds.size.height / 3.0); + let top = text_bounds.origin.y + vertical_margin; + let bottom = text_bounds.bottom_left().y - vertical_margin; + if event.position.y < top { + scroll_delta.y = -scale_vertical_mouse_autoscroll_delta(top - event.position.y); + } + if event.position.y > bottom { + scroll_delta.y = scale_vertical_mouse_autoscroll_delta(event.position.y - bottom); + } + + // We need horizontal width of text + let style = editor.style.clone().unwrap_or_default(); + let font_id = window.text_system().resolve_font(&style.text.font()); + let font_size = style.text.font_size.to_pixels(window.rem_size()); + let em_width = window + .text_system() + .em_width(font_id, font_size) + .unwrap_or(font_size); + + let scroll_margin_x = EditorSettings::get_global(cx).horizontal_scroll_margin; + + let scroll_space: Pixels = scroll_margin_x * em_width; + + let left = text_bounds.origin.x + scroll_space; + let right = text_bounds.top_right().x - scroll_space; + + if event.position.x < left { + scroll_delta.x = -scale_horizontal_mouse_autoscroll_delta(left - event.position.x); + } + if event.position.x > right { + scroll_delta.x = scale_horizontal_mouse_autoscroll_delta(event.position.x - right); + } + scroll_delta + }; + + if !editor.has_pending_selection() { + let drop_anchor = position_map + .snapshot + .display_point_to_anchor(point_for_position.nearest_valid, Bias::Left); + match editor.selection_drag_state { + SelectionDragState::Dragging { + ref mut drop_cursor, + ref mut hide_drop_cursor, + .. + } => { + drop_cursor.start = drop_anchor; + drop_cursor.end = drop_anchor; + *hide_drop_cursor = !text_hitbox.is_hovered(window); + editor.apply_scroll_delta(scroll_delta, window, cx); + cx.notify(); + } + SelectionDragState::ReadyToDrag { + ref selection, + ref click_position, + ref mouse_down_time, + } => { + let drag_and_drop_delay = Duration::from_millis( + EditorSettings::get_global(cx) + .drag_and_drop_selection + .delay + .0, + ); + if mouse_down_time.elapsed() >= drag_and_drop_delay { + let drop_cursor = Selection { + id: post_inc(&mut editor.selections.next_selection_id()), + start: drop_anchor, + end: drop_anchor, + reversed: false, + goal: SelectionGoal::None, + }; + editor.selection_drag_state = SelectionDragState::Dragging { + selection: selection.clone(), + drop_cursor, + hide_drop_cursor: false, + }; + editor.apply_scroll_delta(scroll_delta, window, cx); + cx.notify(); + } else { + let click_point = position_map.point_for_position(*click_position); + editor.selection_drag_state = SelectionDragState::None; + editor.select( + SelectPhase::Begin { + position: click_point.nearest_valid, + add: false, + click_count: 1, + }, + window, + cx, + ); + editor.select( + SelectPhase::Update { + position: point_for_position.nearest_valid, + goal_column: point_for_position.exact_unclipped.column(), + scroll_delta, + }, + window, + cx, + ); + } + } + _ => {} + } + } else { + editor.select( + SelectPhase::Update { + position: point_for_position.nearest_valid, + goal_column: point_for_position.exact_unclipped.column(), + scroll_delta, + }, + window, + cx, + ); + } + } + + fn update_visible_cursor( + editor: &mut Editor, + point: DisplayPoint, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + let snapshot = &position_map.snapshot; + let Some(hub) = editor.collaboration_hub() else { + return; + }; + let start = snapshot.display_snapshot.clip_point( + DisplayPoint::new(point.row(), point.column().saturating_sub(1)), + Bias::Left, + ); + let end = snapshot.display_snapshot.clip_point( + DisplayPoint::new( + point.row(), + (point.column() + 1).min(snapshot.line_len(point.row())), + ), + Bias::Right, + ); + + let range = snapshot + .buffer_snapshot() + .anchor_before(start.to_point(&snapshot.display_snapshot)) + ..snapshot + .buffer_snapshot() + .anchor_after(end.to_point(&snapshot.display_snapshot)); + + let Some(selection) = snapshot.remote_selections_in_range(&range, hub, cx).next() else { + return; + }; + let key = HoveredCursor { + replica_id: selection.replica_id, + selection_id: selection.selection.id, + }; + editor.hovered_cursors.insert( + key.clone(), + cx.spawn_in(window, async move |editor, cx| { + cx.background_executor().timer(CURSORS_VISIBLE_FOR).await; + editor + .update(cx, |editor, cx| { + editor.hovered_cursors.remove(&key); + cx.notify(); + }) + .ok(); + }), + ); + cx.notify() + } +} + +fn scale_vertical_mouse_autoscroll_delta(delta: Pixels) -> f32 { + (delta.pow(1.2) / 100.0).min(px(3.0)).into() +} + +fn scale_horizontal_mouse_autoscroll_delta(delta: Pixels) -> f32 { + (delta.pow(1.2) / 300.0).into() +} diff --git a/crates/editor/src/lsp_ext.rs b/crates/editor/src/lsp_ext.rs index 881c14903b2..e70f1b1202f 100644 --- a/crates/editor/src/lsp_ext.rs +++ b/crates/editor/src/lsp_ext.rs @@ -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() diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index c6f9c6fc51c..f6febdc5c4e 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -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 { diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index e671872f525..6ca38e15541 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -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, ) { 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::() { - 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 @@ -5986,7 +5929,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() @@ -6265,7 +6208,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); @@ -6715,7 +6658,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)) diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 58b1af74fb6..d19ac552067 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -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; diff --git a/crates/git_ui/src/solo_diff_view.rs b/crates/git_ui/src/solo_diff_view.rs new file mode 100644 index 00000000000..052206c3293 --- /dev/null +++ b/crates/git_ui/src/solo_diff_view.rs @@ -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_id: RepositoryId, + repo_path: RepoPath, + buffer: Entity, + editor: Entity, + workspace: WeakEntity, + _settings_subscription: Subscription, +} + +impl SoloDiffView { + pub fn open_or_focus( + entry: GitStatusEntry, + repository: Entity, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + 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::(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, + repository: Entity, + repo_path: RepoPath, + buffer: Entity, + diff: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> 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::(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, 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::>(); + 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) { + 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 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 { + 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 { + 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.editor.deactivated(window, cx); + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + cx: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else { + self.editor.act_as_type(type_id, cx) + } + } + + fn as_searchable(&self, _: &Entity, _: &App) -> Option> { + 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.editor.update(cx, |editor, cx| { + editor.rhs_editor().update(cx, |editor, _| { + editor.set_nav_history(Some(nav_history)); + }) + }); + } + + fn navigate( + &mut self, + data: Arc, + window: &mut Window, + cx: &mut Context, + ) -> 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, Option)> { + self.editor.breadcrumbs(cx) + } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + 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, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.editor.save(options, project, window, cx) + } +} + +impl Render for SoloDiffView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + self.editor.clone() + } +} + +pub struct SoloDiffStyleToolbar { + solo_diff: Option>, +} + +pub struct SoloDiffGitToolbar { + solo_diff: Option>, +} + +impl SoloDiffStyleToolbar { + pub fn new(_: &mut Context) -> Self { + Self { solo_diff: None } + } + + fn solo_diff(&self) -> Option> { + self.solo_diff.as_ref()?.upgrade() + } + + fn set_diff_view_style( + &mut self, + diff_view_style: DiffViewStyle, + window: &mut Window, + cx: &mut Context, + ) { + let Some(solo_diff) = self.solo_diff() else { + return; + }; + let workspace = solo_diff.read(cx).workspace.clone(); + + update_settings_file(::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::(), cx)) + .filter_map(|item| item.downcast::().ok()) + .collect::>() + }; + + 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 for SoloDiffStyleToolbar {} + +impl ToolbarItemView for SoloDiffStyleToolbar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + self.solo_diff = active_pane_item + .and_then(|item| item.act_as::(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) -> 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 { solo_diff: None } + } + + fn solo_diff(&self) -> Option> { + self.solo_diff.as_ref()?.upgrade() + } + + fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) { + 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) { + 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) { + 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 for SoloDiffGitToolbar {} + +impl ToolbarItemView for SoloDiffGitToolbar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + self.solo_diff = active_pane_item + .and_then(|item| item.act_as::(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) -> 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); + })), + ), + ) + } +} diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 7f80d788925..5a729dcc5f5 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -741,6 +741,44 @@ impl ListState { pub fn viewport_bounds(&self) -> Bounds { 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 { + 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 { + 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) -> 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(); diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index c1afce81073..7f8d7da0d48 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -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, not_send: PhantomData>, } @@ -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, _) = { + 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() } } diff --git a/crates/gpui/src/platform/test/dispatcher.rs b/crates/gpui/src/platform/test/dispatcher.rs index 29aff84ff9d..ef662c6c488 100644 --- a/crates/gpui/src/platform/test/dispatcher.rs +++ b/crates/gpui/src/platform/test/dispatcher.rs @@ -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) { diff --git a/crates/gpui/src/platform_scheduler.rs b/crates/gpui/src/platform_scheduler.rs index 0087c588d8d..6a3929e91bb 100644 --- a/crates/gpui/src/platform_scheduler.rs +++ b/crates/gpui/src/platform_scheduler.rs @@ -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) -> 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) { + fn schedule_local(&self, _session_id: SessionId, runnable: Runnable) { 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, + f: Box< + dyn FnOnce( + LocalExecutor, + ) + -> Pin> + 'static>> + + Send + + 'static, + >, + ) -> Task> { + 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 { + 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) { + 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>` 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>, + } + 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::(); + let (drop_tx, drop_rx) = mpsc::channel::(); + + 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::(); + + 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" + ); + } +} diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 8d44101c181..66716ebdadb 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -12,15 +12,12 @@ use language_model::{ LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter, }; -use open_ai::{ - ReasoningEffort, - responses::{StreamResponseOptions, stream_response_with_options}, -}; +use open_ai::{ReasoningEffort, responses::stream_response}; use rand::RngCore as _; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{SystemTime, UNIX_EPOCH}; use ui::{ConfiguredApiCard, prelude::*}; use url::form_urlencoded; use util::ResultExt as _; @@ -38,31 +35,6 @@ const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const CREDENTIALS_KEY: &str = "https://chatgpt.com/backend-api/codex"; const TOKEN_REFRESH_BUFFER_MS: u64 = 5 * 60 * 1000; -const CODEX_RESPONSE_HEADER_TIMEOUT: Duration = Duration::from_secs(10); - -fn codex_extra_headers( - account_id: Option<&str>, - session_id: Option<&str>, -) -> Vec<(String, String)> { - let mut extra_headers: Vec<(String, String)> = vec![ - ("originator".into(), "zed".into()), - ("OpenAI-Beta".into(), "responses=experimental".into()), - ]; - - if let Some(id) = account_id { - if !id.is_empty() { - extra_headers.push(("ChatGPT-Account-Id".into(), id.into())); - } - } - - if let Some(id) = session_id { - if !id.is_empty() { - extra_headers.push(("session-id".into(), id.into())); - } - } - - extra_headers -} #[derive(Serialize, Deserialize, Clone, Debug)] struct CodexCredentials { @@ -500,7 +472,6 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { // The Codex backend rejects `max_output_tokens` (`Unsupported parameter`), // unlike the public OpenAI Responses API. Pass `None` so the field is // omitted from the serialized request body entirely. - let session_id = request.thread_id.clone(); let mut responses_request = into_open_ai_response( request, self.model.id(), @@ -539,24 +510,26 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { let future = cx.spawn(async move |cx| { let creds = get_fresh_credentials(&state, &http_client, cx).await?; - let extra_headers = - codex_extra_headers(creds.account_id.as_deref(), session_id.as_deref()); + let mut extra_headers: Vec<(String, String)> = vec![ + ("originator".into(), "zed".into()), + ("OpenAI-Beta".into(), "responses=experimental".into()), + ]; + if let Some(ref id) = creds.account_id { + if !id.is_empty() { + extra_headers.push(("ChatGPT-Account-Id".into(), id.clone())); + } + } let access_token = creds.access_token.clone(); - let background_executor = cx.background_executor().clone(); request_limiter .stream(async move { - stream_response_with_options( + stream_response( http_client.as_ref(), PROVIDER_NAME.0.as_str(), CODEX_BASE_URL, &access_token, responses_request, extra_headers, - StreamResponseOptions::response_header_timeout( - CODEX_RESPONSE_HEADER_TIMEOUT, - background_executor.timer(CODEX_RESPONSE_HEADER_TIMEOUT), - ), ) .await .map_err(LanguageModelCompletionError::from) @@ -1135,7 +1108,6 @@ mod tests { use super::*; use gpui::TestAppContext; use http_client::FakeHttpClient; - use language_model::{LanguageModelRequestMessage, Role}; use parking_lot::Mutex; use std::future::Future; use std::pin::Pin; @@ -1185,30 +1157,6 @@ mod tests { } } - #[test] - fn test_codex_extra_headers_include_session_id() { - assert_eq!( - codex_extra_headers(Some("account-1"), Some("thread-1")), - vec![ - ("originator".into(), "zed".into()), - ("OpenAI-Beta".into(), "responses=experimental".into()), - ("ChatGPT-Account-Id".into(), "account-1".into()), - ("session-id".into(), "thread-1".into()), - ] - ); - } - - #[test] - fn test_codex_extra_headers_omit_empty_optional_ids() { - assert_eq!( - codex_extra_headers(Some(""), Some("")), - vec![ - ("originator".into(), "zed".into()), - ("OpenAI-Beta".into(), "responses=experimental".into()), - ] - ); - } - fn make_expired_credentials() -> CodexCredentials { CodexCredentials { access_token: "old_access".to_string(), @@ -1229,13 +1177,6 @@ mod tests { } } - fn make_fresh_credentials_with_account() -> CodexCredentials { - CodexCredentials { - account_id: Some("account-1".to_string()), - ..make_fresh_credentials() - } - } - fn fake_token_response() -> String { serde_json::json!({ "access_token": "fresh_access", @@ -1245,127 +1186,6 @@ mod tests { .to_string() } - #[gpui::test] - async fn test_stream_completion_sends_codex_session_header(cx: &mut TestAppContext) { - let captured_headers = Arc::new(Mutex::new(None::)); - let captured_headers_clone = captured_headers.clone(); - let http_client = FakeHttpClient::create(move |request| { - *captured_headers_clone.lock() = Some(request.headers().clone()); - async move { - let body = r#"data: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}"#; - Ok(http_client::Response::builder() - .status(200) - .body(http_client::AsyncBody::from(format!("{body}\n\n")))?) - } - }); - - let state = cx.new(|_cx| State { - credentials: Some(make_fresh_credentials_with_account()), - sign_in_task: None, - refresh_task: None, - load_task: None, - credentials_provider: Arc::new(FakeCredentialsProvider::new()), - auth_generation: 0, - last_auth_error: None, - }); - - let model = OpenAiSubscribedLanguageModel { - id: LanguageModelId::from(ChatGptModel::Gpt55.id().to_string()), - model: ChatGptModel::Gpt55, - state, - http_client, - request_limiter: RateLimiter::new(4), - }; - let request = LanguageModelRequest { - thread_id: Some("thread-1".to_string()), - prompt_id: Some("prompt-1".to_string()), - messages: vec![LanguageModelRequestMessage { - role: Role::User, - content: vec!["Hello".into()], - cache: false, - reasoning_details: None, - }], - ..Default::default() - }; - - let mut stream = model - .stream_completion(request, &cx.to_async()) - .await - .expect("stream should start"); - stream - .next() - .await - .expect("stream should emit event") - .expect("event should parse"); - - let captured_headers = captured_headers - .lock() - .clone() - .expect("request headers should be captured"); - assert_eq!( - captured_headers - .get("session-id") - .and_then(|value| value.to_str().ok()), - Some("thread-1") - ); - assert_eq!( - captured_headers - .get("ChatGPT-Account-Id") - .and_then(|value| value.to_str().ok()), - Some("account-1") - ); - } - - #[gpui::test] - async fn test_stream_completion_times_out_before_codex_headers(cx: &mut TestAppContext) { - let http_client = FakeHttpClient::create(|_request| { - futures::future::pending::>>() - }); - - let state = cx.new(|_cx| State { - credentials: Some(make_fresh_credentials()), - sign_in_task: None, - refresh_task: None, - load_task: None, - credentials_provider: Arc::new(FakeCredentialsProvider::new()), - auth_generation: 0, - last_auth_error: None, - }); - - let model = OpenAiSubscribedLanguageModel { - id: LanguageModelId::from(ChatGptModel::Gpt55.id().to_string()), - model: ChatGptModel::Gpt55, - state, - http_client, - request_limiter: RateLimiter::new(4), - }; - let request = LanguageModelRequest { - thread_id: Some("thread-1".to_string()), - prompt_id: Some("prompt-1".to_string()), - messages: vec![LanguageModelRequestMessage { - role: Role::User, - content: vec!["Hello".into()], - cache: false, - reasoning_details: None, - }], - ..Default::default() - }; - - let stream_completion = model.stream_completion(request, &cx.to_async()); - cx.run_until_parked(); - cx.executor().advance_clock(CODEX_RESPONSE_HEADER_TIMEOUT); - - let error = match stream_completion.await { - Ok(_) => panic!("stream should time out before headers arrive"), - Err(error) => error, - }; - assert!(matches!( - error, - LanguageModelCompletionError::HttpSend { provider, .. } - if provider == PROVIDER_NAME - )); - } - #[gpui::test] async fn test_concurrent_refresh_deduplicates(cx: &mut TestAppContext) { let refresh_count = Arc::new(AtomicUsize::new(0)); diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs index ea6f9d3d4d7..312cdee5a66 100644 --- a/crates/language_models/src/provider/vercel_ai_gateway.rs +++ b/crates/language_models/src/provider/vercel_ai_gateway.rs @@ -316,12 +316,6 @@ fn map_open_ai_error(error: open_ai::RequestError) -> LanguageModelCompletionErr retry_after, ) } - open_ai::RequestError::ResponseHeaderTimeout { timeout, .. } => { - LanguageModelCompletionError::HttpSend { - provider: PROVIDER_NAME, - error: anyhow::anyhow!("response headers timed out after {timeout:?}"), - } - } open_ai::RequestError::Other(error) => LanguageModelCompletionError::Other(error), } } diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index 623853b5214..51eb2e3b81d 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -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 { + if model.supports_reasoning_effort() { + Some(open_ai::ReasoningEffort::Low) + } else { + None + } +} + +fn reasoning_effort_for_request( + request: &LanguageModelRequest, + model: &x_ai::Model, +) -> Option { + 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::().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 { + 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 { + 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::>(); + + 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) -> impl IntoElement { let env_var_set = self.state.read(cx).api_key_state.is_from_env_var(); diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index cea5b1169b0..8a72b5df40e 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -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 { diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 5b9e5958267..0ff1308d52a 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -11,7 +11,7 @@ use http_client::{ pub use language_model_core::ReasoningEffort; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::{convert::TryFrom, future::Future, time::Duration}; +use std::{convert::TryFrom, future::Future}; use strum::EnumIter; use thiserror::Error; @@ -684,8 +684,6 @@ pub enum RequestError { body: String, headers: HeaderMap, }, - #[error("response headers from {provider}'s API timed out after {timeout:?}")] - ResponseHeaderTimeout { provider: String, timeout: Duration }, #[error(transparent)] Other(#[from] anyhow::Error), } @@ -905,10 +903,6 @@ impl From for language_model_core::LanguageModelCompletionError { Self::from_http_status(provider.into(), status_code, body, retry_after) } - RequestError::ResponseHeaderTimeout { provider, timeout } => Self::HttpSend { - provider: provider.into(), - error: anyhow!("response headers timed out after {timeout:?}"), - }, RequestError::Other(e) => Self::Other(e), } } diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index c3ee619fe73..6cc05699254 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -1,266 +1,11 @@ use anyhow::{Result, anyhow}; -use futures::{ - AsyncBufReadExt, AsyncReadExt, FutureExt, StreamExt, future::BoxFuture, io::BufReader, - stream::BoxStream, -}; +use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::{future::Future, time::Duration}; use crate::{ReasoningEffort, RequestError, Role, ServiceTier, ToolChoice}; -#[derive(Default)] -pub struct StreamResponseOptions { - response_header_timeout: Option<(Duration, BoxFuture<'static, ()>)>, -} - -impl StreamResponseOptions { - pub fn response_header_timeout( - timeout: Duration, - timer: impl Future + Send + 'static, - ) -> Self { - Self { - response_header_timeout: Some((timeout, timer.boxed())), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use futures::{FutureExt, StreamExt, future}; - use http_client::{ - AsyncBody, HttpClient, Request as HttpRequest, Response as HttpResponse, Url, - }; - use std::{ - io::{Cursor, Read}, - pin::Pin, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, - task::{Context, Poll, Waker}, - }; - - struct TestHttpClient { - handler: Arc< - dyn Fn( - HttpRequest, - ) -> BoxFuture<'static, anyhow::Result>> - + Send - + Sync, - >, - } - - impl TestHttpClient { - fn new(handler: F) -> Self - where - F: Fn( - HttpRequest, - ) -> BoxFuture<'static, anyhow::Result>> - + Send - + Sync - + 'static, - { - Self { - handler: Arc::new(handler), - } - } - } - - impl HttpClient for TestHttpClient { - fn user_agent(&self) -> Option<&http_client::http::HeaderValue> { - None - } - - fn proxy(&self) -> Option<&Url> { - None - } - - fn send( - &self, - request: HttpRequest, - ) -> BoxFuture<'static, anyhow::Result>> { - (self.handler)(request) - } - } - - struct DelayedBody { - state: Arc, - bytes: Cursor>, - } - - struct DelayedBodyState { - released: AtomicBool, - waker: Mutex>, - } - - struct DelayedBodyHandle { - state: Arc, - } - - impl DelayedBody { - fn new(bytes: Vec) -> (Self, DelayedBodyHandle) { - let state = Arc::new(DelayedBodyState { - released: AtomicBool::new(false), - waker: Mutex::new(None), - }); - - ( - Self { - state: state.clone(), - bytes: Cursor::new(bytes), - }, - DelayedBodyHandle { state }, - ) - } - } - - impl DelayedBodyHandle { - fn release(&self) { - self.state.released.store(true, Ordering::SeqCst); - if let Some(waker) = self.state.waker.lock().expect("lock poisoned").take() { - waker.wake(); - } - } - } - - impl futures::AsyncRead for DelayedBody { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buffer: &mut [u8], - ) -> Poll> { - if !self.state.released.load(Ordering::SeqCst) { - self.state - .waker - .lock() - .expect("lock poisoned") - .replace(cx.waker().clone()); - return Poll::Pending; - } - - Poll::Ready(self.bytes.read(buffer)) - } - } - - fn test_request() -> Request { - Request { - model: "gpt-test".into(), - instructions: None, - input: Vec::new(), - include: Vec::new(), - stream: true, - temperature: None, - top_p: None, - max_output_tokens: None, - parallel_tool_calls: None, - tool_choice: None, - tools: Vec::new(), - prompt_cache_key: None, - reasoning: None, - store: None, - service_tier: None, - } - } - - #[test] - fn stream_response_times_out_before_headers() { - futures::executor::block_on(async { - let client = TestHttpClient::new(|_| { - future::pending::>>().boxed() - }); - - let result = stream_response_with_options( - &client, - "Test Provider", - "https://api.test/v1", - "test-key", - test_request(), - Vec::new(), - StreamResponseOptions::response_header_timeout( - Duration::from_secs(10), - future::ready(()), - ), - ) - .await; - - assert!(matches!( - result, - Err(RequestError::ResponseHeaderTimeout { - provider, - timeout - }) if provider == "Test Provider" && timeout == Duration::from_secs(10) - )); - }); - } - - #[test] - fn stream_response_does_not_timeout_after_headers_arrive() { - futures::executor::block_on(async { - let body = r#"data: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}"#; - let (delayed_body, delayed_body_handle) = - DelayedBody::new(format!("{body}\n\n").into_bytes()); - let delayed_body = Mutex::new(Some(delayed_body)); - let client = TestHttpClient::new(move |_| { - let delayed_body = delayed_body - .lock() - .expect("lock poisoned") - .take() - .expect("test sends only one request"); - async { - Ok(HttpResponse::builder() - .status(200) - .body(AsyncBody::from_reader(delayed_body))?) - } - .boxed() - }); - let (timeout_tx, timeout_rx) = futures::channel::oneshot::channel::<()>(); - - let mut stream = stream_response_with_options( - &client, - "Test Provider", - "https://api.test/v1", - "test-key", - test_request(), - Vec::new(), - StreamResponseOptions::response_header_timeout( - Duration::from_secs(10), - async move { - assert!( - timeout_rx.await.is_ok(), - "timer should be dropped after headers arrive" - ); - }, - ), - ) - .await - .expect("headers should arrive before timeout"); - - assert!( - timeout_tx.send(()).is_err(), - "timeout future should be dropped after headers arrive" - ); - - assert!( - stream.next().now_or_never().is_none(), - "stream should wait for delayed body bytes" - ); - - delayed_body_handle.release(); - - let event = stream - .next() - .await - .expect("stream should produce an event") - .expect("event should parse"); - - assert!(matches!(event, StreamEvent::Completed { .. })); - }); - } -} - #[derive(Serialize, Debug)] pub struct Request { pub model: String, @@ -695,27 +440,6 @@ pub async fn stream_response( api_key: &str, request: Request, extra_headers: Vec<(String, String)>, -) -> Result>, RequestError> { - stream_response_with_options( - client, - provider_name, - api_url, - api_key, - request, - extra_headers, - StreamResponseOptions::default(), - ) - .await -} - -pub async fn stream_response_with_options( - client: &dyn HttpClient, - provider_name: &str, - api_url: &str, - api_key: &str, - request: Request, - extra_headers: Vec<(String, String)>, - options: StreamResponseOptions, ) -> Result>, RequestError> { let uri = format!("{api_url}/responses"); let mut request_builder = HttpRequest::builder() @@ -734,24 +458,7 @@ pub async fn stream_response_with_options( )) .map_err(|e| RequestError::Other(e.into()))?; - let mut response = if let Some((timeout, timer)) = options.response_header_timeout { - let send_request = client.send(request).fuse(); - let timer = timer.fuse(); - futures::pin_mut!(send_request); - futures::pin_mut!(timer); - - futures::select! { - response = send_request => response?, - () = timer => { - return Err(RequestError::ResponseHeaderTimeout { - provider: provider_name.to_owned(), - timeout, - }); - } - } - } else { - client.send(request).await? - }; + let mut response = client.send(request).await?; if response.status().is_success() { if is_streaming { let reader = BufReader::new(response.into_body()); diff --git a/crates/prompt_store/Cargo.toml b/crates/prompt_store/Cargo.toml index 8c1f296f171..bc4179e5e72 100644 --- a/crates/prompt_store/Cargo.toml +++ b/crates/prompt_store/Cargo.toml @@ -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 diff --git a/crates/prompt_store/src/prompt_store.rs b/crates/prompt_store/src/prompt_store.rs index bf9b2f981bd..df270bb0a42 100644 --- a/crates/prompt_store/src/prompt_store.rs +++ b/crates/prompt_store/src/prompt_store.rs @@ -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 for PromptId { @@ -173,14 +157,9 @@ impl std::fmt::Display for PromptId { pub struct PromptStore { env: heed::Env, metadata_cache: RwLock, - metadata: Database, SerdeJson>, bodies: Database, Str>, } -pub struct PromptsUpdatedEvent; - -impl EventEmitter for PromptStore {} - #[derive(Default)] struct MetadataCache { metadata: Vec, @@ -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 { self.metadata_cache.read().metadata.clone() } - - pub fn default_prompt_metadata(&self) -> Vec { - return self - .metadata_cache - .read() - .metadata - .iter() - .filter(|metadata| metadata.default) - .cloned() - .collect::>(); - } - - pub fn delete(&self, id: PromptId, cx: &Context) -> Task> { - 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<()>>( - &txn, - Some("metadata"), - )? - { - metadata_v1_db.delete(&mut txn, &prompt_id_v1)?; - } - - if let Some(bodies_v1_db) = db_connection - .open_database::, 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 { - self.metadata_cache.read().metadata_by_id.get(&id).cloned() - } - - pub fn first(&self) -> Option { - self.metadata_cache.read().metadata.first().cloned() - } - - pub fn id_for_title(&self, title: &str) -> Option { - 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, - cx: &App, - ) -> Task> { - 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::>(); - 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, - default: bool, - body: Rope, - cx: &Context, - ) -> Task> { - 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, - default: bool, - cx: &Context, - ) -> Task> { - 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 = 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> = - db_env.create_database(&mut txn, Some("metadata")).unwrap(); - - let bodies_v1_db: Database, SerdeBincode> = - 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" - ); } } diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index 85e07aee0b4..993872b179f 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -1555,7 +1555,7 @@ type ResponseChannels = Mutex, oneshot::Sender<()>)>>>>; -struct Signal { +struct Signal { tx: Mutex>>, rx: Shared>>, } diff --git a/crates/rules_library/Cargo.toml b/crates/rules_library/Cargo.toml deleted file mode 100644 index 352f86bd72f..00000000000 --- a/crates/rules_library/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -[package] -name = "rules_library" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/rules_library.rs" - - -[dependencies] -anyhow.workspace = true -collections.workspace = true -editor.workspace = true -gpui.workspace = true -language.workspace = true -language_model.workspace = true -log.workspace = true -menu.workspace = true -picker.workspace = true -platform_title_bar.workspace = true -prompt_store.workspace = true -release_channel.workspace = true -rope.workspace = true -serde.workspace = true -settings.workspace = true -theme_settings.workspace = true -ui.workspace = true -ui_input.workspace = true -util.workspace = true -workspace.workspace = true -zed_actions.workspace = true diff --git a/crates/rules_library/LICENSE-GPL b/crates/rules_library/LICENSE-GPL deleted file mode 120000 index 89e542f750c..00000000000 --- a/crates/rules_library/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs deleted file mode 100644 index 9f87d403e72..00000000000 --- a/crates/rules_library/src/rules_library.rs +++ /dev/null @@ -1,1341 +0,0 @@ -use anyhow::Result; -use collections::{HashMap, HashSet}; -use editor::SelectionEffects; -use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab}; -use gpui::{ - App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable, PromptLevel, - Subscription, Task, TaskExt, TextStyle, Tiling, TitlebarOptions, WindowBounds, WindowHandle, - WindowOptions, actions, point, size, transparent_black, -}; -use language::{Buffer, LanguageRegistry, language_settings::SoftWrap}; -use language_model::{ConfiguredModel, LanguageModelRegistry}; -use picker::{Picker, PickerDelegate}; -use platform_title_bar::PlatformTitleBar; -use release_channel::ReleaseChannel; -use rope::Rope; -use settings::{ActionSequence, Settings}; -use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::time::Duration; -use theme_settings::ThemeSettings; -use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*}; -use ui_input::ErasedEditor; -use util::{ResultExt, TryFutureExt}; -use workspace::{MultiWorkspace, Workspace, WorkspaceSettings, client_side_decorations}; -use zed_actions::assistant::InlineAssist; - -use prompt_store::*; - -pub fn init(cx: &mut App) { - prompt_store::init(cx); -} - -actions!( - rules_library, - [ - /// Creates a new rule in the rules library. - NewRule, - /// Deletes the selected rule. - DeleteRule, - /// Duplicates the selected rule. - DuplicateRule, - /// Toggles whether the selected rule is a default rule. - ToggleDefaultRule, - /// Restores a built-in rule to its default content. - RestoreDefaultContent - ] -); - -pub trait InlineAssistDelegate { - fn assist( - &self, - prompt_editor: &Entity, - initial_prompt: Option, - window: &mut Window, - cx: &mut Context, - ); - - /// Returns whether the Agent panel was focused. - fn focus_agent_panel( - &self, - workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context, - ) -> bool; -} - -/// This function opens a new rules library window if one doesn't exist already. -/// If one exists, it brings it to the foreground. -/// -/// Note that, when opening a new window, this waits for the PromptStore to be -/// initialized. If it was initialized successfully, it returns a window handle -/// to a rules library. -pub fn open_rules_library( - language_registry: Arc, - inline_assist_delegate: Box, - prompt_to_select: Option, - cx: &mut App, -) -> Task>> { - let store = PromptStore::global(cx); - cx.spawn(async move |cx| { - // We query windows in spawn so that all windows have been returned to GPUI - let existing_window = cx.update(|cx| { - let existing_window = cx - .windows() - .into_iter() - .find_map(|window| window.downcast::()); - if let Some(existing_window) = existing_window { - existing_window - .update(cx, |rules_library, window, cx| { - if let Some(prompt_to_select) = prompt_to_select { - rules_library.load_rule(prompt_to_select, true, window, cx); - } - window.activate_window() - }) - .ok(); - - Some(existing_window) - } else { - None - } - }); - - if let Some(existing_window) = existing_window { - return Ok(existing_window); - } - - let store = store.await?; - cx.update(|cx| { - let app_id = ReleaseChannel::global(cx).app_id(); - let bounds = Bounds::centered(None, size(px(1024.0), px(768.0)), cx); - let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") { - Ok(val) if val == "server" => gpui::WindowDecorations::Server, - Ok(val) if val == "client" => gpui::WindowDecorations::Client, - _ => match WorkspaceSettings::get_global(cx).window_decorations { - settings::WindowDecorations::Server => gpui::WindowDecorations::Server, - settings::WindowDecorations::Client => gpui::WindowDecorations::Client, - }, - }; - cx.open_window( - WindowOptions { - titlebar: Some(TitlebarOptions { - title: Some("Rules Library".into()), - appears_transparent: true, - traffic_light_position: Some(point(px(12.0), px(12.0))), - }), - app_id: Some(app_id.to_owned()), - window_bounds: Some(WindowBounds::Windowed(bounds)), - window_background: cx.theme().window_background_appearance(), - window_decorations: Some(window_decorations), - window_min_size: Some(DEFAULT_ADDITIONAL_WINDOW_SIZE), - kind: gpui::WindowKind::Floating, - ..Default::default() - }, - |window, cx| { - cx.new(|cx| { - RulesLibrary::new( - store, - language_registry, - inline_assist_delegate, - prompt_to_select, - window, - cx, - ) - }) - }, - ) - }) - }) -} - -pub struct RulesLibrary { - title_bar: Option>, - store: Entity, - language_registry: Arc, - rule_editors: HashMap, - active_rule_id: Option, - picker: Entity>, - pending_load: Task<()>, - inline_assist_delegate: Box, - _subscriptions: Vec, -} - -struct RuleEditor { - title_editor: Entity, - body_editor: Entity, - next_title_and_body_to_save: Option<(String, Rope)>, - pending_save: Option>>, - _subscriptions: Vec, -} - -enum RulePickerEntry { - Header(SharedString), - Rule(PromptMetadata), - Separator, -} - -struct RulePickerDelegate { - store: Entity, - selected_index: usize, - filtered_entries: Vec, -} - -enum RulePickerEvent { - Selected { prompt_id: PromptId }, - Confirmed { prompt_id: PromptId }, - Deleted { prompt_id: PromptId }, - ToggledDefault { prompt_id: PromptId }, -} - -impl EventEmitter for Picker {} - -impl PickerDelegate for RulePickerDelegate { - type ListItem = AnyElement; - - fn match_count(&self) -> usize { - self.filtered_entries.len() - } - - fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { - Some("No rules found matching your search.".into()) - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context>) { - self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1)); - - if let Some(RulePickerEntry::Rule(rule)) = self.filtered_entries.get(self.selected_index) { - cx.emit(RulePickerEvent::Selected { prompt_id: rule.id }); - } - - cx.notify(); - } - - fn can_select(&self, ix: usize, _: &mut Window, _: &mut Context>) -> bool { - match self.filtered_entries.get(ix) { - Some(RulePickerEntry::Rule(_)) => true, - Some(RulePickerEntry::Header(_)) | Some(RulePickerEntry::Separator) | None => false, - } - } - - fn select_on_hover(&self) -> bool { - false - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Search…".into() - } - - fn update_matches( - &mut self, - query: String, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - let cancellation_flag = Arc::new(AtomicBool::default()); - let search = self.store.read(cx).search(query, cancellation_flag, cx); - - let prev_prompt_id = self - .filtered_entries - .get(self.selected_index) - .and_then(|entry| { - if let RulePickerEntry::Rule(rule) = entry { - Some(rule.id) - } else { - None - } - }); - - cx.spawn_in(window, async move |this, cx| { - let (filtered_entries, selected_index) = cx - .background_spawn(async move { - let matches = search.await; - - let (built_in_rules, user_rules): (Vec<_>, Vec<_>) = - matches.into_iter().partition(|rule| rule.id.is_built_in()); - let (default_rules, other_rules): (Vec<_>, Vec<_>) = - user_rules.into_iter().partition(|rule| rule.default); - - let mut filtered_entries = Vec::new(); - - if !built_in_rules.is_empty() { - filtered_entries.push(RulePickerEntry::Header("Built-in Rules".into())); - - for rule in built_in_rules { - filtered_entries.push(RulePickerEntry::Rule(rule)); - } - - filtered_entries.push(RulePickerEntry::Separator); - } - - if !default_rules.is_empty() { - filtered_entries.push(RulePickerEntry::Header("Default Rules".into())); - - for rule in default_rules { - filtered_entries.push(RulePickerEntry::Rule(rule)); - } - - filtered_entries.push(RulePickerEntry::Separator); - } - - for rule in other_rules { - filtered_entries.push(RulePickerEntry::Rule(rule)); - } - - let selected_index = prev_prompt_id - .and_then(|prev_prompt_id| { - filtered_entries.iter().position(|entry| { - if let RulePickerEntry::Rule(rule) = entry { - rule.id == prev_prompt_id - } else { - false - } - }) - }) - .unwrap_or_else(|| { - filtered_entries - .iter() - .position(|entry| matches!(entry, RulePickerEntry::Rule(_))) - .unwrap_or(0) - }); - - (filtered_entries, selected_index) - }) - .await; - - this.update_in(cx, |this, window, cx| { - this.delegate.filtered_entries = filtered_entries; - this.set_selected_index( - selected_index, - Some(picker::Direction::Down), - true, - window, - cx, - ); - cx.notify(); - }) - .ok(); - }) - } - - fn confirm(&mut self, _secondary: bool, _: &mut Window, cx: &mut Context>) { - if let Some(RulePickerEntry::Rule(rule)) = self.filtered_entries.get(self.selected_index) { - cx.emit(RulePickerEvent::Confirmed { prompt_id: rule.id }); - } - } - - fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} - - fn render_match( - &self, - ix: usize, - selected: bool, - _: &mut Window, - cx: &mut Context>, - ) -> Option { - match self.filtered_entries.get(ix)? { - RulePickerEntry::Header(title) => { - let tooltip_text = if title.as_ref() == "Built-in Rules" { - "Built-in rules are those included out of the box with Zed." - } else { - "Default Rules are attached by default with every new thread." - }; - - Some( - ListSubHeader::new(title.clone()) - .end_slot( - IconButton::new("info", IconName::Info) - .style(ButtonStyle::Transparent) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text(tooltip_text)) - .into_any_element(), - ) - .inset(true) - .into_any_element(), - ) - } - RulePickerEntry::Separator => Some( - h_flex() - .py_1() - .child(Divider::horizontal()) - .into_any_element(), - ), - RulePickerEntry::Rule(rule) => { - let default = rule.default; - let prompt_id = rule.id; - - Some( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - Label::new(rule.title.clone().unwrap_or("Untitled".into())) - .truncate() - .mr_10(), - ) - .end_slot::((default && !prompt_id.is_built_in()).then(|| { - IconButton::new("toggle-default-rule", IconName::Paperclip) - .toggle_state(true) - .icon_color(Color::Accent) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Remove from Default Rules")) - .on_click(cx.listener(move |_, _, _, cx| { - cx.emit(RulePickerEvent::ToggledDefault { prompt_id }) - })) - })) - .when(!prompt_id.is_built_in(), |this| { - this.end_slot_on_hover( - h_flex() - .child( - IconButton::new("delete-rule", IconName::Trash) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Delete Rule")) - .on_click(cx.listener(move |_, _, _, cx| { - cx.emit(RulePickerEvent::Deleted { prompt_id }) - })), - ) - .child( - IconButton::new("toggle-default-rule", IconName::Plus) - .selected_icon(IconName::Dash) - .toggle_state(default) - .icon_size(IconSize::Small) - .icon_color(if default { - Color::Accent - } else { - Color::Muted - }) - .map(|this| { - if default { - this.tooltip(Tooltip::text( - "Remove from Default Rules", - )) - } else { - this.tooltip(move |_window, cx| { - Tooltip::with_meta( - "Add to Default Rules", - None, - "Always included in every thread.", - cx, - ) - }) - } - }) - .on_click(cx.listener(move |_, _, _, cx| { - cx.emit(RulePickerEvent::ToggledDefault { - prompt_id, - }) - })), - ), - ) - }) - .into_any_element(), - ) - } - } - } - - fn render_editor( - &self, - editor: &Arc, - _: &mut Window, - cx: &mut Context>, - ) -> Div { - let editor = editor.as_any().downcast_ref::>().unwrap(); - - h_flex() - .py_1() - .px_1p5() - .mx_1() - .gap_1p5() - .rounded_sm() - .bg(cx.theme().colors().editor_background) - .border_1() - .border_color(cx.theme().colors().border) - .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted)) - .child(editor.clone()) - } -} - -impl RulesLibrary { - fn new( - store: Entity, - language_registry: Arc, - inline_assist_delegate: Box, - rule_to_select: Option, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let (_selected_index, _matches) = if let Some(rule_to_select) = rule_to_select { - let matches = store.read(cx).all_prompt_metadata(); - let selected_index = matches - .iter() - .enumerate() - .find(|(_, metadata)| metadata.id == rule_to_select) - .map_or(0, |(ix, _)| ix); - (selected_index, matches) - } else { - (0, vec![]) - }; - - let picker_delegate = RulePickerDelegate { - store: store.clone(), - selected_index: 0, - filtered_entries: Vec::new(), - }; - - let picker = cx.new(|cx| { - let picker = Picker::list(picker_delegate, window, cx) - .modal(false) - .max_height(None); - picker.focus(window, cx); - picker - }); - - Self { - title_bar: if !cfg!(target_os = "macos") { - Some(cx.new(|cx| PlatformTitleBar::new("rules-library-title-bar", cx))) - } else { - None - }, - store, - language_registry, - rule_editors: HashMap::default(), - active_rule_id: None, - pending_load: Task::ready(()), - inline_assist_delegate, - _subscriptions: vec![cx.subscribe_in(&picker, window, Self::handle_picker_event)], - picker, - } - } - - fn handle_picker_event( - &mut self, - _: &Entity>, - event: &RulePickerEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - RulePickerEvent::Selected { prompt_id } => { - self.load_rule(*prompt_id, false, window, cx); - } - RulePickerEvent::Confirmed { prompt_id } => { - self.load_rule(*prompt_id, true, window, cx); - } - RulePickerEvent::ToggledDefault { prompt_id } => { - self.toggle_default_for_rule(*prompt_id, window, cx); - } - RulePickerEvent::Deleted { prompt_id } => { - self.delete_rule(*prompt_id, window, cx); - } - } - } - - pub fn new_rule(&mut self, window: &mut Window, cx: &mut Context) { - // If we already have an untitled rule, use that instead - // of creating a new one. - if let Some(metadata) = self.store.read(cx).first() - && metadata.title.is_none() - { - self.load_rule(metadata.id, true, window, cx); - return; - } - - let prompt_id = PromptId::new(); - let save = self.store.update(cx, |store, cx| { - store.save(prompt_id, None, false, "".into(), cx) - }); - self.picker - .update(cx, |picker, cx| picker.refresh(window, cx)); - cx.spawn_in(window, async move |this, cx| { - save.await?; - this.update_in(cx, |this, window, cx| { - this.load_rule(prompt_id, true, window, cx) - }) - }) - .detach_and_log_err(cx); - } - - pub fn save_rule(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context) { - const SAVE_THROTTLE: Duration = Duration::from_millis(500); - - if !prompt_id.can_edit() { - return; - } - - let rule_metadata = self.store.read(cx).metadata(prompt_id).unwrap(); - let rule_editor = self.rule_editors.get_mut(&prompt_id).unwrap(); - let title = rule_editor.title_editor.read(cx).text(cx); - let body = rule_editor.body_editor.update(cx, |editor, cx| { - editor - .buffer() - .read(cx) - .as_singleton() - .unwrap() - .read(cx) - .as_rope() - .clone() - }); - - let store = self.store.clone(); - let executor = cx.background_executor().clone(); - - rule_editor.next_title_and_body_to_save = Some((title, body)); - if rule_editor.pending_save.is_none() { - rule_editor.pending_save = Some(cx.spawn_in(window, async move |this, cx| { - async move { - loop { - let title_and_body = this.update(cx, |this, _| { - this.rule_editors - .get_mut(&prompt_id)? - .next_title_and_body_to_save - .take() - })?; - - if let Some((title, body)) = title_and_body { - let title = if title.trim().is_empty() { - None - } else { - Some(SharedString::from(title)) - }; - cx.update(|_window, cx| { - store.update(cx, |store, cx| { - store.save(prompt_id, title, rule_metadata.default, body, cx) - }) - })? - .await - .log_err(); - this.update_in(cx, |this, window, cx| { - this.picker - .update(cx, |picker, cx| picker.refresh(window, cx)); - cx.notify(); - })?; - - executor.timer(SAVE_THROTTLE).await; - } else { - break; - } - } - - this.update(cx, |this, _cx| { - if let Some(rule_editor) = this.rule_editors.get_mut(&prompt_id) { - rule_editor.pending_save = None; - } - }) - } - .log_err() - .await - })); - } - } - - pub fn delete_active_rule(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(active_rule_id) = self.active_rule_id { - self.delete_rule(active_rule_id, window, cx); - } - } - - pub fn duplicate_active_rule(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(active_rule_id) = self.active_rule_id { - self.duplicate_rule(active_rule_id, window, cx); - } - } - - pub fn toggle_default_for_active_rule(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(active_rule_id) = self.active_rule_id { - self.toggle_default_for_rule(active_rule_id, window, cx); - } - } - - pub fn restore_default_content_for_active_rule( - &mut self, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(active_rule_id) = self.active_rule_id { - self.restore_default_content(active_rule_id, window, cx); - } - } - - pub fn restore_default_content( - &mut self, - prompt_id: PromptId, - window: &mut Window, - cx: &mut Context, - ) { - let Some(built_in) = prompt_id.as_built_in() else { - return; - }; - - if let Some(rule_editor) = self.rule_editors.get(&prompt_id) { - rule_editor.body_editor.update(cx, |editor, cx| { - editor.set_text(built_in.default_content(), window, cx); - }); - } - } - - pub fn toggle_default_for_rule( - &mut self, - prompt_id: PromptId, - window: &mut Window, - cx: &mut Context, - ) { - self.store.update(cx, move |store, cx| { - if let Some(rule_metadata) = store.metadata(prompt_id) { - store - .save_metadata(prompt_id, rule_metadata.title, !rule_metadata.default, cx) - .detach_and_log_err(cx); - } - }); - self.picker - .update(cx, |picker, cx| picker.refresh(window, cx)); - cx.notify(); - } - - pub fn load_rule( - &mut self, - prompt_id: PromptId, - focus: bool, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(rule_editor) = self.rule_editors.get(&prompt_id) { - if focus { - rule_editor - .body_editor - .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)); - } - self.set_active_rule(Some(prompt_id), window, cx); - } else if let Some(rule_metadata) = self.store.read(cx).metadata(prompt_id) { - let language_registry = self.language_registry.clone(); - let rule = self.store.read(cx).load(prompt_id, cx); - self.pending_load = cx.spawn_in(window, async move |this, cx| { - let rule = rule.await; - let markdown = language_registry.language_for_name("Markdown").await; - this.update_in(cx, |this, window, cx| match rule { - Ok(rule) => { - let title_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Untitled", window, cx); - editor.set_text(rule_metadata.title.unwrap_or_default(), window, cx); - if prompt_id.is_built_in() { - editor.set_read_only(true); - editor.set_show_edit_predictions(Some(false), window, cx); - } - editor - }); - let body_editor = cx.new(|cx| { - let buffer = cx.new(|cx| { - let mut buffer = Buffer::local(rule, cx); - buffer.set_language(markdown.log_err(), cx); - buffer.set_language_registry(language_registry); - buffer - }); - - let mut editor = Editor::for_buffer(buffer, None, window, cx); - if !prompt_id.can_edit() { - editor.set_read_only(true); - editor.set_show_edit_predictions(Some(false), window, cx); - } - editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); - editor.set_show_gutter(false, cx); - editor.set_show_wrap_guides(false, cx); - editor.set_show_indent_guides(false, cx); - editor.set_use_modal_editing(true); - editor.set_current_line_highlight(Some(CurrentLineHighlight::None)); - if focus { - window.focus(&editor.focus_handle(cx), cx); - } - editor - }); - let _subscriptions = vec![ - cx.subscribe_in( - &title_editor, - window, - move |this, editor, event, window, cx| { - this.handle_rule_title_editor_event( - prompt_id, editor, event, window, cx, - ) - }, - ), - cx.subscribe_in( - &body_editor, - window, - move |this, editor, event, window, cx| { - this.handle_rule_body_editor_event( - prompt_id, editor, event, window, cx, - ) - }, - ), - ]; - this.rule_editors.insert( - prompt_id, - RuleEditor { - title_editor, - body_editor, - next_title_and_body_to_save: None, - pending_save: None, - _subscriptions, - }, - ); - this.set_active_rule(Some(prompt_id), window, cx); - } - Err(error) => { - // TODO: we should show the error in the UI. - log::error!("error while loading rule: {:?}", error); - } - }) - .ok(); - }); - } - } - - fn set_active_rule( - &mut self, - prompt_id: Option, - window: &mut Window, - cx: &mut Context, - ) { - self.active_rule_id = prompt_id; - self.picker.update(cx, |picker, cx| { - if let Some(prompt_id) = prompt_id { - if picker - .delegate - .filtered_entries - .get(picker.delegate.selected_index()) - .is_none_or(|old_selected_prompt| { - if let RulePickerEntry::Rule(rule) = old_selected_prompt { - rule.id != prompt_id - } else { - true - } - }) - && let Some(ix) = picker.delegate.filtered_entries.iter().position(|mat| { - if let RulePickerEntry::Rule(rule) = mat { - rule.id == prompt_id - } else { - false - } - }) - { - picker.set_selected_index(ix, None, true, window, cx); - } - } else { - picker.focus(window, cx); - } - }); - cx.notify(); - } - - pub fn delete_rule( - &mut self, - prompt_id: PromptId, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(metadata) = self.store.read(cx).metadata(prompt_id) { - let confirmation = window.prompt( - PromptLevel::Warning, - &format!( - "Are you sure you want to delete {}", - metadata.title.unwrap_or("Untitled".into()) - ), - None, - &["Delete", "Cancel"], - cx, - ); - - cx.spawn_in(window, async move |this, cx| { - if confirmation.await.ok() == Some(0) { - this.update_in(cx, |this, window, cx| { - if this.active_rule_id == Some(prompt_id) { - this.set_active_rule(None, window, cx); - } - this.rule_editors.remove(&prompt_id); - this.store - .update(cx, |store, cx| store.delete(prompt_id, cx)) - .detach_and_log_err(cx); - this.picker - .update(cx, |picker, cx| picker.refresh(window, cx)); - cx.notify(); - })?; - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - } - - pub fn duplicate_rule( - &mut self, - prompt_id: PromptId, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(rule) = self.rule_editors.get(&prompt_id) { - const DUPLICATE_SUFFIX: &str = " copy"; - let title_to_duplicate = rule.title_editor.read(cx).text(cx); - let existing_titles = self - .rule_editors - .iter() - .filter(|&(&id, _)| id != prompt_id) - .map(|(_, rule_editor)| rule_editor.title_editor.read(cx).text(cx)) - .filter(|title| title.starts_with(&title_to_duplicate)) - .collect::>(); - - let title = if existing_titles.is_empty() { - title_to_duplicate + DUPLICATE_SUFFIX - } else { - let mut i = 1; - loop { - let new_title = format!("{title_to_duplicate}{DUPLICATE_SUFFIX} {i}"); - if !existing_titles.contains(&new_title) { - break new_title; - } - i += 1; - } - }; - - let new_id = PromptId::new(); - let body = rule.body_editor.read(cx).text(cx); - let save = self.store.update(cx, |store, cx| { - store.save(new_id, Some(title.into()), false, body.into(), cx) - }); - self.picker - .update(cx, |picker, cx| picker.refresh(window, cx)); - cx.spawn_in(window, async move |this, cx| { - save.await?; - this.update_in(cx, |rules_library, window, cx| { - rules_library.load_rule(new_id, true, window, cx) - }) - }) - .detach_and_log_err(cx); - } - } - - fn focus_active_rule(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { - if let Some(active_rule) = self.active_rule_id { - self.rule_editors[&active_rule] - .body_editor - .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)); - cx.stop_propagation(); - } - } - - fn focus_picker(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { - self.picker - .update(cx, |picker, cx| picker.focus(window, cx)); - } - - pub fn inline_assist( - &mut self, - action: &InlineAssist, - window: &mut Window, - cx: &mut Context, - ) { - let Some(active_rule_id) = self.active_rule_id else { - cx.propagate(); - return; - }; - - let rule_editor = &self.rule_editors[&active_rule_id].body_editor; - let Some(ConfiguredModel { provider, .. }) = - LanguageModelRegistry::read_global(cx).inline_assistant_model() - else { - return; - }; - - let initial_prompt = action.prompt.clone(); - if provider.is_authenticated(cx) { - self.inline_assist_delegate - .assist(rule_editor, initial_prompt, window, cx); - } else { - for window in cx.windows() { - if let Some(multi_workspace) = window.downcast::() { - let panel = multi_workspace - .update(cx, |multi_workspace, window, cx| { - window.activate_window(); - multi_workspace.workspace().update(cx, |workspace, cx| { - self.inline_assist_delegate - .focus_agent_panel(workspace, window, cx) - }) - }) - .ok(); - if panel == Some(true) { - return; - } - } - } - } - } - - fn move_down_from_title( - &mut self, - _: &zed_actions::editor::MoveDown, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(rule_id) = self.active_rule_id - && let Some(rule_editor) = self.rule_editors.get(&rule_id) - { - window.focus(&rule_editor.body_editor.focus_handle(cx), cx); - } - } - - fn move_up_from_body( - &mut self, - _: &zed_actions::editor::MoveUp, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(rule_id) = self.active_rule_id - && let Some(rule_editor) = self.rule_editors.get(&rule_id) - { - window.focus(&rule_editor.title_editor.focus_handle(cx), cx); - } - } - - fn handle_rule_title_editor_event( - &mut self, - prompt_id: PromptId, - title_editor: &Entity, - event: &EditorEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - EditorEvent::BufferEdited => { - self.save_rule(prompt_id, window, cx); - } - EditorEvent::Blurred => { - title_editor.update(cx, |title_editor, cx| { - title_editor.change_selections( - SelectionEffects::no_scroll(), - window, - cx, - |selections| { - let cursor = selections.oldest_anchor().head(); - selections.select_anchor_ranges([cursor..cursor]); - }, - ); - }); - } - _ => {} - } - } - - fn handle_rule_body_editor_event( - &mut self, - prompt_id: PromptId, - body_editor: &Entity, - event: &EditorEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - EditorEvent::BufferEdited => { - self.save_rule(prompt_id, window, cx); - } - EditorEvent::Blurred => { - body_editor.update(cx, |body_editor, cx| { - body_editor.change_selections( - SelectionEffects::no_scroll(), - window, - cx, - |selections| { - let cursor = selections.oldest_anchor().head(); - selections.select_anchor_ranges([cursor..cursor]); - }, - ); - }); - } - _ => {} - } - } - - fn render_rule_list(&mut self, cx: &mut Context) -> impl IntoElement { - v_flex() - .id("rule-list") - .capture_action(cx.listener(Self::focus_active_rule)) - .px_1p5() - .h_full() - .w_64() - .overflow_x_hidden() - .bg(cx.theme().colors().panel_background) - .map(|this| { - if cfg!(target_os = "macos") { - this.child( - h_flex() - .p(DynamicSpacing::Base04.rems(cx)) - .h_9() - .w_full() - .flex_none() - .justify_end() - .child( - IconButton::new("new-rule", IconName::Plus) - .tooltip(move |_window, cx| { - Tooltip::for_action("New Rule", &NewRule, cx) - }) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(NewRule), cx); - }), - ), - ) - } else { - this.child( - h_flex().p_1().w_full().child( - Button::new("new-rule", "New Rule") - .full_width() - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Plus) - .size(IconSize::Small) - .color(Color::Muted), - ) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(NewRule), cx); - }), - ), - ) - } - }) - .child(div().flex_grow().child(self.picker.clone())) - } - - fn render_active_rule_editor( - &self, - editor: &Entity, - read_only: bool, - cx: &mut Context, - ) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let text_color = if read_only { - cx.theme().colors().text_muted - } else { - cx.theme().colors().text - }; - - div() - .w_full() - .pl_1() - .border_1() - .border_color(transparent_black()) - .rounded_sm() - .when(!read_only, |this| { - this.group_hover("active-editor-header", |this| { - this.border_color(cx.theme().colors().border_variant) - }) - }) - .on_action(cx.listener(Self::move_down_from_title)) - .child(EditorElement::new( - &editor, - EditorStyle { - background: cx.theme().system().transparent, - local_player: cx.theme().players().local(), - text: TextStyle { - color: text_color, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_size: HeadlineSize::Medium.rems().into(), - font_weight: settings.ui_font.weight, - line_height: relative(settings.buffer_line_height.value()), - ..Default::default() - }, - scrollbar_width: Pixels::ZERO, - syntax: cx.theme().syntax().clone(), - status: cx.theme().status().clone(), - inlay_hints_style: editor::make_inlay_hints_style(cx), - edit_prediction_styles: editor::make_suggestion_styles(cx), - ..EditorStyle::default() - }, - )) - } - - fn render_duplicate_rule_button(&self) -> impl IntoElement { - IconButton::new("duplicate-rule", IconName::BookCopy) - .tooltip(move |_window, cx| Tooltip::for_action("Duplicate Rule", &DuplicateRule, cx)) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(DuplicateRule), cx); - }) - } - - fn render_built_in_rule_controls(&self) -> impl IntoElement { - h_flex() - .gap_1() - .child(self.render_duplicate_rule_button()) - .child( - IconButton::new("restore-default", IconName::RotateCcw) - .tooltip(move |_window, cx| { - Tooltip::for_action( - "Restore to Default Content", - &RestoreDefaultContent, - cx, - ) - }) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(RestoreDefaultContent), cx); - }), - ) - } - - fn render_regular_rule_controls(&self, default: bool) -> impl IntoElement { - h_flex() - .gap_1() - .child( - IconButton::new("toggle-default-rule", IconName::Paperclip) - .toggle_state(default) - .when(default, |this| this.icon_color(Color::Accent)) - .map(|this| { - if default { - this.tooltip(Tooltip::text("Remove from Default Rules")) - } else { - this.tooltip(move |_window, cx| { - Tooltip::with_meta( - "Add to Default Rules", - None, - "Always included in every thread.", - cx, - ) - }) - } - }) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(ToggleDefaultRule), cx); - }), - ) - .child(self.render_duplicate_rule_button()) - .child( - IconButton::new("delete-rule", IconName::Trash) - .tooltip(move |_window, cx| Tooltip::for_action("Delete Rule", &DeleteRule, cx)) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(DeleteRule), cx); - }), - ) - } - - fn render_active_rule(&mut self, cx: &mut Context) -> gpui::Stateful
{ - div() - .id("rule-editor") - .h_full() - .flex_grow() - .border_l_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background) - .children(self.active_rule_id.and_then(|prompt_id| { - let rule_metadata = self.store.read(cx).metadata(prompt_id)?; - let rule_editor = &self.rule_editors[&prompt_id]; - let focus_handle = rule_editor.body_editor.focus_handle(cx); - let built_in = prompt_id.is_built_in(); - - Some( - v_flex() - .id("rule-editor-inner") - .size_full() - .relative() - .overflow_hidden() - .on_click(cx.listener(move |_, _, window, cx| { - window.focus(&focus_handle, cx); - })) - .child( - h_flex() - .group("active-editor-header") - .h_12() - .px_2() - .gap_2() - .justify_between() - .child(self.render_active_rule_editor( - &rule_editor.title_editor, - built_in, - cx, - )) - .child(h_flex().h_full().flex_shrink_0().map(|this| { - if built_in { - this.child(self.render_built_in_rule_controls()) - } else { - this.child( - self.render_regular_rule_controls( - rule_metadata.default, - ), - ) - } - })), - ) - .child( - div() - .on_action(cx.listener(Self::focus_picker)) - .on_action(cx.listener(Self::inline_assist)) - .on_action(cx.listener(Self::move_up_from_body)) - .h_full() - .flex_grow() - .child( - h_flex() - .py_2() - .pl_2p5() - .h_full() - .flex_1() - .child(rule_editor.body_editor.clone()), - ), - ), - ) - })) - } -} - -impl Render for RulesLibrary { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme_settings::setup_ui_font(window, cx); - let theme = cx.theme().clone(); - - client_side_decorations( - v_flex() - .id("rules-library") - .key_context("RulesLibrary") - .on_action( - |action_sequence: &ActionSequence, window: &mut Window, cx: &mut App| { - for action in &action_sequence.0 { - window.dispatch_action(action.boxed_clone(), cx); - } - }, - ) - .on_action(cx.listener(|this, &NewRule, window, cx| this.new_rule(window, cx))) - .on_action( - cx.listener(|this, &DeleteRule, window, cx| { - this.delete_active_rule(window, cx) - }), - ) - .on_action(cx.listener(|this, &DuplicateRule, window, cx| { - this.duplicate_active_rule(window, cx) - })) - .on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| { - this.toggle_default_for_active_rule(window, cx) - })) - .on_action(cx.listener(|this, &RestoreDefaultContent, window, cx| { - this.restore_default_content_for_active_rule(window, cx) - })) - .size_full() - .overflow_hidden() - .font(ui_font) - .text_color(theme.colors().text) - .children(self.title_bar.clone()) - .bg(theme.colors().background) - .child( - h_flex() - .flex_1() - .when(!cfg!(target_os = "macos"), |this| { - this.border_t_1().border_color(cx.theme().colors().border) - }) - .child(self.render_rule_list(cx)) - .child(self.render_active_rule(cx)), - ), - window, - cx, - Tiling::default(), - ) - } -} diff --git a/crates/scheduler/src/executor.rs b/crates/scheduler/src/executor.rs index 2a678ec3b25..e5474a4cf03 100644 --- a/crates/scheduler/src/executor.rs +++ b/crates/scheduler/src/executor.rs @@ -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, + // 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) + Send + Sync>, not_send: PhantomData>, } -impl ForegroundExecutor { - pub fn new(session_id: SessionId, scheduler: Arc) -> 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, + dispatch: impl Fn(Runnable) + 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(&self, f: F) -> Task + where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + Sync + 'static, + { + self.scheduler + .clone() + .spawn_dedicated(box_dedicated(f)) + .downcast::() + } +} + +/// 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` on the dedicated side and downcast +/// back to `Fut::Output` by [`Task::downcast`] in the wrapper. +fn box_dedicated( + f: F, +) -> Box< + dyn FnOnce(LocalExecutor) -> Pin> + '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 }) + }) } #[derive(Clone)] @@ -193,6 +253,27 @@ impl BackgroundExecutor { pub fn scheduler(&self) -> &Arc { &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(&self, f: F) -> Task + where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + Sync + 'static, + { + self.scheduler + .clone() + .spawn_dedicated(box_dedicated(f)) + .downcast::() + } } /// 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(TaskState); -#[derive(Debug)] enum TaskState { /// A task that is ready to return a value Ready(Option), /// A task that is currently running. Spawned(async_task::Task), + + /// A typed view of a [`Task>`] obtained via + /// [`Task::downcast`]. The inner task drives the actual work; the + /// downcast layer just unwraps the `Box` on poll. + Downcast { + inner: Box>>, + marker: PhantomData T>, + }, } impl Task { @@ -229,6 +316,7 @@ impl Task { match &self.0 { TaskState::Ready(_) => true, TaskState::Spawned(task) => task.is_finished(), + TaskState::Downcast { inner, .. } => inner.is_ready(), } } @@ -237,6 +325,7 @@ impl Task { match self { Task(TaskState::Ready(_)) => {} Task(TaskState::Spawned(task)) => task.detach(), + Task(TaskState::Downcast { inner, .. }) => inner.detach(), } } @@ -245,10 +334,43 @@ impl Task { 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> { + /// 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(self) -> Task { + Task(TaskState::Downcast { + inner: Box::new(self), + marker: PhantomData, + }) + } +} + +impl std::fmt::Debug for Task { + 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` instead of panicking when cancelled. #[must_use] pub struct FallibleTask(FallibleTaskState); @@ -259,6 +381,12 @@ enum FallibleTaskState { /// A task that is currently running (wraps async_task::FallibleTask). Spawned(async_task::FallibleTask), + + /// Mirror of [`TaskState::Downcast`] for fallible tasks. + Downcast { + inner: Box>>, + marker: PhantomData T>, + }, } impl FallibleTask { @@ -272,17 +400,29 @@ impl FallibleTask { match self.0 { FallibleTaskState::Ready(_) => {} FallibleTaskState::Spawned(task) => task.detach(), + FallibleTaskState::Downcast { inner, .. } => inner.detach(), } } } -impl Future for FallibleTask { +impl Future for FallibleTask { type Output = Option; fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { 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::() + .expect("FallibleTask::poll: downcast type mismatch"), + )), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } } } } @@ -294,17 +434,29 @@ impl std::fmt::Debug for FallibleTask { FallibleTaskState::Spawned(task) => { f.debug_tuple("FallibleTask::Spawned").field(task).finish() } + FallibleTaskState::Downcast { inner, .. } => f + .debug_tuple("FallibleTask::Downcast") + .field(inner) + .finish(), } } } -impl Future for Task { +impl Future for Task { type Output = T; fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { 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::() + .expect("Task::poll: downcast type mismatch"), + ), + Poll::Pending => Poll::Pending, + }, } } } diff --git a/crates/scheduler/src/scheduler.rs b/crates/scheduler/src/scheduler.rs index 05d285df8d9..92125456232 100644 --- a/crates/scheduler/src/scheduler.rs +++ b/crates/scheduler/src/scheduler.rs @@ -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, ) -> bool; - fn schedule_foreground(&self, session_id: SessionId, runnable: Runnable); + /// 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); /// 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; + /// 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` 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, + f: Box< + dyn FnOnce( + LocalExecutor, + ) + -> Pin> + 'static>> + + Send + + 'static, + >, + ) -> Task>; + 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( + session_id: SessionId, + scheduler: Arc, + f: F, +) -> Task +where + F: FnOnce(LocalExecutor) -> Fut + Send + 'static, + Fut: Future + 'static, + Fut::Output: Send + 'static, +{ + let (runnable_sender, runnable_receiver) = flume::unbounded::>(); + let (task_sender, task_receiver) = flume::bounded::>(1); + + thread::Builder::new() + .name(format!("spawn_dedicated session {:?}", session_id)) + .spawn(move || { + let dispatch = move |runnable: Runnable| { + 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); diff --git a/crates/scheduler/src/test_scheduler.rs b/crates/scheduler/src/test_scheduler.rs index 656d2f63be7..722dfb13587 100644 --- a/crates/scheduler/src/test_scheduler.rs +++ b/crates/scheduler/src/test_scheduler.rs @@ -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) -> ForegroundExecutor { + /// Create a local executor for this scheduler. + pub fn foreground(self: &Arc) -> 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) { + fn schedule_local(&self, session_id: SessionId, runnable: Runnable) { 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, + f: Box< + dyn FnOnce( + LocalExecutor, + ) + -> Pin> + 'static>> + + Send + + 'static, + >, + ) -> Task> { + 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) } diff --git a/crates/scheduler/src/tests.rs b/crates/scheduler/src/tests.rs index 8c6bc2bef41..1e29211ca87 100644 --- a/crates/scheduler/src/tests.rs +++ b/crates/scheduler/src/tests.rs @@ -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>` 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::::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::::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`. diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index 3b7edef415f..f5a416ad3c3 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -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::(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>, + cx: &App, + ) -> Task> { + self.tasks.associated_tasks(buffer, cx) + } + + fn lsp_task_source(&self) -> Option { + Some(LanguageServerName::new_static(TEST_LSP_NAME)) + } + } + fn emulate_task_schedule( tasks_picker: Entity>, project: &Entity, diff --git a/crates/x_ai/src/x_ai.rs b/crates/x_ai/src/x_ai.rs index 7ba13d83529..6d9c558de85 100644 --- a/crates/x_ai/src/x_ai.rs +++ b/crates/x_ai/src/x_ai.rs @@ -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, + } + } } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index a4771d0e08c..f9c04b7c910 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -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); @@ -5252,7 +5257,6 @@ mod tests { "recent_projects", "remote_debug", "repl", - "rules_library", "search", "settings_editor", "settings_profile_selector", diff --git a/nix/build.nix b/nix/build.nix index 2f283f83a4d..0375b0de5f8 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -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 diff --git a/script/community-pr-track-mapping.json b/script/community-pr-track-mapping.json index 3b3bfe6168d..274c65d403d 100644 --- a/script/community-pr-track-mapping.json +++ b/script/community-pr-track-mapping.json @@ -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", diff --git a/script/github-check-new-issue-for-duplicates.py b/script/github-check-new-issue-for-duplicates.py index 023449bb27d..8a4eb8ec22a 100644 --- a/script/github-check-new-issue-for-duplicates.py +++ b/script/github-check-new-issue-for-duplicates.py @@ -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"""
Additional recent context for triagers @@ -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} diff --git a/script/github-track-duplicate-bot-effectiveness.py b/script/github-track-duplicate-bot-effectiveness.py index 06dcae67cd8..02be1b9e137 100644 --- a/script/github-track-duplicate-bot-effectiveness.py +++ b/script/github-track-duplicate-bot-effectiveness.py @@ -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__": diff --git a/script/update_top_ranking_issues/pyproject.toml b/script/update_top_ranking_issues/pyproject.toml index 18d4afe9508..fcfc23e83d0 100644 --- a/script/update_top_ranking_issues/pyproject.toml +++ b/script/update_top_ranking_issues/pyproject.toml @@ -11,4 +11,5 @@ dependencies = [ "typer>=0.15.1", "types-pytz>=2025.1.0.20250204", "types-requests>=2.32.0", + "urllib3>=2.7.0", ] diff --git a/script/update_top_ranking_issues/uv.lock b/script/update_top_ranking_issues/uv.lock index 0e98447aa5b..f1b3ec28515 100644 --- a/script/update_top_ranking_issues/uv.lock +++ b/script/update_top_ranking_issues/uv.lock @@ -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" }, ]