From fa4ae33e20791134909b4d5bb035f8d34cae66e9 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 10 Jun 2026 05:20:25 -0400 Subject: [PATCH 01/38] Add telemetry for context compaction (#58928) Adds an `"Agent Compaction Completed"` telemetry event that fires for both threshold-triggered auto-compaction and the manual `/compact` command (both already behind the `handoff` flag). The event records the model, model provider, thinking effort, the model's context window size, the token counts immediately before and after compaction, whether the compaction succeeded/failed/canceled (with the error string on failure), the user's configured auto-compaction threshold (both the raw form and the resolved absolute token count), whether auto-compaction is enabled, and the number of retries. Both compaction paths funnel through `stream_compaction`, so a `CompactionTelemetry` snapshot is captured when a compaction starts and stashed on the thread. On success, emission is deferred until the next completion request reports usage, so `tokens_after` reflects the real post-compaction context size rather than an estimate (we have no token-counting API, and it's safe to assume a compaction is always followed by another request). On failure or cancellation the event fires immediately with no `tokens_after`. Retries are accumulated across attempts so a single logical compaction produces exactly one event. While wiring up retry counting I noticed the existing auto-compaction retry path in `run_turn_internal` never increments `attempt`, so a repeatedly-failing retryable compaction could retry without bound. I left that behavior untouched as out of scope, but the new `retries` field will surface it if it happens. Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner --- crates/agent/src/thread.rs | 172 +++++++++++++++++++- crates/agent_settings/src/agent_settings.rs | 16 ++ 2 files changed, 183 insertions(+), 5 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 11c78737e85ec3..0cf5fc84d84a5a 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1140,6 +1140,7 @@ pub struct Thread { /// `cumulative_token_usage` for the in-flight completion request. Reset at /// the start of each request. current_request_token_usage: TokenUsage, + pending_compaction_telemetry: Option, #[allow(unused)] initial_project_snapshot: Shared>>>, pub(crate) context_server_registry: Entity, @@ -1273,6 +1274,7 @@ impl Thread { request_token_usage: HashMap::default(), cumulative_token_usage: TokenUsage::default(), current_request_token_usage: TokenUsage::default(), + pending_compaction_telemetry: None, initial_project_snapshot: { let project_snapshot = Self::project_snapshot(project.clone(), cx); cx.foreground_executor() @@ -1651,6 +1653,7 @@ impl Thread { request_token_usage: db_thread.request_token_usage.clone(), cumulative_token_usage: db_thread.cumulative_token_usage, current_request_token_usage: TokenUsage::default(), + pending_compaction_telemetry: None, initial_project_snapshot: Task::ready(db_thread.initial_project_snapshot).shared(), context_server_registry, profile_id, @@ -2244,6 +2247,10 @@ impl Thread { (model, request) }); + if compaction.is_some() { + self.pending_compaction_telemetry = self.build_compaction_telemetry("manual", cx); + } + self.clear_summary(); cx.notify(); @@ -2271,13 +2278,27 @@ impl Thread { // If we were cancelled, `cancel()` already took `running_turn` // (possibly for a new turn), so leave it alone. if *cancellation_rx.borrow() { + this.update(cx, |this, _| { + this.emit_compaction_telemetry_outcome("canceled", None) + }) + .log_err(); return; } match result { + // On success, the telemetry event is deferred until the next + // completion reports usage (see `handle_completion_event`), + // so we leave `pending_compaction_telemetry` in place here. Ok(_) => event_stream.send_stop(acp::StopReason::EndTurn), Err(error) => { log::error!("Manual compaction failed: {:?}", error); + this.update(cx, |this, _| { + this.emit_compaction_telemetry_outcome( + "failed", + Some(error.to_string()), + ) + }) + .log_err(); event_stream.send_error(error); } } @@ -2416,12 +2437,23 @@ impl Thread { ) .await { + // On success the telemetry event is deferred until the + // completion below reports usage, so we can record an + // accurate post-compaction context size (see + // `handle_completion_event`). Ok(ControlFlow::Continue(())) => {} - Ok(ControlFlow::Break(())) => return Ok(()), + Ok(ControlFlow::Break(())) => { + this.update(cx, |this, _| { + this.emit_compaction_telemetry_outcome("canceled", None) + })?; + return Ok(()); + } Err(error) => { log::error!("Compaction failed: {}", error); + let error_message = error.to_string(); match error.downcast::() { Ok(error) => { + attempt += 1; match Self::retry_completion_error( this, event_stream, @@ -2430,13 +2462,44 @@ impl Thread { attempt, cx, ) - .await? + .await { - ControlFlow::Break(()) => return Ok(()), - ControlFlow::Continue(()) => continue, + Ok(ControlFlow::Break(())) => { + this.update(cx, |this, _| { + this.emit_compaction_telemetry_outcome("canceled", None) + })?; + return Ok(()); + } + Ok(ControlFlow::Continue(())) => { + this.update(cx, |this, _| { + if let Some(telemetry) = + this.pending_compaction_telemetry.as_mut() + { + telemetry.retries += 1; + } + })?; + continue; + } + Err(retry_error) => { + this.update(cx, |this, _| { + this.emit_compaction_telemetry_outcome( + "failed", + Some(error_message), + ) + })?; + return Err(retry_error); + } } } - Err(error) => return Err(error), + Err(error) => { + this.update(cx, |this, _| { + this.emit_compaction_telemetry_outcome( + "failed", + Some(error_message), + ) + })?; + return Err(error); + } } } } @@ -2746,6 +2809,11 @@ impl Thread { let model = this.model.clone()?; let request = this.build_compaction_request(insertion_ix, &model, cx); this.current_request_token_usage = TokenUsage::default(); + // Preserve telemetry across retries so the retry count keeps + // accumulating rather than resetting on each attempt. + if this.pending_compaction_telemetry.is_none() { + this.pending_compaction_telemetry = this.build_compaction_telemetry("auto", cx); + } Some((model, request, insertion_ix)) })? else { @@ -3017,6 +3085,12 @@ impl Thread { cache_creation_input_tokens = usage.cache_creation_input_tokens, cache_read_input_tokens = usage.cache_read_input_tokens, ); + // A successful compaction defers its telemetry until the first + // completion that follows it, so `tokens_after` reflects the + // real post-compaction context size. + if let Some(telemetry) = self.pending_compaction_telemetry.take() { + telemetry.emit("succeeded", None, Some(total_input_tokens(usage))); + } self.update_token_usage(usage, cx); } Stop(StopReason::Refusal) => return Err(CompletionError::Refusal.into()), @@ -3857,6 +3931,48 @@ impl Thread { .rposition(|message| matches!(&**message, Message::Compaction(_))) } + /// Captures the data for an `"Agent Compaction Completed"` telemetry event + /// at the moment a compaction starts. Returns `None` if there's no model. + fn build_compaction_telemetry( + &self, + trigger: &'static str, + cx: &App, + ) -> Option { + let model = self.model.as_ref()?; + let auto_compact = AgentSettings::get_global(cx).auto_compact; + let max_tokens = model.max_token_count(); + let tokens_before = self + .latest_request_token_usage() + .map(|usage| total_input_tokens(usage).saturating_add(usage.output_tokens)); + Some(CompactionTelemetry { + trigger, + thread_id: self.id.to_string(), + parent_thread_id: self.parent_thread_id().map(|id| id.to_string()), + prompt_id: self.prompt_id.to_string(), + model: model.telemetry_id(), + model_provider: model.provider_id().to_string(), + thinking_effort: self.thinking_effort.clone(), + max_tokens, + tokens_before, + auto_compact_enabled: auto_compact.enabled, + auto_compact_threshold: auto_compact.threshold.to_string(), + auto_compact_threshold_tokens: auto_compact_threshold_token_count( + auto_compact.threshold, + max_tokens, + ), + retries: 0, + }) + } + + /// Emits a pending compaction telemetry event for a non-success outcome + /// (`"failed"` or `"canceled"`), with no post-compaction token count. A + /// no-op if no compaction telemetry is pending. + fn emit_compaction_telemetry_outcome(&mut self, status: &'static str, error: Option) { + if let Some(telemetry) = self.pending_compaction_telemetry.take() { + telemetry.emit(status, error, None); + } + } + fn compaction_message_target_ix(&self, cx: &App) -> Option { let auto_compact = AgentSettings::get_global(cx).auto_compact; if !auto_compact.enabled { @@ -4131,6 +4247,52 @@ fn auto_compact_threshold_token_count( } } +/// Snapshot of the data needed to report an `"Agent Compaction Completed"` +/// telemetry event, captured when a compaction starts. +struct CompactionTelemetry { + /// `"auto"` for threshold-triggered compaction, `"manual"` for `/compact`. + trigger: &'static str, + thread_id: String, + parent_thread_id: Option, + prompt_id: String, + model: String, + model_provider: String, + thinking_effort: Option, + max_tokens: u64, + /// Tokens in the context window immediately before compaction. + tokens_before: Option, + auto_compact_enabled: bool, + auto_compact_threshold: String, + auto_compact_threshold_tokens: u64, + /// Number of times the compaction request was retried before the final + /// outcome. + retries: u32, +} + +impl CompactionTelemetry { + fn emit(self, status: &'static str, error: Option, tokens_after: Option) { + telemetry::event!( + "Agent Compaction Completed", + trigger = self.trigger, + status = status, + error = error, + thread_id = self.thread_id, + parent_thread_id = self.parent_thread_id, + prompt_id = self.prompt_id, + model = self.model, + model_provider = self.model_provider, + thinking_effort = self.thinking_effort, + max_tokens = self.max_tokens, + tokens_before = self.tokens_before, + tokens_after = tokens_after, + auto_compact_enabled = self.auto_compact_enabled, + auto_compact_threshold = self.auto_compact_threshold, + auto_compact_threshold_tokens = self.auto_compact_threshold_tokens, + retries = self.retries, + ); + } +} + fn user_message_byte_len(message: &LanguageModelRequestMessage) -> usize { message .content diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 7ba386236b1ef0..bcd5d85afa96b2 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -2,6 +2,7 @@ mod agent_profile; mod user_agents_md; use std::cmp::Ordering::{Equal, Greater, Less}; +use std::fmt; use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, LazyLock}; @@ -155,6 +156,16 @@ impl AutoCompactThreshold { pub const DEFAULT: Self = Self::Percentage(0.9); } +impl fmt::Display for AutoCompactThreshold { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Percentage(percent) => write!(formatter, "{}%", percent * 100.0), + Self::TokensUsed(tokens) => write!(formatter, "{tokens}"), + Self::TokensRemaining(tokens) => write!(formatter, "-{tokens}"), + } + } +} + #[derive(Clone, Copy, Debug, PartialEq)] pub struct AutoCompactSettings { pub enabled: bool, @@ -921,6 +932,11 @@ mod tests { TokensRemaining(20_000) ); + assert_eq!(Percentage(0.9).to_string(), "90%"); + assert_eq!(Percentage(0.925).to_string(), "92.5%"); + assert_eq!(TokensUsed(100_000).to_string(), "100000"); + assert_eq!(TokensRemaining(20_000).to_string(), "-20000"); + // 0 is invalid in every form. assert!(parse_auto_compact_threshold("0").is_err()); assert!(parse_auto_compact_threshold("0%").is_err()); From 297c4a4d78a7ca1fe6384770959e921ac2cc8b53 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 10 Jun 2026 05:26:50 -0400 Subject: [PATCH 02/38] Bench app context phase 2 (#58202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR builds out GPUI's benchmark harness so render benchmarks measure realistic frame costs using GPUI-owned, runtime-gated instrumentation. It adds measurements for frame draw time, dirty-to-draw latency, invalidation coalescing, and frame-budget overruns, and runs benchmark workloads with production-like concurrency. ## How it works **Frame timings flow through the GPUI profiler.** `Window::draw` emits a `FrameTiming { window_id, dirty_at, invalidations, draw_start, draw_end }` event into a global ring buffer in `gpui::profiler`, mirroring the existing task-timing channel. Collection is runtime-gated by `profiler::set_frame_trace_enabled` (one relaxed atomic load when disabled — no `Instant::now` calls in production). `BenchReport` is a pure listener: it drains events through a cursor-based `FrameTimingCollector` and builds histograms in the bench layer. `Window` carries no bench-only cfg fields, and the same event channel can later feed the miniprofiler UI or an in-app frame-time HUD. **`BenchDispatcher`** is a multithreaded `PlatformDispatcher` for benchmarks: background tasks run on a worker pool (same priority queue as `LinuxDispatcher`, with task-profiler hooks), timers fire in real time on a dedicated thread, and foreground tasks queue until the bench thread drains them with a blocking `run_until_idle()`. Unlike `TestDispatcher`, work executes in parallel in real time, so wall-clock measurements reflect production concurrency. In-flight accounting is panic-safe via drop guards. **`gpui::bench_platform()`** returns a per-process `TestPlatform` backed by the `BenchDispatcher`, cached in a thread-local so worker threads persist across Criterion calibration passes. This replaces the earlier approach of constructing a real platform per invocation, which had process-global singleton issues, never ran foreground tasks (no run loop pumped the main queue), and couldn't open windows on headless CI. **Text shaping** uses `NoopTextSystem`: deterministic across machines/font installations and CI-portable. Measured cost of this trade: ~10% of editor draw time vs `MacTextSystem` (Noop still emits one glyph per character at fixed advances, so downstream layout/paint structure is preserved). **GPU coverage (macOS only for now).** `PlatformHeadlessRenderer` gained `render_scene`, which encodes and submits the scene to Metal against a cached offscreen target without blocking on completion or reading pixels back — matching production `present()` CPU cost (`render_scene_to_image` would overstate it: it waits for the GPU and copies pixels back). `TestWindow::draw` forwards scenes to the renderer, the real `MetalAtlas` means glyph/SVG rasterization happens during paint, and `bench_renderer` presents after each measured update. Platforms without a headless renderer degrade to discarding the scene. ## Example ```rust #[gpui::bench] fn editor_render(cx: &mut BenchAppContext) { init_context(cx); let buffer = cx.update(|cx| { /* build a MultiBuffer */ }); let mut window = cx.add_empty_window(); let editor = window.update(|window, cx| { let editor = window.replace_root(cx, |window, cx| { let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx); editor.set_style(editor::EditorStyle::default(), window, cx); editor }); window.focus(&editor.focus_handle(cx), cx); editor }); let mut move_down = true; cx.bench_renderer(editor, move |editor, window, cx| { if move_down { editor.move_down(&MoveDown, window, cx); } else { editor.move_up(&MoveUp, window, cx); } move_down = !move_down; }); } ``` ## Example output (release, M-series) ``` editor_render time: [329.75 µs 330.17 µs 330.69 µs] GPUI bench report (all observed iterations): editor_render note: includes Criterion warmup/calibration window dirty-to-draw: samples: 31533 mean: 0.321ms p50: 0.322ms p90: 0.336ms p95: 0.342ms p99: 0.360ms max: 0.504ms frame budget overruns total: 0 frame budget overruns max: 0 window draw: samples: 31533 mean: 0.295ms p50: 0.295ms p90: 0.307ms p95: 0.313ms p99: 0.330ms max: 0.455ms frame budget overruns total: 0 frame budget overruns max: 0 invalidations per frame: mean 5.00, max 5 ``` (`invalidations per frame: mean 5.00` is real signal: each `move_down` notifies the window five times before the draw.) ## Known limitations - **Draw-per-flush**: the harness draws synchronously when effects flush rather than coalescing invalidations to a vsync tick, so `dirty-to-draw` excludes queueing delay, and `frame budget overruns` is a draw-time budget proxy rather than actual missed presents. A frame-paced mode is natural follow-up work. - **GPU submission is measured on macOS only**; other platforms have no headless renderer yet. - The GPUI report includes Criterion warmup/calibration samples (noted in the output); Criterion's `time` is the regression-gating number. - `run_until_idle` waits for queued, running, and already-due work, but not for timers that haven't reached their due time — the dispatcher runs in real time and can't skip ahead like `TestDispatcher`'s virtual clock. ## Future work - A vsync-like frame-pacing mode (suppress draw-on-flush; tick-driven draw + present) so dirty-to-draw captures queueing delay - Record present duration in `FrameTiming` so the report can split draw vs present - Benches that scroll through novel content (cold layout caches) and an agent-panel render bench - Headless renderers for Windows/Linux - Move benches into a dedicated crate Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the UI/UX checklist - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- Cargo.lock | 34 +- Cargo.toml | 1 + crates/agent/Cargo.toml | 6 - crates/benchmarks/Cargo.toml | 52 ++ crates/benchmarks/LICENSE-GPL | 1 + .../benches/display_map.rs | 0 .../benches/edit_file_tool.rs | 0 .../benches/editor_render.rs | 58 +-- crates/benchmarks/src/benchmarks.rs | 6 + crates/editor/Cargo.toml | 10 - crates/gpui/Cargo.toml | 2 + crates/gpui/src/app.rs | 8 +- crates/gpui/src/app/bench_context.rs | 469 ++++++++++++++++-- crates/gpui/src/gpui.rs | 10 +- crates/gpui/src/platform.rs | 20 + crates/gpui/src/platform/bench_dispatcher.rs | 441 ++++++++++++++++ crates/gpui/src/platform/test/window.rs | 12 +- crates/gpui/src/profiler.rs | 127 ++++- crates/gpui/src/queue.rs | 5 + crates/gpui/src/window.rs | 52 ++ crates/gpui_macos/src/metal_renderer.rs | 90 ++++ crates/gpui_macros/src/bench.rs | 50 +- crates/gpui_macros/src/gpui_macros.rs | 4 + 23 files changed, 1347 insertions(+), 111 deletions(-) create mode 100644 crates/benchmarks/Cargo.toml create mode 120000 crates/benchmarks/LICENSE-GPL rename crates/{editor => benchmarks}/benches/display_map.rs (100%) rename crates/{agent => benchmarks}/benches/edit_file_tool.rs (100%) rename crates/{editor => benchmarks}/benches/editor_render.rs (80%) create mode 100644 crates/benchmarks/src/benchmarks.rs create mode 100644 crates/gpui/src/platform/bench_dispatcher.rs diff --git a/Cargo.lock b/Cargo.lock index 04adbe202632d5..b1273f3895d130 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -243,7 +243,6 @@ dependencies = [ "cloud_llm_client", "collections", "context_server", - "criterion", "ctor", "db", "editor", @@ -2123,6 +2122,37 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" +[[package]] +name = "benchmarks" +version = "0.1.0" +dependencies = [ + "action_log", + "agent", + "agent_settings", + "assets", + "criterion", + "editor", + "futures 0.3.32", + "gpui", + "gpui_platform", + "itertools 0.14.0", + "language", + "language_model", + "lsp", + "multi_buffer", + "project", + "prompt_store", + "rand 0.9.4", + "serde_json", + "settings", + "text", + "theme", + "theme_settings", + "ui", + "util", + "zed_actions", +] + [[package]] name = "bigdecimal" version = "0.4.8" @@ -5682,7 +5712,6 @@ dependencies = [ "clock", "collections", "convert_case 0.11.0", - "criterion", "ctor", "dap", "db", @@ -7706,6 +7735,7 @@ dependencies = [ "core-graphics 0.24.0", "core-text", "core-video", + "criterion", "ctor", "derive_more", "embed-resource", diff --git a/Cargo.toml b/Cargo.toml index cdb78d78dd389b..0c9fb4d0b899fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "crates/auto_update_ui", "crates/aws_http_client", "crates/bedrock", + "crates/benchmarks", "crates/breadcrumbs", "crates/buffer_diff", "crates/call", diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 4e9647be1033fb..3dd3f106336c3f 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -84,7 +84,6 @@ agent_servers = { workspace = true, "features" = ["test-support"] } client = { workspace = true, "features" = ["test-support"] } clock = { workspace = true, "features" = ["test-support"] } context_server = { workspace = true, "features" = ["test-support"] } -criterion.workspace = true ctor.workspace = true db = { workspace = true, "features" = ["test-support"] } editor = { workspace = true, "features" = ["test-support"] } @@ -110,8 +109,3 @@ theme_settings.workspace = true unindent = { workspace = true } zlog.workspace = true - -[[bench]] -name = "edit_file_tool" -harness = false -required-features = ["test-support"] diff --git a/crates/benchmarks/Cargo.toml b/crates/benchmarks/Cargo.toml new file mode 100644 index 00000000000000..20f980a88110f2 --- /dev/null +++ b/crates/benchmarks/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "benchmarks" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lib] +path = "src/benchmarks.rs" +doctest = false + +[lints] +workspace = true + +[dev-dependencies] +action_log.workspace = true +agent = { workspace = true, features = ["test-support"] } +agent_settings.workspace = true +assets.workspace = true +criterion.workspace = true +editor = { workspace = true, features = ["test-support"] } +futures.workspace = true +gpui = { workspace = true, features = ["bench"] } +gpui_platform = { workspace = true, features = ["test-support"] } +itertools.workspace = true +language = { workspace = true, features = ["test-support"] } +language_model = { workspace = true, features = ["test-support"] } +lsp = { workspace = true, features = ["test-support"] } +multi_buffer.workspace = true +project = { workspace = true, features = ["test-support"] } +prompt_store.workspace = true +rand.workspace = true +serde_json.workspace = true +settings = { workspace = true, features = ["test-support"] } +text.workspace = true +theme = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true +ui.workspace = true +util = { workspace = true, features = ["test-support"] } +zed_actions.workspace = true + +[[bench]] +name = "editor_render" +harness = false + +[[bench]] +name = "display_map" +harness = false + +[[bench]] +name = "edit_file_tool" +harness = false diff --git a/crates/benchmarks/LICENSE-GPL b/crates/benchmarks/LICENSE-GPL new file mode 120000 index 00000000000000..89e542f750cd38 --- /dev/null +++ b/crates/benchmarks/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/editor/benches/display_map.rs b/crates/benchmarks/benches/display_map.rs similarity index 100% rename from crates/editor/benches/display_map.rs rename to crates/benchmarks/benches/display_map.rs diff --git a/crates/agent/benches/edit_file_tool.rs b/crates/benchmarks/benches/edit_file_tool.rs similarity index 100% rename from crates/agent/benches/edit_file_tool.rs rename to crates/benchmarks/benches/edit_file_tool.rs diff --git a/crates/editor/benches/editor_render.rs b/crates/benchmarks/benches/editor_render.rs similarity index 80% rename from crates/editor/benches/editor_render.rs rename to crates/benchmarks/benches/editor_render.rs index 2840d782bd594f..91885fa74a50c5 100644 --- a/crates/editor/benches/editor_render.rs +++ b/crates/benchmarks/benches/editor_render.rs @@ -6,18 +6,18 @@ use editor::{ use gpui::{AppContext as _, BenchAppContext, Focusable as _, TestAppContext, TestDispatcher}; use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; use settings::SettingsStore; -use ui::IntoElement; use util::RandomCharIter; +use zed_actions::editor::{MoveDown, MoveUp}; #[gpui::bench] -fn editor_input_with_1000_cursors(bencher: &mut Bencher<'_>, cx: &mut BenchAppContext) { +fn editor_input_with_1000_cursors(cx: &mut BenchAppContext) { init_context(cx); let text = String::from_iter(["line:\n"; 1000]); let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); - let mut cx = cx.add_empty_window(); - let editor = cx.update(|window, cx| { + let mut window = cx.add_empty_window(); + let editor = window.update(|window, cx| { let editor = cx.new(|cx| { let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx); editor.set_style(editor::EditorStyle::default(), window, cx); @@ -35,8 +35,8 @@ fn editor_input_with_1000_cursors(bencher: &mut Bencher<'_>, cx: &mut BenchAppCo editor }); - bencher.iter(|| { - cx.update(|window, cx| { + cx.bench_iter(|_| { + window.update(|window, cx| { editor.update(cx, |editor, cx| { editor.handle_input("hello world", window, cx); editor.delete_to_previous_word_start( @@ -80,8 +80,10 @@ fn open_editor_with_one_long_line(bencher: &mut Bencher<'_>, args: &(String, Tes }); } -fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) { - let mut cx = cx.clone(); +#[gpui::bench] +fn editor_render(cx: &mut BenchAppContext) { + init_context(cx); + let buffer = cx.update(|cx| { let mut rng = StdRng::seed_from_u64(1); let text_len = rng.random_range(10000..90000); @@ -95,9 +97,9 @@ fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) { } }); - let cx = cx.add_empty_window(); - let editor = cx.update(|window, cx| { - let editor = cx.new(|cx| { + let mut window = cx.add_empty_window(); + let editor = window.update(|window, cx| { + let editor = window.replace_root(cx, |window, cx| { let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx); editor.set_style(editor::EditorStyle::default(), window, cx); editor @@ -106,14 +108,15 @@ fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) { editor }); - bencher.iter(|| { - cx.update(|window, cx| { - let mut view = editor.clone().into_any_element(); - let _ = view.request_layout(window, cx); - let _ = view.prepaint(window, cx); - view.paint(window, cx); - }); - }) + let mut move_down = true; + cx.bench_renderer(editor, move |editor, window, cx| { + if move_down { + editor.move_down(&MoveDown, window, cx); + } else { + editor.move_up(&MoveUp, window, cx); + } + move_down = !move_down; + }); } fn init_context(cx: &mut BenchAppContext) { @@ -141,16 +144,8 @@ fn criterion_benches(criterion: &mut criterion::Criterion) { let cx = gpui::TestAppContext::build(dispatcher, None); init_test_context(&cx); - let mut group = criterion.benchmark_group("Time to render"); - group.bench_with_input( - BenchmarkId::new("editor_render", "TestAppContext"), - &cx, - editor_render, - ); - group.finish(); - let text = String::from_iter(["char"; 1000]); - let input = (text, cx.clone()); + let input = (text, cx); let mut group = criterion.benchmark_group("Build buffer with one long line"); group.bench_with_input( BenchmarkId::new("editor_with_one_long_line", "(String, TestAppContext )"), @@ -160,5 +155,10 @@ fn criterion_benches(criterion: &mut criterion::Criterion) { group.finish(); } -gpui::bench_group!(benches, editor_input_with_1000_cursors, criterion_benches); +gpui::bench_group!( + benches, + editor_input_with_1000_cursors, + editor_render, + criterion_benches +); gpui::bench_main!(benches); diff --git a/crates/benchmarks/src/benchmarks.rs b/crates/benchmarks/src/benchmarks.rs new file mode 100644 index 00000000000000..1b606305052c8b --- /dev/null +++ b/crates/benchmarks/src/benchmarks.rs @@ -0,0 +1,6 @@ +//! Benchmark targets for Zed crates. +//! +//! Benchmarks live in their own crate so benchmark-only dependencies +//! (Criterion, `gpui_platform`, gpui's `bench` feature, ...) don't weigh down +//! the test builds of the crates being benchmarked. Each file in `benches/` +//! targets one area of the codebase. diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 813a8a9bc510f3..1ca500832e2807 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -106,7 +106,6 @@ zed_actions.workspace = true zlog.workspace = true [dev-dependencies] -criterion.workspace = true ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } @@ -138,12 +137,3 @@ util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } zlog.workspace = true - - -[[bench]] -name = "editor_render" -harness = false - -[[bench]] -name = "display_map" -harness = false diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 2270777f98f3b3..2f9c47dbef9935 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -26,6 +26,7 @@ test-support = [ "x11", "proptest", ] +bench = ["test-support", "dep:criterion", "dep:hdrhistogram"] inspector = ["gpui_macros/inspector"] leak-detection = ["backtrace"] wayland = [ @@ -53,6 +54,7 @@ backtrace = { workspace = true, optional = true } bitflags = { workspace = true, optional = true } collections.workspace = true +criterion = { workspace = true, optional = true } ctor.workspace = true derive_more.workspace = true etagere = "0.2" diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 3631499fa98ffc..794009f5189455 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -23,8 +23,8 @@ use parking_lot::RwLock; use slotmap::SlotMap; pub use async_context::*; -#[cfg(any(test, feature = "test-support"))] -pub use bench_context::{BenchAppContext, BenchWindowContext}; +#[cfg(feature = "bench")] +pub use bench_context::{BenchAppContext, BenchReport, BenchWindowContext, bench_platform}; use collections::{FxHashMap, FxHashSet, HashMap, TypeIdHashMap, TypeIdHashSet, VecDeque}; pub use context::*; pub use entity_map::*; @@ -58,7 +58,7 @@ use crate::{ }; mod async_context; -#[cfg(any(test, feature = "test-support"))] +#[cfg(feature = "bench")] mod bench_context; mod context; mod entity_map; @@ -1489,7 +1489,7 @@ impl App { } } } else { - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "test-support", feature = "bench"))] for window in self .windows .values() diff --git a/crates/gpui/src/app/bench_context.rs b/crates/gpui/src/app/bench_context.rs index 6af1b7a9fa9088..aee68633f44037 100644 --- a/crates/gpui/src/app/bench_context.rs +++ b/crates/gpui/src/app/bench_context.rs @@ -1,14 +1,267 @@ -use std::{future::Future, rc::Rc, sync::Arc}; +use std::{ + cell::{OnceCell, RefCell}, + future::Future, + rc::Rc, + sync::Arc, + time::Duration, +}; use anyhow::{Result, anyhow}; +use hdrhistogram::Histogram; use crate::{ - AnyView, AnyWindowHandle, App, AppCell, AppContext, BackgroundExecutor, Bounds, Context, Empty, - Entity, EntityId, Focusable, ForegroundExecutor, Global, Render, Reservation, Task, - TestDispatcher, TestPlatform, VisualContext, Window, WindowBounds, WindowHandle, WindowOptions, - app::{GpuiBorrow, GpuiMode}, + AnyView, AnyWindowHandle, App, AppCell, AppContext, BackgroundExecutor, BenchDispatcher, + Bounds, Context, Empty, Entity, EntityId, Focusable, ForegroundExecutor, Global, + NoopTextSystem, Platform, PlatformHeadlessRenderer, Render, Reservation, Task, TestPlatform, + VisualContext, Window, WindowBounds, WindowHandle, WindowOptions, + app::GpuiBorrow, + profiler::{self, FrameTiming, FrameTimingCollector}, }; +/// Returns this thread's shared benchmark platform, creating it on first use. +/// +/// The platform is a [`TestPlatform`] backed by a multithreaded +/// [`BenchDispatcher`], so background work runs with production concurrency in +/// real time. It is cached per thread and reused across benchmark invocations +/// so worker and timer threads persist for the whole process instead of being +/// recreated for every Criterion calibration pass. +/// +/// Text is shaped with [`NoopTextSystem`] (one glyph per character at fixed +/// advances). This keeps results deterministic across machines and font +/// installations while preserving the structure of downstream layout and paint +/// work; absolute timings exclude production text shaping (roughly 10% of draw +/// time for a full editor frame). Benchmarks that need real shaping can build +/// a [`TestPlatform`] with a platform text system and pass it to +/// [`BenchAppContext::new_with_platform_and_report`]. +/// +/// `headless_renderer_factory` (only used on first call) supplies a renderer +/// for benchmark windows, e.g. `gpui_platform::current_headless_renderer`. +/// When present, scenes drawn by benchmarks are rasterized through the real +/// sprite atlas and submitted to the GPU on present, so quad/sprite +/// regressions show up in measurements. When `None`, presenting discards the +/// scene. Currently only macOS provides a headless renderer (Metal), so GPU +/// submission is excluded from benchmark measurements on other platforms. +pub fn bench_platform( + headless_renderer_factory: Option Option>>>, +) -> Rc { + thread_local! { + static PLATFORM: OnceCell> = const { OnceCell::new() }; + } + PLATFORM.with(|cell| { + cell.get_or_init(|| { + let dispatcher = Arc::new(BenchDispatcher::new()); + let background_executor = BackgroundExecutor::new(dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(dispatcher); + TestPlatform::with_platform( + background_executor, + foreground_executor, + Arc::new(NoopTextSystem::new()), + headless_renderer_factory, + ) + }) + .clone() as Rc + }) +} + +/// Default target frame rate when a benchmark doesn't specify `fps = N`. +const DEFAULT_FPS: u64 = 120; + +const NANOS_PER_SECOND: u128 = 1_000_000_000; + +/// A small report produced by GPUI benchmarks. +#[derive(Clone)] +pub struct BenchReport { + frame_snapshot: Rc>, + frame_budget_nanos: u128, +} + +impl Default for BenchReport { + fn default() -> Self { + Self::with_fps(DEFAULT_FPS) + } +} + +impl BenchReport { + /// Creates a report whose per-frame budget is one frame at `fps` when + /// counting frame budget overruns. + pub fn with_fps(fps: u64) -> Self { + assert!(fps > 0, "frame rate must be greater than zero"); + Self::with_frame_budget_nanos(NANOS_PER_SECOND / fps as u128) + } + + /// Creates a report that treats `frame_budget_nanos` as the per-frame budget + /// when counting frame budget overruns. + pub fn with_frame_budget_nanos(frame_budget_nanos: u128) -> Self { + Self { + frame_snapshot: Rc::new(RefCell::new(WindowFrameSnapshot::new())), + frame_budget_nanos, + } + } + + fn record_frame_timings<'i>(&self, timings: impl IntoIterator) { + let mut snapshot = self.frame_snapshot.borrow_mut(); + // `.ok()` on `record`: this operation is infallible (the histograms auto-resize). + for timing in timings { + snapshot + .draw + .record(timing.draw_duration().as_nanos() as u64) + .ok(); + if let Some(dirty_to_draw) = timing.dirty_to_draw_duration() { + snapshot + .dirty_to_draw + .record(dirty_to_draw.as_nanos() as u64) + .ok(); + } + if timing.invalidations > 0 { + snapshot + .invalidations_per_frame + .record(timing.invalidations) + .ok(); + } + } + } + + fn total_budget_overruns(&self, histogram: &Histogram) -> u64 { + histogram + .iter_recorded() + .map(|value| { + self.budget_overruns(Duration::from_nanos(value.value_iterated_to())) + * value.count_at_value() + }) + .sum() + } + + /// Returns how many whole frame budgets `foreground_time` exceeded the + /// per frame budget by. This is a synthetic proxy for missed frames: the + /// benchmark harness has no vsync, so it counts how many frame deadlines + /// would have elapsed while the foreground thread was busy. + fn budget_overruns(&self, foreground_time: Duration) -> u64 { + let foreground_nanos = foreground_time.as_nanos(); + if foreground_nanos <= self.frame_budget_nanos { + return 0; + } + + let over_budget_nanos = foreground_nanos - self.frame_budget_nanos; + over_budget_nanos.div_ceil(self.frame_budget_nanos) as u64 + } + + /// Prints this report to stderr. + pub fn print(&self, benchmark_name: Option<&'static str>) { + let frame_snapshot = self.frame_snapshot.borrow(); + if frame_snapshot.is_empty() { + return; + } + + let benchmark_name = benchmark_name.unwrap_or("unknown benchmark"); + eprintln!("GPUI bench report (all observed iterations): {benchmark_name}"); + eprintln!(" note: includes Criterion warmup/calibration"); + self.print_histogram("window dirty-to-draw", &frame_snapshot.dirty_to_draw); + self.print_histogram("window draw", &frame_snapshot.draw); + if !frame_snapshot.invalidations_per_frame.is_empty() { + eprintln!( + " invalidations per frame: mean {:.2}, max {}", + frame_snapshot.invalidations_per_frame.mean(), + frame_snapshot.invalidations_per_frame.max() + ); + } + } + + fn print_histogram(&self, name: &str, histogram: &Histogram) { + if histogram.is_empty() { + return; + } + + let max_foreground_time = Duration::from_nanos(histogram.max()); + eprintln!(" {name}:"); + eprintln!(" samples: {}", histogram.len()); + eprintln!( + " mean: {}", + format_duration(Duration::from_nanos(histogram.mean() as u64)) + ); + eprintln!( + " p50: {}", + format_duration(Duration::from_nanos(histogram.value_at_quantile(0.50))) + ); + eprintln!( + " p90: {}", + format_duration(Duration::from_nanos(histogram.value_at_quantile(0.90))) + ); + eprintln!( + " p95: {}", + format_duration(Duration::from_nanos(histogram.value_at_quantile(0.95))) + ); + eprintln!( + " p99: {}", + format_duration(Duration::from_nanos(histogram.value_at_quantile(0.99))) + ); + eprintln!(" max: {}", format_duration(max_foreground_time)); + eprintln!( + " frame budget overruns total: {}", + self.total_budget_overruns(histogram) + ); + eprintln!( + " frame budget overruns max: {}", + self.budget_overruns(max_foreground_time) + ); + } +} + +struct WindowFrameSnapshot { + dirty_to_draw: Histogram, + draw: Histogram, + invalidations_per_frame: Histogram, +} + +impl WindowFrameSnapshot { + fn new() -> Self { + Self { + dirty_to_draw: Histogram::new(3).expect("3 significant digits is valid"), + draw: Histogram::new(3).expect("3 significant digits is valid"), + invalidations_per_frame: Histogram::new(3).expect("3 significant digits is valid"), + } + } + + fn is_empty(&self) -> bool { + self.dirty_to_draw.is_empty() && self.draw.is_empty() + } +} + +fn format_duration(duration: Duration) -> String { + format!("{:.3}ms", duration.as_secs_f64() * 1000.) +} + +/// Enables frame tracing for the duration of a measurement and collects the +/// frames recorded within it. The previous tracing state is restored on drop, +/// so a panicking measurement doesn't leave tracing enabled for unrelated code +/// (e.g. a later benchmark in the same process). +struct FrameTraceScope { + collector: FrameTimingCollector, + was_already_enabled: bool, +} + +impl FrameTraceScope { + fn start() -> Self { + let was_already_enabled = !profiler::set_frame_trace_enabled(true); + Self { + collector: FrameTimingCollector::new(), + was_already_enabled, + } + } + + fn finish(mut self) -> Vec { + self.collector.collect_unseen() + // Dropping `self` restores the previous tracing state. + } +} + +impl Drop for FrameTraceScope { + fn drop(&mut self) { + if !self.was_already_enabled { + profiler::set_frame_trace_enabled(false); + } + } +} + /// A GPUI app context for Criterion benchmarks. /// /// `BenchAppContext` is intentionally separate from `TestAppContext`: it owns a @@ -16,41 +269,70 @@ use crate::{ /// benchmark setup. Criterion remains responsible for the measured loop via its /// `Bencher` API. #[derive(Clone)] -pub struct BenchAppContext { +pub struct BenchAppContext<'a, 'measurement> { app: Rc, background_executor: BackgroundExecutor, foreground_executor: ForegroundExecutor, - dispatcher: TestDispatcher, benchmark_name: Option<&'static str>, + bencher: Rc>>>, + report: BenchReport, } -impl BenchAppContext { - /// Creates a new benchmark app context. - pub fn new(benchmark_name: Option<&'static str>) -> Self { - Self::with_seed(benchmark_name, 0) - } - - /// Creates a new benchmark app context with the provided scheduler seed. - pub fn with_seed(benchmark_name: Option<&'static str>, seed: u64) -> Self { - Self::build(TestDispatcher::new(seed), benchmark_name) - } - - fn build(dispatcher: TestDispatcher, benchmark_name: Option<&'static str>) -> Self { - let dispatcher = Arc::new(dispatcher); - let background_executor = BackgroundExecutor::new(dispatcher.clone()); - let foreground_executor = ForegroundExecutor::new(dispatcher.clone()); - let platform = TestPlatform::new(background_executor.clone(), foreground_executor.clone()); +impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { + /// Creates a new benchmark app context backed by the provided platform. + /// + /// The platform's executors must be backed by a [`BenchDispatcher`] + /// (see [`bench_platform`]) so the context can drain foreground work via + /// [`Self::run_until_idle`]; panics otherwise. + pub fn new( + platform: Rc, + benchmark_name: Option<&'static str>, + bencher: &'a mut criterion::Bencher<'measurement>, + ) -> Self { + Self::build(platform, benchmark_name, bencher, BenchReport::default()) + } + + /// Creates a new benchmark app context backed by the provided platform. + /// + /// The platform's executors must be backed by a [`BenchDispatcher`] + /// (see [`bench_platform`]) so the context can drain foreground work via + /// [`Self::run_until_idle`]; panics otherwise. + #[doc(hidden)] + pub fn new_with_platform_and_report( + platform: Rc, + benchmark_name: Option<&'static str>, + bencher: &'a mut criterion::Bencher<'measurement>, + report: BenchReport, + ) -> Self { + Self::build(platform, benchmark_name, bencher, report) + } + + fn build( + platform: Rc, + benchmark_name: Option<&'static str>, + bencher: &'a mut criterion::Bencher<'measurement>, + report: BenchReport, + ) -> Self { + let background_executor = platform.background_executor(); + // Validate up front so misconfiguration fails at construction with a + // clear message instead of deep inside `run_until_idle`. + assert!( + background_executor.dispatcher().as_bench().is_some(), + "BenchAppContext requires a platform whose executors are backed by a \ + BenchDispatcher; construct one with gpui::bench_platform" + ); + let foreground_executor = platform.foreground_executor(); let asset_source = Arc::new(()); let http_client = http_client::FakeHttpClient::with_404_response(); let app = App::new_app(platform, asset_source, http_client); - app.borrow_mut().mode = GpuiMode::test(); Self { app, background_executor, foreground_executor, - dispatcher: (*dispatcher).clone(), benchmark_name, + bencher: Rc::new(RefCell::new(Some(bencher))), + report, } } @@ -69,11 +351,6 @@ impl BenchAppContext { &self.foreground_executor } - /// Runs pending scheduled work until the benchmark app is idle. - pub fn run_until_idle(&self) { - self.dispatcher.run_until_parked(); - } - /// Updates the app and flushes synchronous GPUI effects afterward. pub fn update(&mut self, update: impl FnOnce(&mut App) -> R) -> R { let mut app = self.app.borrow_mut(); @@ -86,8 +363,85 @@ impl BenchAppContext { read(&app) } + /// Runs queued foreground tasks on this thread and waits for in flight + /// background work to finish. Timers that aren't due yet are not waited + /// for (see [`BenchDispatcher::run_until_idle`]). + pub fn run_until_idle(&self) { + self.background_executor + .dispatcher() + .as_bench() + .expect("validated in BenchAppContext::build") + .run_until_idle(); + } + + /// Measures a generic benchmark workload using Criterion's iteration loop. + /// + /// The closure is invoked once per Criterion iteration with this + /// benchmark app context so it can update GPUI state. + /// + /// Any window draws triggered by the workload are recorded into the + /// benchmark's frame report through the GPUI frame profiler. + pub fn bench_iter(&mut self, mut benchmark: impl FnMut(&mut Self)) { + let bencher = self.take_bencher("bench_iter"); + let collector = FrameTraceScope::start(); + let mut benchmark = || benchmark(self); + bencher.iter(&mut benchmark); + self.report.record_frame_timings(collector.finish().iter()); + self.replace_bencher(bencher); + } + + /// Measures frame latency after updating a GPUI entity in its current window. + /// + /// Each iteration runs `update` against the entity in its current window. In + /// bench builds, flushing the update's effects synchronously draws dirty + /// windows. The entity should be part of the window's render tree, such as the + /// root view or a child of it. + /// + /// Frame timings are collected through the GPUI frame profiler + /// ([`crate::profiler::record_frame_timing`]), which is enabled for the + /// duration of the measurement. + pub fn bench_renderer( + &mut self, + view: Entity, + mut update: impl FnMut(&mut V, &mut Window, &mut Context), + ) where + V: 'static + Render, + { + let bencher = self.take_bencher("bench_renderer"); + let window_id = self + .with_window(view.entity_id(), |window, _| { + window.window_handle().window_id() + }) + .expect("cannot benchmark renderer for entity without a current window"); + + let collector = FrameTraceScope::start(); + + let mut benchmark = || { + self.with_window(view.entity_id(), |window, cx| { + view.update(cx, |view, cx| update(view, window, cx)); + }) + .expect("cannot benchmark renderer for entity without a current window"); + // Submit the frame drawn by the update's effect flush, mirroring + // production where every drawn frame is presented. With a headless + // renderer this includes scene submission to the GPU. + self.with_window(view.entity_id(), |window, _| { + window.present_if_needed(); + }) + .expect("cannot benchmark renderer for entity without a current window"); + }; + bencher.iter(&mut benchmark); + + let timings = collector.finish(); + self.report.record_frame_timings( + timings + .iter() + .filter(|timing| timing.window_id == window_id), + ); + self.replace_bencher(bencher); + } + /// Adds a window with an empty root view for benchmark setup. - pub fn add_empty_window(&mut self) -> BenchWindowContext { + pub fn add_empty_window(&mut self) -> BenchWindowContext<'a, 'measurement> { let window = { let mut app = self.app.borrow_mut(); let bounds = Bounds::maximized(None, &app); @@ -111,18 +465,40 @@ impl BenchAppContext { } } + fn take_bencher(&self, benchmark_kind: &str) -> &'a mut criterion::Bencher<'measurement> { + self.bencher.borrow_mut().take().unwrap_or_else(|| { + panic!("cannot start {benchmark_kind}: benchmark measurement is already running") + }) + } + + fn replace_bencher(&self, bencher: &'a mut criterion::Bencher<'measurement>) { + let previous = self.bencher.borrow_mut().replace(bencher); + assert!( + previous.is_none(), + "benchmark bencher was unexpectedly present after measurement" + ); + } + /// Runs GPUI benchmark teardown. + /// + /// Forgets any timers still armed on the shared dispatcher so they can't + /// fire during a later benchmark; assumes no other `BenchAppContext` is + /// live on this thread. pub fn teardown(mut self) { self.run_until_idle(); self.update(|cx| { - cx.background_executor().forbid_parking(); cx.quit(); }); self.run_until_idle(); + self.background_executor + .dispatcher() + .as_bench() + .expect("validated in BenchAppContext::build") + .forget_pending_timers(); } } -impl AppContext for BenchAppContext { +impl AppContext for BenchAppContext<'_, '_> { fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { let mut app = self.app.borrow_mut(); app.new(build_entity) @@ -151,7 +527,7 @@ impl AppContext for BenchAppContext { app.update_entity(handle, update) } - fn as_mut<'a, T>(&'a mut self, _: &Entity) -> GpuiBorrow<'a, T> + fn as_mut<'b, T>(&'b mut self, _: &Entity) -> GpuiBorrow<'b, T> where T: 'static, { @@ -216,14 +592,14 @@ impl AppContext for BenchAppContext { /// This is separate from `VisualTestContext`; it provides access to a benchmark /// window without exposing test-only helpers such as input simulation. #[derive(Clone)] -pub struct BenchWindowContext { - cx: BenchAppContext, +pub struct BenchWindowContext<'a, 'measurement> { + cx: BenchAppContext<'a, 'measurement>, window: AnyWindowHandle, } -impl BenchWindowContext { +impl<'a, 'measurement> BenchWindowContext<'a, 'measurement> { /// Returns the underlying benchmark app context. - pub fn app_context(&mut self) -> &mut BenchAppContext { + pub fn app_context(&mut self) -> &mut BenchAppContext<'a, 'measurement> { &mut self.cx } @@ -232,20 +608,21 @@ impl BenchWindowContext { self.window } + /// Runs queued foreground tasks on this thread and waits for in-flight + /// background work to finish. Pending timers are not waited for. + pub fn run_until_idle(&self) { + self.cx.run_until_idle(); + } + /// Updates the benchmark window. pub fn update(&mut self, update: impl FnOnce(&mut Window, &mut App) -> R) -> R { self.cx .update_window(self.window, |_, window, cx| update(window, cx)) .expect("benchmark window was unexpectedly closed") } - - /// Runs pending scheduled work until the benchmark app is idle. - pub fn run_until_idle(&self) { - self.cx.run_until_idle(); - } } -impl AppContext for BenchWindowContext { +impl AppContext for BenchWindowContext<'_, '_> { fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { self.window .update(&mut self.cx, |_, _, cx| cx.new(build_entity)) @@ -276,7 +653,7 @@ impl AppContext for BenchWindowContext { self.cx.update_entity(handle, update) } - fn as_mut<'a, T>(&'a mut self, handle: &Entity) -> GpuiBorrow<'a, T> + fn as_mut<'b, T>(&'b mut self, handle: &Entity) -> GpuiBorrow<'b, T> where T: 'static, { @@ -331,7 +708,7 @@ impl AppContext for BenchWindowContext { } } -impl VisualContext for BenchWindowContext { +impl VisualContext for BenchWindowContext<'_, '_> { type Result = Result; fn window_handle(&self) -> AnyWindowHandle { diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index b792718f88120b..a81ff265c3edd8 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -33,9 +33,15 @@ mod keymap; mod path_builder; mod platform; pub mod prelude; -/// Profiling utilities for task timing and thread performance tracking. +/// Profiling utilities for task, frame, and thread performance tracking. pub mod profiler; -#[cfg(any(target_os = "windows", target_os = "linux", target_family = "wasm"))] +#[cfg(any( + test, + target_os = "windows", + target_os = "linux", + target_family = "wasm", + feature = "bench" +))] #[expect(missing_docs)] pub mod queue; mod scene; diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 06072b0d5e61e6..60355b8e2446bd 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -6,6 +6,9 @@ mod keystroke; #[expect(missing_docs)] pub mod layer_shell; +#[cfg(any(test, feature = "bench"))] +mod bench_dispatcher; + #[cfg(any(test, feature = "test-support"))] mod test; @@ -77,6 +80,9 @@ pub(crate) use test::*; #[cfg(any(test, feature = "test-support"))] pub use test::{TestDispatcher, TestScreenCaptureSource, TestScreenCaptureStream}; +#[cfg(any(test, feature = "bench"))] +pub use bench_dispatcher::BenchDispatcher; + #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] pub use visual_test::VisualTestPlatform; @@ -745,6 +751,13 @@ pub trait PlatformHeadlessRenderer { size: Size, ) -> Result; + /// Render a scene to an offscreen target without reading the result back. + /// + /// This is the headless analogue of presenting a frame: it performs the + /// same CPU-side scene encoding and GPU submission as drawing to a real + /// window, but doesn't block on GPU completion or copy pixels back. + fn render_scene(&mut self, scene: &Scene, size: Size) -> Result<()>; + /// Returns the sprite atlas used by this renderer. fn sprite_atlas(&self) -> Arc; } @@ -786,6 +799,13 @@ pub trait PlatformDispatcher: Send + Sync { fn as_test(&self) -> Option<&TestDispatcher> { None } + + // This cfg must match the `bench_dispatcher` module's, which implements + // this method whenever it compiles. + #[cfg(any(test, feature = "bench"))] + fn as_bench(&self) -> Option<&BenchDispatcher> { + None + } } #[expect(missing_docs)] diff --git a/crates/gpui/src/platform/bench_dispatcher.rs b/crates/gpui/src/platform/bench_dispatcher.rs new file mode 100644 index 00000000000000..80aff1c3895f83 --- /dev/null +++ b/crates/gpui/src/platform/bench_dispatcher.rs @@ -0,0 +1,441 @@ +use std::{ + collections::BinaryHeap, + sync::Arc, + thread, + time::{Duration, Instant}, +}; + +use parking_lot::{Condvar, Mutex}; + +use crate::{ + PlatformDispatcher, Priority, RunnableVariant, profiler, + queue::{PriorityQueueReceiver, PriorityQueueSender}, +}; + +const MIN_THREADS: usize = 2; + +/// A multithreaded [`PlatformDispatcher`] for benchmarks. +/// +/// Background tasks run in parallel on a pool of worker threads and timers fire +/// in real time on a dedicated timer thread, mirroring the production +/// dispatchers (see `LinuxDispatcher`). Main-thread tasks are queued until the +/// benchmark thread drains them via [`Self::run_until_idle`], since there is no +/// platform run loop pumping them. +/// +/// Unlike [`TestDispatcher`](crate::TestDispatcher), which runs everything on a +/// single thread with a virtual clock, work dispatched through this dispatcher +/// executes with production concurrency, so wall-clock measurements reflect +/// real parallelism. +pub struct BenchDispatcher { + background_sender: PriorityQueueSender, + main_sender: PriorityQueueSender, + main_receiver: Mutex>, + timers: Arc, + idle: Arc, + main_thread_id: thread::ThreadId, +} + +/// Tracks how many background and timer runnables are queued or running so +/// [`BenchDispatcher::run_until_idle`] knows when to stop waiting. +#[derive(Default)] +struct IdleTracker { + inflight: Mutex, + condvar: Condvar, +} + +impl IdleTracker { + fn increment(&self) { + *self.inflight.lock() += 1; + } + + fn decrement(&self) { + let mut inflight = self.inflight.lock(); + *inflight -= 1; + if *inflight == 0 { + self.condvar.notify_all(); + } + } + + /// Returns a guard that decrements the in-flight count when dropped, so + /// the count stays correct even if the runnable being executed panics. + fn decrement_on_drop(&self) -> impl Drop + '_ { + gpui_util::defer(|| self.decrement()) + } + + /// Notifies waiters while holding the in-flight lock. `run_until_idle` + /// re-checks its wake conditions under this lock before waiting, so the + /// notification can't slip between its check and its wait and be lost. + fn notify_under_lock(&self) { + let _inflight = self.inflight.lock(); + self.condvar.notify_all(); + } +} + +struct TimerQueue { + state: Mutex, + condvar: Condvar, +} + +struct TimerQueueState { + heap: BinaryHeap, + next_seq: u64, +} + +struct TimerEntry { + due: Instant, + seq: u64, + runnable: RunnableVariant, +} + +impl PartialEq for TimerEntry { + fn eq(&self, other: &Self) -> bool { + self.due == other.due && self.seq == other.seq + } +} + +impl Eq for TimerEntry {} + +impl PartialOrd for TimerEntry { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for TimerEntry { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // Reversed so that the entry with the earliest due time (breaking ties + // by insertion order) is at the top of the max-heap. + other + .due + .cmp(&self.due) + .then_with(|| other.seq.cmp(&self.seq)) + } +} + +impl Default for BenchDispatcher { + fn default() -> Self { + Self::new() + } +} + +impl BenchDispatcher { + /// Creates a dispatcher whose main thread is the calling thread. + /// + /// Worker and timer threads live for the lifetime of the process; the + /// dispatcher is expected to be created once and reused across benchmarks. + pub fn new() -> Self { + let (background_sender, background_receiver) = PriorityQueueReceiver::new(); + let (main_sender, main_receiver) = PriorityQueueReceiver::new(); + let idle = Arc::new(IdleTracker::default()); + + let thread_count = + thread::available_parallelism().map_or(MIN_THREADS, |i| i.get().max(MIN_THREADS)); + for i in 0..thread_count { + let mut receiver: PriorityQueueReceiver = background_receiver.clone(); + let idle = idle.clone(); + thread::Builder::new() + .name(format!("BenchWorker-{i}")) + .spawn(move || { + while let Ok(runnable) = receiver.pop() { + let _decrement = idle.decrement_on_drop(); + let location = runnable.metadata().location; + let spawned = runnable.metadata().spawned; + profiler::update_running_task(spawned, location); + runnable.run(); + profiler::save_task_timing(); + } + }) + .expect("failed to spawn benchmark worker thread"); + } + drop(background_receiver); + + let timers = Arc::new(TimerQueue { + state: Mutex::new(TimerQueueState { + heap: BinaryHeap::new(), + next_seq: 0, + }), + condvar: Condvar::new(), + }); + { + let timers = timers.clone(); + let idle = idle.clone(); + thread::Builder::new() + .name("BenchTimer".to_owned()) + .spawn(move || { + let mut state = timers.state.lock(); + loop { + let Some(entry) = state.heap.peek() else { + timers.condvar.wait(&mut state); + continue; + }; + let due = entry.due; + if due > Instant::now() { + timers.condvar.wait_until(&mut state, due); + continue; + } + let Some(entry) = state.heap.pop() else { + continue; + }; + // Count the firing timer as in-flight before releasing + // the lock so it can spawn follow-up work that + // `run_until_idle` will wait for. Lock order is always + // timer state, then in-flight count; `run_until_idle` + // never takes them in the opposite order. + idle.increment(); + drop(state); + + { + let _decrement = idle.decrement_on_drop(); + let location = entry.runnable.metadata().location; + let spawned = entry.runnable.metadata().spawned; + profiler::update_running_task(spawned, location); + entry.runnable.run(); + profiler::save_task_timing(); + } + + state = timers.state.lock(); + } + }) + .expect("failed to spawn benchmark timer thread"); + } + + Self { + background_sender, + main_sender, + main_receiver: Mutex::new(main_receiver), + timers, + idle, + main_thread_id: thread::current().id(), + } + } + + /// Runs queued main thread tasks and waits until no background or timer + /// work is queued, running, or already due. + /// + /// Timers that haven't reached their due time yet are *not* waited for: + /// the dispatcher runs in real time and cannot skip ahead like the + /// `TestDispatcher`'s virtual clock, so waiting on a future timer would + /// block for its full real duration. Tasks sleeping on such timers are + /// considered idle. Must be called on the thread that created this + /// dispatcher. + pub fn run_until_idle(&self) { + assert!( + self.is_main_thread(), + "run_until_idle must be called on the benchmark main thread" + ); + loop { + if self.drain_main_queue() { + continue; + } + + // Checked before taking the in-flight lock; the timer thread + // locks them in the opposite order, so nesting would deadlock. + if self.has_due_timer() { + // Poll briefly: a firing timer leaves the heap just before it + // registers as in-flight. + let mut inflight = self.idle.inflight.lock(); + self.idle + .condvar + .wait_for(&mut inflight, Duration::from_millis(1)); + continue; + } + + let mut inflight = self.idle.inflight.lock(); + // Re-checked under the lock that `dispatch_on_main_thread` + // notifies under, so the notification can't be lost. + if self.main_queue_has_work() { + continue; + } + if *inflight == 0 { + // Main-thread sends happen before in-flight decrements, and + // decrements happen under this lock, so the check above + // observed all completed work. + return; + } + // Woken when main-thread work arrives or the in-flight count + // reaches zero; both notify under this lock. + self.idle.condvar.wait(&mut inflight); + } + } + + /// Forgets all pending timers so timers armed by one benchmark can't fire + /// during a later benchmark sharing this process-lifetime dispatcher. + /// + /// The runnables are leaked rather than dropped, since dropping one wakes + /// the awaiting task as if the timer had fired. + pub fn forget_pending_timers(&self) { + let mut state = self.timers.state.lock(); + for entry in state.heap.drain() { + std::mem::forget(entry.runnable); + } + } + + fn has_due_timer(&self) -> bool { + let state = self.timers.state.lock(); + state + .heap + .peek() + .is_some_and(|entry| entry.due <= Instant::now()) + } + + fn main_queue_has_work(&self) -> bool { + !self.main_receiver.lock().is_empty() + } + + fn drain_main_queue(&self) -> bool { + let mut ran_any = false; + loop { + // Lock only around the pop so runnables can re-entrantly dispatch + // more main-thread work through the sender while they run. + let runnable = self.main_receiver.lock().try_pop(); + match runnable { + Ok(Some(runnable)) => { + let location = runnable.metadata().location; + let spawned = runnable.metadata().spawned; + profiler::update_running_task(spawned, location); + runnable.run(); + profiler::save_task_timing(); + ran_any = true; + } + Ok(None) | Err(_) => return ran_any, + } + } + } +} + +impl PlatformDispatcher for BenchDispatcher { + fn is_main_thread(&self) -> bool { + thread::current().id() == self.main_thread_id + } + + fn dispatch(&self, runnable: RunnableVariant, priority: Priority) { + self.idle.increment(); + self.background_sender + .send(priority, runnable) + .unwrap_or_else(|_| panic!("benchmark worker threads are no longer running")); + } + + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority) { + if let Err(error) = self.main_sender.send(priority, runnable) { + // The main receiver lives as long as this dispatcher, so a failed + // send means we're mid-teardown. The runnable may wrap a !Send + // future, so forget it rather than dropping it on this thread + // (mirrors LinuxDispatcher). + std::mem::forget(error); + return; + } + // Wake `run_until_idle` if it's waiting for main-thread work. + self.idle.notify_under_lock(); + } + + fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { + let mut state = self.timers.state.lock(); + let seq = state.next_seq; + state.next_seq += 1; + state.heap.push(TimerEntry { + due: Instant::now() + duration, + seq, + runnable, + }); + self.timers.condvar.notify_one(); + } + + fn spawn_realtime(&self, f: Box) { + // Benchmarks don't need realtime scheduling priority; a plain thread + // keeps this portable. + thread::Builder::new() + .name("BenchRealtime".to_owned()) + .spawn(f) + .expect("failed to spawn benchmark realtime thread"); + } + + fn as_bench(&self) -> Option<&BenchDispatcher> { + Some(self) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + + use super::*; + use crate::{BackgroundExecutor, ForegroundExecutor}; + + #[test] + fn run_until_idle_completes_background_to_main_handoffs() { + let dispatcher = Arc::new(BenchDispatcher::new()); + let background = BackgroundExecutor::new(dispatcher.clone()); + let foreground = ForegroundExecutor::new(dispatcher.clone()); + + let (sender, receiver) = futures::channel::oneshot::channel(); + background + .spawn(async move { + thread::sleep(Duration::from_millis(10)); + sender.send(()).ok(); + }) + .detach(); + + let completed = Arc::new(AtomicBool::new(false)); + foreground + .spawn({ + let completed = completed.clone(); + async move { + receiver.await.ok(); + completed.store(true, Ordering::SeqCst); + } + }) + .detach(); + + dispatcher.run_until_idle(); + assert!(completed.load(Ordering::SeqCst)); + } + + #[test] + fn timers_fire_in_real_time() { + let dispatcher = Arc::new(BenchDispatcher::new()); + let background = BackgroundExecutor::new(dispatcher); + + let fired = Arc::new(AtomicBool::new(false)); + let timer = background.timer(Duration::from_millis(10)); + background + .spawn({ + let fired = fired.clone(); + async move { + timer.await; + fired.store(true, Ordering::SeqCst); + } + }) + .detach(); + + let deadline = Instant::now() + Duration::from_secs(10); + while !fired.load(Ordering::SeqCst) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(1)); + } + assert!(fired.load(Ordering::SeqCst)); + } + + #[test] + fn forget_pending_timers_prevents_stale_timers_from_firing() { + let dispatcher = Arc::new(BenchDispatcher::new()); + let background = BackgroundExecutor::new(dispatcher.clone()); + + let fired = Arc::new(AtomicBool::new(false)); + let timer = background.timer(Duration::from_millis(250)); + background + .spawn({ + let fired = fired.clone(); + async move { + timer.await; + fired.store(true, Ordering::SeqCst); + } + }) + .detach(); + + dispatcher.run_until_idle(); + dispatcher.forget_pending_timers(); + + thread::sleep(Duration::from_millis(400)); + dispatcher.run_until_idle(); + assert!(!fired.load(Ordering::SeqCst)); + } +} diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 2b5399ca9840e3..8894350edc5edb 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -6,6 +6,7 @@ use crate::{ WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, }; use collections::HashMap; +use gpui_util::ResultExt as _; use image::RgbaImage; use parking_lot::Mutex; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; @@ -291,7 +292,14 @@ impl PlatformWindow for TestWindow { fn on_appearance_changed(&self, _callback: Box) {} - fn draw(&self, _scene: &Scene) {} + fn draw(&self, scene: &Scene) { + let scale_factor = self.scale_factor(); + let mut state = self.0.lock(); + let device_size: Size = state.bounds.size.to_device_pixels(scale_factor); + if let Some(renderer) = &mut state.renderer { + renderer.render_scene(scene, device_size).warn_on_err(); + } + } fn sprite_atlas(&self) -> sync::Arc { self.0.lock().sprite_atlas.clone() @@ -299,10 +307,10 @@ impl PlatformWindow for TestWindow { #[cfg(any(test, feature = "test-support"))] fn render_to_image(&self, scene: &Scene) -> anyhow::Result { + let scale_factor = self.scale_factor(); let mut state = self.0.lock(); let size = state.bounds.size; if let Some(renderer) = &mut state.renderer { - let scale_factor = 2.0; let device_size: Size = size.to_device_pixels(scale_factor); renderer.render_scene_to_image(scene, device_size) } else { diff --git a/crates/gpui/src/profiler.rs b/crates/gpui/src/profiler.rs index d4318853c30752..c9e9e8c58784da 100644 --- a/crates/gpui/src/profiler.rs +++ b/crates/gpui/src/profiler.rs @@ -18,7 +18,7 @@ pub(crate) use actions::{save_action_timing, update_running_action}; use serde::{Deserialize, Serialize}; -use crate::{SharedString, TasksIncluded}; +use crate::{SharedString, TasksIncluded, WindowId}; #[cfg(feature = "profiler")] #[doc(hidden)] @@ -695,3 +695,128 @@ pub fn set_trace_enabled(enabled: bool) -> bool { pub fn trace_enabled() -> bool { PROFILER_ENABLED.load(Ordering::Relaxed) } + +/// Timing for a single drawn window frame. +#[derive(Debug, Copy, Clone)] +pub struct FrameTiming { + /// The window that was drawn. + pub window_id: WindowId, + /// When the frame first became dirty (its first invalidation). `None` if + /// frame tracing was not yet enabled when the invalidation occurred. + pub dirty_at: Option, + /// Number of invalidations coalesced into this frame. + pub invalidations: u64, + /// When `Window::draw` started. + pub draw_start: Instant, + /// When `Window::draw` finished. + pub draw_end: Instant, +} + +impl FrameTiming { + /// Time spent inside `Window::draw`. + pub fn draw_duration(&self) -> Duration { + self.draw_end.duration_since(self.draw_start) + } + + /// Time from the frame's first invalidation to the end of its draw, if the + /// first invalidation was observed. + pub fn dirty_to_draw_duration(&self) -> Option { + self.dirty_at + .map(|dirty_at| self.draw_end.duration_since(dirty_at)) + } +} + +// Allow 16MiB of frame timing entries. +const MAX_FRAME_TIMINGS: usize = (16 * 1024 * 1024) / core::mem::size_of::(); + +struct FrameTimings { + timings: VecDeque, + total_pushed: u64, +} + +static FRAME_TIMINGS: spin::Mutex = spin::Mutex::new(FrameTimings { + timings: VecDeque::new(), + total_pushed: 0, +}); + +static FRAME_TRACE_ENABLED: AtomicBool = AtomicBool::new(false); + +/// Enables or disables frame timing collection at runtime. +/// +/// When transitioning from enabled to disabled, the buffered frame timings are +/// cleared so stale data isn't reported after a later re-enable. Returns false +/// if the value was unchanged. +pub fn set_frame_trace_enabled(enabled: bool) -> bool { + if FRAME_TRACE_ENABLED.swap(enabled, Ordering::AcqRel) == enabled { + return false; + } + + if !enabled { + let mut frames = FRAME_TIMINGS.lock(); + frames.timings.clear(); + frames.timings.shrink_to_fit(); + frames.total_pushed = 0; + } + true +} + +/// Returns whether frame timing collection is enabled. +pub fn frame_trace_enabled() -> bool { + FRAME_TRACE_ENABLED.load(Ordering::Relaxed) +} + +/// Records the timing of a drawn window frame. +/// +/// No-op unless frame tracing is enabled via [`set_frame_trace_enabled`]. +pub fn record_frame_timing(timing: FrameTiming) { + if !frame_trace_enabled() { + return; + } + std::hint::cold_path(); // optimize for when profiling is off + + let mut frames = FRAME_TIMINGS.lock(); + if frames.timings.len() >= MAX_FRAME_TIMINGS { + frames.timings.pop_front(); + } + frames.timings.push_back(timing); + frames.total_pushed += 1; +} + +/// Drains frame timings recorded after this collector was created, tracking a +/// cursor so each call to [`Self::collect_unseen`] returns only new entries. +pub struct FrameTimingCollector { + cursor: u64, +} + +impl Default for FrameTimingCollector { + fn default() -> Self { + Self::new() + } +} + +impl FrameTimingCollector { + /// Creates a collector that only sees frames recorded from this point on. + pub fn new() -> Self { + Self { + cursor: FRAME_TIMINGS.lock().total_pushed, + } + } + + /// Returns frame timings recorded since the previous call (or since the + /// collector was created). If the ring buffer wrapped around since the + /// previous poll, the evicted entries are lost. + pub fn collect_unseen(&mut self) -> Vec { + let frames = FRAME_TIMINGS.lock(); + let buffer_len = frames.timings.len() as u64; + let buffer_start = frames.total_pushed.saturating_sub(buffer_len); + let skip = self.cursor.saturating_sub(buffer_start) as usize; + let unseen = frames + .timings + .iter() + .skip(skip.min(frames.timings.len())) + .copied() + .collect(); + self.cursor = frames.total_pushed; + unseen + } +} diff --git a/crates/gpui/src/queue.rs b/crates/gpui/src/queue.rs index 6e7cf2445e3d6d..f2890488159834 100644 --- a/crates/gpui/src/queue.rs +++ b/crates/gpui/src/queue.rs @@ -220,6 +220,11 @@ impl PriorityQueueReceiver { (sender, receiver) } + /// Returns whether the queue currently contains no elements. + pub fn is_empty(&self) -> bool { + self.state.queues.lock().is_empty() + } + /// Tries to pop one element from the priority queue without blocking. /// /// This will early return if there are no elements in the queue. diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index f52fde9aeb01b4..d5fce7a0aafb81 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -117,6 +117,17 @@ struct WindowInvalidatorInner { pub draw_phase: DrawPhase, pub dirty_views: FxHashSet, pub update_count: usize, + pub frame_dirty: FrameDirtyAccumulator, +} + +/// Per-frame invalidation bookkeeping, drained at draw time and emitted to the +/// frame profiler. Tracks when the current frame first became dirty and how +/// many invalidations were coalesced into it. Only populated while +/// `profiler::frame_trace_enabled()` is set. +#[derive(Default)] +struct FrameDirtyAccumulator { + dirty_at: Option, + invalidations: u64, } #[derive(Clone)] @@ -132,6 +143,7 @@ impl WindowInvalidator { draw_phase: DrawPhase::None, dirty_views: FxHashSet::default(), update_count: 0, + frame_dirty: FrameDirtyAccumulator::default(), })), } } @@ -141,6 +153,7 @@ impl WindowInvalidator { inner.update_count += 1; inner.dirty_views.insert(entity); if inner.draw_phase == DrawPhase::None { + Self::record_frame_dirty(&mut inner); inner.dirty = true; cx.push_effect(Effect::Notify { emitter: entity }); true @@ -158,6 +171,7 @@ impl WindowInvalidator { inner.dirty = dirty; if dirty { inner.update_count += 1; + Self::record_frame_dirty(&mut inner); } } @@ -169,6 +183,17 @@ impl WindowInvalidator { self.inner.borrow().update_count } + fn record_frame_dirty(inner: &mut WindowInvalidatorInner) { + if profiler::frame_trace_enabled() { + inner.frame_dirty.dirty_at.get_or_insert_with(Instant::now); + inner.frame_dirty.invalidations += 1; + } + } + + fn take_frame_dirty(&self) -> FrameDirtyAccumulator { + mem::take(&mut self.inner.borrow_mut().frame_dirty) + } + pub fn take_views(&self) -> FxHashSet { mem::take(&mut self.inner.borrow_mut().dirty_views) } @@ -2576,6 +2601,11 @@ impl Window { /// the contents of the new [`Scene`], use [`Self::present`]. #[profiling::function] pub fn draw(&mut self, cx: &mut App) -> ArenaClearNeeded { + // Drain unconditionally so a stale first-invalidation timestamp can't + // leak into a later frame across enable/disable of frame tracing. + let frame_dirty = self.invalidator.take_frame_dirty(); + let draw_started_at = profiler::frame_trace_enabled().then(Instant::now); + // Set up the per-App arena for element allocation during this draw. // This ensures that multiple test Apps have isolated arenas. let _arena_scope = ElementArenaScope::enter(&cx.element_arena); @@ -2669,6 +2699,16 @@ impl Window { self.invalidator.set_phase(DrawPhase::None); self.needs_present.set(true); + if let Some(draw_start) = draw_started_at { + profiler::record_frame_timing(profiler::FrameTiming { + window_id: self.handle.window_id(), + dirty_at: frame_dirty.dirty_at, + invalidations: frame_dirty.invalidations, + draw_start, + draw_end: Instant::now(), + }); + } + ArenaClearNeeded::new(&cx.element_arena) } @@ -2703,6 +2743,18 @@ impl Window { profiling::finish_frame!(); } + /// Presents the most recently drawn frame if it hasn't been presented yet. + /// + /// Benchmarks drive drawing synchronously rather than through a platform + /// frame-request loop, so they call this after each measured update to + /// submit the frame like production presentation would. + #[cfg(feature = "bench")] + pub fn present_if_needed(&mut self) { + if self.needs_present.get() { + self.present(); + } + } + /// Returns a snapshot of the current input-latency histograms. #[cfg(feature = "input-latency-histogram")] pub fn input_latency_snapshot(&self) -> InputLatencySnapshot { diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs index 73b53ce6ea5d7e..5a72e23e140715 100644 --- a/crates/gpui_macos/src/metal_renderer.rs +++ b/crates/gpui_macos/src/metal_renderer.rs @@ -133,6 +133,10 @@ pub(crate) struct MetalRenderer { path_intermediate_texture: Option, path_intermediate_msaa_texture: Option, path_sample_count: u32, + /// Offscreen render target reused across `render_scene` calls when + /// rendering headlessly without reading pixels back. + #[cfg(any(test, feature = "test-support"))] + headless_render_target: Option, } #[repr(C)] @@ -347,6 +351,8 @@ impl MetalRenderer { path_intermediate_texture: None, path_intermediate_msaa_texture: None, path_sample_count: PATH_SAMPLE_COUNT, + #[cfg(any(test, feature = "test-support"))] + headless_render_target: None, } } @@ -729,6 +735,86 @@ impl MetalRenderer { } } + /// Renders a scene to a reused offscreen texture without reading pixels + /// back or blocking on GPU completion. + /// + /// This mirrors the CPU cost of presenting a frame to a window (scene + /// encoding, instance buffer writes, command submission) and is used by + /// headless benchmark rendering, where the produced pixels are never + /// inspected. + #[cfg(any(test, feature = "test-support"))] + pub fn render_scene(&mut self, scene: &Scene, size: Size) -> Result<()> { + if size.width.0 <= 0 || size.height.0 <= 0 { + anyhow::bail!("Invalid size for render_scene: {:?}", size); + } + + self.update_path_intermediate_textures(size); + + let needs_new_target = self.headless_render_target.as_ref().is_none_or(|texture| { + texture.width() != size.width.0 as u64 || texture.height() != size.height.0 as u64 + }); + if needs_new_target { + let texture_descriptor = metal::TextureDescriptor::new(); + texture_descriptor.set_width(size.width.0 as u64); + texture_descriptor.set_height(size.height.0 as u64); + texture_descriptor.set_pixel_format(MTLPixelFormat::BGRA8Unorm); + texture_descriptor.set_usage( + metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead, + ); + texture_descriptor.set_storage_mode(metal::MTLStorageMode::Private); + self.headless_render_target = Some(self.device.new_texture(&texture_descriptor)); + } + let target_texture = self + .headless_render_target + .clone() + .expect("just ensured the render target exists"); + + loop { + let mut instance_buffer = self + .instance_buffer_pool + .lock() + .acquire(&self.device, self.is_unified_memory); + + let command_buffer = + self.draw_primitives_to_texture(scene, &mut instance_buffer, &target_texture, size); + + match command_buffer { + Ok(command_buffer) => { + let instance_buffer_pool = self.instance_buffer_pool.clone(); + let instance_buffer = Cell::new(Some(instance_buffer)); + let block = ConcreteBlock::new(move |_| { + if let Some(instance_buffer) = instance_buffer.take() { + instance_buffer_pool.lock().release(instance_buffer); + } + }); + let block = block.copy(); + command_buffer.add_completed_handler(&block); + + // Commit without waiting, mirroring presentation to a real + // window where the CPU doesn't block on the GPU. + command_buffer.commit(); + return Ok(()); + } + Err(err) => { + log::error!( + "failed to render: {}. retrying with larger instance buffer size", + err + ); + let mut instance_buffer_pool = self.instance_buffer_pool.lock(); + let buffer_size = instance_buffer_pool.buffer_size; + if buffer_size >= 256 * 1024 * 1024 { + anyhow::bail!("instance buffer size grew too large: {}", buffer_size); + } + instance_buffer_pool.reset(buffer_size * 2); + log::info!( + "increased instance buffer size to {}", + instance_buffer_pool.buffer_size + ); + } + } + } + } + fn draw_primitives( &mut self, scene: &Scene, @@ -1703,6 +1789,10 @@ impl gpui::PlatformHeadlessRenderer for MetalHeadlessRenderer { self.renderer.render_scene_to_image(scene, size) } + fn render_scene(&mut self, scene: &Scene, size: Size) -> anyhow::Result<()> { + self.renderer.render_scene(scene, size) + } + fn sprite_atlas(&self) -> Arc { self.renderer.sprite_atlas().clone() } diff --git a/crates/gpui_macros/src/bench.rs b/crates/gpui_macros/src/bench.rs index 7d7b2ad89399a2..d5fad465e4bd07 100644 --- a/crates/gpui_macros/src/bench.rs +++ b/crates/gpui_macros/src/bench.rs @@ -1,15 +1,35 @@ use proc_macro::TokenStream; use quote::{format_ident, quote}; -use syn::{ItemFn, spanned::Spanned}; +use syn::{ItemFn, parse::Parser, spanned::Spanned}; pub fn bench(args: TokenStream, function: TokenStream) -> TokenStream { + let mut fps: Option = None; if !args.is_empty() { - return error_to_stream(syn::Error::new( - proc_macro2::TokenStream::from(args).span(), - "#[gpui::bench] does not accept arguments yet", - )); + let parser = syn::meta::parser(|meta| { + if meta.path.is_ident("fps") { + let value: syn::LitInt = meta.value()?.parse()?; + let value = value.base10_parse::()?; + if value == 0 { + return Err(meta.error("#[gpui::bench] `fps` must be greater than zero")); + } + fps = Some(value); + Ok(()) + } else { + Err(meta.error("#[gpui::bench] only accepts `fps = N`")) + } + }); + if let Err(error) = parser.parse(args) { + return error_to_stream(error); + } } + // The frame budget math lives in `BenchReport` so `bench_context` is the + // single source of truth; `default()` supplies the default frame rate. + let report_expr = match fps { + Some(fps) => quote! { gpui::BenchReport::with_fps(#fps) }, + None => quote! { gpui::BenchReport::default() }, + }; + let mut inner_fn = match syn::parse::(function) { Ok(function) => function, Err(error) => return error_to_stream(error), @@ -30,11 +50,23 @@ pub fn bench(args: TokenStream, function: TokenStream) -> TokenStream { #inner_fn fn #outer_fn_name(criterion: &mut criterion::Criterion) { - criterion.bench_function(stringify!(#outer_fn_name), |bencher| { - let mut cx = gpui::BenchAppContext::new(Some(stringify!(#outer_fn_name))); - #inner_fn_name(bencher, &mut cx); - cx.teardown(); + let report = #report_expr; + criterion.bench_function(stringify!(#outer_fn_name), { + let report = report.clone(); + move |bencher| { + let mut cx = gpui::BenchAppContext::new_with_platform_and_report( + gpui::bench_platform(Some(Box::new(|| { + gpui_platform::current_headless_renderer() + }))), + Some(stringify!(#outer_fn_name)), + bencher, + report.clone(), + ); + #inner_fn_name(&mut cx); + cx.teardown(); + } }); + report.print(Some(stringify!(#outer_fn_name))); } }) diff --git a/crates/gpui_macros/src/gpui_macros.rs b/crates/gpui_macros/src/gpui_macros.rs index 1fe3fa97eaedba..41ad72c67ab34f 100644 --- a/crates/gpui_macros/src/gpui_macros.rs +++ b/crates/gpui_macros/src/gpui_macros.rs @@ -191,6 +191,10 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { } /// `#[gpui::bench]` annotates a Criterion benchmark that runs with GPUI support. +/// +/// The benchmark crate must add `criterion` and `gpui_platform` (with its +/// `test-support` feature) to its dev-dependencies and enable gpui's `bench` +/// feature, since the generated code references all three. #[proc_macro_attribute] pub fn bench(args: TokenStream, function: TokenStream) -> TokenStream { bench::bench(args, function) From efa2987b8685c6a3c926365b9724f547be0270d1 Mon Sep 17 00:00:00 2001 From: Konstantinos St Date: Wed, 10 Jun 2026 06:28:16 -0300 Subject: [PATCH 03/38] Escape control characters in syntax tree view (#59012) Escaping control characters so they correctly render in the syntax tree. Note: the original issue debates whether "\n" should be printed at all. It should if it's part of the grammar syntax. In the example posted below, "\n" is printed because it's part of the c-preprocessor grammar, but "\t" which is not, is not printed. Note2: I've added an inline test since I saw that only bigger, integration tests get their own file. Feel free to give guidance on the topic. Screenshots of before and after: Screenshot 2026-06-10 at 10 42 14 Screenshot 2026-06-10 at 10 46 20 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #54725 Release Notes: - Fixed rendering of control-characters in syntax tree view --- crates/language_tools/src/syntax_tree_view.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/language_tools/src/syntax_tree_view.rs b/crates/language_tools/src/syntax_tree_view.rs index 9c751dd8eaf712..8ceb3127509d83 100644 --- a/crates/language_tools/src/syntax_tree_view.rs +++ b/crates/language_tools/src/syntax_tree_view.rs @@ -377,7 +377,7 @@ impl SyntaxTreeView { row.child(if node.is_named() { Label::new(node.kind()).color(Color::Default) } else { - Label::new(format!("\"{}\"", node.kind())).color(Color::Created) + Label::new(format_anonymous_node_kind(node.kind())).color(Color::Created) }) .child( div() @@ -719,6 +719,10 @@ fn format_node_range(node: Node) -> String { ) } +fn format_anonymous_node_kind(kind: &str) -> String { + format!("\"{}\"", kind.escape_debug()) +} + impl Render for SyntaxTreeToolbarItemView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { h_flex() @@ -749,3 +753,16 @@ impl ToolbarItemView for SyntaxTreeToolbarItemView { ToolbarItemLocation::Hidden } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anonymous_node_kinds_escape_control_characters() { + assert_eq!(format_anonymous_node_kind("\n"), "\"\\n\""); + assert_eq!(format_anonymous_node_kind("\r\n"), "\"\\r\\n\""); + assert_eq!(format_anonymous_node_kind("\t"), "\"\\t\""); + assert_eq!(format_anonymous_node_kind(","), "\",\""); + } +} From 5c90b0664fe112bc7c788eeabda2c4240b722f94 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 10 Jun 2026 11:46:00 +0200 Subject: [PATCH 04/38] agent: Fix race where compaction would be marked as cancelled (#59014) Fixes an issue where compaction would get marked as cancelled if the previous turn took a while to cancel. E.g. you could reproduce this when sending a normal message, and then interrupting generation by sending `/compact`. If the task for the prior turn took a while to complete, it would mark the compaction triggered by `/compact` as cancelled, even though it was not. Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 98 ++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 466447d33fe945..07f3f39460da70 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -2750,7 +2750,7 @@ impl AcpThread { } let canceled = matches!(r.stop_reason, acp::StopReason::Cancelled); - if canceled { + if canceled && is_same_turn { this.mark_pending_entries_as_canceled(cx); } @@ -5790,6 +5790,102 @@ mod tests { ); } + #[gpui::test] + async fn test_stale_cancelled_response_does_not_cancel_current_compaction( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let (first_complete_tx, first_complete_rx) = futures::channel::oneshot::channel::<()>(); + let first_complete_rx = RefCell::new(Some(first_complete_rx)); + let compaction_id = ContextCompactionId("test-compaction".into()); + + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + let compaction_id = compaction_id.clone(); + move |params, thread, mut cx| { + let first_complete_rx = first_complete_rx.borrow_mut().take(); + let is_first = params.prompt.iter().any(|content| { + matches!(content, acp::ContentBlock::Text(text) if text.text.contains("first")) + }); + let compaction_id = compaction_id.clone(); + + async move { + if is_first { + if let Some(rx) = first_complete_rx { + rx.await + .expect("first completion sender should still be alive"); + } + + thread.update(&mut cx, |thread, cx| { + thread.push_context_compaction( + ContextCompaction { + id: compaction_id, + status: ContextCompactionStatus::InProgress, + summary: None, + }, + cx, + ); + })?; + + Ok(acp::PromptResponse::new(acp::StopReason::Cancelled)) + } else { + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) + } + } + .boxed_local() + } + })); + + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + let first_request = thread.update(cx, |thread, cx| thread.send_raw("first", cx)); + assert_eq!(thread.read_with(cx, |thread, _| thread.turn_id), 1); + + let second_request = thread.update(cx, |thread, cx| thread.send_raw("second", cx)); + assert_eq!(thread.read_with(cx, |thread, _| thread.turn_id), 2); + + first_complete_tx + .send(()) + .expect("first completion receiver should still be alive"); + + let response = first_request + .await + .expect("first request should complete") + .expect("first request should have response"); + assert_eq!(response.stop_reason, acp::StopReason::Cancelled); + + thread.read_with(cx, |thread, _| { + let compaction = thread + .entries + .iter() + .find_map(|entry| { + let AgentThreadEntry::ContextCompaction(compaction) = entry else { + return None; + }; + (compaction.id == compaction_id).then_some(compaction) + }) + .expect("compaction entry should exist"); + + assert_eq!( + compaction.status, + ContextCompactionStatus::InProgress, + "a stale cancelled response from an older turn should not cancel current compaction" + ); + }); + + second_request + .await + .expect("second request should complete"); + } + #[gpui::test] async fn test_send_assigns_message_id_without_truncate_support(cx: &mut TestAppContext) { init_test(cx); From 806f3f9322b89446f2f48c1768db02839d862d2d Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 10 Jun 2026 06:02:53 -0400 Subject: [PATCH 05/38] Remove handoff feature flag and un-gate compaction (#58931) Removes the `handoff` feature flag entirely and un-gates context compaction so auto-compaction and the `/compact` command are available to everyone. The `HandoffFeatureFlag` definition is deleted, and every flag check is removed: auto-compaction always runs in the agent turn loop (still governed by the `agent.auto_compact` setting and the model's context window size), the `/compact` slash command is always registered and routed to manual compaction, the token-limit callout always defers to auto-compaction when the window is large enough, and the Auto Compact settings always appear in the settings UI. Merging this is the switch that ships the feature; until then it stays stacked behind the telemetry and retry-fix PRs. Release Notes: - Added auto-compaction and /compact to Zed Agent --------- Co-authored-by: Bennet Bo Fenner --- crates/agent/src/agent.rs | 116 +++----------- crates/agent/src/thread.rs | 141 +++++++++--------- crates/agent_ui/src/conversation_view.rs | 2 +- .../src/conversation_view/thread_view.rs | 14 +- crates/feature_flags/src/flags.rs | 8 - crates/settings_ui/src/page_data.rs | 126 ++++++++-------- 6 files changed, 156 insertions(+), 251 deletions(-) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index c02711d5dd9328..148e60b2bf87b0 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -14,7 +14,6 @@ mod tools; use context_server::ContextServerId; pub use db::*; -use feature_flags::{FeatureFlagAppExt as _, HandoffFeatureFlag}; use itertools::Itertools; pub use native_agent_server::NativeAgentServer; pub use pattern_extraction::*; @@ -1494,24 +1493,21 @@ impl NativeAgent { let Some(state) = project_state else { return Vec::new(); }; - let compact_command = cx.has_flag::().then(|| { - acp::AvailableCommand::new( - COMPACT_COMMAND_NAME, - "Summarize the conversation so far to free up context", - ) - .meta(acp_thread::meta_with_command_category( - acp_thread::CommandCategory::Native, - )) - }); + let compact_command = acp::AvailableCommand::new( + COMPACT_COMMAND_NAME, + "Summarize the conversation so far to free up context", + ) + .meta(acp_thread::meta_with_command_category( + acp_thread::CommandCategory::Native, + )); let registry = state.context_server_registry.read(cx); - // Reserve the built-in command name (when active) so a same-named MCP - // prompt is force-prefixed (`/.compact`) and stays reachable: - // an unqualified `/compact` always routes to the native command. - let reserved = compact_command.as_ref().map(|_| COMPACT_COMMAND_NAME); + // Reserve the built-in command name so a same-named MCP prompt is + // force-prefixed (`/.compact`) and stays reachable: an + // unqualified `/compact` always routes to the native command. let ambiguous_prompt_names = ambiguous_mcp_prompt_names( - reserved, + [COMPACT_COMMAND_NAME], registry.prompts().map(|p| p.prompt.name.as_str()), ); @@ -1550,7 +1546,9 @@ impl NativeAgent { Some(command) }); - compact_command.into_iter().chain(mcp_commands).collect() + std::iter::once(compact_command) + .chain(mcp_commands) + .collect() } pub fn load_thread( @@ -2583,9 +2581,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { }; if let Some(parsed_command) = Command::parse(¶ms.prompt) { - if cx.has_flag::() - && parsed_command.is_unqualified(COMPACT_COMMAND_NAME) - { + if parsed_command.is_unqualified(COMPACT_COMMAND_NAME) { return self.0.update(cx, |agent, cx| { agent.send_compact_command(id, session_id, cx) }); @@ -3594,7 +3590,7 @@ mod internal_tests { use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelInfo, MentionUri}; use agent_settings::COMPACTION_PROMPT; use fs::FakeFs; - use gpui::{TestAppContext, UpdateGlobal}; + use gpui::TestAppContext; use indoc::formatdoc; use language_model::fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}; use language_model::{ @@ -3666,27 +3662,9 @@ mod internal_tests { .collect() } - fn set_handoff_flag_override(value: &str, cx: &mut TestAppContext) { - cx.update(|cx| { - SettingsStore::update_global(cx, |store, _| { - store.register_setting::(); - }); - cx.update_flags(false, vec![]); - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |content| { - content - .feature_flags - .get_or_insert_default() - .insert("handoff".to_string(), value.to_string()); - }); - }); - }); - } - #[gpui::test] - async fn test_compact_command_requires_handoff_feature_flag(cx: &mut TestAppContext) { + async fn test_compact_command_is_available(cx: &mut TestAppContext) { init_test(cx); - set_handoff_flag_override("off", cx); let fs = FakeFs::new(cx.executor()); let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); @@ -3706,30 +3684,11 @@ mod internal_tests { .unwrap(); cx.run_until_parked(); - cx.update(|cx| { - let commands = acp_thread.read(cx).available_commands(); - assert!(commands.is_empty()); - }); - - set_handoff_flag_override("on", cx); - - let acp_thread = cx - .update(|cx| { - Rc::new(connection.clone()).new_session( - project.clone(), - PathList::new(&[Path::new("/")]), - cx, - ) - }) - .await - .unwrap(); - cx.run_until_parked(); - cx.update(|cx| { let commands = acp_thread.read(cx).available_commands(); let compact = commands.iter().find(|command| command.name == "compact"); - let compact = compact.expect("compact command should be available behind the flag"); + let compact = compact.expect("compact command should be available"); assert_eq!( acp_thread::command_category_from_meta(&compact.meta), Some(acp_thread::CommandCategory::Native), @@ -3738,43 +3697,8 @@ mod internal_tests { } #[gpui::test] - async fn test_compact_prompt_is_regular_prompt_without_handoff(cx: &mut TestAppContext) { - init_test(cx); - set_handoff_flag_override("off", cx); - - let (connection, agent, _project, acp_thread) = setup_native_agent_session(cx).await; - let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); - let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx)); - let model = Arc::new(FakeLanguageModel::default()); - cx.update(|cx| thread.update(cx, |thread, cx| thread.set_model(model.clone(), cx))); - - let message_id = UserMessageId::new(); - let prompt_task = cx.update(|cx| { - connection.prompt( - message_id.clone(), - acp::PromptRequest::new(session_id.clone(), vec!["/compact".into()]), - cx, - ) - }); - cx.run_until_parked(); - - let request = model.pending_completions().pop().unwrap(); - assert_eq!(request.intent, Some(CompletionIntent::UserPrompt)); - assert_eq!( - request_texts_after_system(&request.messages), - vec!["/compact".to_string()] - ); - - model.send_completion_stream_text_chunk(&request, "regular response"); - model.end_completion_stream(&request); - cx.run_until_parked(); - prompt_task.await.unwrap(); - } - - #[gpui::test] - async fn test_compact_prompt_routes_to_manual_compaction_with_handoff(cx: &mut TestAppContext) { + async fn test_compact_prompt_routes_to_manual_compaction(cx: &mut TestAppContext) { init_test(cx); - cx.update(|cx| cx.update_flags(true, vec!["handoff".to_string()])); let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await; let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx)); @@ -3833,7 +3757,7 @@ mod internal_tests { assert!(ambiguous.contains("compact")); assert!(!ambiguous.contains("deploy")); - // Without the reservation (handoff off), a unique MCP prompt is left bare. + // Without the reservation, a unique MCP prompt is left bare. let ambiguous = ambiguous_mcp_prompt_names([], ["compact", "deploy"]); assert!(ambiguous.is_empty()); diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 0cf5fc84d84a5a..c152082748473a 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -10,7 +10,6 @@ use crate::{ use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; use agent_settings::UserAgentsMd; -use feature_flags::{FeatureFlagAppExt as _, HandoffFeatureFlag}; use crate::sandboxing::{SandboxRequest, ThreadSandboxGrants, sandboxing_enabled}; use agent_client_protocol::schema as acp; @@ -2428,78 +2427,76 @@ impl Thread { // Set when a refusal fallback occurs so subsequent iterations use the fallback model. let mut refusal_fallback_model: Option> = None; loop { - if cx.update(|cx| cx.has_flag::()) { - match Self::perform_compaction_if_needed( - this, - event_stream, - cancellation_rx.clone(), - cx, - ) - .await - { - // On success the telemetry event is deferred until the - // completion below reports usage, so we can record an - // accurate post-compaction context size (see - // `handle_completion_event`). - Ok(ControlFlow::Continue(())) => {} - Ok(ControlFlow::Break(())) => { - this.update(cx, |this, _| { - this.emit_compaction_telemetry_outcome("canceled", None) - })?; - return Ok(()); - } - Err(error) => { - log::error!("Compaction failed: {}", error); - let error_message = error.to_string(); - match error.downcast::() { - Ok(error) => { - attempt += 1; - match Self::retry_completion_error( - this, - event_stream, - &mut cancellation_rx, - error, - attempt, - cx, - ) - .await - { - Ok(ControlFlow::Break(())) => { - this.update(cx, |this, _| { - this.emit_compaction_telemetry_outcome("canceled", None) - })?; - return Ok(()); - } - Ok(ControlFlow::Continue(())) => { - this.update(cx, |this, _| { - if let Some(telemetry) = - this.pending_compaction_telemetry.as_mut() - { - telemetry.retries += 1; - } - })?; - continue; - } - Err(retry_error) => { - this.update(cx, |this, _| { - this.emit_compaction_telemetry_outcome( - "failed", - Some(error_message), - ) - })?; - return Err(retry_error); - } + match Self::perform_compaction_if_needed( + this, + event_stream, + cancellation_rx.clone(), + cx, + ) + .await + { + // On success the telemetry event is deferred until the + // completion below reports usage, so we can record an + // accurate post-compaction context size (see + // `handle_completion_event`). + Ok(ControlFlow::Continue(())) => {} + Ok(ControlFlow::Break(())) => { + this.update(cx, |this, _| { + this.emit_compaction_telemetry_outcome("canceled", None) + })?; + return Ok(()); + } + Err(error) => { + log::error!("Compaction failed: {}", error); + let error_message = error.to_string(); + match error.downcast::() { + Ok(error) => { + attempt += 1; + match Self::retry_completion_error( + this, + event_stream, + &mut cancellation_rx, + error, + attempt, + cx, + ) + .await + { + Ok(ControlFlow::Break(())) => { + this.update(cx, |this, _| { + this.emit_compaction_telemetry_outcome("canceled", None) + })?; + return Ok(()); + } + Ok(ControlFlow::Continue(())) => { + this.update(cx, |this, _| { + if let Some(telemetry) = + this.pending_compaction_telemetry.as_mut() + { + telemetry.retries += 1; + } + })?; + continue; + } + Err(retry_error) => { + this.update(cx, |this, _| { + this.emit_compaction_telemetry_outcome( + "failed", + Some(error_message), + ) + })?; + return Err(retry_error); } } - Err(error) => { - this.update(cx, |this, _| { - this.emit_compaction_telemetry_outcome( - "failed", - Some(error_message), - ) - })?; - return Err(error); - } + } + Err(error) => { + this.update(cx, |this, _| { + this.emit_compaction_telemetry_outcome( + "failed", + Some(error_message), + ) + })?; + return Err(error); } } } @@ -6127,7 +6124,6 @@ mod tests { let new_user_message_id = UserMessageId::new(); cx.update(|cx| { - cx.update_flags(true, vec!["handoff".to_string()]); thread.update(cx, |thread, cx| { thread.set_model(model.clone(), cx); thread @@ -6443,7 +6439,6 @@ mod tests { }; cx.update(|cx| { - cx.update_flags(true, vec!["handoff".to_string()]); thread.update(cx, |thread, cx| { thread.set_model(model.clone(), cx); thread diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 925b813cd0a091..f59420b1f2b097 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -24,7 +24,7 @@ use editor::scroll::Autoscroll; use editor::{ Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior, }; -use feature_flags::{AgentSharingFeatureFlag, FeatureFlagAppExt as _, HandoffFeatureFlag}; +use feature_flags::{AgentSharingFeatureFlag, FeatureFlagAppExt as _}; use file_icons::FileIcons; use fs::Fs; use futures::FutureExt as _; diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 7f85a297ff72ac..b5722c86e75b4e 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -10150,14 +10150,12 @@ impl ThreadView { let token_usage = self.thread.read(cx).token_usage()?; - // When auto-compaction is available (the handoff feature flag is enabled - // and the model's context window is large enough), the thread is - // compacted automatically before it reaches the limit, so there's no - // need to warn the user. Models with a context window that's too small - // can't be auto-compacted, so we fall back to the normal warning. - if cx.has_flag::() - && token_usage.max_tokens >= agent::MIN_COMPACTION_CONTEXT_WINDOW - { + // When auto-compaction is available (the model's context window is large + // enough), the thread is compacted automatically before it reaches the + // limit, so there's no need to warn the user. Models with a context + // window that's too small can't be auto-compacted, so we fall back to + // the normal warning. + if token_usage.max_tokens >= agent::MIN_COMPACTION_CONTEXT_WINDOW { return None; } diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index d2131a0a65e43c..e84b08d5772529 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -35,14 +35,6 @@ impl FeatureFlag for AgentSharingFeatureFlag { } register_feature_flag!(AgentSharingFeatureFlag); -pub struct HandoffFeatureFlag; - -impl FeatureFlag for HandoffFeatureFlag { - const NAME: &'static str = "handoff"; - type Value = PresenceFlag; -} -register_feature_flag!(HandoffFeatureFlag); - pub struct DiffReviewFeatureFlag; impl FeatureFlag for DiffReviewFeatureFlag { diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index f54c841e0b9b74..4480d60855373b 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -75,7 +75,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec { terminal_page(), version_control_page(), collaboration_page(), - ai_page(cx), + ai_page(), network_page(), developer_page(cx), ] @@ -7806,7 +7806,7 @@ fn collaboration_page() -> SettingsPage { } } -fn ai_page(cx: &App) -> SettingsPage { +fn ai_page() -> SettingsPage { fn general_section() -> [SettingsPageItem; 3] { [ SettingsPageItem::SectionHeader("General"), @@ -7841,9 +7841,7 @@ fn ai_page(cx: &App) -> SettingsPage { ] } - fn agent_configuration_section(cx: &App) -> Box<[SettingsPageItem]> { - use feature_flags::FeatureFlagAppExt as _; - + fn agent_configuration_section() -> Box<[SettingsPageItem]> { let mut items = vec![ SettingsPageItem::SectionHeader("Agent Configuration"), SettingsPageItem::SubPageLink(SubPageLink { @@ -8129,67 +8127,65 @@ fn ai_page(cx: &App) -> SettingsPage { }), ]); - if cx.has_flag::() { - items.extend([ - SettingsPageItem::SettingItem(SettingItem { - title: "Auto Compact", - description: "Automatically compact the agent's context when it grows too large, summarizing earlier messages to free up room in the model's context window.", - field: Box::new(SettingField { - organization_override: None, - json_path: Some("agent.auto_compact.enabled"), - pick: |settings_content| { - settings_content - .agent - .as_ref()? - .auto_compact - .as_ref()? - .enabled - .as_ref() - }, - write: |settings_content, value, _| { - settings_content - .agent - .get_or_insert_default() - .auto_compact - .get_or_insert_default() - .enabled = value; - }, - }), - metadata: None, - files: USER, + items.extend([ + SettingsPageItem::SettingItem(SettingItem { + title: "Auto Compact", + description: "Automatically compact the agent's context when it grows too large, summarizing earlier messages to free up room in the model's context window.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("agent.auto_compact.enabled"), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .auto_compact + .as_ref()? + .enabled + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .agent + .get_or_insert_default() + .auto_compact + .get_or_insert_default() + .enabled = value; + }, }), - SettingsPageItem::SettingItem(SettingItem { - title: "Auto Compact Threshold", - description: "When auto compaction runs. A percentage string like \"90%\" is measured against the context window. A positive integer is the number of used tokens to compact after. A negative integer is the number of tokens remaining in the context window before compacting.", - field: Box::new(SettingField { - organization_override: None, - json_path: Some("agent.auto_compact.threshold"), - pick: |settings_content| { - settings_content - .agent - .as_ref()? - .auto_compact - .as_ref()? - .threshold - .as_ref() - }, - write: |settings_content, value, _| { - settings_content - .agent - .get_or_insert_default() - .auto_compact - .get_or_insert_default() - .threshold = value; - }, - }), - metadata: Some(Box::new(SettingsFieldMetadata { - placeholder: Some("90%"), - ..Default::default() - })), - files: USER, + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Auto Compact Threshold", + description: "When auto compaction runs. A percentage string like \"90%\" is measured against the context window. A positive integer is the number of used tokens to compact after. A negative integer is the number of tokens remaining in the context window before compacting.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("agent.auto_compact.threshold"), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .auto_compact + .as_ref()? + .threshold + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .agent + .get_or_insert_default() + .auto_compact + .get_or_insert_default() + .threshold = value; + }, }), - ]); - } + metadata: Some(Box::new(SettingsFieldMetadata { + placeholder: Some("90%"), + ..Default::default() + })), + files: USER, + }), + ]); items.into_boxed_slice() } @@ -8250,7 +8246,7 @@ fn ai_page(cx: &App) -> SettingsPage { title: "AI", items: concat_sections![ general_section(), - agent_configuration_section(cx), + agent_configuration_section(), context_servers_section(), edit_prediction_language_settings_section(), edit_prediction_display_sub_section() From 3ecb86952003ab70235658764a96a1a11b1413ae Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 10 Jun 2026 06:19:03 -0400 Subject: [PATCH 06/38] Stop rendering native slash commands as user messages (#58935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running `/compact` rendered a `/compact` user-message bubble during the live session, but that text is never sent to the model as an ordinary user turn — it triggers a built-in compaction that produces its own "Context compacted" entry. Showing the bubble was misleading (it implied the model received `/compact`), and it was also inconsistent: after a reload the bubble vanished, since the persisted thread only carries an empty marker. This changes the direction so native slash commands are never echoed as user messages at all, live or after reload. `AcpThread` gains a `send_command` path that runs the turn (so the agent still receives and handles `/compact`) without pushing a user-message entry or capturing a git checkpoint. In the UI, `leading_native_command` now matches a native command whether or not it has trailing text, so both `/compact` and `/compact do X` route through the command path; the queued-message path detects native commands too, so a `/compact` typed while a turn is generating behaves the same. MCP/ACP commands are unaffected and still render as normal user messages, since their text is a real argument the agent consumes. Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner --- crates/acp_thread/src/acp_thread.rs | 149 +++++++++++++++--- .../src/conversation_view/thread_view.rs | 75 ++++++--- 2 files changed, 179 insertions(+), 45 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 07f3f39460da70..83b2f304d31ac0 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -2607,6 +2607,27 @@ impl AcpThread { &mut self, message: Vec, cx: &mut Context, + ) -> BoxFuture<'static, Result>> { + self.send_inner(message, true, cx) + } + + /// Sends a prompt without displaying a user-message bubble for it. + /// This is used for native slash commands (e.g. `/compact`) that run a turn + /// which produces its own thread entry (like the compaction summary). The + /// typed command isn't sent to the model as an ordinary user turn. + pub fn send_command( + &mut self, + message: Vec, + cx: &mut Context, + ) -> BoxFuture<'static, Result>> { + self.send_inner(message, false, cx) + } + + fn send_inner( + &mut self, + message: Vec, + push_user_message: bool, + cx: &mut Context, ) -> BoxFuture<'static, Result>> { let block = ContentBlock::new_combined( message.clone(), @@ -2620,32 +2641,38 @@ impl AcpThread { let message_id = UserMessageId::new(); self.run_turn(cx, async move |this, cx| { - this.update(cx, |this, cx| { - this.push_entry( - AgentThreadEntry::UserMessage(UserMessage { - id: Some(message_id.clone()), - content: block, - chunks: message, - checkpoint: None, - indented: false, - }), - cx, - ); - }) - .ok(); + if push_user_message { + this.update(cx, |this, cx| { + this.push_entry( + AgentThreadEntry::UserMessage(UserMessage { + id: Some(message_id.clone()), + content: block, + chunks: message, + checkpoint: None, + indented: false, + }), + cx, + ); + }) + .ok(); + + let old_checkpoint = git_store + .update(cx, |git, cx| git.checkpoint(cx)) + .await + .context("failed to get old checkpoint") + .log_err(); + this.update(cx, |this, _cx| { + if let Some((_ix, message)) = this.last_user_message() { + message.checkpoint = old_checkpoint.map(|git_checkpoint| Checkpoint { + git_checkpoint, + show: false, + }); + } + }) + .ok(); + } - let old_checkpoint = git_store - .update(cx, |git, cx| git.checkpoint(cx)) - .await - .context("failed to get old checkpoint") - .log_err(); this.update(cx, |this, cx| { - if let Some((_ix, message)) = this.last_user_message() { - message.checkpoint = old_checkpoint.map(|git_checkpoint| Checkpoint { - git_checkpoint, - show: false, - }); - } this.connection.prompt(message_id, request, cx) })? .await @@ -4033,6 +4060,80 @@ mod tests { ); } + /// `send_command` runs the turn (the connection receives the typed command) + /// but never echoes a user-message bubble, so commands like `/compact` don't + /// show a fake user message implying the text was sent to the model. + #[gpui::test] + async fn test_send_command_does_not_echo_user_message(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let received_prompt: Rc>> = Rc::new(RefCell::new(None)); + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + let received_prompt = received_prompt.clone(); + move |request, thread, mut cx| { + let received_prompt = received_prompt.clone(); + async move { + if let Some(acp::ContentBlock::Text(text)) = request.prompt.first() { + *received_prompt.borrow_mut() = Some(text.text.clone()); + } + // Simulate a native command producing its own thread entry + // (here a compaction) rather than echoing a user message. + thread.update(&mut cx, |thread, cx| { + thread.push_context_compaction( + ContextCompaction { + id: ContextCompactionId("c1".into()), + status: ContextCompactionStatus::Completed, + summary: None, + }, + cx, + ); + })?; + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) + } + .boxed_local() + } + })); + + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.send_command(vec!["/compact".into()], cx) + }) + }) + .await + .unwrap(); + + // The command turn ran: the connection received the typed command. + assert_eq!(received_prompt.borrow().as_deref(), Some("/compact")); + + thread.update(cx, |thread, _cx| { + assert!( + !thread + .entries + .iter() + .any(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))), + "send_command must not echo a user message" + ); + // The command's own entry (here a compaction) is still shown. + assert!( + thread + .entries + .iter() + .any(|entry| matches!(entry, AgentThreadEntry::ContextCompaction(_))), + "the command's own thread entry should still be present" + ); + }); + } + #[gpui::test] async fn test_ignore_echoed_user_message_chunks_during_active_turn( cx: &mut gpui::TestAppContext, diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index b5722c86e75b4e..825c70b780fea1 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1454,8 +1454,9 @@ impl ThreadView { } } - // A built-in command (e.g. `/compact`) with trailing text: send the bare - // command and queue the rest, so the extra text isn't silently dropped. + // A built-in command (e.g. `/compact`): run the bare command without + // echoing it as a user message, and queue any trailing text the user + // typed so it isn't silently dropped. let native_command = leading_native_command(text, self.session_capabilities.read().available_commands()); if let Some(command_name) = native_command { @@ -1519,6 +1520,7 @@ impl ThreadView { } this.send_content( Task::ready(Ok(Some((vec![command_block], Vec::new())))), + true, window, cx, ); @@ -1564,12 +1566,13 @@ impl ThreadView { Ok(Some((contents, tracked_buffers))) }); - self.send_content(contents_task, window, cx); + self.send_content(contents_task, false, window, cx); } pub fn send_content( &mut self, contents_task: Task, Vec>)>>>, + is_native_command: bool, window: &mut Window, cx: &mut Context, ) { @@ -1664,7 +1667,11 @@ impl ThreadView { side = side ); - thread.send(contents, cx) + if is_native_command { + thread.send_command(contents, cx) + } else { + thread.send(contents, cx) + } })?; let _ = this.update(cx, |this, cx| { @@ -2055,6 +2062,21 @@ impl ThreadView { let content = queued.content; let tracked_buffers = queued.tracked_buffers; + // A queued message can itself be a built-in command (e.g. the user typed + // `/compact` while a turn was generating). Detect that so we run it as a + // command turn without echoing it as a user message, matching the + // non-queued path. + let is_native_command = content + .first() + .and_then(|block| match block { + acp::ContentBlock::Text(text) => Some(text.text.as_str()), + _ => None, + }) + .and_then(|text| { + leading_native_command(text, self.session_capabilities.read().available_commands()) + }) + .is_some(); + // Only increment skip count for "Send Now" operations (out-of-order sends) // Normal auto-processing from the Stopped handler doesn't need to skip. // We only skip the Stopped event from the cancelled generation, NOT the @@ -2083,7 +2105,7 @@ impl ThreadView { Ok(Some((content, tracked_buffers))) }); - self.send_content(contents_task, window, cx); + self.send_content(contents_task, is_native_command, window, cx); } pub fn move_queued_message_to_main_editor( @@ -10795,22 +10817,23 @@ pub(crate) fn open_link( } } -/// If `text` is a built-in (native-category) slash command followed by extra -/// text — e.g. `/compact summarize the API work` — returns the command name. -/// Built-in commands ignore trailing arguments, so the caller sends the bare -/// command and queues the remainder rather than discarding it. Commands from -/// MCP servers and ACP agents are excluded: their trailing text is a real -/// argument the agent consumes. +/// Returns the name of the leading built-in (native-category) slash command — +/// e.g. `compact` for `/compact` or `/compact summarize the API work` — whether +/// or not the user typed any trailing text after it. Built-in commands ignore +/// trailing arguments, so the caller sends the bare command and queues any +/// remainder rather than discarding it. Commands from MCP servers and ACP +/// agents are excluded: their trailing text is a real argument the agent +/// consumes. +/// +/// Native commands run a turn that produces its own thread entry, so the typed +/// command is never echoed as a user message (see `send_command_queueing_remainder`). fn leading_native_command( text: &str, available_commands: &[acp::AvailableCommand], ) -> Option { let rest = text.trim_start().strip_prefix('/')?; - let name_end = rest.find(char::is_whitespace)?; + let name_end = rest.find(char::is_whitespace).unwrap_or(rest.len()); let name = &rest[..name_end]; - if rest[name_end..].trim().is_empty() { - return None; - } let is_native = available_commands.iter().any(|command| { command.name == name && acp_thread::command_category_from_meta(&command.meta) @@ -10851,10 +10874,10 @@ mod tests { } #[test] - fn test_leading_native_command_only_splits_native_with_remainder() { + fn test_leading_native_command_matches_bare_and_with_remainder() { let commands = [native_command("compact"), mcp_command("deploy")]; - // Native command with trailing text -> split. + // Native command with trailing text. assert_eq!( leading_native_command("/compact summarize the API work", &commands), Some("compact".to_string()) @@ -10865,12 +10888,22 @@ mod tests { Some("compact".to_string()) ); - // Bare native command (no remainder) -> no split; it sends normally. - assert_eq!(leading_native_command("/compact", &commands), None); - assert_eq!(leading_native_command("/compact ", &commands), None); + // Bare native command (no remainder) is still recognized, so it runs as + // a command turn (without echoing a user message) rather than being sent + // to the model as a normal prompt. + assert_eq!( + leading_native_command("/compact", &commands), + Some("compact".to_string()) + ); + assert_eq!( + leading_native_command("/compact ", &commands), + Some("compact".to_string()) + ); - // MCP/ACP commands consume their trailing text as an argument. + // MCP/ACP commands are not native: their trailing text is a real + // argument the agent consumes, and they echo as normal user messages. assert_eq!(leading_native_command("/deploy prod", &commands), None); + assert_eq!(leading_native_command("/deploy", &commands), None); // Unknown command, or not a slash command at all. assert_eq!(leading_native_command("/unknown foo", &commands), None); From b27c1bb78e929cd9497fb81f50a65d5bb0f79881 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 10 Jun 2026 14:02:29 +0200 Subject: [PATCH 07/38] Allow opening a new window by default (#58805) Adds `default_open_behavior` which let's users control which action should be the default (add to existing window/open a new window) TODO: - [x] Use sensible icon (not `IconName::Screen`) when `default_open_behavior` is set to `new_window` - [x] Tweak wording for actions in recent projects menu image Release Notes: - Added `default_open_behavior` which controls which action (add to sidebar/open in new window) should be the default when selecting a project from the recent projects menu --------- Co-authored-by: Danilo Leal --- assets/icons/this_window.svg | 5 + assets/keymaps/default-linux.json | 12 +- assets/keymaps/default-macos.json | 8 +- assets/keymaps/default-windows.json | 6 +- assets/settings/default.json | 8 ++ crates/icons/src/icons.rs | 1 + .../src/system_window_tabs.rs | 2 +- crates/recent_projects/src/recent_projects.rs | 117 ++++++++++++++---- crates/recent_projects/src/remote_servers.rs | 4 +- .../src/sidebar_recent_projects.rs | 6 +- crates/settings/src/vscode_import.rs | 1 + crates/settings_content/src/workspace.rs | 29 +++++ crates/settings_ui/src/page_data.rs | 19 +++ crates/settings_ui/src/settings_ui.rs | 1 + crates/sidebar/src/sidebar.rs | 12 +- crates/title_bar/src/title_bar.rs | 27 +--- crates/workspace/src/welcome.rs | 10 +- crates/workspace/src/workspace.rs | 25 ++-- crates/workspace/src/workspace_settings.rs | 2 + crates/zed/src/zed.rs | 15 ++- crates/zed/src/zed/app_menus.rs | 15 +-- crates/zed_actions/src/lib.rs | 8 +- 22 files changed, 222 insertions(+), 111 deletions(-) create mode 100644 assets/icons/this_window.svg diff --git a/assets/icons/this_window.svg b/assets/icons/this_window.svg new file mode 100644 index 00000000000000..879bb5e9577761 --- /dev/null +++ b/assets/icons/this_window.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 158b554507f922..9e735bdd9d5374 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -602,15 +602,15 @@ { "context": "Workspace", "bindings": { - "alt-open": ["projects::OpenRecent", { "create_new_window": false }], + "alt-open": "projects::OpenRecent", // Change the default action on `menu::Confirm` by setting the parameter // "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": true }], - "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": false }], - "ctrl-r": ["projects::OpenRecent", { "create_new_window": false }], - "alt-shift-open": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], + "alt-ctrl-o": "projects::OpenRecent", + "ctrl-r": "projects::OpenRecent", + "alt-shift-open": ["projects::OpenRemote", { "from_existing_connection": false }], // Change to open path modal for existing remote connection by setting the parameter - // "alt-ctrl-shift-o": "["projects::OpenRemote", { "from_existing_connection": true }]", - "alt-ctrl-shift-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], + // "alt-ctrl-shift-o": ["projects::OpenRemote", { "from_existing_connection": true }], + "alt-ctrl-shift-o": ["projects::OpenRemote", { "from_existing_connection": false }], "alt-ctrl-shift-b": "branches::OpenRecent", "alt-ctrl-shift-w": "git::Worktree", "alt-shift-enter": "toast::RunAction", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 0915f4663666ed..7ad25ce6a4864e 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -667,10 +667,10 @@ "bindings": { // Change the default action on `menu::Confirm` by setting the parameter // "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }], - "alt-cmd-o": ["projects::OpenRecent", { "create_new_window": false }], - "ctrl-r": ["projects::OpenRecent", { "create_new_window": false }], - "ctrl-cmd-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], - "ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true, "create_new_window": false }], + "alt-cmd-o": "projects::OpenRecent", + "ctrl-r": "projects::OpenRecent", + "ctrl-cmd-o": ["projects::OpenRemote", { "from_existing_connection": false }], + "ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true }], "cmd-ctrl-b": "branches::OpenRecent", "cmd-ctrl-w": "git::Worktree", "ctrl-~": "workspace::NewTerminal", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 2097c9de0cd2b5..f12157e68e3f61 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -602,10 +602,10 @@ "bindings": { // Change the default action on `menu::Confirm` by setting the parameter // "ctrl-alt-o": ["projects::OpenRecent", { "create_new_window": true }], - "ctrl-r": ["projects::OpenRecent", { "create_new_window": false }], + "ctrl-r": "projects::OpenRecent", // Change to open path modal for existing remote connection by setting the parameter - // "ctrl-shift-alt-o": "["projects::OpenRemote", { "from_existing_connection": true }]", - "ctrl-shift-alt-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], + // "ctrl-shift-alt-o": ["projects::OpenRemote", { "from_existing_connection": true }], + "ctrl-shift-alt-o": ["projects::OpenRemote", { "from_existing_connection": false }], "shift-alt-b": "branches::OpenRecent", "shift-alt-w": "git::Worktree", "shift-alt-enter": "toast::RunAction", diff --git a/assets/settings/default.json b/assets/settings/default.json index 25084f304b6027..011766943c0535 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -150,6 +150,14 @@ // that are already part of an open project) // "cli_default_open_behavior": "new_window" "cli_default_open_behavior": "existing_window", + // The default behavior when opening projects from the UI. + // + // May take 2 values: + // 1. Open projects as a new workspace in the current Zed window's sidebar + // "default_open_behavior": "existing_window" + // 2. Open projects in a new window + // "default_open_behavior": "new_window" + "default_open_behavior": "existing_window", // Whether to attempt to restore previous file's state when opening it again. // The state is stored per pane. // When disabled, defaults are applied instead of the state restoration. diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 698818ced9fb1d..68314ccff272b3 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -254,6 +254,7 @@ pub enum IconName { TextUnwrap, ThinkingMode, ThinkingModeOff, + ThisWindow, Thread, ThreadFromSummary, ThreadsSidebarLeftClosed, diff --git a/crates/platform_title_bar/src/system_window_tabs.rs b/crates/platform_title_bar/src/system_window_tabs.rs index f465d2ab8476eb..507dac9632706d 100644 --- a/crates/platform_title_bar/src/system_window_tabs.rs +++ b/crates/platform_title_bar/src/system_window_tabs.rs @@ -484,7 +484,7 @@ impl Render for SystemWindowTabs { .on_click(|_event, window, cx| { window.dispatch_action( Box::new(zed_actions::OpenRecent { - create_new_window: true, + create_new_window: Some(true), }), cx, ); diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 2c69b75b865112..938aa9eda330d7 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -35,7 +35,7 @@ use picker::{ use project::{Worktree, git_store::Repository}; pub use remote_connections::RemoteSettings; pub use remote_servers::RemoteServerProjects; -use settings::{Settings, WorktreeId}; +use settings::{DefaultOpenBehavior, Settings, WorktreeId}; use ui_input::ErasedEditor; use workspace::ProjectGroupKey; @@ -277,10 +277,19 @@ fn get_branch_for_worktree( }) } +pub(crate) fn default_open_in_new_window(cx: &App) -> bool { + matches!( + workspace::WorkspaceSettings::get_global(cx).default_open_behavior, + DefaultOpenBehavior::NewWindow + ) +} + pub fn init(cx: &mut App) { #[cfg(target_os = "windows")] cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| { - let create_new_window = open_wsl.create_new_window; + let create_new_window = open_wsl + .create_new_window + .unwrap_or_else(|| default_open_in_new_window(cx)); with_active_or_new_workspace(cx, move |workspace, window, cx| { use gpui::PathPromptOptions; use project::DirectoryLister; @@ -364,7 +373,9 @@ pub fn init(cx: &mut App) { #[cfg(target_os = "windows")] cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenWsl, cx| { - let create_new_window = open_wsl.create_new_window; + let create_new_window = open_wsl + .create_new_window + .unwrap_or_else(|| default_open_in_new_window(cx)); with_active_or_new_workspace(cx, move |workspace, window, cx| { let handle = cx.entity().downgrade(); let fs = workspace.project().read(cx).fs().clone(); @@ -380,8 +391,15 @@ pub fn init(cx: &mut App) { with_active_or_new_workspace(cx, move |workspace, window, cx| { let fs = workspace.project().read(cx).fs().clone(); add_wsl_distro(fs, &open_wsl.distro, cx); + let requesting_window = + match workspace::WorkspaceSettings::get_global(cx).default_open_behavior { + DefaultOpenBehavior::ExistingWindow => { + window.window_handle().downcast::() + } + DefaultOpenBehavior::NewWindow => None, + }; let open_options = OpenOptions { - requesting_window: window.window_handle().downcast::(), + requesting_window, ..Default::default() }; @@ -468,7 +486,9 @@ pub fn init(cx: &mut App) { }); cx.on_action(|open_remote: &OpenRemote, cx| { let from_existing_connection = open_remote.from_existing_connection; - let create_new_window = open_remote.create_new_window; + let create_new_window = open_remote + .create_new_window + .unwrap_or_else(|| default_open_in_new_window(cx)); with_active_or_new_workspace(cx, move |workspace, window, cx| { if from_existing_connection { cx.propagate(); @@ -664,7 +684,7 @@ impl RecentProjects { pub fn open( workspace: &mut Workspace, - create_new_window: bool, + create_new_window: Option, window_project_groups: Vec, window: &mut Window, focus_handle: FocusHandle, @@ -674,6 +694,8 @@ impl RecentProjects { let open_folders = get_open_folders(workspace, cx); let fs = Some(workspace.app_state().fs.clone()); + let create_new_window = create_new_window.unwrap_or_else(|| default_open_in_new_window(cx)); + workspace.toggle_modal(window, cx, |window, cx| { let delegate = RecentProjectsDelegate::new( weak, @@ -691,7 +713,7 @@ impl RecentProjects { pub fn popover( workspace: WeakEntity, window_project_groups: Vec, - create_new_window: bool, + create_new_window: Option, focus_handle: FocusHandle, window: &mut Window, cx: &mut App, @@ -707,6 +729,8 @@ impl RecentProjects { }) .unwrap_or_else(|| (Vec::new(), None)); + let create_new_window = create_new_window.unwrap_or_else(|| default_open_in_new_window(cx)); + cx.new(|cx| { let delegate = RecentProjectsDelegate::new( workspace, @@ -1504,6 +1528,21 @@ impl PickerDelegate for RecentProjectsDelegate { }; let focus_handle = self.focus_handle.clone(); + let secondary_confirm_tooltip = if self.create_new_window { + "Open Project in This Window" + } else { + "Open Project in New Window" + }; + let primary_confirm_tooltip = if self.create_new_window { + "Open Project in New Window" + } else { + "Open Project in This Window" + }; + let secondary_confirm_icon = if self.create_new_window { + IconName::ThisWindow + } else { + IconName::ArrowUpRight + }; let secondary_actions = h_flex() .gap_px() @@ -1534,12 +1573,12 @@ impl PickerDelegate for RecentProjectsDelegate { ) }) .child( - IconButton::new("open_new_window", IconName::ArrowUpRight) + IconButton::new("alternate_open", secondary_confirm_icon) .icon_size(IconSize::Small) .tooltip({ move |_, cx| { Tooltip::for_action_in( - "Open Project in New Window", + secondary_confirm_tooltip, &menu::SecondaryConfirm, &focus_handle, cx, @@ -1556,7 +1595,7 @@ impl PickerDelegate for RecentProjectsDelegate { .child( IconButton::new("delete", IconName::Close) .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Delete from Recent Projects")) + .tooltip(Tooltip::text("Remove from Recent Projects")) .on_click(cx.listener(move |this, _event, window, cx| { cx.stop_propagation(); window.prevent_default(); @@ -1595,7 +1634,7 @@ impl PickerDelegate for RecentProjectsDelegate { }) .tooltip(move |_, cx| { Tooltip::with_meta( - "Open Project in This Window", + primary_confirm_tooltip, None, tooltip_path.clone(), cx, @@ -1648,7 +1687,7 @@ impl PickerDelegate for RecentProjectsDelegate { .child(Label::new("Open Local Folders")) .child(KeyBinding::for_action_in( &workspace::Open { - create_new_window: self.create_new_window, + create_new_window: Some(self.create_new_window), }, &focus_handle, cx, @@ -1678,20 +1717,23 @@ impl PickerDelegate for RecentProjectsDelegate { .child(KeyBinding::for_action( &OpenRemote { from_existing_connection: false, - create_new_window: false, + create_new_window: Some(self.create_new_window), }, cx, )), ) - .on_click(|_, window, cx| { - window.dispatch_action( - OpenRemote { - from_existing_connection: false, - create_new_window: false, - } - .boxed_clone(), - cx, - ) + .on_click({ + let create_new_window = self.create_new_window; + move |_, window, cx| { + window.dispatch_action( + OpenRemote { + from_existing_connection: false, + create_new_window: Some(create_new_window), + } + .boxed_clone(), + cx, + ) + } }), ) .into_any(), @@ -1735,7 +1777,7 @@ impl PickerDelegate for RecentProjectsDelegate { .into_any_element(), ), Some(ProjectPickerEntry::RecentProject(_)) => Some( - Button::new("delete_recent", "Delete") + Button::new("delete_recent", "Remove") .key_binding(KeyBinding::for_action_in( &RemoveSelected, &focus_handle, @@ -1797,6 +1839,29 @@ impl PickerDelegate for RecentProjectsDelegate { window.dispatch_action(menu::Confirm.boxed_clone(), cx) }), ) + } else if self.create_new_window { + this.child( + Button::new("open_here", "This Window") + .key_binding(KeyBinding::for_action_in( + &menu::SecondaryConfirm, + &focus_handle, + cx, + )) + .on_click(|_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) + }), + ) + .child( + Button::new("open_new_window", "Open") + .key_binding(KeyBinding::for_action_in( + &menu::Confirm, + &focus_handle, + cx, + )) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx) + }), + ) } else { this.child( Button::new("open_new_window", "New Window") @@ -1844,7 +1909,9 @@ impl PickerDelegate for RecentProjectsDelegate { let focus_handle = focus_handle.clone(); let workspace_handle = self.workspace.clone(); let create_new_window = self.create_new_window; - let open_action = workspace::Open { create_new_window }; + let open_action = workspace::Open { + create_new_window: Some(create_new_window), + }; let show_add_to_workspace = match selected_entry { Some(ProjectPickerEntry::RecentProject(hit)) => self .workspaces @@ -1892,7 +1959,7 @@ impl PickerDelegate for RecentProjectsDelegate { "Open Remote Folder", OpenRemote { from_existing_connection: false, - create_new_window: false, + create_new_window: Some(create_new_window), } .boxed_clone(), ) diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 7dc53a55a96a42..c1b0ce24cab020 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -987,10 +987,12 @@ impl RemoteServerProjects { pub fn popover( fs: Arc, workspace: WeakEntity, - create_new_window: bool, + create_new_window: Option, window: &mut Window, cx: &mut App, ) -> Entity { + let create_new_window = + create_new_window.unwrap_or_else(|| crate::default_open_in_new_window(cx)); cx.new(|cx| { let server = Self::new(create_new_window, fs, window, workspace, cx); server.focus_handle(cx).focus(window, cx); diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index a8726024e84716..91b12100296e4f 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -401,7 +401,7 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { .border_color(cx.theme().colors().border_variant) .child({ let open_action = workspace::Open { - create_new_window: false, + create_new_window: Some(false), }; ButtonLike::new("open_local_folder") @@ -429,7 +429,7 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { .child(KeyBinding::for_action( &OpenRemote { from_existing_connection: false, - create_new_window: false, + create_new_window: Some(false), }, cx, )), @@ -438,7 +438,7 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { window.dispatch_action( OpenRemote { from_existing_connection: false, - create_new_window: false, + create_new_window: Some(false), } .boxed_clone(), cx, diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index c2a9ec74c26de6..83b5c454876be9 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -1011,6 +1011,7 @@ impl VsCodeSettings { bottom_dock_layout: None, centered_layout: None, cli_default_open_behavior: None, + default_open_behavior: None, close_on_file_delete: None, close_panel_on_toggle: None, command_aliases: Default::default(), diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index fbfcdf210b68b8..7873b332832db2 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -54,6 +54,10 @@ pub struct WorkspaceSettingsContent { /// /// Default: existing_window pub cli_default_open_behavior: Option, + /// The default behavior when opening projects from the UI. + /// + /// Default: existing_window + pub default_open_behavior: Option, /// Whether to attempt to restore previous file's state when opening it again. /// The state is stored per pane. /// When disabled, defaults are applied instead of the state restoration. @@ -410,6 +414,31 @@ pub enum CliDefaultOpenBehavior { NewWindow, } +#[derive( + Copy, + Clone, + PartialEq, + Eq, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + Debug, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum DefaultOpenBehavior { + /// Open projects in the current Zed window. + #[default] + #[strum(serialize = "Add to Existing Window")] + ExistingWindow, + /// Open projects in a new window. + #[strum(serialize = "Open a New Window")] + NewWindow, +} + #[derive( Copy, Clone, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 4480d60855373b..57038d8a6ce393 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -257,6 +257,25 @@ fn general_page(cx: &App) -> SettingsPage { })), files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Default Open Behavior", + description: "How projects open from the UI by default.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("default_open_behavior"), + pick: |settings_content| { + settings_content.workspace.default_open_behavior.as_ref() + }, + write: |settings_content, value, _| { + settings_content.workspace.default_open_behavior = value; + }, + }), + metadata: Some(Box::new(SettingsFieldMetadata { + should_do_titlecase: Some(false), + ..Default::default() + })), + files: USER, + }), ] } fn security_section() -> [SettingsPageItem; 2] { diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index ffff6f4e870891..57c284a228e461 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -516,6 +516,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_font_picker) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 5add7fe1d08907..ebd42f38730a74 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -6570,15 +6570,7 @@ impl Sidebar { IconButton::new("open-project", IconName::OpenFolder) .icon_size(IconSize::Small) .selected_style(ButtonStyle::Tinted(TintColor::Accent)), - |_window, cx| { - Tooltip::for_action( - "Add Project", - &OpenRecent { - create_new_window: false, - }, - cx, - ) - }, + |_window, cx| Tooltip::for_action("Add Project", &OpenRecent::default(), cx), ) .offset(gpui::Point { x: px(-2.0), @@ -7274,7 +7266,7 @@ impl Sidebar { telemetry::event!("Sidebar Add Project Clicked", side = side); window.dispatch_action( Open { - create_new_window: false, + create_new_window: Some(false), } .boxed_clone(), cx, diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 74aa24a9cef331..169aa944e18515 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -630,7 +630,7 @@ impl TitleBar { Some(recent_projects::RemoteServerProjects::popover( fs, workspace.clone(), - false, + None, window, cx, )) @@ -657,10 +657,7 @@ impl TitleBar { move |_window, cx| { Tooltip::with_meta( tooltip_title, - Some(&OpenRemote { - from_existing_connection: false, - create_new_window: false, - }), + Some(&OpenRemote::default()), meta.clone(), cx, ) @@ -816,7 +813,7 @@ impl TitleBar { Some(recent_projects::RecentProjects::popover( workspace.clone(), window_project_groups.clone(), - false, + None, focus_handle.clone(), window, cx, @@ -835,13 +832,7 @@ impl TitleBar { .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .when(!is_project_selected, |s| s.color(Color::Muted)), move |_window, cx| { - Tooltip::for_action( - "Recent Projects", - &zed_actions::OpenRecent { - create_new_window: false, - }, - cx, - ) + Tooltip::for_action("Recent Projects", &zed_actions::OpenRecent::default(), cx) }, ) .anchor(gpui::Anchor::TopLeft) @@ -873,7 +864,7 @@ impl TitleBar { Some(recent_projects::RecentProjects::popover( workspace.clone(), window_project_groups.clone(), - false, + None, focus_handle.clone(), window, cx, @@ -892,13 +883,7 @@ impl TitleBar { .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .when(!is_project_selected, |s| s.color(Color::Muted)), move |_window, cx| { - Tooltip::for_action( - "Recent Projects", - &zed_actions::OpenRecent { - create_new_window: false, - }, - cx, - ) + Tooltip::for_action("Recent Projects", &zed_actions::OpenRecent::default(), cx) }, ) .anchor(gpui::Anchor::TopLeft) diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs index 122cc468a4547f..1bf0e81f1bdbe5 100644 --- a/crates/workspace/src/welcome.rs +++ b/crates/workspace/src/welcome.rs @@ -1,6 +1,6 @@ use crate::{ NewFile, Open, OpenMode, PathList, RecentWorkspace, SerializedWorkspaceLocation, - ToggleWorkspaceSidebar, Workspace, + ToggleWorkspaceSidebar, Workspace, WorkspaceSettings, item::{Item, ItemEvent}, persistence::WorkspaceDb, }; @@ -15,7 +15,7 @@ use menu::{SelectNext, SelectPrevious}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::Settings; +use settings::{DefaultOpenBehavior, Settings}; use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*}; use util::ResultExt; use zed_actions::{ @@ -307,10 +307,14 @@ impl WelcomePage { if is_local { let paths = workspace.paths.paths().to_vec(); + let open_mode = match WorkspaceSettings::get_global(cx).default_open_behavior { + DefaultOpenBehavior::ExistingWindow => OpenMode::Activate, + DefaultOpenBehavior::NewWindow => OpenMode::NewWindow, + }; self.workspace .update(cx, |workspace, cx| { workspace - .open_workspace_for_paths(OpenMode::Activate, paths, window, cx) + .open_workspace_for_paths(open_mode, paths, window, cx) .detach_and_log_err(cx); }) .log_err(); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 870060d2ba4085..5edc156371161b 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -111,7 +111,8 @@ use schemars::JsonSchema; use serde::Deserialize; use session::AppSession; use settings::{ - CenteredPaddingSettings, Settings, SettingsLocation, SettingsStore, update_settings_file, + CenteredPaddingSettings, DefaultOpenBehavior, Settings, SettingsLocation, SettingsStore, + update_settings_file, }; use sqlez::{ @@ -225,21 +226,16 @@ pub trait DebuggerProvider { #[action(namespace = workspace)] pub struct Open { /// When true, opens in a new window. When false, adds to the current - /// window as a new workspace (multi-workspace). - #[serde(default = "Open::default_create_new_window")] - pub create_new_window: bool, + /// window as a new workspace (multi-workspace). When omitted, uses + /// `default_open_behavior`. + #[serde(default)] + pub create_new_window: Option, } impl Open { pub const DEFAULT: Self = Self { - create_new_window: false, + create_new_window: None, }; - - /// Used by `#[serde(default)]` on the `create_new_window` field so that - /// the serde default and `Open::DEFAULT` stay in sync. - fn default_create_new_window() -> bool { - Self::DEFAULT.create_new_window - } } impl Default for Open { @@ -786,7 +782,12 @@ pub fn init(app_state: Arc, cx: &mut App) { multiple: true, prompt: None, }, - action.create_new_window, + action.create_new_window.unwrap_or_else(|| { + matches!( + WorkspaceSettings::get_global(cx).default_open_behavior, + DefaultOpenBehavior::NewWindow + ) + }), cx, ); }) diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 53ef067193ef80..bed95a5f9f8800 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -21,6 +21,7 @@ pub struct WorkspaceSettings { pub autosave: AutosaveSetting, pub restore_on_startup: settings::RestoreOnStartupBehavior, pub cli_default_open_behavior: settings::CliDefaultOpenBehavior, + pub default_open_behavior: settings::DefaultOpenBehavior, pub restore_on_file_reopen: bool, pub drop_target_size: f32, pub use_system_path_prompts: bool, @@ -101,6 +102,7 @@ impl Settings for WorkspaceSettings { autosave: workspace.autosave.unwrap(), restore_on_startup: workspace.restore_on_startup.unwrap(), cli_default_open_behavior: workspace.cli_default_open_behavior.unwrap(), + default_open_behavior: workspace.default_open_behavior.unwrap(), restore_on_file_reopen: workspace.restore_on_file_reopen.unwrap(), drop_target_size: workspace.drop_target_size.unwrap(), use_system_path_prompts: workspace.use_system_path_prompts.unwrap(), diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 1b5e00c68d526b..9f6c5cd2171a3c 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -65,10 +65,10 @@ use release_channel::{AppCommitSha, AppVersion, ReleaseChannel}; use rope::Rope; use search::project_search::ProjectSearchBar; use settings::{ - BaseKeymap, DEFAULT_KEYMAP_PATH, InvalidSettingsError, KeybindSource, KeymapFile, - KeymapFileLoadResult, MigrationStatus, Settings, SettingsFile, SettingsStore, VIM_KEYMAP_PATH, - initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content, - update_settings_file, + BaseKeymap, DEFAULT_KEYMAP_PATH, DefaultOpenBehavior, InvalidSettingsError, KeybindSource, + KeymapFile, KeymapFileLoadResult, MigrationStatus, Settings, SettingsFile, SettingsStore, + VIM_KEYMAP_PATH, initial_local_debug_tasks_content, initial_project_settings_content, + initial_tasks_content, update_settings_file, }; use sidebar::Sidebar; #[cfg(debug_assertions)] @@ -940,7 +940,12 @@ fn register_actions( multiple: true, prompt: None, }, - action.create_new_window, + action.create_new_window.unwrap_or_else(|| { + matches!( + WorkspaceSettings::get_global(cx).default_open_behavior, + DefaultOpenBehavior::NewWindow + ) + }), window, cx, ); diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index f3913a6556626e..29a06747f9e4bb 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -121,19 +121,8 @@ pub fn app_menus(cx: &mut App) -> Vec { }, workspace::Open::default(), ), - MenuItem::action( - "Open Recent...", - zed_actions::OpenRecent { - create_new_window: false, - }, - ), - MenuItem::action( - "Open Remote...", - zed_actions::OpenRemote { - create_new_window: false, - from_existing_connection: false, - }, - ), + MenuItem::action("Open Recent…", zed_actions::OpenRecent::default()), + MenuItem::action("Open Remote…", zed_actions::OpenRemote::default()), MenuItem::separator(), MenuItem::action("Add Folder to Project…", workspace::AddFolderToProject), MenuItem::separator(), diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 8ff04471dd0a0f..b0ff0a7ae0fff6 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -620,7 +620,7 @@ pub mod assistant { #[serde(deny_unknown_fields)] pub struct OpenRecent { #[serde(default)] - pub create_new_window: bool, + pub create_new_window: Option, } /// Creates a project from a selected template. @@ -631,7 +631,7 @@ pub struct OpenRemote { #[serde(default)] pub from_existing_connection: bool, #[serde(default)] - pub create_new_window: bool, + pub create_new_window: Option, } /// Opens the dev container connection modal. @@ -795,7 +795,7 @@ pub mod wsl_actions { #[serde(deny_unknown_fields)] pub struct OpenFolderInWsl { #[serde(default)] - pub create_new_window: bool, + pub create_new_window: Option, } /// Open a wsl distro. @@ -804,7 +804,7 @@ pub mod wsl_actions { #[serde(deny_unknown_fields)] pub struct OpenWsl { #[serde(default)] - pub create_new_window: bool, + pub create_new_window: Option, } } From c52842c3aa1a46029e99beb1128c5e07232f3a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:06:15 +0200 Subject: [PATCH 08/38] debugger: Fix running ignored Rust tests in nested modules (#54787) Debugging an ignored Rust test whose code lives in a nested module (e.g. `variant_get::test::get_complex_variant`) sometimes exited immediately with "0 tests matched", When the test name itself came from tree-sitter runnable capture, which sees only the bare function identifier. So `--exact get_complex_variant` filtered out the actual test. The fix is to only append `--exact` when the name contains `::`. rust-analyzer's `experimental/runnables` produces qualified paths and keeps the disambiguation introduced in #43110, and the runnables from tree-sitter no longer break for nested modules. Closes #51810. Release Notes: - Fixed debugging Rust tests in nested modules sometimes immediately exiting with "0 tests matched". --- crates/project/src/debugger/locators/cargo.rs | 78 +++++++++++++++++-- 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/crates/project/src/debugger/locators/cargo.rs b/crates/project/src/debugger/locators/cargo.rs index 4206e130a23ac7..e68d38a821f0ab 100644 --- a/crates/project/src/debugger/locators/cargo.rs +++ b/crates/project/src/debugger/locators/cargo.rs @@ -208,14 +208,7 @@ impl DapLocator for CargoLocator { anyhow::bail!("Couldn't get executable in cargo locator"); }; - let mut args: Vec<_> = test_name.into_iter().collect(); - if is_test { - args.push("--nocapture".to_owned()); - if is_ignored { - args.push("--include-ignored".to_owned()); - args.push("--exact".to_owned()); - } - } + let args = build_test_binary_args(test_name.as_deref(), is_test, is_ignored); Ok(DebugRequest::Launch(task::LaunchRequest { program: executable, @@ -225,3 +218,72 @@ impl DapLocator for CargoLocator { })) } } + +fn build_test_binary_args(test_name: Option<&str>, is_test: bool, is_ignored: bool) -> Vec { + let mut args: Vec = test_name.map(str::to_owned).into_iter().collect(); + if is_test { + args.push("--nocapture".to_owned()); + if is_ignored { + args.push("--include-ignored".to_owned()); + // Append `--exact` only if we can be sure the name is fully + // qualified. Runnables produced from tree-sitter are not qualified + // (see #51810). + if test_name.is_some_and(|name| name.contains("::")) { + args.push("--exact".to_owned()); + } + } + } + args +} + +#[cfg(test)] +mod tests { + use super::build_test_binary_args; + + #[test] + fn non_test_invocation_has_no_test_args() { + assert_eq!( + build_test_binary_args(None, false, false), + Vec::::new() + ); + } + + #[test] + fn bare_test_name_does_not_get_exact() { + // Zed's tree-sitter runnable template for Rust tests captures only the function + // identifier and always passes `--include-ignored`, so `is_ignored` is true here + // for a regular (non-ignored) test. + assert_eq!( + build_test_binary_args(Some("get_complex_variant"), true, true), + vec![ + "get_complex_variant".to_owned(), + "--nocapture".to_owned(), + "--include-ignored".to_owned(), + ], + ); + } + + #[test] + fn qualified_test_name_gets_exact() { + assert_eq!( + build_test_binary_args(Some("variant_get::test::get_complex_variant"), true, true,), + vec![ + "variant_get::test::get_complex_variant".to_owned(), + "--nocapture".to_owned(), + "--include-ignored".to_owned(), + "--exact".to_owned(), + ], + ); + } + + #[test] + fn test_without_include_ignored_never_gets_exact() { + assert_eq!( + build_test_binary_args(Some("variant_get::test::get_complex_variant"), true, false,), + vec![ + "variant_get::test::get_complex_variant".to_owned(), + "--nocapture".to_owned(), + ], + ); + } +} From 2beeb353a51a4723f33f9163a8859d6463083dbf Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 10 Jun 2026 16:13:38 +0200 Subject: [PATCH 09/38] docs: Compaction (#59029) Release Notes: - N/A --- docs/src/ai/agent-panel.md | 9 ++++++--- docs/src/ai/agent-settings.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index e0d019655d952e..e29ed36a9fbcb8 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -148,12 +148,15 @@ OpenAI GPT-4o and later, Anthropic Claude 3 and later, Google Gemini 1.5 and 2.0 To add an image, you can either search in your project's folder by @-mentioning it, or drag it from your file system directly into the Agent Panel message editor. Copying an image and pasting it is also supported. -## Token Usage {#token-usage} +## Token Usage and Compaction {#token-usage} Zed surfaces how many tokens you are consuming for your currently active thread near the profile selector in the panel's message editor. -Once you approach the model's context window, a banner appears above the message editor suggesting to start a new thread with the current one summarized and added as context. -You can also do this at any time with an ongoing thread via the "Agent Options" menu on the top right, where you'll see a "New from Summary" button, as well as simply @-mentioning a past thread in a new one. +Zed automatically compacts long Zed Agent threads as they approach the configured token threshold. Compaction summarizes earlier messages and replaces them in the model context with that summary, leaving more room for the next turn. The thread shows a **Context Compacted** entry that you can expand to inspect the summary. You can compact manually by typing `/compact` in the message editor. + +If the selected model's context window is too small for automatic compaction (less than 80000 tokens), a banner appears above the message editor as you approach the token limit. Use **Start New Thread** from that banner, or choose **New From Summary** from the "Agent Options" menu, to continue in a new thread seeded with a summary. You can also @-mention a past thread in a new one. + +Configure automatic compaction with `agent.auto_compact`. See [Agent Settings](./agent-settings.md#automatic-compaction) for options. ## Changing Models {#changing-models} diff --git a/docs/src/ai/agent-settings.md b/docs/src/ai/agent-settings.md index f421f2765cacf4..3919ebc24d007e 100644 --- a/docs/src/ai/agent-settings.md +++ b/docs/src/ai/agent-settings.md @@ -53,6 +53,35 @@ Use `agent.commit_message_instructions` for instructions that apply only to gene For feature-specific model examples, see [Feature-specific Models](#feature-specific-models). +## Automatic Compaction {#automatic-compaction} + +Zed Agent can automatically compact long threads before they reach the selected model's context window. Compaction summarizes earlier messages and keeps the conversation usable without starting a new thread. + +Automatic compaction is enabled by default and runs when the thread reaches `90%` of the model's context window. You can change the threshold or disable automatic compaction in `settings.json`: + +```json [settings] +{ + "agent": { + "auto_compact": { + "enabled": true, + "threshold": "90%" + } + } +} +``` + +The `threshold` value can be one of: + +| Value | Meaning | +| ------------------------------- | ------------------------------------------------------------------------------ | +| Percentage string, like `90%` | Compact when the thread uses that percentage of the model's context window. | +| Positive integer, like `100000` | Compact after that many tokens have been used. | +| Negative integer, like `-20000` | Compact once fewer than that many tokens remain in the model's context window. | + +`0` is not a valid threshold. If the threshold is invalid, Zed falls back to `90%`. + +You can compact a Zed Agent thread manually at any time by typing `/compact` in the Agent Panel message editor. For more on thread token usage and compaction behavior, see [Token Usage and Compaction](./agent-panel.md#token-usage). + ## External Agents {#external-agents} The External Agents section configures ACP-integrated agents. From 70bb09eeaafe306d3464ec35208c64b859127e3c Mon Sep 17 00:00:00 2001 From: Vlad Ionescu Date: Wed, 10 Jun 2026 17:18:05 +0300 Subject: [PATCH 10/38] docs: Add csharp-ls to C# docs (#59028) I was chatting with somebody today and they told me Zed is terrible because it [only supports OmniSharp for C# as per the marketing site](https://zed.dev/languages/csharp) which I knew 100% to be false. While looking to do a quick fix for that, I realized the Zed marketing website code is not part of this repo (as [confirmed by a quick search](https://github.com/search?q=repo%3Azed-industries%2Fzed+LINQ+expressions&type=code)) but that [Zed C# docs](https://zed.dev/docs/languages/csharp) are part of the repo and they too don't include the changes from https://github.com/zed-extensions/csharp/pull/77. Here's a quick update for that! I did test that `csharp-ls` actually receives the [extra settings](https://github.com/razzmatazz/csharp-language-server#configuration) by setting `"razorSupport": true` and confirming that with the LSP logs. **Note**: I'd wait for https://github.com/zed-extensions/csharp/pull/89 to be merged before merging this PR. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- docs/src/languages/csharp.md | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/src/languages/csharp.md b/docs/src/languages/csharp.md index 5f046ed2047798..1742def73b2c49 100644 --- a/docs/src/languages/csharp.md +++ b/docs/src/languages/csharp.md @@ -10,15 +10,16 @@ C# support is available through the [C# extension](https://github.com/zed-extens - Tree-sitter: [tree-sitter/tree-sitter-c-sharp](https://github.com/tree-sitter/tree-sitter-c-sharp) - Language Servers: - [roslyn-language-server](https://www.nuget.org/packages/roslyn-language-server#readme) + - [csharp-ls](https://github.com/razzmatazz/csharp-language-server) - [OmniSharp/omnisharp-roslyn](https://github.com/OmniSharp/omnisharp-roslyn) -Roslyn is enabled by default. To switch back to OmniSharp, add the following to your Zed settings file: +Roslyn is enabled by default. To switch to csharp-ls or OmniSharp, add the following to your Zed settings file: ```json [settings] { "languages": { "CSharp": { - "language_servers": ["omnisharp", "!roslyn", "..."] + "language_servers": ["csharp-ls", "!roslyn", "!omnisharp", "..."] } } } @@ -114,6 +115,37 @@ Roslyn can be configured with the following language server settings: } ``` +csharp-ls can be configured in a Zed settings file with: + +```json [settings] +{ + "lsp": { + "csharp-ls": { + "binary": { + "path": "/path/to/csharp-ls", + "arguments": [ + /* add extra arguments */ + ] + }, + "settings": { + // Default values are shown below. + "logLevel": "information", + "applyFormattingOptions": false, + "analyzersEnabled": false, + "useMetadataUris": true, + "razorSupport": false, + "solutionPathOverride": null, + "locale": null, + "debug": { + "debugMode": false, + "solutionLoadDelay": null + } + } + } + } +} +``` + OmniSharp can be configured in a Zed settings file with: ```json [settings] From a98485809be0d6c14a52384f5eea5327151e2986 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 10 Jun 2026 08:29:02 -0600 Subject: [PATCH 11/38] Return typed completion errors from Cloud provider (#58997) Stop the Zed cloud LLM provider from funneling completion failures through anyhow::Error and collapsing them into LanguageModelCompletionError::Other (which surfaced as a generic "Request failed."). - perform_llm_completion now returns a typed LanguageModelCompletionError, mapping each failure to its real variant (SerializeRequest, HttpSend, ApiReadResponseError, and ApiError-derived status variants). - response_lines yields a typed ResponseStreamError so mid-stream read/ deserialize failures become ApiReadResponseError/DeserializeResponse without a runtime downcast. - Add a first-class PaymentRequired variant for HTTP 402 and remove the now-dead PaymentRequiredError struct and its anyhow downcast checks. Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A --------- Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- crates/agent/src/thread.rs | 6 +- crates/agent_ui/src/conversation_view.rs | 3 +- crates/language_model/src/language_model.rs | 2 - crates/language_model/src/model.rs | 3 - .../language_model/src/model/cloud_model.rs | 15 -- .../src/language_model_core.rs | 2 + .../src/language_models_cloud.rs | 152 ++++++++++++++---- 7 files changed, 125 insertions(+), 58 deletions(-) delete mode 100644 crates/language_model/src/model.rs delete mode 100644 crates/language_model/src/model/cloud_model.rs diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index c152082748473a..c34df08273c2f2 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -4206,10 +4206,8 @@ impl Thread { max_attempts: 3, }) } - Other(err) if err.is::() => { - // Retrying won't help for Payment Required errors. - None - } + // Retrying won't help for Payment Required errors. + PaymentRequired => None, // Retrying won't help until the user consents to data retention // or switches models. DataRetentionConsentRequired { .. } => None, diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index f59420b1f2b097..95f8b7d80970d9 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -163,8 +163,6 @@ impl From for ThreadError { Self::MaxOutputTokens } else if error.is::() { Self::NoModelSelected - } else if error.is::() { - Self::PaymentRequired } else if let Some(acp_error) = error.downcast_ref::() && acp_error.code == acp::ErrorCode::AuthRequired { @@ -181,6 +179,7 @@ impl From for ThreadError { } } PromptTooLarge { .. } => Self::PromptTooLarge, + PaymentRequired => Self::PaymentRequired, NoApiKey { provider } => Self::NoApiKey { provider: provider.to_string().into(), }, diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 5cf1a6ea087b40..1eb2ec5b680f13 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -1,5 +1,4 @@ mod api_key; -mod model; mod registry; mod request; @@ -17,7 +16,6 @@ use parking_lot::Mutex; use std::sync::Arc; pub use crate::api_key::{ApiKey, ApiKeyState}; -pub use crate::model::*; pub use crate::registry::*; pub use crate::request::{LanguageModelImageExt, gpui_size_to_image_size, image_size_to_gpui}; pub use env_var::{EnvVar, env_var}; diff --git a/crates/language_model/src/model.rs b/crates/language_model/src/model.rs deleted file mode 100644 index db4c55daa7db99..00000000000000 --- a/crates/language_model/src/model.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod cloud_model; - -pub use cloud_model::*; diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs deleted file mode 100644 index 8cd71928b10fb1..00000000000000 --- a/crates/language_model/src/model/cloud_model.rs +++ /dev/null @@ -1,15 +0,0 @@ -use std::fmt; - -use thiserror::Error; - -#[derive(Error, Debug)] -pub struct PaymentRequiredError; - -impl fmt::Display for PaymentRequiredError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!( - f, - "Payment required to use this language model. Please upgrade your account." - ) - } -} diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs index 3dd8330f71cbb0..bc3d8b8e521e7c 100644 --- a/crates/language_model_core/src/language_model_core.rs +++ b/crates/language_model_core/src/language_model_core.rs @@ -174,6 +174,8 @@ pub enum LanguageModelCompletionError { }, #[error("stream from {provider} ended unexpectedly")] StreamEndedUnexpectedly { provider: LanguageModelProviderName }, + #[error("payment required to use this language model; please upgrade your account")] + PaymentRequired, #[error(transparent)] Other(#[from] anyhow::Error), } diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index c129042dec6b51..6fdd11caf571f2 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -1,5 +1,5 @@ use anthropic::AnthropicModelMode; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result}; use cloud_llm_client::{ CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, CLIENT_SUPPORTS_STATUS_STREAM_ENDED_HEADER_NAME, CLIENT_SUPPORTS_X_AI_HEADER_NAME, CompletionBody, CompletionEvent, CompletionRequestStatus, @@ -23,9 +23,8 @@ use language_model::{ LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProviderId, LanguageModelProviderName, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolSchemaFormat, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, - PaymentRequiredError, RateLimiter, X_AI_PROVIDER_ID, X_AI_PROVIDER_NAME, ZED_CLOUD_PROVIDER_ID, - ZED_CLOUD_PROVIDER_NAME, + LanguageModelToolSchemaFormat, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, RateLimiter, + X_AI_PROVIDER_ID, X_AI_PROVIDER_NAME, ZED_CLOUD_PROVIDER_ID, ZED_CLOUD_PROVIDER_NAME, }; use schemars::JsonSchema; @@ -123,9 +122,16 @@ impl CloudLanguageModel { auth_context: TP::AuthContext, app_version: Option, body: CompletionBody, - ) -> Result { - let url = http_client.build_zed_llm_url("/completions", &[])?; - let body = serde_json::to_string(&body)?; + ) -> Result { + let url = http_client + .build_zed_llm_url("/completions", &[]) + .map_err(LanguageModelCompletionError::Other)?; + let body = serde_json::to_string(&body).map_err(|error| { + LanguageModelCompletionError::SerializeRequest { + provider: PROVIDER_NAME, + error, + } + })?; let mut response = authenticated_llm_request(http_client, token_provider, auth_context, |token| { Ok(http_client::Request::builder() @@ -140,7 +146,11 @@ impl CloudLanguageModel { .header(CLIENT_SUPPORTS_STATUS_STREAM_ENDED_HEADER_NAME, "true") .body(body.clone().into())?) }) - .await?; + .await + .map_err(|error| LanguageModelCompletionError::HttpSend { + provider: PROVIDER_NAME, + error, + })?; let status = response.status(); if status.is_success() { @@ -156,17 +166,25 @@ impl CloudLanguageModel { } if status == StatusCode::PAYMENT_REQUIRED { - return Err(anyhow!(PaymentRequiredError)); + return Err(LanguageModelCompletionError::PaymentRequired); } let mut body = String::new(); let headers = response.headers().clone(); - response.body_mut().read_to_string(&mut body).await?; - Err(anyhow!(ApiError { + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(|error| LanguageModelCompletionError::ApiReadResponseError { + provider: PROVIDER_NAME, + error, + })?; + Err(ApiError { status, body, - headers - })) + headers, + } + .into()) } } @@ -469,15 +487,15 @@ impl LanguageModel for CloudLanguageModel() { - Ok(api_err) => anyhow!(LanguageModelCompletionError::from(api_err)), - Err(err) => anyhow!(err), - })?; + .await?; let mut mapper = AnthropicEventMapper::new(); Ok(map_cloud_completion_events( @@ -534,8 +552,12 @@ impl LanguageModel for CloudLanguageModel LanguageModel for CloudLanguageModel LanguageModel for CloudLanguageModel CloudModelProvider { } pub fn map_cloud_completion_events( - stream: Pin>> + Send>>, + stream: Pin, ResponseStreamError>> + Send>>, provider: &LanguageModelProviderName, mut map_callback: F, ) -> BoxStream<'static, Result> @@ -804,7 +834,7 @@ where Poll::Ready(Some(event)) => { let items = match event { Err(error) => { - vec![Err(LanguageModelCompletionError::from(error))] + vec![Err(error.into_completion_error(provider.clone()))] } Ok(CompletionEvent::Status(CompletionRequestStatus::StreamEnded)) => { saw_stream_ended = true; @@ -852,10 +882,36 @@ pub fn provider_name( } } +/// A failure while reading the streamed completion response body. +/// +/// Kept as a typed error (rather than `anyhow::Error`) so the consumer can +/// attach the provider name and build a structured +/// [`LanguageModelCompletionError`] without a runtime downcast. +pub enum ResponseStreamError { + Read(std::io::Error), + Deserialize(serde_json::Error), +} + +impl ResponseStreamError { + fn into_completion_error( + self, + provider: LanguageModelProviderName, + ) -> LanguageModelCompletionError { + match self { + ResponseStreamError::Read(error) => { + LanguageModelCompletionError::ApiReadResponseError { provider, error } + } + ResponseStreamError::Deserialize(error) => { + LanguageModelCompletionError::DeserializeResponse { provider, error } + } + } + } +} + pub fn response_lines( response: Response, includes_status_messages: bool, -) -> impl Stream>> { +) -> impl Stream, ResponseStreamError>> { futures::stream::try_unfold( (String::new(), BufReader::new(response.into_body())), move |(mut line, mut body)| async move { @@ -863,15 +919,19 @@ pub fn response_lines( Ok(0) => Ok(None), Ok(_) => { let event = if includes_status_messages { - serde_json::from_str::>(&line)? + serde_json::from_str::>(&line) + .map_err(ResponseStreamError::Deserialize)? } else { - CompletionEvent::Event(serde_json::from_str::(&line)?) + CompletionEvent::Event( + serde_json::from_str::(&line) + .map_err(ResponseStreamError::Deserialize)?, + ) }; line.clear(); Ok(Some((event, (line, body)))) } - Err(e) => Err(e.into()), + Err(error) => Err(ResponseStreamError::Read(error)), } }, ) @@ -1027,4 +1087,32 @@ mod tests { ), } } + + #[test] + fn test_response_stream_error_maps_to_structured_variant() { + // Read/deserialize failures mid-stream must keep their structured + // variant rather than collapsing into `Other` (the source of the + // generic "Request failed." message). + let read = ResponseStreamError::Read(std::io::Error::from(std::io::ErrorKind::BrokenPipe)) + .into_completion_error(PROVIDER_NAME); + assert!( + matches!( + read, + LanguageModelCompletionError::ApiReadResponseError { .. } + ), + "Expected ApiReadResponseError, got: {read:?}" + ); + + let deserialize = ResponseStreamError::Deserialize( + serde_json::from_str::("not json").unwrap_err(), + ) + .into_completion_error(PROVIDER_NAME); + assert!( + matches!( + deserialize, + LanguageModelCompletionError::DeserializeResponse { .. } + ), + "Expected DeserializeResponse, got: {deserialize:?}" + ); + } } From 11115a967f40131a2ff6655ab8f0916722648346 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:54:19 -0400 Subject: [PATCH 12/38] git_ui: Add button to remove open worktrees from the window in the worktree picker (#58996) The git worktree picker shows worktrees open in the current window, but had no way to remove them. This adds a "Remove Worktree from Window" button (same `X` icon as the recent projects picker's "Remove Project from Window") that closes the corresponding workspace in the multi-workspace, without deleting the git worktree itself. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Added a button to the git worktree picker to remove an open worktree from the current window. --- crates/git_ui/src/worktree_picker.rs | 207 +++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index ea997b73100ad1..5d09dfbbd2f57e 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -671,6 +671,72 @@ impl WorktreePickerDelegate { .detach_and_log_err(cx); } + /// Finds the workspace in this window (other than the picker's own + /// workspace) that has `worktree_path` open as a visible worktree. + fn workspace_for_open_worktree( + &self, + worktree_path: &Path, + window: &Window, + cx: &App, + ) -> Option> { + if self.active_worktree_paths.contains(worktree_path) { + return None; + } + let multi_workspace = window.root::().flatten()?; + let workspace = self.workspace.upgrade()?; + let group_key = workspace.read(cx).project_group_key(cx); + multi_workspace + .read(cx) + .workspaces_for_project_group(&group_key, cx)? + .into_iter() + .find(|group_workspace| { + *group_workspace != workspace + && group_workspace + .read(cx) + .project() + .read(cx) + .visible_worktrees(cx) + .any(|worktree| worktree.read(cx).abs_path().as_ref() == worktree_path) + }) + } + + fn remove_worktree_from_window( + &mut self, + ix: usize, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(WorktreeEntry::Worktree { worktree, .. }) = self.matches.get(ix) else { + return; + }; + let Some(workspace_to_remove) = + self.workspace_for_open_worktree(&worktree.path, window, cx) + else { + return; + }; + let Some(window_handle) = window.window_handle().downcast::() else { + return; + }; + + cx.spawn_in(window, async move |picker, cx| { + let removed = window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.close_workspace(&workspace_to_remove, window, cx) + })? + .await?; + + if removed { + picker.update_in(cx, |picker, window, cx| { + picker.delegate.refresh_project_worktree_paths(window, cx); + picker.refresh(window, cx); + })?; + } + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + fn sync_selected_index(&mut self, has_query: bool) { if !has_query { return; @@ -1091,6 +1157,8 @@ impl PickerDelegate for WorktreePickerDelegate { let is_current = self.active_worktree_paths.contains(&worktree.path); let is_deleting = self.deleting_worktree_paths.contains(&worktree.path); let can_delete = self.can_delete_worktree(worktree); + let can_remove_from_window = + !is_current && self.project_worktree_paths.contains(&worktree.path); let entry_icon = if is_current { IconName::Check @@ -1246,10 +1314,23 @@ impl PickerDelegate for WorktreePickerDelegate { })), ); + let remove_from_window_button = IconButton::new( + ("remove-worktree-from-window", ix), + IconName::Close, + ) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Remove Worktree from Window")) + .on_click(cx.listener(move |picker, _, window, cx| { + picker.delegate.remove_worktree_from_window(ix, window, cx); + })); + this.end_slot( h_flex() .gap_0p5() .child(open_in_new_window_button) + .when(can_remove_from_window, |this| { + this.child(remove_from_window_button) + }) .when(can_delete, |this| this.child(delete_button)), ) .show_end_slot_on_hover() @@ -2054,4 +2135,130 @@ mod tests { }) }); } + + #[gpui::test] + async fn test_remove_open_worktree_workspace_from_window(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "project": { + ".git": {}, + "file.txt": "buffer_text", + }, + "worktrees": {}, + }), + ) + .await; + fs.set_head_for_repo( + path!("/root/project/.git").as_ref(), + &[("file.txt", "buffer_text".to_string())], + "deadbeef", + ); + + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project.repositories(cx).values().next().unwrap().clone() + }); + let worktree_path = PathBuf::from(path!("/root/worktrees/open-wt")); + cx.update(|cx| { + repository.update(cx, |repository, _| { + repository.create_worktree( + git::repository::CreateWorktreeTarget::NewBranch { + branch_name: "open-wt".to_string(), + base_sha: Some("deadbeef".to_string()), + }, + worktree_path.clone(), + ) + }) + }) + .await + .unwrap() + .unwrap(); + + let worktree_project = Project::test(fs.clone(), [worktree_path.as_path()], cx).await; + cx.executor().run_until_parked(); + + let main_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx)); + let worktree_group_key = + worktree_project.read_with(cx, |project, cx| project.project_group_key(cx)); + assert_eq!( + main_group_key, worktree_group_key, + "the worktree workspace should belong to the same project group as the main repo" + ); + + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let worktree_workspace = window_handle + .update(cx, |multi_workspace, window, cx| { + let worktree_workspace = + cx.new(|cx| Workspace::test_new(worktree_project.clone(), window, cx)); + multi_workspace.add(worktree_workspace.clone(), window, cx); + worktree_workspace + }) + .unwrap(); + + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + let worktree_picker = cx.update(|window, cx| { + cx.new(|cx| WorktreePicker::new(project, workspace.downgrade(), window, cx)) + }); + cx.run_until_parked(); + + let index = worktree_index(&worktree_picker, &worktree_path, &mut cx); + worktree_picker.update(&mut cx, |worktree_picker, cx| { + worktree_picker.picker.update(cx, |picker, _| { + assert!( + picker + .delegate + .project_worktree_paths + .contains(&worktree_path), + "the worktree should be considered open in this window" + ); + }) + }); + + worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| { + worktree_picker.picker.update(cx, |picker, cx| { + picker + .delegate + .remove_worktree_from_window(index, window, cx); + }) + }); + cx.run_until_parked(); + + window_handle + .read_with(&cx, |multi_workspace, _| { + assert!( + multi_workspace + .workspaces() + .all(|workspace| *workspace != worktree_workspace), + "the worktree workspace should be removed from the window" + ); + }) + .unwrap(); + + worktree_picker.update(&mut cx, |worktree_picker, cx| { + worktree_picker.picker.update(cx, |picker, _| { + assert!( + !picker + .delegate + .project_worktree_paths + .contains(&worktree_path), + "the worktree should no longer be considered open in this window" + ); + }) + }); + + assert!( + repo_contains_worktree(&repository, &worktree_path, &mut cx).await, + "removing the worktree from the window should not delete the git worktree" + ); + } } From eb68bbd35ce55404b8885380ebfcbde20bf4091b Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 10 Jun 2026 11:00:07 -0400 Subject: [PATCH 13/38] Disable commit title width limit by default (#58960) This is a bit too opinionated to be turning on for everyone by default. We could consider bringing back a default-enabled setting with a less intrusive UI. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Warnings about the lengths of commit message titles are now disabled by default. --- assets/settings/default.json | 4 ++-- crates/settings_content/src/settings_content.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 011766943c0535..7c568873be4840 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -998,8 +998,8 @@ // Maximum length of the commit message title before a warning is shown. // Set to 0 to disable. // - // Default: 72 - "commit_title_max_length": 72, + // Default: 0 + "commit_title_max_length": 0, }, "message_editor": { // Whether to automatically replace emoji shortcodes with emoji characters. diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index ec1ab6f08bde33..9ac8b4b14fe2f1 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -709,7 +709,7 @@ pub struct GitPanelSettingsContent { /// Maximum length of the commit message title before a warning is shown. /// Set to 0 to disable. /// - /// Default: 72 + /// Default: 0 pub commit_title_max_length: Option, } From 7220d880d19a7527cb591d9cafdadc3707e656fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:04:38 +0200 Subject: [PATCH 14/38] client: Format the whole error chain on failure to connect to Cloud (#59035) On sign-in only, so it's a tiny change. I wonder if we could make the error read nicer too, but at least now we're not throwing away the error source anymore. Release Notes: - N/A --- crates/client/src/client.rs | 41 ++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 1d6524ec7f4a12..2c8dfa8ba47ef7 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -971,7 +971,7 @@ impl Client { Ok(valid) => Ok(valid), Err(err) => { self.set_status(Status::AuthenticationError, cx); - Err(anyhow!("failed to validate credentials: {}", err)) + Err(err.context("failed to validate credentials")) } } } @@ -2242,6 +2242,45 @@ mod tests { assert_eq!(credentials.access_token, "2"); } + #[gpui::test] + async fn test_sign_in_reports_connection_failure(cx: &mut TestAppContext) { + init_test(cx); + let http_client = FakeHttpClient::create(|_request| async move { + Ok(http_client::Response::builder() + .status(200) + .body("".into()) + .unwrap()) + }); + let client = + cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client.clone(), cx)); + client.override_authenticate(move |cx| { + cx.background_spawn(async move { + Ok(Credentials { + user_id: 1, + access_token: "token".into(), + }) + }) + }); + + // Sign in once so that the credentials are cached on the client. + client.sign_in(false, &cx.to_async()).await.unwrap(); + + // Simulate a transport-level failure (DNS/TCP/TLS/timeout) where the + // request never receives a response while validating cached credentials. + http_client + .as_fake() + .replace_handler(|_, _request| async move { + Err(anyhow!("connection reset by peer").context("boom")) + }); + + let error = client.sign_in(false, &cx.to_async()).await.unwrap_err(); + + assert_eq!( + format!("{error:#}"), + "failed to validate credentials: boom: connection reset by peer" + ); + } + #[gpui::test(iterations = 10)] async fn test_authenticating_more_than_once( cx: &mut TestAppContext, From 03872382616421e578d15368594d684929ba8db3 Mon Sep 17 00:00:00 2001 From: Kunall Banerjee Date: Wed, 10 Jun 2026 11:50:24 -0400 Subject: [PATCH 15/38] Document `auto_update_extensions` setting (#58954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Originally added in https://github.com/zed-industries/zed/pull/46130 but accidentally dropped in https://github.com/zed-industries/zed/pull/45276. Bring it back, as we’ve actually had users ask about this previously and [recently](https://zed-industries.slack.com/archives/C0AJ37231HS/p1779466189363979), as well. Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- docs/src/reference/all-settings.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 511b4ffa4e1e69..630771c6248363 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -192,6 +192,28 @@ Define extensions which should be installed (`true`) or never installed (`false` } ``` +## Auto Update extensions + +- Description: Disable auto-updates for specific extensions. +- Setting: `auto_update_extensions` +- Default: `{}` + +**Options** + +By default, every installed extension is auto-updated when Zed starts. +Add an extension here with `false` to pin it to its currently installed version. + +```json [settings] +{ + "auto_update_extensions": { + "html": false + } +} +``` + +Selecting **Install Another Version…** from an extension's `⋯` menu on the Extensions +page ({#action zed::Extensions}) does this automatically. + ## Autosave - Description: When to automatically save edited buffers. From 74e65edf512fbe88264adf8f937b1b9858f72d1d Mon Sep 17 00:00:00 2001 From: "John D. Swanson" Date: Wed, 10 Jun 2026 13:06:33 -0400 Subject: [PATCH 16/38] Remove dormant and disabled GitHub Actions workflows (#58975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Routine housekeeping to remove seven inactive GitHub Actions workflows, surfaced by a workflow-activity review (run history via the Actions API). The removals fall into two groups: - **Four hand-written workflows** that are manually disabled, never run, or long dormant. - **Three xtask-generated workflows** (manual eval/perf tools) dormant for >90 days — removed at the source in `tooling/xtask` and regenerated, with now-unused helper code dropped. Reusable workflows `extension_tests` and `extension_bump` were intentionally **kept** — their low standalone run counts are an artifact of `workflow_call` (they run constantly via `run_tests` and `extension_auto_bump`), so they are not dormant. ## What is being removed | Workflow | Roughly what it does | Author | Last run | Reason | |---|---|---|---|---| | `assign-reviewers.yml` | Auto-assigns reviewers to PRs (via a GitHub App token) | John D. Swanson | 2026-04-17 | Manually disabled in Actions settings | | `assign_contributor_issue.yml` | Assigns/labels contributor issues and notifies Slack | Lena | 2026-05-12 | Manually disabled in Actions settings | | `background_agent_mvp.yml` | Experimental background-agent MVP (manual dispatch; schedule commented out) | morgankrey | 2026-02-24 | Dormant >90 days; experimental, never promoted | | `randomized_tests.yml` | Runs randomized tests via `script/randomized-test-ci` | Max Brunsfeld | never | Never run; only triggers on pushes to a branch that is never pushed | | `compare_perf.yml` \* | Manual perf comparison between two commits for a crate | Conrad Irwin | 2025-11-06 | Dormant >90 days; on-demand tool | | `run_unit_evals.yml` \* | Manual agent unit evals for a given model/commit | Ben Kunkle | 2025-11-14 | Dormant >90 days; on-demand tool | | `run_cron_unit_evals.yml` \* | Agent unit evals across a model matrix (manual dispatch) | Richard Feldman | 2026-01-27 | Dormant >90 days; on-demand tool | \* xtask-generated — removed via `tooling/xtask` and regenerated (commit 2). ## Notes - **Commit 1** removes the four hand-written workflows. - **Commit 2** removes the three xtask-generated workflows: it deletes their generator modules and registry entries, regenerates with `cargo xtask workflows`, and drops the helper code left unused by the removal (`vars` secrets and `steps::git_checkout`). Regeneration is a no-op for all other workflows, and `./script/clippy` (deny-warnings) is clean. Release Notes: - N/A --- .github/workflows/assign-reviewers.yml | 104 ------ .../workflows/assign_contributor_issue.yml | 70 ---- .github/workflows/background_agent_mvp.yml | 331 ------------------ .github/workflows/compare_perf.yml | 84 ----- .github/workflows/randomized_tests.yml | 36 -- .github/workflows/run_cron_unit_evals.yml | 79 ----- .github/workflows/run_unit_evals.yml | 73 ---- tooling/xtask/src/tasks/workflows.rs | 5 - .../xtask/src/tasks/workflows/compare_perf.rs | 73 ---- .../src/tasks/workflows/run_agent_evals.rs | 124 ------- tooling/xtask/src/tasks/workflows/steps.rs | 5 - tooling/xtask/src/tasks/workflows/vars.rs | 11 - 12 files changed, 995 deletions(-) delete mode 100644 .github/workflows/assign-reviewers.yml delete mode 100644 .github/workflows/assign_contributor_issue.yml delete mode 100644 .github/workflows/background_agent_mvp.yml delete mode 100644 .github/workflows/compare_perf.yml delete mode 100644 .github/workflows/randomized_tests.yml delete mode 100644 .github/workflows/run_cron_unit_evals.yml delete mode 100644 .github/workflows/run_unit_evals.yml delete mode 100644 tooling/xtask/src/tasks/workflows/compare_perf.rs delete mode 100644 tooling/xtask/src/tasks/workflows/run_agent_evals.rs diff --git a/.github/workflows/assign-reviewers.yml b/.github/workflows/assign-reviewers.yml deleted file mode 100644 index 2a12a69defdd4f..00000000000000 --- a/.github/workflows/assign-reviewers.yml +++ /dev/null @@ -1,104 +0,0 @@ -# Assign Reviewers — Smart team assignment based on diff weight -# -# Triggers on PR open and ready_for_review events. Checks out the coordinator -# repo (zed-industries/codeowner-coordinator) to access the assignment script and rules, -# then assigns the 1-2 most relevant teams as reviewers. -# -# NOTE: This file is stored in the codeowner-coordinator repo but must be deployed to -# the zed repo at .github/workflows/assign-reviewers.yml. See INSTALL.md. -# -# AUTH NOTE: Uses a GitHub App (COORDINATOR_APP_ID + COORDINATOR_APP_PRIVATE_KEY) -# for all API operations: cloning the private coordinator repo, requesting team -# reviewers, and setting PR assignees. GITHUB_TOKEN is not used. -# -# SECURITY INVARIANTS (pull_request_target): -# This workflow runs with access to secrets for ALL PRs including forks. -# It is safe ONLY because: -# 1. The checkout is the coordinator repo at ref: main — NEVER the PR head/branch -# 2. No ${{ }} interpolation of event fields in run: blocks — all routed via env: -# 3. The script never executes, sources, or reads files from the PR branch -# Violating any of these enables remote code execution with secret access. - -name: Assign Reviewers - -on: - # zizmor: ignore[dangerous-triggers] reviewed — no PR code checkout, only coordinator repo at ref: main - pull_request_target: - types: [opened, ready_for_review] - -# GITHUB_TOKEN is not used — all operations use the GitHub App token. -# Declare minimal permissions so the default token has no write access. -permissions: {} - -# Prevent duplicate runs for the same PR (e.g., rapid push + ready_for_review). -concurrency: - group: assign-reviewers-${{ github.event.pull_request.number }} - cancel-in-progress: true - -# NOTE: For ready_for_review events, the webhook payload may still carry -# draft: true due to a GitHub race condition (payload serialized before DB -# update). We trust the event type instead — the script rechecks draft status -# via a live API call as defense-in-depth. -# -# No author_association filter — external and fork PRs also get reviewer -# assignments. Assigned reviewers are inherently scoped to org team members -# by the GitHub Teams API. -jobs: - assign-reviewers: - if: >- - github.event.action == 'ready_for_review' || github.event.pull_request.draft == false - runs-on: ubuntu-latest - steps: - - name: Generate app token - id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 - with: - app-id: ${{ vars.COORDINATOR_APP_ID }} - private-key: ${{ secrets.COORDINATOR_APP_PRIVATE_KEY }} - repositories: codeowner-coordinator,zed - - # SECURITY: checks out the coordinator repo at ref: main, NOT the PR branch. - # persist-credentials: false prevents the token from leaking into .git/config. - - name: Checkout coordinator repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - with: - repository: zed-industries/codeowner-coordinator - ref: main - path: codeowner-coordinator - token: ${{ steps.app-token.outputs.token }} - persist-credentials: false - - - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: "3.11" - - - name: Install dependencies - run: | - pip install --no-deps -q --only-binary ':all:' \ - -r /dev/stdin <<< "pyyaml==6.0.3 --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d" - - - name: Assign reviewers - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - PR_URL: ${{ github.event.pull_request.html_url }} - TARGET_REPO: ${{ github.repository }} - ASSIGN_INTERNAL: ${{ vars.ASSIGN_INTERNAL || 'false' }} - ASSIGN_EXTERNAL: ${{ vars.ASSIGN_EXTERNAL || 'true' }} - run: | - cd codeowner-coordinator - python .github/scripts/assign-reviewers.py \ - --pr "$PR_URL" \ - --apply \ - --rules-file team-membership-rules.yml \ - --repo "$TARGET_REPO" \ - --org zed-industries \ - 2>&1 | tee /tmp/assign-reviewers-output.txt - - - name: Upload output - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: assign-reviewers-output - path: /tmp/assign-reviewers-output.txt - retention-days: 30 diff --git a/.github/workflows/assign_contributor_issue.yml b/.github/workflows/assign_contributor_issue.yml deleted file mode 100644 index 5e968611299e26..00000000000000 --- a/.github/workflows/assign_contributor_issue.yml +++ /dev/null @@ -1,70 +0,0 @@ -# Assign Contributor Issue — auto-assign labeled contributor issues -# -# When an issue has both a `.contrib/good *` label and an `area:` label, -# finds the least-busy contributor interested in that area (via Tally form -# responses), assigns the issue, updates the project board, and notifies -# the contributor on Slack. -# -# Errors and "no candidates" conditions are reported to the Slack activity -# channel. - -name: Assign Contributor Issue - -on: - issues: - types: [labeled] - workflow_dispatch: - inputs: - issue_number: - description: "Issue number to test against" - required: true - type: number - -permissions: - contents: read - -concurrency: - group: assign-contributor-${{ github.event.issue.number || inputs.issue_number }} - cancel-in-progress: true - -jobs: - assign-contributor: - if: >- - github.event_name == 'workflow_dispatch' || - (github.repository == 'zed-industries/zed' && - github.event.issue.state == 'open' && - (startsWith(github.event.label.name, '.contrib/good ') || startsWith(github.event.label.name, 'area:'))) - runs-on: namespace-profile-2x4-ubuntu-2404 - timeout-minutes: 5 - - steps: - - name: Generate app token - id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 - with: - app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} - private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} - owner: zed-industries - - - name: Checkout repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - with: - sparse-checkout: script/github-assign-contributor-issue.py - sparse-checkout-cone-mode: false - - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: "3.12" - - - name: Install dependencies - run: pip install requests - - - name: Assign contributor - env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - TALLY_API_KEY: ${{ secrets.TALLY_API_KEY }} - TALLY_FORM_ID: ${{ vars.TALLY_CONTRIBUTOR_FORM_ID }} - SLACK_BOT_TOKEN: ${{ secrets.SLACK_CONTRIBUTOR_BOT_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }} - run: python script/github-assign-contributor-issue.py "$ISSUE_NUMBER" diff --git a/.github/workflows/background_agent_mvp.yml b/.github/workflows/background_agent_mvp.yml deleted file mode 100644 index 2f048d572df6fb..00000000000000 --- a/.github/workflows/background_agent_mvp.yml +++ /dev/null @@ -1,331 +0,0 @@ -name: background_agent_mvp - -# NOTE: Scheduled runs disabled as of 2026-02-24. The workflow can still be -# triggered manually via workflow_dispatch. See Notion doc "Background Agent -# for Zed" for current status and contact info to resume this work. -on: - # schedule: - # - cron: "0 16 * * 1-5" - workflow_dispatch: - inputs: - crash_ids: - description: "Optional comma-separated Sentry issue IDs (e.g. ZED-4VS,ZED-123)" - required: false - type: string - reviewers: - description: "Optional comma-separated GitHub reviewer handles" - required: false - type: string - top: - description: "Top N candidates when crash_ids is empty" - required: false - type: string - default: "3" - -permissions: - contents: write - pull-requests: write - -env: - FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} - DROID_MODEL: claude-opus-4-5-20251101 - SENTRY_ORG: zed-dev - -jobs: - run-mvp: - runs-on: ubuntu-latest - timeout-minutes: 180 - - steps: - - name: Checkout repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - with: - fetch-depth: 0 - - - name: Install Droid CLI - run: | - curl -fsSL https://app.factory.ai/cli | sh - echo "${HOME}/.local/bin" >> "$GITHUB_PATH" - echo "DROID_BIN=${HOME}/.local/bin/droid" >> "$GITHUB_ENV" - "${HOME}/.local/bin/droid" --version - - - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.12" - - - name: Resolve reviewers - id: reviewers - env: - INPUT_REVIEWERS: ${{ inputs.reviewers }} - DEFAULT_REVIEWERS: ${{ vars.BACKGROUND_AGENT_REVIEWERS }} - run: | - set -euo pipefail - if [ -z "$DEFAULT_REVIEWERS" ]; then - DEFAULT_REVIEWERS="eholk,morgankrey,osiewicz,bennetbo" - fi - REVIEWERS="${INPUT_REVIEWERS:-$DEFAULT_REVIEWERS}" - REVIEWERS="$(echo "$REVIEWERS" | tr -d '[:space:]')" - echo "reviewers=$REVIEWERS" >> "$GITHUB_OUTPUT" - - - name: Select crash candidates - id: candidates - env: - INPUT_CRASH_IDS: ${{ inputs.crash_ids }} - INPUT_TOP: ${{ inputs.top }} - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_BACKGROUND_AGENT_MVP_TOKEN }} - run: | - set -euo pipefail - - PREFETCH_DIR="/tmp/crash-data" - ARGS=(--select-only --prefetch-dir "$PREFETCH_DIR" --org "$SENTRY_ORG") - if [ -n "$INPUT_CRASH_IDS" ]; then - ARGS+=(--crash-ids "$INPUT_CRASH_IDS") - else - TARGET_DRAFT_PRS="${INPUT_TOP:-3}" - if ! [[ "$TARGET_DRAFT_PRS" =~ ^[0-9]+$ ]] || [ "$TARGET_DRAFT_PRS" -lt 1 ]; then - TARGET_DRAFT_PRS="3" - fi - CANDIDATE_TOP=$((TARGET_DRAFT_PRS * 5)) - if [ "$CANDIDATE_TOP" -gt 100 ]; then - CANDIDATE_TOP=100 - fi - ARGS+=(--top "$CANDIDATE_TOP" --sample-size 100) - fi - - IDS="$(python3 script/run-background-agent-mvp-local "${ARGS[@]}")" - - if [ -z "$IDS" ]; then - echo "No candidates selected" - exit 1 - fi - - echo "Using crash IDs: $IDS" - echo "ids=$IDS" >> "$GITHUB_OUTPUT" - - - name: Run background agent pipeline per crash - id: pipeline - env: - GH_TOKEN: ${{ github.token }} - REVIEWERS: ${{ steps.reviewers.outputs.reviewers }} - CRASH_IDS: ${{ steps.candidates.outputs.ids }} - TARGET_DRAFT_PRS_INPUT: ${{ inputs.top }} - run: | - set -euo pipefail - - git config user.name "factory-droid[bot]" - git config user.email "138933559+factory-droid[bot]@users.noreply.github.com" - - # Crash ID format validation regex - CRASH_ID_PATTERN='^[A-Za-z0-9]+-[A-Za-z0-9]+$' - TARGET_DRAFT_PRS="${TARGET_DRAFT_PRS_INPUT:-3}" - if ! [[ "$TARGET_DRAFT_PRS" =~ ^[0-9]+$ ]] || [ "$TARGET_DRAFT_PRS" -lt 1 ]; then - TARGET_DRAFT_PRS="3" - fi - CREATED_DRAFT_PRS=0 - - IFS=',' read -r -a CRASH_ID_ARRAY <<< "$CRASH_IDS" - - for CRASH_ID in "${CRASH_ID_ARRAY[@]}"; do - if [ "$CREATED_DRAFT_PRS" -ge "$TARGET_DRAFT_PRS" ]; then - echo "Reached target draft PR count ($TARGET_DRAFT_PRS), stopping candidate processing" - break - fi - - CRASH_ID="$(echo "$CRASH_ID" | xargs)" - [ -z "$CRASH_ID" ] && continue - - # Validate crash ID format to prevent injection via branch names or prompts - if ! [[ "$CRASH_ID" =~ $CRASH_ID_PATTERN ]]; then - echo "ERROR: Invalid crash ID format: '$CRASH_ID' — skipping" - continue - fi - - BRANCH="background-agent/mvp-${CRASH_ID,,}-$(date +%Y%m%d)" - echo "Running crash pipeline for $CRASH_ID on $BRANCH" - - # Deduplication: skip if a draft PR already exists for this crash - EXISTING_BRANCH_PR="$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number' || echo "")" - if [ -n "$EXISTING_BRANCH_PR" ]; then - echo "Draft PR #$EXISTING_BRANCH_PR already exists for $CRASH_ID — skipping" - continue - fi - - if ! git fetch origin main; then - echo "WARNING: Failed to fetch origin/main for $CRASH_ID — skipping" - continue - fi - - if ! git checkout -B "$BRANCH" origin/main; then - echo "WARNING: Failed to create checkout branch $BRANCH for $CRASH_ID — skipping" - continue - fi - - CRASH_DATA_FILE="/tmp/crash-data/crash-${CRASH_ID}.md" - if [ ! -f "$CRASH_DATA_FILE" ]; then - echo "WARNING: No pre-fetched crash data for $CRASH_ID at $CRASH_DATA_FILE — skipping" - continue - fi - - python3 -c " - import sys - crash_id, data_file = sys.argv[1], sys.argv[2] - prompt = f'''You are running the weekly background crash-fix MVP pipeline for crash {crash_id}. - - The crash report has been pre-fetched and is available at: {data_file} - Read this file to get the crash data. Do not call script/sentry-fetch. - - Required workflow: - 1. Read the crash report from {data_file} - 2. Read and follow .rules. - 3. Follow .factory/prompts/crash/investigate.md and write ANALYSIS.md - 4. Follow .factory/prompts/crash/link-issues.md and write LINKED_ISSUES.md - 5. Follow .factory/prompts/crash/fix.md to implement a minimal fix with tests - 6. Run validators required by the fix prompt for the affected code paths - 7. Write PR_BODY.md with sections: - - Crash Summary - - Root Cause - - Fix - - Validation - - Potentially Related Issues (High/Medium/Low from LINKED_ISSUES.md) - - Reviewer Checklist - - Release Notes (final section; format as Release Notes:, then a blank line, then one bullet like - N/A) - - Constraints: - - Do not merge or auto-approve. - - Keep changes narrowly scoped to this crash. - - Do not modify files in .github/, .factory/, or script/ directories. - - When investigating git history, limit your search to the last 2 weeks of commits. Do not traverse older history. - - If the crash is not solvable with available context, write a clear blocker summary to PR_BODY.md. - ''' - import textwrap - with open('/tmp/background-agent-prompt.md', 'w') as f: - f.write(textwrap.dedent(prompt)) - " "$CRASH_ID" "$CRASH_DATA_FILE" - - if ! "$DROID_BIN" exec --auto medium -m "$DROID_MODEL" -f /tmp/background-agent-prompt.md; then - echo "Droid execution failed for $CRASH_ID, continuing to next candidate" - continue - fi - - for REPORT_FILE in ANALYSIS.md LINKED_ISSUES.md PR_BODY.md; do - if [ -f "$REPORT_FILE" ]; then - echo "::group::${CRASH_ID} ${REPORT_FILE}" - cat "$REPORT_FILE" - echo "::endgroup::" - fi - done - - if git diff --quiet; then - echo "No code changes produced for $CRASH_ID" - continue - fi - - # Stage only expected file types — not git add -A - git add -- '*.rs' '*.toml' 'Cargo.lock' 'ANALYSIS.md' 'LINKED_ISSUES.md' 'PR_BODY.md' - - # Reject changes to protected paths - PROTECTED_CHANGES="$(git diff --cached --name-only | grep -E '^(\.github/|\.factory/|script/)' || true)" - if [ -n "$PROTECTED_CHANGES" ]; then - echo "ERROR: Agent modified protected paths — aborting commit for $CRASH_ID:" - echo "$PROTECTED_CHANGES" - git reset HEAD -- . - continue - fi - - if ! git diff --cached --quiet; then - git commit -m "Fix crash ${CRASH_ID}" - fi - - git push -u origin "$BRANCH" - - CRATE_PREFIX="" - CHANGED_CRATES="$(git diff --cached --name-only | awk -F/ '/^crates\/[^/]+\// {print $2}' | sort -u)" - if [ -n "$CHANGED_CRATES" ] && [ "$(printf "%s\n" "$CHANGED_CRATES" | wc -l | tr -d ' ')" -eq 1 ]; then - CRATE_PREFIX="${CHANGED_CRATES}: " - fi - - TITLE="${CRATE_PREFIX}Fix crash ${CRASH_ID}" - BODY_FILE="PR_BODY.md" - if [ ! -f "$BODY_FILE" ]; then - BODY_FILE="/tmp/pr-body-${CRASH_ID}.md" - printf "Automated draft crash-fix pipeline output for %s.\n\nNo PR_BODY.md was generated by the agent; please review commit and linked artifacts manually.\n" "$CRASH_ID" > "$BODY_FILE" - fi - - python3 -c ' - import re - import sys - - path = sys.argv[1] - body = open(path, encoding="utf-8").read() - pattern = re.compile(r"(^|\n)Release Notes:\r?\n(?:\r?\n)*(?P(?:\s*-\s+.*(?:\r?\n|$))+)", re.MULTILINE) - match = pattern.search(body) - - if match: - bullets = [ - re.sub(r"^\s*", "", bullet) - for bullet in re.findall(r"^\s*-\s+.*$", match.group("bullets"), re.MULTILINE) - ] - if not bullets: - bullets = ["- N/A"] - section = "Release Notes:\n\n" + "\n".join(bullets) - body_without_release_notes = (body[: match.start()] + body[match.end() :]).rstrip() - if body_without_release_notes: - normalized_body = f"{body_without_release_notes}\n\n{section}\n" - else: - normalized_body = f"{section}\n" - else: - normalized_body = body.rstrip() + "\n\nRelease Notes:\n\n- N/A\n" - - with open(path, "w", encoding="utf-8") as file: - file.write(normalized_body) - ' "$BODY_FILE" - - EXISTING_PR="$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')" - if [ -n "$EXISTING_PR" ]; then - gh pr edit "$EXISTING_PR" --title "$TITLE" --body-file "$BODY_FILE" - PR_NUMBER="$EXISTING_PR" - else - PR_URL="$(gh pr create --draft --base main --head "$BRANCH" --title "$TITLE" --body-file "$BODY_FILE")" - PR_NUMBER="$(basename "$PR_URL")" - fi - - if [ -n "$REVIEWERS" ]; then - IFS=',' read -r -a REVIEWER_ARRAY <<< "$REVIEWERS" - for REVIEWER in "${REVIEWER_ARRAY[@]}"; do - [ -z "$REVIEWER" ] && continue - gh pr edit "$PR_NUMBER" --add-reviewer "$REVIEWER" || true - done - fi - - CREATED_DRAFT_PRS=$((CREATED_DRAFT_PRS + 1)) - echo "Created/updated draft PRs this run: $CREATED_DRAFT_PRS/$TARGET_DRAFT_PRS" - done - - echo "created_draft_prs=$CREATED_DRAFT_PRS" >> "$GITHUB_OUTPUT" - echo "target_draft_prs=$TARGET_DRAFT_PRS" >> "$GITHUB_OUTPUT" - - - name: Cleanup pre-fetched crash data - if: always() - run: rm -rf /tmp/crash-data - - - name: Workflow summary - if: always() - env: - SUMMARY_CRASH_IDS: ${{ steps.candidates.outputs.ids }} - SUMMARY_REVIEWERS: ${{ steps.reviewers.outputs.reviewers }} - SUMMARY_CREATED_DRAFT_PRS: ${{ steps.pipeline.outputs.created_draft_prs }} - SUMMARY_TARGET_DRAFT_PRS: ${{ steps.pipeline.outputs.target_draft_prs }} - run: | - { - echo "## Background Agent MVP" - echo "" - echo "- Crash IDs: ${SUMMARY_CRASH_IDS:-none}" - echo "- Reviewer routing: ${SUMMARY_REVIEWERS:-NOT CONFIGURED}" - echo "- Draft PRs created: ${SUMMARY_CREATED_DRAFT_PRS:-0}/${SUMMARY_TARGET_DRAFT_PRS:-3}" - echo "- Pipeline: investigate -> link-issues -> fix -> draft PR" - } >> "$GITHUB_STEP_SUMMARY" - -concurrency: - group: background-agent-mvp - cancel-in-progress: false diff --git a/.github/workflows/compare_perf.yml b/.github/workflows/compare_perf.yml deleted file mode 100644 index 154276a7104733..00000000000000 --- a/.github/workflows/compare_perf.yml +++ /dev/null @@ -1,84 +0,0 @@ -# Generated from xtask::workflows::compare_perf -# Rebuild with `cargo xtask workflows`. -name: compare_perf -on: - workflow_dispatch: - inputs: - head: - description: head - required: true - type: string - base: - description: base - required: true - type: string - crate_name: - description: crate_name - type: string - default: '' -jobs: - run_perf: - runs-on: namespace-profile-16x32-ubuntu-2204 - steps: - - name: steps::checkout_repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - with: - clean: false - - name: steps::setup_cargo_config - run: | - mkdir -p ./../.cargo - cp ./.cargo/ci-config.toml ./../.cargo/config.toml - - name: steps::setup_linux - run: ./script/linux - - name: steps::download_wasi_sdk - run: ./script/download-wasi-sdk - - name: compare_perf::run_perf::install_hyperfine - uses: taiki-e/install-action@b4f2d5cb8597b15997c8ede873eb6185efc5f0ad - - name: steps::git_checkout - run: git fetch origin "$REF_NAME" && git checkout "$REF_NAME" - env: - REF_NAME: ${{ inputs.base }} - - name: compare_perf::run_perf::cargo_perf_test - run: |2- - - if [ -n "$CRATE_NAME" ]; then - cargo perf-test -p "$CRATE_NAME" -- --json="$REF_NAME"; - else - cargo perf-test -p vim -- --json="$REF_NAME"; - fi - env: - REF_NAME: ${{ inputs.base }} - CRATE_NAME: ${{ inputs.crate_name }} - - name: steps::git_checkout - run: git fetch origin "$REF_NAME" && git checkout "$REF_NAME" - env: - REF_NAME: ${{ inputs.head }} - - name: compare_perf::run_perf::cargo_perf_test - run: |2- - - if [ -n "$CRATE_NAME" ]; then - cargo perf-test -p "$CRATE_NAME" -- --json="$REF_NAME"; - else - cargo perf-test -p vim -- --json="$REF_NAME"; - fi - env: - REF_NAME: ${{ inputs.head }} - CRATE_NAME: ${{ inputs.crate_name }} - - name: compare_perf::run_perf::compare_runs - run: cargo perf-compare --save=results.md "$BASE" "$HEAD" - env: - BASE: ${{ inputs.base }} - HEAD: ${{ inputs.head }} - - name: run_bundling::upload_artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: results.md - path: results.md - if-no-files-found: error - - name: steps::cleanup_cargo_config - if: always() - run: | - rm -rf ./../.cargo -defaults: - run: - shell: bash -euxo pipefail {0} diff --git a/.github/workflows/randomized_tests.yml b/.github/workflows/randomized_tests.yml deleted file mode 100644 index 9655a81235d79e..00000000000000 --- a/.github/workflows/randomized_tests.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Randomized Tests - -concurrency: randomized-tests - -on: - push: - branches: - - randomized-tests-runner - # schedule: - # - cron: '0 * * * *' - -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - ZED_SERVER_URL: https://zed.dev - -jobs: - tests: - name: Run randomized tests - if: github.repository_owner == 'zed-industries' - runs-on: - - namespace-profile-16x32-ubuntu-2204 - steps: - - name: Install Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: "18" - - - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - with: - clean: false - - - name: Run randomized tests - run: script/randomized-test-ci diff --git a/.github/workflows/run_cron_unit_evals.yml b/.github/workflows/run_cron_unit_evals.yml deleted file mode 100644 index 7cc0d40760a74b..00000000000000 --- a/.github/workflows/run_cron_unit_evals.yml +++ /dev/null @@ -1,79 +0,0 @@ -# Generated from xtask::workflows::run_cron_unit_evals -# Rebuild with `cargo xtask workflows`. -name: run_cron_unit_evals -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: '0' - RUST_BACKTRACE: '1' - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} -on: - workflow_dispatch: {} -jobs: - cron_unit_evals: - runs-on: namespace-profile-16x32-ubuntu-2204 - strategy: - matrix: - model: - - anthropic/claude-sonnet-4-5-latest - - anthropic/claude-opus-4-5-latest - - google/gemini-3.1-pro - - openai/gpt-5 - fail-fast: false - steps: - - name: steps::checkout_repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - with: - clean: false - - name: steps::setup_cargo_config - run: | - mkdir -p ./../.cargo - cp ./.cargo/ci-config.toml ./../.cargo/config.toml - - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 - with: - cache: rust - path: ~/.rustup - - name: steps::setup_linux - run: ./script/linux - - name: steps::download_wasi_sdk - run: ./script/download-wasi-sdk - - name: steps::cargo_install_nextest - uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - - name: steps::clear_target_dir_if_large - run: ./script/clear-target-dir-if-larger-than 350 200 - - name: steps::setup_sccache - run: ./script/setup-sccache - env: - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - SCCACHE_BUCKET: sccache-zed - - name: ./script/run-unit-evals - run: ./script/run-unit-evals - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }} - GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }} - ZED_AGENT_MODEL: ${{ matrix.model }} - - name: steps::show_sccache_stats - run: sccache --show-stats || true - - name: steps::cleanup_cargo_config - if: always() - run: | - rm -rf ./../.cargo - - name: run_agent_evals::cron_unit_evals::send_failure_to_slack - if: ${{ failure() }} - uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 - with: - method: chat.postMessage - token: ${{ secrets.SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN }} - payload: | - channel: C04UDRNNJFQ - text: "Unit Evals Failed: https://github.com/zed-industries/zed/actions/runs/${{ github.run_id }}" -concurrency: - group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} - cancel-in-progress: true -defaults: - run: - shell: bash -euxo pipefail {0} diff --git a/.github/workflows/run_unit_evals.yml b/.github/workflows/run_unit_evals.yml deleted file mode 100644 index 4b70d15012cd03..00000000000000 --- a/.github/workflows/run_unit_evals.yml +++ /dev/null @@ -1,73 +0,0 @@ -# Generated from xtask::workflows::run_unit_evals -# Rebuild with `cargo xtask workflows`. -name: run_unit_evals -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: '0' - RUST_BACKTRACE: '1' - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} - ZED_EVAL_TELEMETRY: '1' - MODEL_NAME: ${{ inputs.model_name }} -on: - workflow_dispatch: - inputs: - model_name: - description: model_name - required: true - type: string - commit_sha: - description: commit_sha - required: true - type: string -jobs: - run_unit_evals: - runs-on: namespace-profile-16x32-ubuntu-2204 - steps: - - name: steps::checkout_repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - with: - clean: false - - name: steps::setup_cargo_config - run: | - mkdir -p ./../.cargo - cp ./.cargo/ci-config.toml ./../.cargo/config.toml - - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 - with: - cache: rust - path: ~/.rustup - - name: steps::setup_linux - run: ./script/linux - - name: steps::download_wasi_sdk - run: ./script/download-wasi-sdk - - name: steps::cargo_install_nextest - uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - - name: steps::clear_target_dir_if_large - run: ./script/clear-target-dir-if-larger-than 350 200 - - name: steps::setup_sccache - run: ./script/setup-sccache - env: - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - SCCACHE_BUCKET: sccache-zed - - name: ./script/run-unit-evals - run: ./script/run-unit-evals - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }} - GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }} - UNIT_EVAL_COMMIT: ${{ inputs.commit_sha }} - - name: steps::show_sccache_stats - run: sccache --show-stats || true - - name: steps::cleanup_cargo_config - if: always() - run: | - rm -rf ./../.cargo -concurrency: - group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.run_id }} - cancel-in-progress: true -defaults: - run: - shell: bash -euxo pipefail {0} diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs index 6f21be93907bf2..14516c18f22a8d 100644 --- a/tooling/xtask/src/tasks/workflows.rs +++ b/tooling/xtask/src/tasks/workflows.rs @@ -12,7 +12,6 @@ mod autofix_pr; mod bump_patch_version; mod bump_zed_version; mod cherry_pick; -mod compare_perf; mod compliance_check; mod danger; mod deploy_collab; @@ -28,7 +27,6 @@ mod release_nightly; mod run_bundling; mod release; -mod run_agent_evals; mod run_tests; mod runners; mod steps; @@ -227,7 +225,6 @@ pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> { WorkflowFile::zed(bump_patch_version::bump_patch_version), WorkflowFile::zed(bump_zed_version::bump_zed_version), WorkflowFile::zed(cherry_pick::cherry_pick), - WorkflowFile::zed(compare_perf::compare_perf), WorkflowFile::zed(compliance_check::compliance_check), WorkflowFile::zed(danger::danger), WorkflowFile::zed(deploy_collab::deploy_collab), @@ -241,8 +238,6 @@ pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> { WorkflowFile::zed(publish_extension_cli::publish_extension_cli), WorkflowFile::zed(release::release), WorkflowFile::zed(release_nightly::release_nightly), - WorkflowFile::zed(run_agent_evals::run_cron_unit_evals), - WorkflowFile::zed(run_agent_evals::run_unit_evals), WorkflowFile::zed(run_bundling::run_bundling), WorkflowFile::zed(run_tests::run_tests), /* workflows used for CI/CD in extension repositories */ diff --git a/tooling/xtask/src/tasks/workflows/compare_perf.rs b/tooling/xtask/src/tasks/workflows/compare_perf.rs deleted file mode 100644 index 39f17b8d148bd6..00000000000000 --- a/tooling/xtask/src/tasks/workflows/compare_perf.rs +++ /dev/null @@ -1,73 +0,0 @@ -use gh_workflow::*; - -use crate::tasks::workflows::run_bundling::upload_artifact; -use crate::tasks::workflows::steps::FluentBuilder; -use crate::tasks::workflows::{ - runners, - steps::{self, NamedJob, named}, - vars::WorkflowInput, -}; - -pub fn compare_perf() -> Workflow { - let head = WorkflowInput::string("head", None); - let base = WorkflowInput::string("base", None); - let crate_name = WorkflowInput::string("crate_name", Some("".to_owned())); - let run_perf = run_perf(&base, &head, &crate_name); - named::workflow() - .on(Event::default().workflow_dispatch( - WorkflowDispatch::default() - .add_input(head.name, head.input()) - .add_input(base.name, base.input()) - .add_input(crate_name.name, crate_name.input()), - )) - .add_job(run_perf.name, run_perf.job) -} - -pub fn run_perf( - base: &WorkflowInput, - head: &WorkflowInput, - crate_name: &WorkflowInput, -) -> NamedJob { - fn cargo_perf_test(ref_name: &WorkflowInput, crate_name: &WorkflowInput) -> Step { - named::bash( - r#" - if [ -n "$CRATE_NAME" ]; then - cargo perf-test -p "$CRATE_NAME" -- --json="$REF_NAME"; - else - cargo perf-test -p vim -- --json="$REF_NAME"; - fi"#, - ) - .add_env(("REF_NAME", ref_name.to_string())) - .add_env(("CRATE_NAME", crate_name.to_string())) - } - - fn install_hyperfine() -> Step { - named::uses( - "taiki-e", - "install-action", - "b4f2d5cb8597b15997c8ede873eb6185efc5f0ad", // hyperfine - ) - } - - fn compare_runs(head: &WorkflowInput, base: &WorkflowInput) -> Step { - named::bash(r#"cargo perf-compare --save=results.md "$BASE" "$HEAD""#) - .add_env(("BASE", base.to_string())) - .add_env(("HEAD", head.to_string())) - } - - named::job( - Job::default() - .runs_on(runners::LINUX_DEFAULT) - .add_step(steps::checkout_repo()) - .add_step(steps::setup_cargo_config(runners::Platform::Linux)) - .map(steps::install_linux_dependencies) - .add_step(install_hyperfine()) - .add_step(steps::git_checkout(base)) - .add_step(cargo_perf_test(base, crate_name)) - .add_step(steps::git_checkout(head)) - .add_step(cargo_perf_test(head, crate_name)) - .add_step(compare_runs(head, base)) - .add_step(upload_artifact("results.md")) - .add_step(steps::cleanup_cargo_config(runners::Platform::Linux)), - ) -} diff --git a/tooling/xtask/src/tasks/workflows/run_agent_evals.rs b/tooling/xtask/src/tasks/workflows/run_agent_evals.rs deleted file mode 100644 index 8146552e6567fc..00000000000000 --- a/tooling/xtask/src/tasks/workflows/run_agent_evals.rs +++ /dev/null @@ -1,124 +0,0 @@ -use gh_workflow::{Event, Expression, Job, Run, Step, Strategy, Use, Workflow, WorkflowDispatch}; -use serde_json::json; - -use crate::tasks::workflows::{ - runners::{self, Platform}, - steps::{self, FluentBuilder as _, NamedJob, named}, - vars::{self, WorkflowInput}, -}; - -pub(crate) fn run_unit_evals() -> Workflow { - let model_name = WorkflowInput::string("model_name", None); - let commit_sha = WorkflowInput::string("commit_sha", None); - - let unit_evals = named::job(unit_evals(Some(&commit_sha))); - - named::workflow() - .name("run_unit_evals") - .on(Event::default().workflow_dispatch( - WorkflowDispatch::default() - .add_input(model_name.name, model_name.input()) - .add_input(commit_sha.name, commit_sha.input()), - )) - .concurrency(vars::allow_concurrent_runs()) - .add_env(("CARGO_TERM_COLOR", "always")) - .add_env(("CARGO_INCREMENTAL", 0)) - .add_env(("RUST_BACKTRACE", 1)) - .add_env(("ZED_CLIENT_CHECKSUM_SEED", vars::ZED_CLIENT_CHECKSUM_SEED)) - .add_env(("ZED_EVAL_TELEMETRY", 1)) - .add_env(("MODEL_NAME", model_name.to_string())) - .add_job(unit_evals.name, unit_evals.job) -} - -fn add_api_keys(step: Step) -> Step { - step.add_env(("ANTHROPIC_API_KEY", vars::ANTHROPIC_API_KEY)) - .add_env(("OPENAI_API_KEY", vars::OPENAI_API_KEY)) - .add_env(("GOOGLE_AI_API_KEY", vars::GOOGLE_AI_API_KEY)) - .add_env(("GOOGLE_CLOUD_PROJECT", vars::GOOGLE_CLOUD_PROJECT)) -} - -pub(crate) fn run_cron_unit_evals() -> Workflow { - let unit_evals = cron_unit_evals(); - - named::workflow() - .name("run_cron_unit_evals") - .on(Event::default() - // .schedule([ - // // GitHub might drop jobs at busy times, so we choose a random time in the middle of the night. - // Schedule::default().cron("47 1 * * 2"), - // ]) - .workflow_dispatch(WorkflowDispatch::default())) - .concurrency(vars::one_workflow_per_non_main_branch()) - .add_env(("CARGO_TERM_COLOR", "always")) - .add_env(("CARGO_INCREMENTAL", 0)) - .add_env(("RUST_BACKTRACE", 1)) - .add_env(("ZED_CLIENT_CHECKSUM_SEED", vars::ZED_CLIENT_CHECKSUM_SEED)) - .add_job(unit_evals.name, unit_evals.job) -} - -fn cron_unit_evals() -> NamedJob { - fn send_failure_to_slack() -> Step { - named::uses( - "slackapi", - "slack-github-action", - "b0fa283ad8fea605de13dc3f449259339835fc52", - ) - .if_condition(Expression::new("${{ failure() }}")) - .add_with(("method", "chat.postMessage")) - .add_with(("token", vars::SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN)) - .add_with(("payload", indoc::indoc!{r#" - channel: C04UDRNNJFQ - text: "Unit Evals Failed: https://github.com/zed-industries/zed/actions/runs/${{ github.run_id }}" - "#})) - } - - named::job(cron_unit_evals_job().add_step(send_failure_to_slack())) -} - -const UNIT_EVAL_MODELS: &[&str] = &[ - "anthropic/claude-sonnet-4-5-latest", - "anthropic/claude-opus-4-5-latest", - "google/gemini-3.1-pro", - "openai/gpt-5", -]; - -fn cron_unit_evals_job() -> Job { - let script_step = add_api_keys(steps::script("./script/run-unit-evals")) - .add_env(("ZED_AGENT_MODEL", "${{ matrix.model }}")); - - Job::default() - .runs_on(runners::LINUX_DEFAULT) - .strategy(Strategy::default().fail_fast(false).matrix(json!({ - "model": UNIT_EVAL_MODELS - }))) - .add_step(steps::checkout_repo()) - .add_step(steps::setup_cargo_config(Platform::Linux)) - .add_step(steps::cache_rust_dependencies_namespace()) - .map(steps::install_linux_dependencies) - .add_step(steps::cargo_install_nextest()) - .add_step(steps::clear_target_dir_if_large(Platform::Linux)) - .add_step(steps::setup_sccache(Platform::Linux)) - .add_step(script_step) - .add_step(steps::show_sccache_stats(Platform::Linux)) - .add_step(steps::cleanup_cargo_config(Platform::Linux)) -} - -fn unit_evals(commit: Option<&WorkflowInput>) -> Job { - let script_step = add_api_keys(steps::script("./script/run-unit-evals")); - - Job::default() - .runs_on(runners::LINUX_DEFAULT) - .add_step(steps::checkout_repo()) - .add_step(steps::setup_cargo_config(Platform::Linux)) - .add_step(steps::cache_rust_dependencies_namespace()) - .map(steps::install_linux_dependencies) - .add_step(steps::cargo_install_nextest()) - .add_step(steps::clear_target_dir_if_large(Platform::Linux)) - .add_step(steps::setup_sccache(Platform::Linux)) - .add_step(match commit { - Some(commit) => script_step.add_env(("UNIT_EVAL_COMMIT", commit)), - None => script_step, - }) - .add_step(steps::show_sccache_stats(Platform::Linux)) - .add_step(steps::cleanup_cargo_config(Platform::Linux)) -} diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 8e73906396e008..fde9150b1a72c4 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -754,11 +754,6 @@ pub fn download_artifact() -> DownloadArtifactStep { } } -pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step { - named::bash(r#"git fetch origin "$REF_NAME" && git checkout "$REF_NAME""#) - .add_env(("REF_NAME", ref_name.to_string())) -} - /// Non-exhaustive list of the permissions to be set for a GitHub app token. /// /// See https://github.com/actions/create-github-app-token?tab=readme-ov-file#permission-permission-name diff --git a/tooling/xtask/src/tasks/workflows/vars.rs b/tooling/xtask/src/tasks/workflows/vars.rs index 5923c7a1c5423a..8b4ff10bc5acd5 100644 --- a/tooling/xtask/src/tasks/workflows/vars.rs +++ b/tooling/xtask/src/tasks/workflows/vars.rs @@ -19,10 +19,6 @@ macro_rules! var { }; } -secret!(ANTHROPIC_API_KEY); -secret!(OPENAI_API_KEY); -secret!(GOOGLE_AI_API_KEY); -secret!(GOOGLE_CLOUD_PROJECT); secret!(APPLE_NOTARIZATION_ISSUER_ID); secret!(APPLE_NOTARIZATION_KEY); secret!(APPLE_NOTARIZATION_KEY_ID); @@ -41,7 +37,6 @@ secret!(SENTRY_AUTH_TOKEN); secret!(ZED_CLIENT_CHECKSUM_SEED); secret!(ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON); secret!(ZED_SENTRY_MINIDUMP_ENDPOINT); -secret!(SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN); secret!(ZED_ZIPPY_APP_ID); secret!(ZED_ZIPPY_APP_PRIVATE_KEY); secret!(DISCORD_WEBHOOK_RELEASE_NOTES); @@ -104,12 +99,6 @@ pub fn one_workflow_per_non_main_branch_and_token>(token: T) -> Co .cancel_in_progress(true) } -pub(crate) fn allow_concurrent_runs() -> Concurrency { - Concurrency::default() - .group("${{ github.workflow }}-${{ github.ref_name }}-${{ github.run_id }}") - .cancel_in_progress(true) -} - // Represents a pattern to check for changed files and corresponding output variable pub struct PathCondition { pub name: &'static str, From c1b45aaa5f31401fa5368a8c9636f9d8db979517 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 10 Jun 2026 13:13:08 -0400 Subject: [PATCH 17/38] Bump notify to fix a hang when watching or unwatching paths (#59047) For https://github.com/zed-industries/notify/pull/5 Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A --- Cargo.lock | 6 ++---- Cargo.toml | 2 ++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1273f3895d130..ed80ea5af68b8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11500,8 +11500,7 @@ dependencies = [ [[package]] name = "notify" version = "9.0.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b44b771d4dd781ef14c84078693e67495da6b47f609f72e8a4da8420a861240e" +source = "git+https://github.com/zed-industries/notify?rev=faecbc33db4f59313e5225ef766bfd9e54a54cfd#faecbc33db4f59313e5225ef766bfd9e54a54cfd" dependencies = [ "bitflags 2.10.0", "inotify 0.11.0", @@ -11531,8 +11530,7 @@ dependencies = [ [[package]] name = "notify-types" version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +source = "git+https://github.com/zed-industries/notify?rev=faecbc33db4f59313e5225ef766bfd9e54a54cfd#faecbc33db4f59313e5225ef766bfd9e54a54cfd" dependencies = [ "bitflags 2.10.0", ] diff --git a/Cargo.toml b/Cargo.toml index 0c9fb4d0b899fa..65054bd4d2905f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -889,6 +889,8 @@ windows-capture = { git = "https://github.com/zed-industries/windows-capture.git calloop = { git = "https://github.com/zed-industries/calloop" } livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } +notify = { git = "https://github.com/zed-industries/notify", rev = "faecbc33db4f59313e5225ef766bfd9e54a54cfd" } +notify-types = { git = "https://github.com/zed-industries/notify", rev = "faecbc33db4f59313e5225ef766bfd9e54a54cfd" } webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } [profile.dev] From 5976ffb4b628a73d36bf9984954fe37067fb9bf2 Mon Sep 17 00:00:00 2001 From: "zed-zippy[bot]" <234243425+zed-zippy[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:58:34 -0400 Subject: [PATCH 18/38] Bump Zed to v1.8.0 (#59048) Release Notes: - N/A Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ed80ea5af68b8d..dbae254fb27866 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22831,7 +22831,7 @@ dependencies = [ [[package]] name = "zed" -version = "1.7.0" +version = "1.8.0" dependencies = [ "acp_thread", "acp_tools", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 664cdd703c4764..5e125b7ec34fcc 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "1.7.0" +version = "1.8.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From eb4e2e8c7b822ce74254ca3534ac0e018e41597a Mon Sep 17 00:00:00 2001 From: Katie Geer Date: Wed, 10 Jun 2026 11:48:30 -0700 Subject: [PATCH 19/38] agent: Add thread history import telemetry (#59050) ## Summary - Emit `Thread History Viewed` from the shared archive/history opening path so it covers button, keyboard, and onboarding entry points. - Emit `Agent Threads Import Clicked` when users open external-agent thread import from thread history or onboarding. - Emit `Agent Threads Import Clicked` when users click cross-channel thread import onboarding. ## Tests - `cargo fmt --check` - `git diff --check` - `./script/clippy -p sidebar` - `cargo test -p sidebar` Release Notes: - N/A --- crates/sidebar/src/sidebar.rs | 39 ++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index ebd42f38730a74..b3fa58a0ee3471 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -7458,7 +7458,21 @@ impl Sidebar { .map(|w| w.read(cx).workspace().clone()) } - fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context) { + fn show_thread_import_modal( + &mut self, + source: &'static str, + window: &mut Window, + cx: &mut Context, + ) { + telemetry::event!( + "Agent Threads Import Clicked", + source = source, + side = match self.side(cx) { + SidebarSide::Left => "left", + SidebarSide::Right => "right", + } + ); + let Some(active_workspace) = self.active_workspace(cx) else { return; }; @@ -7514,7 +7528,7 @@ impl Sidebar { ) -> impl IntoElement { let on_import = cx.listener(|this, _, window, cx| { this.show_archive(window, cx); - this.show_thread_import_modal(window, cx); + this.show_thread_import_modal("external_agent_onboarding", window, cx); }); render_import_onboarding_banner( "acp", @@ -7553,6 +7567,14 @@ impl Sidebar { ); let on_import = cx.listener(|this, _, _window, cx| { + telemetry::event!( + "Agent Threads Import Clicked", + source = "cross_channel_onboarding", + side = match this.side(cx) { + SidebarSide::Left => "left", + SidebarSide::Right => "right", + } + ); CrossChannelImportOnboarding::dismiss(cx); if let Some(workspace) = this.active_workspace(cx) { workspace.update(cx, |workspace, cx| { @@ -7583,11 +7605,6 @@ impl Sidebar { ) { match &self.view { SidebarView::ThreadList => { - let side = match self.side(cx) { - SidebarSide::Left => "left", - SidebarSide::Right => "right", - }; - telemetry::event!("Thread History Viewed", side = side); self.show_archive(window, cx); } SidebarView::Archive(_) => self.show_thread_list(window, cx), @@ -7595,6 +7612,12 @@ impl Sidebar { } fn show_archive(&mut self, window: &mut Window, cx: &mut Context) { + let side = match self.side(cx) { + SidebarSide::Left => "left", + SidebarSide::Right => "right", + }; + telemetry::event!("Thread History Viewed", side = side); + let Some(active_workspace) = self .multi_workspace .upgrade() @@ -7639,7 +7662,7 @@ impl Sidebar { this.restoring_tasks.remove(thread_id); } ThreadsArchiveViewEvent::Import => { - this.show_thread_import_modal(window, cx); + this.show_thread_import_modal("thread_history", window, cx); } }, ); From 9053e7dd1cd9da8a3a170cbfe8680bd0f137f5ad Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 10 Jun 2026 15:27:15 -0400 Subject: [PATCH 20/38] Fix not being able to scroll sidebar when the mouse is hovering a header (#59054) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed a bug that prevented scrolling the sidebar when the mouse was positioned over a project header. --- crates/sidebar/src/sidebar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index b3fa58a0ee3471..4f5f3d8d307b2f 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -2442,7 +2442,7 @@ impl Sidebar { } }), ) - .occlude(); + .block_mouse_except_scroll(); if !is_collapsed && !has_threads { v_flex() From 20a3f7705f18a9913571d4fcdee687b76abdb213 Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Wed, 10 Jun 2026 12:35:36 -0700 Subject: [PATCH 21/38] Add supports_disabling_thinking to Cloud model listings (#58980) Claude Fable 5 always thinks and cannot honor a request with thinking disabled, but the cloud models listing gives clients no way to tell it apart from models where thinking is optional (e.g. Claude Opus 4.6): both report `supports_thinking: true` plus the same adaptive effort levels. As a result, the agent panel shows a thinking toggle for Fable even though turning it off isn't actually supported. zed-industries/cloud#2789 adds a `supports_disabling_thinking` field to the models listing. This PR mirrors it through `cloud_llm_client::LanguageModel` (serde-defaulted to `false`, so a server without the field is treated as "don't claim thinking can be turned off") and exposes it as `LanguageModel::supports_disabling_thinking()`, forwarded from the listing by `CloudLanguageModel`. The agent panel now hides the thinking toggle for models that report `false`, showing only the effort selector. The trait default is `true`: every non-cloud provider in the tree treats thinking as toggleable today, and only the cloud listing knows about always-thinking models. Draft until zed-industries/cloud#2789 lands and deploys. Release Notes: - Fixed the agent panel offering a thinking toggle for models that cannot run with thinking disabled. --- crates/agent/src/tests/mod.rs | 26 +++++++++++++ crates/agent/src/thread.rs | 5 ++- .../src/conversation_view/thread_view.rs | 37 ++++++++++++++++++- .../cloud_llm_client/src/cloud_llm_client.rs | 6 +++ crates/language_model/src/fake_provider.rs | 10 +++++ crates/language_model/src/language_model.rs | 7 ++++ crates/language_models/src/provider/cloud.rs | 1 + .../src/language_models_cloud.rs | 4 ++ 8 files changed, 93 insertions(+), 3 deletions(-) diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index beb1997309b49d..b75485420eb4ef 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -481,6 +481,32 @@ async fn test_thinking(cx: &mut TestAppContext) { assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); } +#[gpui::test] +async fn test_thinking_allowed_when_model_cannot_disable_thinking(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + fake_model.set_supports_thinking(true); + + // With thinking toggled off, a model that can disable thinking honors + // the toggle... + thread.update(cx, |thread, cx| { + thread.set_thinking_enabled(false, cx); + let request = thread + .build_completion_request(CompletionIntent::UserPrompt, cx) + .unwrap(); + assert!(!request.thinking_allowed); + }); + + // ...but a model that always thinks ignores the stale toggle state. + fake_model.set_supports_disabling_thinking(false); + thread.update(cx, |thread, cx| { + let request = thread + .build_completion_request(CompletionIntent::UserPrompt, cx) + .unwrap(); + assert!(request.thinking_allowed); + }); +} + #[gpui::test] async fn test_system_prompt(cx: &mut TestAppContext) { let ThreadTest { diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index c34df08273c2f2..19c5475c170b1e 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -3678,7 +3678,10 @@ impl Thread { tool_choice: None, stop: Vec::new(), temperature: AgentSettings::temperature_for_model(model, cx), - thinking_allowed: self.thinking_enabled, + // Models that can't run with thinking disabled ignore the + // toggle state, which may be stale from a previously selected + // model that could. + thinking_allowed: self.thinking_enabled || !model.supports_disabling_thinking(), thinking_effort: self.thinking_effort.clone(), speed: self.speed(), }; diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 825c70b780fea1..e92cb7dd051c89 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -4651,6 +4651,24 @@ impl ThreadView { return None; } + // A toggle would be dishonest for models that always think: only + // offer the effort selector. + if !model.supports_disabling_thinking() { + let effort_levels = model.supported_effort_levels(); + if effort_levels.is_empty() { + return None; + } + return Some( + self.render_effort_selector( + effort_levels, + thread.thinking_effort().cloned(), + true, + cx, + ) + .into_any_element(), + ); + } + let thinking = thread.thinking_enabled(); let (tooltip_label, icon, color) = if thinking { @@ -4715,6 +4733,7 @@ impl ThreadView { let right_btn = self.render_effort_selector( model.supported_effort_levels(), thread.thinking_effort().cloned(), + false, cx, ); @@ -4729,6 +4748,7 @@ impl ThreadView { &self, supported_effort_levels: Vec, selected_effort: Option, + standalone: bool, cx: &Context, ) -> impl IntoElement { let weak_self = cx.weak_entity(); @@ -4794,9 +4814,17 @@ impl ThreadView { } }); + // When rendered as the right half of the split button next to the + // thinking toggle, only the right corners are rounded. + let trigger = if standalone { + ButtonLike::new("effort-selector-trigger") + } else { + ButtonLike::new_rounded_right("effort-selector-trigger") + }; + PopoverMenu::new("effort-selector") .trigger_with_tooltip( - ButtonLike::new_rounded_right("effort-selector-trigger") + trigger .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .child(Label::new(label).size(LabelSize::Small).color(label_color)) .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)), @@ -10532,7 +10560,12 @@ impl Render for ThreadView { } if let Some(thread) = this.as_native_thread(cx) { thread.update(cx, |thread, cx| { - thread.set_thinking_enabled(!thread.thinking_enabled(), cx); + let model_allows_disabling = thread + .model() + .is_none_or(|model| model.supports_disabling_thinking()); + if model_allows_disabling { + thread.set_thinking_enabled(!thread.thinking_enabled(), cx); + } }); } })) diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index 5796fb7c1b0425..eb1a1194d80285 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -298,6 +298,12 @@ pub struct LanguageModel { pub supports_tools: bool, pub supports_images: bool, pub supports_thinking: bool, + /// Whether thinking can be turned off entirely for this model, allowing + /// clients to offer an "off" choice alongside `supported_effort_levels`. + /// Some models (e.g. Claude Fable 5) always think and cannot honor an + /// "off" request. Only meaningful when `supports_thinking` is `true`. + #[serde(default)] + pub supports_disabling_thinking: bool, #[serde(default)] pub supports_fast_mode: bool, pub supported_effort_levels: Vec, diff --git a/crates/language_model/src/fake_provider.rs b/crates/language_model/src/fake_provider.rs index e9f4fba4430069..7bb4f8100d3506 100644 --- a/crates/language_model/src/fake_provider.rs +++ b/crates/language_model/src/fake_provider.rs @@ -124,6 +124,7 @@ pub struct FakeLanguageModel { >, forbid_requests: AtomicBool, supports_thinking: AtomicBool, + supports_disabling_thinking: AtomicBool, supports_streaming_tools: AtomicBool, supports_images: AtomicBool, max_token_count: AtomicU64, @@ -140,6 +141,7 @@ impl Default for FakeLanguageModel { current_completion_txs: Mutex::new(Vec::new()), forbid_requests: AtomicBool::new(false), supports_thinking: AtomicBool::new(false), + supports_disabling_thinking: AtomicBool::new(true), supports_streaming_tools: AtomicBool::new(false), supports_images: AtomicBool::new(false), max_token_count: AtomicU64::new(1_000_000), @@ -176,6 +178,10 @@ impl FakeLanguageModel { self.supports_thinking.store(supports, SeqCst); } + pub fn set_supports_disabling_thinking(&self, supports: bool) { + self.supports_disabling_thinking.store(supports, SeqCst); + } + pub fn set_supports_streaming_tools(&self, supports: bool) { self.supports_streaming_tools.store(supports, SeqCst); } @@ -306,6 +312,10 @@ impl LanguageModel for FakeLanguageModel { self.supports_thinking.load(SeqCst) } + fn supports_disabling_thinking(&self) -> bool { + self.supports_disabling_thinking.load(SeqCst) + } + fn supports_streaming_tools(&self) -> bool { self.supports_streaming_tools.load(SeqCst) } diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 1eb2ec5b680f13..fc91c00bdeedfb 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -86,6 +86,13 @@ pub trait LanguageModel: Send + Sync { false } + /// Whether thinking can be turned off entirely for this model. Some + /// models (e.g. Claude Fable 5) always think and cannot honor an "off" + /// request. Only meaningful when `supports_thinking` returns `true`. + fn supports_disabling_thinking(&self) -> bool { + true + } + fn supports_fast_mode(&self) -> bool { false } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 92c210623676a0..47fcdaf3c4c7c3 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -764,6 +764,7 @@ mod tests { supports_tools: true, supports_images: false, supports_thinking: false, + supports_disabling_thinking: false, supports_fast_mode: false, supported_effort_levels: Vec::new(), supports_streaming_tools: false, diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 6fdd11caf571f2..d4598b89e7000c 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -354,6 +354,10 @@ impl LanguageModel for CloudLanguageModel bool { + self.model.supports_disabling_thinking + } + fn supports_fast_mode(&self) -> bool { self.model.supports_fast_mode } From 53f1ae01a4480f07a70ac49182ab00134ef4a1da Mon Sep 17 00:00:00 2001 From: allison <28279548+transitoryangel@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:12:21 -0500 Subject: [PATCH 22/38] docs: Remove Alpine, clean up glibc guidance, minor wording fixes (#56674) Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: Martin Ye --- docs/src/linux.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/src/linux.md b/docs/src/linux.md index ed441271d021e4..410e8f153f029b 100644 --- a/docs/src/linux.md +++ b/docs/src/linux.md @@ -21,12 +21,12 @@ curl -f https://zed.dev/install.sh | ZED_CHANNEL=preview sh The Zed installed by the script works best on systems that: -- have a Vulkan compatible GPU available (for example Linux on an M-series macBook) -- have a system-wide glibc (NixOS and Alpine do not by default) +- have a Vulkan compatible GPU available (for example Linux on an M-series MacBook) +- have a system-wide glibc - x86_64 (Intel/AMD): glibc version >= 2.31 (Ubuntu 20 and newer) - aarch64 (ARM): glibc version >= 2.35 (Ubuntu 22 and newer) -Both Nix and Alpine have third-party Zed packages available (though they are currently a few weeks out of date). If you'd like to use our builds they do work if you install a glibc compatibility layer. On NixOS you can try [nix-ld](https://github.com/Mic92/nix-ld), and on Alpine [gcompat](https://wiki.alpinelinux.org/wiki/Running_glibc_programs). +NixOS does not have a system-wide glibc by default. If you'd like to use our builds on NixOS, they may work if you install a glibc compatibility layer such as [nix-ld](https://github.com/Mic92/nix-ld). You will need to build from source for: @@ -40,11 +40,10 @@ Zed is open source, and [you can install from source](./development/linux.md). ### Installing via a package manager -There are several third-party Zed packages for various Linux distributions and package managers, sometimes under `zed-editor`. You may be able to install Zed using these packages: +There are several third-party Zed packages for various Linux distributions and package managers, sometimes under `zed-editor`. Availability varies by distribution, but you may be able to install Zed using one of these packages: - Arch: [`zed`](https://archlinux.org/packages/extra/x86_64/zed/) - Arch (AUR): [`zed-git`](https://aur.archlinux.org/packages/zed-git), [`zed-preview`](https://aur.archlinux.org/packages/zed-preview), [`zed-preview-bin`](https://aur.archlinux.org/packages/zed-preview-bin) -- Alpine: `zed` ([aarch64](https://pkgs.alpinelinux.org/package/edge/testing/aarch64/zed)) ([x86_64](https://pkgs.alpinelinux.org/package/edge/testing/x86_64/zed)) - Fedora/Ultramarine (Terra): [`zed`](https://github.com/terrapkg/packages/tree/frawhide/anda/devs/zed/stable), [`zed-preview`](https://github.com/terrapkg/packages/tree/frawhide/anda/devs/zed/preview), [`zed-nightly`](https://github.com/terrapkg/packages/tree/frawhide/anda/devs/zed/nightly) - Manjaro: [`zed`](https://manjaristas.org/branch_compare?q=zed) - Conda: [`zed`](https://anaconda.org/conda-forge/zed) @@ -55,7 +54,7 @@ There are several third-party Zed packages for various Linux distributions and p - AOSC OS: [`zed`](https://packages.aosc.io/packages/zed) - Flathub: [`dev.zed.Zed`](https://flathub.org/apps/dev.zed.Zed) -See [Repology](https://repology.org/project/zed-editor/versions) for a list of Zed packages in various repositories. +See [Repology](https://repology.org/project/zed-editor/versions) for a current list of Zed packages in various repositories. ### Community From 501ab50f9b03f1c3a13df11ade804bbdf11146ff Mon Sep 17 00:00:00 2001 From: Ted Robertson <10043369+tredondo@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:16:38 -0700 Subject: [PATCH 23/38] zed: Hide log file actions when logging to stdout (#57114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When running `zed` [directly from a foreground terminal](https://github.com/zed-industries/zed/issues/51351#issuecomment-4507962670) (stable `zed-editor`, or zed dev builds via `cargo run` as in #51351), Zed only enables the stdout log sink and never creates `Zed.log` on disk. This causes `zed: open log` and `zed: reveal log in file manager` to always failed with `Unable to access/open log file ...: Failed to read file... No such file or directory (os error 2)`. Per maintainer feedback, rather than always creating the log file, this change only registers the `OpenLog` and `RevealLogInFileManager` action handlers when stdout isn't a PTY. In the PTY case the two actions no longer appear in the command palette at all, so they can't be invoked only to error. Repro (before this change): 1. `rm -rf ~/.local/share/zed/logs/` 2. `~/.local/zed.app/libexec/zed-editor` (the GUI binary directly, bypassing the CLI wrapper which detaches and sets `ZED_FORCE_CLI_MODE`) 3. In Zed, run `zed: open log` → error toast Release Notes: - Fixed `zed: open log` and `zed: reveal log in file manager` appearing and erroring when Zed was launched directly from a terminal. These actions are now hidden in that scenario, since logs go to stdout rather than Zed's log file. --------- Co-authored-by: dino --- crates/zed/src/zed.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 9f6c5cd2171a3c..37521ae7045c7c 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -197,15 +197,22 @@ pub fn init(cx: &mut App) { } }) .detach(); - cx.on_action(|_: &OpenLog, cx| { - with_active_or_new_workspace(cx, |workspace, window, cx| { - open_log_file(workspace, window, cx); + + // When Zed logs to stdout rather than the log file, avoid registering + // handlers for both `OpenLog` and `RevealLogInFileManager`, as the log file + // does not exist in that scenario and these actions would error. + if !crate::stdout_is_a_pty() { + cx.on_action(|_: &OpenLog, cx| { + with_active_or_new_workspace(cx, |workspace, window, cx| { + open_log_file(workspace, window, cx); + }); + }) + .on_action(|_: &workspace::RevealLogInFileManager, cx| { + cx.reveal_path(paths::log_file().as_path()); }); - }) - .on_action(|_: &workspace::RevealLogInFileManager, cx| { - cx.reveal_path(paths::log_file().as_path()); - }) - .on_action(|_: &zed_actions::OpenLicenses, cx| { + } + + cx.on_action(|_: &zed_actions::OpenLicenses, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_bundled_file( workspace, From c78bd36fd8bf3204a487f95d214421ebcc0695d5 Mon Sep 17 00:00:00 2001 From: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:54:28 -0700 Subject: [PATCH 24/38] agent_ui: Keep pending subagent edits when regenerating a prompt (#59060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the agent panel silently reverting all pending subagent file changes when an earlier message is edited and regenerated. `ThreadView::regenerate` decides whether to auto-keep pending edits from earlier prompts (behavior introduced in #43347) by scanning the parent thread's entries for diffs. Subagent edits never appear there — they are only forwarded to the parent's action log via the linked-log mechanism — so the auto-keep step was skipped and `rewind`'s unscoped `reject_all_edits` reverted all of them on disk, without confirmation. The fix treats any earlier subagent tool call as potentially having edits, so they get auto-kept just like direct edits from earlier prompts. Keeping all edits is a no-op when the subagent made none. Edits produced by the prompt being regenerated are still auto-rejected, consistent with existing behavior. Also adds a regression test (`test_regenerate_keeps_pending_subagent_edits`) that reproduces the full flow — subagent edit forwarded through a linked action log, follow-up prompt, regenerate — and fails with the exact reported data loss without the fix. Closes AI-386 Closes https://github.com/zed-industries/zed/issues/58932 Release Notes: - Fixed pending subagent file changes being discarded when editing an earlier message in the agent panel. --- crates/agent_ui/src/conversation_view.rs | 172 ++++++++++++++++++ .../src/conversation_view/thread_view.rs | 17 +- 2 files changed, 184 insertions(+), 5 deletions(-) diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 95f8b7d80970d9..bc748f944d1a74 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -5829,6 +5829,178 @@ pub(crate) mod tests { }); } + #[gpui::test] + async fn test_regenerate_keeps_pending_subagent_edits(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + "file.txt": "original content" + }), + ) + .await; + let project = Project::test(fs, [Path::new("/project")], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); + + let connection = Rc::new(StubAgentConnection::new()); + let conversation_view = cx.update(|window, cx| { + cx.new(|cx| { + ConversationView::new( + Rc::new(StubAgentServer::new(connection.as_ref().clone())), + connection_store, + Agent::Custom { id: "Test".into() }, + None, + None, + None, + None, + None, + workspace.downgrade(), + project.clone(), + Some(thread_store.clone()), + AgentThreadSource::AgentPanel, + window, + cx, + ) + }) + }); + + cx.run_until_parked(); + + let thread = conversation_view + .read_with(cx, |view, cx| { + view.active_thread().map(|r| r.read(cx).thread.clone()) + }) + .unwrap(); + + // First turn: a subagent tool call. Subagent edits never appear as + // diffs in the parent thread's entries; they are only forwarded to the + // parent's action log through the linked-log mechanism. + connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall( + acp::ToolCall::new("spawn1", "Subagent task") + .kind(acp::ToolKind::Other) + .status(acp::ToolCallStatus::Completed) + .meta(acp_thread::meta_with_tool_name("spawn_agent")), + )]); + + thread + .update(cx, |thread, cx| thread.send_raw("Use a subagent", cx)) + .await + .unwrap(); + cx.run_until_parked(); + + // Simulate the subagent editing a file: edits performed through a + // child action log are forwarded to the parent thread's action log, + // just like `Thread::new_subagent` wires it up. + let parent_action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + let subagent_action_log = cx.update(|_, cx| { + cx.new(|_| { + ActionLog::new(project.clone()).with_linked_action_log(parent_action_log.clone()) + }) + }); + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path("file.txt", cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + cx.update(|_, cx| { + subagent_action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); + buffer.update(cx, |buffer, cx| { + buffer.set_text("edited by subagent", cx); + }); + subagent_action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + cx.run_until_parked(); + + parent_action_log.read_with(cx, |log, cx| { + assert_eq!( + log.changed_buffers(cx).count(), + 1, + "the subagent edit should be pending review in the parent's action log" + ); + }); + + // Second turn: a plain follow-up. + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response".into()), + )]); + thread + .update(cx, |thread, cx| thread.send_raw("Follow-up", cx)) + .await + .unwrap(); + cx.run_until_parked(); + + let follow_up_ix = thread.read_with(cx, |thread, cx| { + thread + .entries() + .iter() + .position(|entry| entry.to_markdown(cx) == "## User\n\nFollow-up\n\n") + .unwrap() + }); + + // Edit and regenerate the follow-up message. + let user_message_editor = conversation_view.read_with(cx, |view, cx| { + view.active_thread() + .unwrap() + .read(cx) + .entry_view_state + .read(cx) + .entry(follow_up_ix) + .unwrap() + .message_editor() + .unwrap() + .clone() + }); + user_message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Edited follow-up", window, cx); + }); + + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("New response".into()), + )]); + active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| { + view.regenerate(follow_up_ix, user_message_editor.clone(), window, cx); + }); + cx.run_until_parked(); + + // The thread should have been rewound and the edited message resent. + thread.read_with(cx, |thread, cx| { + let entries = thread.entries(); + assert_eq!(entries.len(), 4); + assert_eq!( + entries[2].to_markdown(cx), + "## User\n\nEdited follow-up\n\n" + ); + }); + + // The subagent's edits predate the regenerated prompt, so they must be + // auto-kept rather than rejected by the rewind. + buffer.read_with(cx, |buffer, _| { + assert_eq!( + buffer.text(), + "edited by subagent", + "pending subagent edits should be kept when regenerating a later prompt" + ); + }); + parent_action_log.read_with(cx, |log, cx| { + assert_eq!( + log.changed_buffers(cx).count(), + 0, + "the subagent edit should have been auto-kept" + ); + }); + } + #[gpui::test] async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index e92cb7dd051c89..6180ad43f7d80d 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1942,12 +1942,19 @@ impl ThreadView { // // If editing the prompt that generated the edits, they are auto-rejected // through the `rewind` function in the `acp_thread`. + // + // Subagent edits never show up as diffs in the parent thread's entries (they + // are only forwarded to the parent's action log), so treat any earlier + // subagent tool call as potentially having edits. Keeping all edits is a + // no-op when the subagent didn't make any. let has_earlier_edits = thread.read_with(cx, |thread, _| { - thread - .entries() - .iter() - .take(entry_ix) - .any(|entry| entry.diffs().next().is_some()) + thread.entries().iter().take(entry_ix).any(|entry| { + entry.diffs().next().is_some() + || matches!( + entry, + AgentThreadEntry::ToolCall(tool_call) if tool_call.is_subagent() + ) + }) }); if has_earlier_edits { From 620ceaaaca40b346736660f12eefce38e235cb59 Mon Sep 17 00:00:00 2001 From: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:22:09 -0700 Subject: [PATCH 25/38] agent: Flush thread content to database on app quit (#58962) After prompting an agent, no response was received and only a spinner was shown. After updating and reopening Zed, the thread still appeared in the sidebar but failed to restore with `Failed to Launch` / `no thread found with ID: SessionId(...)`, and the original prompt was lost. ## Root cause A native agent thread is tracked by two independent persistence layers: - **Sidebar metadata** (`ThreadMetadataStore`) and the **serialized agent panel** record the thread's `session_id` through fast, separate write paths. - **Thread content** (`ThreadsDatabase`) is written by a per-session async task (`NativeAgent::save_thread`) that can still be in flight when the process exits. When a graceful quit or update restart raced that async content save, the metadata/serialized session id was persisted but the content row was not. On restore, the metadata gate passes, then `load_thread` finds no content row and hard-fails with `no thread found with ID`. ## Fix Register an `on_app_quit` handler on `NativeAgent` that synchronously commits every newly created non-empty thread's content to `ThreadsDatabase` during shutdown, keeping the two stores consistent across a graceful quit/restart (the auto-updater's path). Closes AI-374 Release Notes: - Fixed agent threads failing to restore after quitting or updating Zed while a response was still in progress --- crates/agent/src/agent.rs | 107 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 4 deletions(-) diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 148e60b2bf87b0..dff6744cdfc2db 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -553,10 +553,15 @@ impl NativeAgent { log::debug!("Creating new NativeAgent"); cx.new(|cx| { - let subscriptions = vec![cx.subscribe( - &LanguageModelRegistry::global(cx), - Self::handle_models_updated_event, - )]; + let subscriptions = vec![ + cx.subscribe( + &LanguageModelRegistry::global(cx), + Self::handle_models_updated_event, + ), + // Flush thread content on quit so an in-flight async save + // can't leave a thread orphaned ("no thread found with ID"). + cx.on_app_quit(Self::flush_threads_on_quit), + ]; if !cx.has_global::() { cx.set_global(SkillIndex::default()); @@ -1765,6 +1770,48 @@ impl NativeAgent { }); } + /// Commits every non-empty thread's content on shutdown so the async + /// `save_thread` losing the race can't leave metadata without content. + fn flush_threads_on_quit( + &mut self, + cx: &mut Context, + ) -> impl Future + use<> { + let database_future = ThreadsDatabase::connect(cx); + + let mut saves = Vec::new(); + for session in self.sessions.values() { + let thread = session.thread.read(cx); + if thread.is_empty() { + continue; + } + let Some(state) = self.projects.get(&session.project_id) else { + continue; + }; + let folder_paths = PathList::new( + &state + .project + .read(cx) + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + .collect::>(), + ); + saves.push((thread.id().clone(), folder_paths, thread.to_db(cx))); + } + + async move { + let Ok(database) = database_future.await else { + return; + }; + for (id, folder_paths, db_thread) in saves { + let db_thread = db_thread.await; + database + .save_thread(id, db_thread, folder_paths) + .await + .log_err(); + } + } + } + fn send_mcp_prompt( &self, message_id: UserMessageId, @@ -3749,6 +3796,58 @@ mod internal_tests { prompt_task.await.unwrap(); } + #[gpui::test] + async fn test_threads_flushed_to_database_on_app_quit(cx: &mut TestAppContext) { + init_test(cx); + + let (_connection, agent, project, acp_thread) = setup_native_agent_session(cx).await; + let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); + let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx)); + + // Give the thread content so it's no longer an empty draft. + cx.update(|cx| { + let path_style = project.read(cx).path_style(cx); + thread.update(cx, |thread, cx| { + thread.push_acp_user_block( + UserMessageId::new(), + [acp::ContentBlock::from("hello from the user")], + path_style, + cx, + ); + }); + }); + cx.run_until_parked(); + + // Reproduce the orphaned state from the bug: the sidebar metadata and + // serialized panel still reference the session, but the per-session + // async content save never landed, so the content row is absent. + let database = cx.update(|cx| ThreadsDatabase::connect(cx)).await.unwrap(); + database.delete_thread(session_id.clone()).await.unwrap(); + assert!( + database + .load_thread(session_id.clone()) + .await + .unwrap() + .is_none(), + "precondition: content row should be missing before the quit flush" + ); + + // Quitting must re-commit the content so the thread can be restored. + let flush = cx.update(|cx| agent.update(cx, |agent, cx| agent.flush_threads_on_quit(cx))); + flush.await; + + let restored = database + .load_thread(session_id.clone()) + .await + .unwrap() + .expect("thread content should be persisted to the database on quit"); + assert_eq!( + restored.messages.len(), + 1, + "the user message should survive the quit flush" + ); + } + #[test] fn test_ambiguous_mcp_prompt_names() { // Reserving the built-in `/compact` forces a same-named MCP prompt to be From 511d1974775d7ce102b799f623ee0b33ed26300f Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Thu, 11 Jun 2026 01:51:59 +0200 Subject: [PATCH 26/38] Enforce adding a message to extension CLI bumps (#58786) This slightly reworks the extension CLI bump workflow - instead of triggering on label push, it now triggers on workflow dispatch with a message enforced to be added there. This primarily allows us to add a message to these bumps to better communicate what changes with that version of the CLI. Furthermore, we can soon restrict the label to be only created by that workflow, which has the advantage that it can only be based off of main. Also, it has the nice side-effect that we actually only ever update the label if everything worked properly. Release Notes: - N/A --- .github/workflows/publish_extension_cli.yml | 38 +++++-- script/bump-extension-cli | 35 +++++- .../tasks/workflows/publish_extension_cli.rs | 102 +++++++++++++----- 3 files changed, 141 insertions(+), 34 deletions(-) diff --git a/.github/workflows/publish_extension_cli.yml b/.github/workflows/publish_extension_cli.yml index 397e8f0731b2d7..b2d8e96fcea1b5 100644 --- a/.github/workflows/publish_extension_cli.yml +++ b/.github/workflows/publish_extension_cli.yml @@ -5,12 +5,15 @@ env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: '0' on: - push: - tags: - - extension-cli + workflow_dispatch: + inputs: + message: + description: Describe why the extension CLI is being bumped and/or what changes are included. + required: true + type: string jobs: publish_job: - if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main' runs-on: namespace-profile-16x32-ubuntu-2204 steps: - name: steps::checkout_repo @@ -31,10 +34,29 @@ jobs: env: DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} + - id: generate-token + name: steps::authenticate_as_zippy + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 + with: + app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} + private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + permission-contents: write + - name: steps::update_tag + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b + with: + script: | + github.rest.git.updateRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'tags/extension-cli', + sha: context.sha, + force: true + }) + github-token: ${{ steps.generate-token.outputs.token }} update_sha_in_zed: needs: - publish_job - if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main' runs-on: namespace-profile-8x16-ubuntu-2204 steps: - id: generate-token @@ -69,6 +91,8 @@ jobs: body: | This PR bumps the extension CLI version used in the extension workflows to `${{ github.sha }}`. + ${{ inputs.message }} + Release Notes: - N/A @@ -84,7 +108,7 @@ jobs: update_sha_in_extensions: needs: - publish_job - if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main' runs-on: namespace-profile-2x4-ubuntu-2404 steps: - id: generate-token @@ -114,6 +138,8 @@ jobs: title: Bump extension CLI version to `${{ steps.short-sha.outputs.sha_short }}` body: | This PR bumps the extension CLI version to https://github.com/zed-industries/zed/commit/${{ github.sha }}. + + ${{ inputs.message }} commit-message: Bump extension CLI version to `${{ steps.short-sha.outputs.sha_short }}` branch: update-extension-cli-sha committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> diff --git a/script/bump-extension-cli b/script/bump-extension-cli index ee7ea6f8c418ac..84055c33ee7687 100755 --- a/script/bump-extension-cli +++ b/script/bump-extension-cli @@ -1,7 +1,34 @@ #!/usr/bin/env bash -set -e +set -eu -git pull --ff-only origin main -git tag -f extension-cli -git push -f origin extension-cli +usage() { + echo "Usage: $0 " + echo "" + echo "Triggers the publish_extension_cli workflow on main to build a new" + echo "extension CLI binary, bump the 'extension-cli' tag after a successful" + echo "build, and open PRs that update the SHA used by the zed and" + echo "zed-industries/extensions repositories." + echo "" + echo "Arguments:" + echo " message Describes why the extension CLI is being bumped /" + echo " what the changes include. Included in the PR bodies." + exit 1 +} + +if [[ $# -lt 1 || -z "${1:-}" ]]; then + echo "error: a message describing the bump is required" >&2 + echo "" >&2 + usage >&2 +fi + +which gh > /dev/null 2>&1 || { + echo "error: GitHub CLI (gh) is required but not installed." >&2 + echo "Install it with: brew install gh" >&2 + exit 1 +} + +gh workflow run publish_extension_cli.yml --ref main -f message="$1" + +echo "Workflow triggered. Monitor progress at:" +echo " https://github.com/zed-industries/zed/actions/workflows/publish_extension_cli.yml" diff --git a/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs b/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs index ea1266d03a9170..cbe656e734eb73 100644 --- a/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs +++ b/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs @@ -1,19 +1,30 @@ use gh_workflow::*; -use indoc::indoc; +use indoc::{formatdoc, indoc}; use crate::tasks::workflows::{ runners, - steps::{self, CommonJobConditions, NamedJob, RepositoryTarget, generate_token, named}, - vars::{self, StepOutput}, + steps::{ + self, DEFAULT_REPOSITORY_OWNER_GUARD, GitRef, NamedJob, RefSha, RepositoryTarget, + TokenPermissions, generate_token, named, + }, + vars::{self, StepOutput, WorkflowInput}, }; +const EXTENSION_CLI_TAG: &str = "extension-cli"; + pub fn publish_extension_cli() -> Workflow { + let message = WorkflowInput::string("message", None).description( + "Describe why the extension CLI is being bumped and/or what changes are included.", + ); + let publish = publish_job(); - let update_sha_in_zed = update_sha_in_zed(&publish); - let update_sha_in_extensions = update_sha_in_extensions(&publish); + let update_sha_in_zed = update_sha_in_zed(&publish, &message); + let update_sha_in_extensions = update_sha_in_extensions(&publish, &message); named::workflow() - .on(Event::default().push(Push::default().tags(vec!["extension-cli".to_string()]))) + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default().add_input(message.name, message.input()), + )) .add_env(("CARGO_TERM_COLOR", "always")) .add_env(("CARGO_INCREMENTAL", 0)) .add_job(publish.name, publish.job) @@ -21,6 +32,16 @@ pub fn publish_extension_cli() -> Workflow { .add_job(update_sha_in_extensions.name, update_sha_in_extensions.job) } +// `workflow_dispatch` can be triggered from any branch where this workflow file +// exists, so we additionally guard the jobs to only run when dispatched from +// `main`. Jobs that depend on `publish_job` inherit this guard transitively +// because they are skipped when `publish_job` is skipped. +fn dispatched_from_main_guard() -> Expression { + Expression::new(format!( + "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'" + )) +} + fn publish_job() -> NamedJob { fn build_extension_cli() -> Step { named::bash("cargo build --release --package extension_cli") @@ -38,19 +59,31 @@ fn publish_job() -> NamedJob { )) } + let (authenticate, token) = steps::authenticate_as_zippy() + .for_repository(RepositoryTarget::current()) + .with_permissions([(TokenPermissions::Contents, Level::Write)]) + .into(); + named::job( Job::default() - .with_repository_owner_guard() + .cond(dispatched_from_main_guard()) .runs_on(runners::LINUX_DEFAULT) .add_step(steps::checkout_repo()) .add_step(steps::cache_rust_dependencies_namespace()) .add_step(steps::setup_linux()) .add_step(build_extension_cli()) - .add_step(upload_binary()), + .add_step(upload_binary()) + .add_step(authenticate) + .add_step(steps::update_ref( + GitRef::tag(EXTENSION_CLI_TAG), + RefSha::Context, + &token, + true, + )), ) } -fn update_sha_in_zed(publish_job: &NamedJob) -> NamedJob { +fn update_sha_in_zed(publish_job: &NamedJob, message: &WorkflowInput) -> NamedJob { let (generate_token, generated_token) = generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY).into(); @@ -69,7 +102,7 @@ fn update_sha_in_zed(publish_job: &NamedJob) -> NamedJob { named::job( Job::default() - .with_repository_owner_guard() + .cond(dispatched_from_main_guard()) .needs(vec![publish_job.name.clone()]) .runs_on(runners::LINUX_LARGE) .add_step(generate_token) @@ -78,28 +111,40 @@ fn update_sha_in_zed(publish_job: &NamedJob) -> NamedJob { .add_step(get_short_sha_step) .add_step(replace_sha()) .add_step(regenerate_workflows()) - .add_step(create_pull_request_zed(&generated_token, &short_sha)), + .add_step(create_pull_request_zed( + &generated_token, + &short_sha, + message, + )), ) } -fn create_pull_request_zed(generated_token: &StepOutput, short_sha: &StepOutput) -> Step { +fn create_pull_request_zed( + generated_token: &StepOutput, + short_sha: &StepOutput, + message: &WorkflowInput, +) -> Step { let title = format!( "extension_ci: Bump extension CLI version to `{}`", short_sha ); - steps::CreatePrStep::new(title, "update-extension-cli-sha", generated_token) - .with_body(indoc::indoc! {r#" - This PR bumps the extension CLI version used in the extension workflows to `${{ github.sha }}`. + let body = formatdoc! {r#" + This PR bumps the extension CLI version used in the extension workflows to `${{{{ github.sha }}}}`. - Release Notes: + {message} - - N/A - "#}) + Release Notes: + + - N/A + "#}; + + steps::CreatePrStep::new(title, "update-extension-cli-sha", generated_token) + .with_body(body) .into() } -fn update_sha_in_extensions(publish_job: &NamedJob) -> NamedJob { +fn update_sha_in_extensions(publish_job: &NamedJob, message: &WorkflowInput) -> NamedJob { let extensions_repo = RepositoryTarget::new("zed-industries", &["extensions"]); let (generate_token, generated_token) = generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY) @@ -127,27 +172,36 @@ fn update_sha_in_extensions(publish_job: &NamedJob) -> NamedJob { named::job( Job::default() - .with_repository_owner_guard() + .cond(dispatched_from_main_guard()) .needs(vec![publish_job.name.clone()]) .runs_on(runners::LINUX_SMALL) .add_step(generate_token) .add_step(get_short_sha_step) .add_step(checkout_extensions_repo(&generated_token)) .add_step(replace_sha()) - .add_step(create_pull_request_extensions(&generated_token, &short_sha)), + .add_step(create_pull_request_extensions( + &generated_token, + &short_sha, + message, + )), ) } fn create_pull_request_extensions( generated_token: &StepOutput, short_sha: &StepOutput, + message: &WorkflowInput, ) -> Step { let title = format!("Bump extension CLI version to `{}`", short_sha); + let body = formatdoc! {r#" + This PR bumps the extension CLI version to https://github.com/zed-industries/zed/commit/${{{{ github.sha }}}}. + + {message} + "#}; + steps::CreatePrStep::new(title, "update-extension-cli-sha", generated_token) - .with_body(indoc::indoc! {r#" - This PR bumps the extension CLI version to https://github.com/zed-industries/zed/commit/${{ github.sha }}. - "#}) + .with_body(body) .with_labels("allow-no-extension") .into() } From cafbf4b5df7fedb67fc0f248850a5654efcec5d9 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 10 Jun 2026 19:52:00 -0400 Subject: [PATCH 27/38] Improve `didChangeWatchedFiles` handler performance (#59078) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: John Tur Co-authored-by: Ben Kunkle --- crates/project/src/lsp_store.rs | 445 +++++++++--------- .../tests/integration/project_tests.rs | 164 +++++++ crates/worktree/src/worktree.rs | 6 + 3 files changed, 401 insertions(+), 214 deletions(-) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index bb9bdd7e843986..454c4d18d87ba1 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -289,7 +289,7 @@ pub struct DocumentDiagnostics { #[derive(Default, Debug)] struct DynamicRegistrations { - did_change_watched_files: HashMap>, + did_change_watched_files: HashSet, diagnostics: HashMap, DiagnosticServerCapabilities>, } @@ -3678,127 +3678,119 @@ impl LocalLspStore { servers_to_remove.into_iter().collect() } - fn rebuild_watched_paths_inner<'a>( - &'a self, + fn register_watcher( + &mut self, + worktrees: &[Entity], + watcher: &FileSystemWatcher, + registration_id: &str, language_server_id: LanguageServerId, - watchers: impl Iterator, cx: &mut Context, - ) -> LanguageServerWatchedPathsBuilder { - let worktrees = self - .worktree_store - .read(cx) - .worktrees() - .filter_map(|worktree| { - self.language_servers_for_worktree(worktree.read(cx).id()) - .find(|server| server.server_id() == language_server_id) - .map(|_| worktree) - }) - .collect::>(); - - let mut worktree_globs = HashMap::default(); - let mut abs_globs = HashMap::default(); - log::trace!( - "Processing new watcher paths for language server with id {}", - language_server_id - ); - - for watcher in watchers { - if let Some((worktree, literal_prefix, pattern)) = - Self::worktree_and_path_for_file_watcher(&worktrees, watcher, cx) - { - worktree.update(cx, |worktree, _| { - if let Some((tree, glob)) = - worktree.as_local_mut().zip(Glob::new(&pattern).log_err()) - { - tree.add_path_prefix_to_scan(literal_prefix); - worktree_globs - .entry(tree.id()) - .or_insert_with(GlobSetBuilder::new) - .add(glob); - } - }); - } else { - let (path, pattern) = match &watcher.glob_pattern { - lsp::GlobPattern::String(s) => { - let watcher_path = SanitizedPath::new(s); - let path = glob_literal_prefix(watcher_path.as_path()); - let pattern = watcher_path - .as_path() - .strip_prefix(&path) - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_else(|e| { - debug_panic!( - "Failed to strip prefix for string pattern: {}, with prefix: {}, with error: {}", - s, - path.display(), - e - ); - watcher_path.as_path().to_string_lossy().into_owned() - }); - (path, pattern) - } - lsp::GlobPattern::Relative(rp) => { - let Ok(mut base_uri) = match &rp.base_uri { - lsp::OneOf::Left(workspace_folder) => &workspace_folder.uri, - lsp::OneOf::Right(base_uri) => base_uri, - } - .to_file_path() else { - continue; - }; - - let path = glob_literal_prefix(Path::new(&rp.pattern)); - let pattern = Path::new(&rp.pattern) - .strip_prefix(&path) - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_else(|e| { - debug_panic!( - "Failed to strip prefix for relative pattern: {}, with prefix: {}, with error: {}", - rp.pattern, - path.display(), - e - ); - rp.pattern.clone() - }); - base_uri.push(path); - (base_uri, pattern) - } - }; + ) { + let watched = self + .language_server_watched_paths + .entry(language_server_id) + .or_default(); + if let Some((worktree, literal_prefix, pattern)) = + Self::worktree_and_path_for_file_watcher(worktrees, watcher, cx) + { + if worktree.read(cx).as_local().is_some() { if let Some(glob) = Glob::new(&pattern).log_err() { - if !path - .components() - .any(|c| matches!(c, path::Component::Normal(_))) - { - // For an unrooted glob like `**/Cargo.toml`, watch it within each worktree, - // rather than adding a new watcher for `/`. - for worktree in &worktrees { - worktree_globs - .entry(worktree.read(cx).id()) - .or_insert_with(GlobSetBuilder::new) - .add(glob.clone()); + let worktree_id = worktree.read(cx).id(); + watched + .worktree_paths + .entry(worktree_id) + .or_default() + .add(registration_id, glob); + worktree.update(cx, |worktree, _| { + if let Some(tree) = worktree.as_local_mut() { + tree.add_path_prefix_to_scan(literal_prefix); } - } else { - abs_globs - .entry(path.into()) - .or_insert_with(GlobSetBuilder::new) - .add(glob); - } + }); } } + + return; } - let mut watch_builder = LanguageServerWatchedPathsBuilder::default(); - for (worktree_id, builder) in worktree_globs { - if let Ok(globset) = builder.build() { - watch_builder.watch_worktree(worktree_id, globset); + let (path, pattern) = match &watcher.glob_pattern { + lsp::GlobPattern::String(s) => { + let watcher_path = SanitizedPath::new(s); + let path = glob_literal_prefix(watcher_path.as_path()); + let pattern = watcher_path + .as_path() + .strip_prefix(&path) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|e| { + debug_panic!( + "Failed to strip prefix for string pattern: {}, with prefix: {}, with error: {}", + s, + path.display(), + e + ); + watcher_path.as_path().to_string_lossy().into_owned() + }); + (path, pattern) } - } - for (abs_path, builder) in abs_globs { - if let Ok(globset) = builder.build() { - watch_builder.watch_abs_path(abs_path, globset); + lsp::GlobPattern::Relative(rp) => { + let Ok(mut base_uri) = match &rp.base_uri { + lsp::OneOf::Left(workspace_folder) => &workspace_folder.uri, + lsp::OneOf::Right(base_uri) => base_uri, + } + .to_file_path() else { + return; + }; + + let path = glob_literal_prefix(Path::new(&rp.pattern)); + let pattern = Path::new(&rp.pattern) + .strip_prefix(&path) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|e| { + debug_panic!( + "Failed to strip prefix for relative pattern: {}, with prefix: {}, with error: {}", + rp.pattern, + path.display(), + e + ); + rp.pattern.clone() + }); + base_uri.push(path); + (base_uri, pattern) + } + }; + + if let Some(glob) = Glob::new(&pattern).log_err() { + if !path + .components() + .any(|c| matches!(c, path::Component::Normal(_))) + { + // For an unrooted glob like `**/Cargo.toml`, watch it within each worktree, + // rather than adding a new watcher for `/`. + for worktree in worktrees { + watched + .worktree_paths + .entry(worktree.read(cx).id()) + .or_default() + .add(registration_id, glob.clone()); + } + } else { + let abs_path: Arc = path.into(); + let fs = self.fs.clone(); + let entry = watched + .abs_paths + .entry(abs_path.clone()) + .or_insert_with(|| { + let task = LanguageServerWatchedPaths::spawn_abs_path_watcher( + abs_path, + fs, + language_server_id, + cx, + ); + (LazyGlobSet::default(), task) + }); + entry.0.add(registration_id, glob); } } - watch_builder } fn worktree_and_path_for_file_watcher( @@ -3844,30 +3836,6 @@ impl LocalLspStore { }) } - fn rebuild_watched_paths( - &mut self, - language_server_id: LanguageServerId, - cx: &mut Context, - ) { - let Some(registrations) = self - .language_server_dynamic_registrations - .get(&language_server_id) - else { - return; - }; - - let watch_builder = self.rebuild_watched_paths_inner( - language_server_id, - registrations.did_change_watched_files.values().flatten(), - cx, - ); - let watcher = watch_builder.build(self.fs.clone(), language_server_id, cx); - self.language_server_watched_paths - .insert(language_server_id, watcher); - - cx.notify(); - } - fn on_lsp_did_change_watched_files( &mut self, language_server_id: LanguageServerId, @@ -3875,16 +3843,35 @@ impl LocalLspStore { params: DidChangeWatchedFilesRegistrationOptions, cx: &mut Context, ) { + log::trace!( + "Processing new watcher paths for language server with id {}", + language_server_id + ); + + let worktrees: Vec> = self + .worktree_store + .read(cx) + .worktrees() + .filter_map(|worktree| { + self.language_servers_for_worktree(worktree.read(cx).id()) + .find(|server| server.server_id() == language_server_id) + .map(|_| worktree) + }) + .collect(); + + for watcher in ¶ms.watchers { + self.register_watcher(&worktrees, watcher, registration_id, language_server_id, cx); + } + let registrations = self .language_server_dynamic_registrations .entry(language_server_id) .or_default(); - registrations .did_change_watched_files - .insert(registration_id.to_string(), params.watchers); + .insert(registration_id.to_string()); - self.rebuild_watched_paths(language_server_id, cx); + cx.notify(); } fn on_lsp_unregister_did_change_watched_files( @@ -3893,15 +3880,16 @@ impl LocalLspStore { registration_id: &str, cx: &mut Context, ) { - let registrations = self + let Some(registrations) = self .language_server_dynamic_registrations - .entry(language_server_id) - .or_default(); + .get_mut(&language_server_id) + else { + return; + }; if registrations .did_change_watched_files .remove(registration_id) - .is_some() { log::info!( "language server {}: unregistered workspace/DidChangeWatchedFiles capability with id {}", @@ -3914,9 +3902,24 @@ impl LocalLspStore { language_server_id, registration_id ); + return; } - self.rebuild_watched_paths(language_server_id, cx); + if let Some(watched) = self + .language_server_watched_paths + .get_mut(&language_server_id) + { + watched.worktree_paths.retain(|_, glob_set| { + glob_set.remove(registration_id); + !glob_set.is_empty() + }); + watched.abs_paths.retain(|_, (glob_set, _)| { + glob_set.remove(registration_id); + !glob_set.is_empty() + }); + } + + cx.notify(); } async fn initialization_options_for_adapter( @@ -12259,7 +12262,9 @@ impl LspStore { return; } - let Some(local) = self.as_local() else { return }; + let Some(local) = self.as_local_mut() else { + return; + }; local.prettier_store.update(cx, |prettier_store, cx| { prettier_store.update_prettier_settings(worktree_handle, changes, cx) @@ -12280,16 +12285,13 @@ impl LspStore { local.language_servers.get(server_id) && let Some(watched_paths) = local .language_server_watched_paths - .get(server_id) - .and_then(|paths| paths.worktree_paths.get(&worktree_id)) + .get_mut(server_id) + .and_then(|paths| paths.worktree_paths.get_mut(&worktree_id)) { let params = lsp::DidChangeWatchedFilesParams { changes: changes .iter() .filter_map(|(path, _, change)| { - if !watched_paths.is_match(path.as_std_path()) { - return None; - } let typ = match change { PathChange::Loaded => return None, PathChange::Added => lsp::FileChangeType::CREATED, @@ -12297,6 +12299,9 @@ impl LspStore { PathChange::Updated => lsp::FileChangeType::CHANGED, PathChange::AddedOrUpdated => lsp::FileChangeType::CHANGED, }; + if !watched_paths.is_match(path.as_std_path()) { + return None; + } let uri = lsp::Uri::from_file_path( worktree_handle.read(cx).absolutize(&path), ) @@ -14261,87 +14266,99 @@ impl RenameActionPredicate { #[derive(Default)] struct LanguageServerWatchedPaths { - worktree_paths: HashMap, - abs_paths: HashMap, (GlobSet, Task<()>)>, + worktree_paths: HashMap, + abs_paths: HashMap, (LazyGlobSet, Task<()>)>, } #[derive(Default)] -struct LanguageServerWatchedPathsBuilder { - worktree_paths: HashMap, - abs_paths: HashMap, GlobSet>, +struct LazyGlobSet { + /// Globs keyed by registration ID. + globs: HashMap>, + /// Compiled from `globs`, lazily on `is_match`. `None` when stale. + compiled: Option, } -impl LanguageServerWatchedPathsBuilder { - fn watch_worktree(&mut self, worktree_id: WorktreeId, glob_set: GlobSet) { - self.worktree_paths.insert(worktree_id, glob_set); +impl LazyGlobSet { + fn add(&mut self, registration_id: &str, glob: Glob) { + self.globs + .entry(registration_id.to_string()) + .or_default() + .push(glob); + self.compiled = None; + } + + fn remove(&mut self, registration_id: &str) { + if self.globs.remove(registration_id).is_some() { + self.compiled = None; + } } - fn watch_abs_path(&mut self, path: Arc, glob_set: GlobSet) { - self.abs_paths.insert(path, glob_set); + + fn is_empty(&self) -> bool { + self.globs.is_empty() } - fn build( - self, + + fn is_match>(&mut self, path: P) -> bool { + let compiled = self.compiled.get_or_insert_with(|| { + let mut builder = GlobSetBuilder::new(); + for glob in self.globs.values().flatten() { + builder.add(glob.clone()); + } + builder.build().log_err().unwrap_or_default() + }); + compiled.is_match(path) + } +} + +impl LanguageServerWatchedPaths { + fn spawn_abs_path_watcher( + abs_path: Arc, fs: Arc, language_server_id: LanguageServerId, cx: &mut Context, - ) -> LanguageServerWatchedPaths { + ) -> Task<()> { let lsp_store = cx.weak_entity(); - const LSP_ABS_PATH_OBSERVE: Duration = Duration::from_millis(100); - let abs_paths = self - .abs_paths - .into_iter() - .map(|(abs_path, globset)| { - let task = cx.spawn({ - let abs_path = abs_path.clone(); - let fs = fs.clone(); - let lsp_store = lsp_store.clone(); - async move |_, cx| { - maybe!(async move { - let mut push_updates = fs.watch(&abs_path, LSP_ABS_PATH_OBSERVE).await; - while let Some(update) = push_updates.0.next().await { - let action = lsp_store - .update(cx, |this, _| { - let Some(local) = this.as_local() else { - return ControlFlow::Break(()); - }; - let Some(watcher) = local - .language_server_watched_paths - .get(&language_server_id) - else { - return ControlFlow::Break(()); - }; - let (globs, _) = watcher.abs_paths.get(&abs_path).expect( - "Watched abs path is not registered with a watcher", - ); - let matching_entries = update - .into_iter() - .filter(|event| globs.is_match(&event.path)) - .collect::>(); - this.lsp_notify_abs_paths_changed( - language_server_id, - matching_entries, - ); - ControlFlow::Continue(()) - }) - .ok()?; + cx.spawn({ + async move |_, cx| { + maybe!(async move { + let mut push_updates = fs.watch(&abs_path, LSP_ABS_PATH_OBSERVE).await; + while let Some(update) = push_updates.0.next().await { + let action = lsp_store + .update(cx, |this, _| { + let Some(local) = this.as_local_mut() else { + return ControlFlow::Break(()); + }; + let Some(watcher) = local + .language_server_watched_paths + .get_mut(&language_server_id) + else { + return ControlFlow::Break(()); + }; + let Some((globs, _)) = watcher.abs_paths.get_mut(&abs_path) else { + return ControlFlow::Break(()); + }; + let matching_entries = update + .into_iter() + .filter(|event| globs.is_match(&event.path)) + .collect::>(); + this.lsp_notify_abs_paths_changed( + language_server_id, + matching_entries, + ); + ControlFlow::Continue(()) + }) + .ok()?; - if action.is_break() { - break; - } - } - Some(()) - }) - .await; + if action.is_break() { + break; + } } - }); - (abs_path, (globset, task)) - }) - .collect(); - LanguageServerWatchedPaths { - worktree_paths: self.worktree_paths, - abs_paths, - } + Some(()) + }) + .await; + } + }) } } diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 674a2ace91087d..b6112e97389112 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -2556,6 +2556,170 @@ async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppCon ); } +#[gpui::test] +async fn test_multiple_did_change_watched_files_registrations(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "src": { + "a.rs": "", + "b.rs": "", + }, + "docs": { + "readme.md": "", + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: "the-language-server", + ..Default::default() + }, + ); + + cx.executor().run_until_parked(); + + project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/root/src/a.rs"), cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + cx.executor().run_until_parked(); + + let file_changes = Arc::new(Mutex::new(Vec::new())); + + // Register two separate watched file registrations. + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "reg-1".to_string(), + method: "workspace/didChangeWatchedFiles".to_string(), + register_options: serde_json::to_value( + lsp::DidChangeWatchedFilesRegistrationOptions { + watchers: vec![lsp::FileSystemWatcher { + glob_pattern: lsp::GlobPattern::String( + path!("/root/src/*.rs").to_string(), + ), + kind: None, + }], + }, + ) + .ok(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + + fake_server + .request::( + lsp::RegistrationParams { + registrations: vec![lsp::Registration { + id: "reg-2".to_string(), + method: "workspace/didChangeWatchedFiles".to_string(), + register_options: serde_json::to_value( + lsp::DidChangeWatchedFilesRegistrationOptions { + watchers: vec![lsp::FileSystemWatcher { + glob_pattern: lsp::GlobPattern::String( + path!("/root/docs/*.md").to_string(), + ), + kind: None, + }], + }, + ) + .ok(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + + fake_server.handle_notification::({ + let file_changes = file_changes.clone(); + move |params, _| { + let mut file_changes = file_changes.lock(); + file_changes.extend(params.changes); + file_changes.sort_by(|a, b| a.uri.cmp(&b.uri)); + } + }); + + cx.executor().run_until_parked(); + + // Both registrations should match their respective patterns. + fs.create_file(path!("/root/src/c.rs").as_ref(), Default::default()) + .await + .unwrap(); + fs.create_file(path!("/root/docs/guide.md").as_ref(), Default::default()) + .await + .unwrap(); + cx.executor().run_until_parked(); + + assert_eq!( + &*file_changes.lock(), + &[ + lsp::FileEvent { + uri: lsp::Uri::from_file_path(path!("/root/docs/guide.md")).unwrap(), + typ: lsp::FileChangeType::CREATED, + }, + lsp::FileEvent { + uri: lsp::Uri::from_file_path(path!("/root/src/c.rs")).unwrap(), + typ: lsp::FileChangeType::CREATED, + }, + ] + ); + file_changes.lock().clear(); + + // Unregister the first registration. + fake_server + .request::( + lsp::UnregistrationParams { + unregisterations: vec![lsp::Unregistration { + id: "reg-1".to_string(), + method: "workspace/didChangeWatchedFiles".to_string(), + }], + }, + DEFAULT_LSP_REQUEST_TIMEOUT, + ) + .await + .into_response() + .unwrap(); + cx.executor().run_until_parked(); + + // Only the second registration should still match. + fs.create_file(path!("/root/src/d.rs").as_ref(), Default::default()) + .await + .unwrap(); + fs.create_file(path!("/root/docs/notes.md").as_ref(), Default::default()) + .await + .unwrap(); + cx.executor().run_until_parked(); + + assert_eq!( + &*file_changes.lock(), + &[lsp::FileEvent { + uri: lsp::Uri::from_file_path(path!("/root/docs/notes.md")).unwrap(), + typ: lsp::FileChangeType::CREATED, + }] + ); +} + #[gpui::test] async fn test_single_file_worktrees_diagnostics(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 24220e5efcbfaf..3539a9fc882999 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -4199,6 +4199,12 @@ impl BackgroundScanner { path_prefix_request = self.path_prefixes_to_scan_rx.recv().fuse() => { let Ok(request) = path_prefix_request else { break }; + + if self.state.lock().await.path_prefixes_to_scan.contains(&request.path) { + self.send_status_update(false, request.done, &[]).await; + continue; + } + log::trace!("adding path prefix {:?}", request.path); let did_scan = self.forcibly_load_paths(std::slice::from_ref(&request.path)).await; From 325ff16742c87c3f639fc5812be6a30f77ae2c88 Mon Sep 17 00:00:00 2001 From: Xin Zhao Date: Thu, 11 Jun 2026 10:26:26 +0800 Subject: [PATCH 28/38] editor: Expand the gutter width to contain new timestamp format (#59008) Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #58922 In #57973, a new timestamp format was introduced. For example, for a commit from 13 months ago, the old formatter would output `"1 year ago"`, whereas the new format outputs `"1 year, 1 month ago"`. This new compound format is significantly longer than `"60 minutes ago"`, which is currently used to calculate the maximum width of the blame column here: https://github.com/zed-industries/zed/blob/d989c7c5cdd057de2375a55bdc109ff61409801c/crates/editor/src/editor.rs#L11376-L11391 As a result, when a blame entry uses the `"{M} years, {N} months ago"` format, its width exceeds the pre-calculated maximum, causing the text to overlap with the line numbers. This PR updates the placeholder string used for the maximum width calculation to `"2 years, 11 months ago"`, ensuring the column is wide enough to accommodate the longest possible compound timestamp. Before: before After: after Release Notes: - Fixed an issue where Git blame text would overlap with line numbers in the gutter. --- crates/editor/src/editor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1dcb21808091b5..70568a66c4631c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -11377,7 +11377,7 @@ impl EditorSnapshot { self.git_blame_gutter_max_author_length .map(|max_author_length| { let renderer = cx.global::().0.clone(); - const MAX_RELATIVE_TIMESTAMP: &str = "60 minutes ago"; + const MAX_RELATIVE_TIMESTAMP: &str = "2 years, 11 months ago"; /// The number of characters to dedicate to gaps and margins. const SPACING_WIDTH: usize = 4; From 053ea47e5ac15c330dd62b669ca41b8a64ff3f02 Mon Sep 17 00:00:00 2001 From: Tom Planche Date: Thu, 11 Jun 2026 04:56:56 +0200 Subject: [PATCH 29/38] editor: Don't treat scheme-prefixed text as a URL when pasting in Markdown (#59071) Pasting text that merely starts with a scheme-like prefix (for example a commit message like `editor: Fix crash in project panel`) over a selection in a Markdown buffer would wrap the selection in a Markdown link, because `url::Url::parse` accepts `editor:` as a valid URL scheme. Paste now only treats clipboard text as a URL when the whole text is a single standalone URL, using `linkify` (already a workspace dependency) instead of `url::Url::parse`. Added a regression test covering the scheme-like prefix case. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #59070 Release Notes: - Fixed pasting text starting with a scheme-like prefix (such as `editor: ...`) over a selection in a Markdown buffer incorrectly creating a Markdown link. --- crates/editor/src/clipboard.rs | 29 +++++++++++++++++++++++------ crates/editor/src/editor_tests.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/crates/editor/src/clipboard.rs b/crates/editor/src/clipboard.rs index d1380a732ce553..2d3afdac12b82a 100644 --- a/crates/editor/src/clipboard.rs +++ b/crates/editor/src/clipboard.rs @@ -139,7 +139,7 @@ impl Editor { &snapshot, range, to_insert, - url::Url::parse(to_insert).ok(), + is_standalone_url(to_insert), ) } else { (range, Cow::Borrowed(to_insert)) @@ -169,7 +169,7 @@ impl Editor { .all::(&this.display_snapshot(cx)); this.change_selections(Default::default(), window, cx, |s| s.select(selections)); } else { - let url = url::Url::parse(&clipboard_text).ok(); + let clipboard_is_url = is_standalone_url(&clipboard_text); let auto_indent_mode = if !clipboard_text.is_empty() { Some(AutoindentMode::Block { @@ -213,7 +213,12 @@ impl Editor { let (edit_range, edit_text) = if let Some(language) = language && language.name() == "Markdown" { - edit_for_markdown_paste(&snapshot, range, text_for_cursor, url.clone()) + edit_for_markdown_paste( + &snapshot, + range, + text_for_cursor, + clipboard_is_url, + ) } else { (range, Cow::Borrowed(text_for_cursor)) }; @@ -538,18 +543,30 @@ fn edit_for_markdown_paste<'a>( buffer: &MultiBufferSnapshot, range: Range, to_insert: &'a str, - url: Option, + to_insert_is_url: bool, ) -> (Range, Cow<'a, str>) { - if url.is_none() { + if !to_insert_is_url { return (range, Cow::Borrowed(to_insert)); }; let old_text = buffer.text_for_range(range.clone()).collect::(); - let new_text = if range.is_empty() || url::Url::parse(&old_text).is_ok() { + let new_text = if range.is_empty() || is_standalone_url(&old_text) { Cow::Borrowed(to_insert) } else { Cow::Owned(format!("[{old_text}]({to_insert})")) }; (range, new_text) } + +/// Whether `text` consists solely of a single URL, as opposed to merely +/// starting with a scheme-like prefix (e.g. a commit message like +/// `editor: Fix ...`, which `url::Url::parse` would accept). +fn is_standalone_url(text: &str) -> bool { + let mut finder = linkify::LinkFinder::new(); + finder.kinds(&[linkify::LinkKind::Url]); + finder + .links(text) + .next() + .is_some_and(|link| link.start() == 0 && link.end() == text.len()) +} diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index b476b9bcdabd2c..fdcf906d4d46a7 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -33268,6 +33268,36 @@ async fn test_paste_plain_text_from_other_app_replaces_selection_without_creatin cx.assert_editor_state(&format!("Hello, {text}ˇ.\nZed is {text}ˇ")); } +#[gpui::test] +async fn test_paste_text_with_scheme_like_prefix_replaces_selection_without_creating_markdown_link( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + // `url::Url::parse` accepts this as a URL with the scheme `editor`, but it + // should not be treated as one when pasting. + let text = "editor: Fix double-click bracket selection for large spans"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state("«(feat on git-ui-add-info-exclude-to-context-menus) Fmtˇ»"); + + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())); + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state(&format!("{text}ˇ")); +} + #[gpui::test] async fn test_paste_url_from_other_app_without_creating_markdown_link_in_non_markdown_language( cx: &mut gpui::TestAppContext, From 03a8544040ff95ec2e7921ca71c83799ad7f17cd Mon Sep 17 00:00:00 2001 From: saberoueslati Date: Thu, 11 Jun 2026 04:23:40 +0100 Subject: [PATCH 30/38] tab_switcher: Middle-truncate long picker filenames (#59072) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context This follow-up was inspired by Christopher’s suggestion in the review comment on #58483: long filenames should truncate in the middle so both the beginning and the suffix/extension remain visible. Previously, very long filenames in picker rows could still be clipped or end-truncated, making files with shared prefixes hard to distinguish. This adds middle-truncation support to GPUI text overflow and applies it to filename-focused picker surfaces: the tab switcher and the Ctrl-P file finder. Normal editor tab bar behavior remains unchanged and continues to use the existing title length cap. ## How to Review - **`crates/gpui/src/style.rs`, `crates/gpui/src/styled.rs`, `crates/gpui/src/elements/text.rs`, `crates/gpui/src/text_system/line_wrapper.rs`**: Adds `TextOverflow::TruncateMiddle`, wires it through text layout, and implements middle truncation while preserving valid text runs. Very narrow widths now show the truncation affix instead of falling back to the abruptly clipped original text. - **`crates/ui/src/components/label/label_like.rs`, `crates/ui/src/components/label/label.rs`, `crates/ui/src/components/label/highlighted_label.rs`**: Exposes middle truncation through the shared label components. - **`crates/workspace/src/item.rs`, `crates/editor/src/items.rs`, `crates/workspace/src/pane.rs`, `crates/tab_switcher/src/tab_switcher.rs`**: Adds an explicit tab-content opt-in for middle truncation. The tab switcher enables it, while the normal tab bar and dragged tabs keep their existing behavior. - **`crates/file_finder/src/file_finder.rs`**: Applies middle truncation to Ctrl-P file finder filenames and lets the path shrink from the start so long paths do not hide important filename suffixes. Manual test before change : [Screencast from 2026-06-10 22-59-28.webm](https://github.com/user-attachments/assets/5e032646-a48c-45f2-8fe2-0424507fd576) Manual test after change : [Screencast from 2026-06-10 22-54-48.webm](https://github.com/user-attachments/assets/4b9253da-f169-4ed1-bdd5-ee71dcf49a83) ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the UI/UX checklist - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Improved long filename truncation in the tab switcher and file finder so extensions remain visible. --- crates/editor/src/items.rs | 20 +- crates/file_finder/src/file_finder.rs | 7 +- crates/gpui/src/elements/text.rs | 1 + crates/gpui/src/style.rs | 4 + crates/gpui/src/styled.rs | 8 + crates/gpui/src/text_system/line_wrapper.rs | 225 ++++++++++++++++++ crates/tab_switcher/src/tab_switcher.rs | 3 +- .../src/components/label/highlighted_label.rs | 6 + crates/ui/src/components/label/label.rs | 6 + crates/ui/src/components/label/label_like.rs | 15 ++ crates/workspace/src/item.rs | 1 + crates/workspace/src/pane.rs | 2 + 12 files changed, 291 insertions(+), 7 deletions(-) diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 4156617ef55bad..a5fc5b2b99149d 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -783,11 +783,20 @@ impl Item for Editor { h_flex() .gap_2() + .when(params.truncate_title_middle, |this| { + this.w_full().min_w_0().overflow_hidden() + }) .child( - Label::new(util::truncate_and_trailoff( - &self.title(cx), - params.max_title_len.unwrap_or(MAX_TAB_TITLE_LEN), - )) + Label::new(if params.truncate_title_middle { + self.title(cx).to_string() + } else { + util::truncate_and_trailoff( + &self.title(cx), + params.max_title_len.unwrap_or(MAX_TAB_TITLE_LEN), + ) + }) + .when(params.truncate_title_middle, |this| this.truncate_middle()) + .when(params.truncate_title_middle, |this| this.flex_1()) .color(label_color) .when(params.preview, |this| this.italic()) .when(was_deleted, |this| this.strikethrough()), @@ -796,6 +805,9 @@ impl Item for Editor { this.child( Label::new(description) .size(LabelSize::XSmall) + .when(params.truncate_title_middle, |this| { + this.truncate_start().flex_shrink() + }) .color(Color::Muted), ) }) diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index f45f81a6edb3d2..a4feaf29523298 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1876,10 +1876,13 @@ impl PickerDelegate for FileFinderDelegate { .toggle_state(selected) .child( h_flex() + .w_full() + .min_w_0() + .overflow_hidden() .gap_2() .py_px() - .child(file_name_label) - .child(full_path_label), + .child(file_name_label.truncate_middle().flex_1()) + .child(full_path_label.truncate_start().flex_shrink()), ), ) } diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index ee4daa38386e75..ae37d3fdd91513 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -669,6 +669,7 @@ impl TextLayout { match text_overflow { TextOverflow::Truncate(s) => (width, s, TruncateFrom::End), TextOverflow::TruncateStart(s) => (width, s, TruncateFrom::Start), + TextOverflow::TruncateMiddle(s) => (width, s, TruncateFrom::Middle), } } else { (None, "".into(), TruncateFrom::End) diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 54e09bc37dd994..2853778d99762a 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -375,6 +375,10 @@ pub enum TextOverflow { /// displaying the provided string at the beginning (e.g., "…ong text here"). /// Typically more adequate for file paths where the end is more important than the beginning. TruncateStart(SharedString), + /// Truncate the text in the middle when it doesn't fit, preserving both the start and end + /// of the string (e.g., "long fi…name.rs"). Useful for filenames where both the prefix + /// and the extension are important context. + TruncateMiddle(SharedString), } /// How to align text within the element diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index 3004e157e47642..901e64d5166c2a 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -99,6 +99,14 @@ pub trait Styled: Sized { self } + /// Sets the truncate overflowing text with an ellipsis (…) in the middle if needed. + /// Preserves the beginning and end of the text. Useful for filenames. + /// Note: This doesn't exist in Tailwind CSS. + fn text_ellipsis_middle(mut self) -> Self { + self.text_style().text_overflow = Some(TextOverflow::TruncateMiddle(ELLIPSIS)); + self + } + /// Sets the text overflow behavior of the element. fn text_overflow(mut self, overflow: TextOverflow) -> Self { self.text_style().text_overflow = Some(overflow); diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 3335e7b31d158a..dd6d2d987079dc 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -9,6 +9,8 @@ pub enum TruncateFrom { Start, /// Truncate text from the end. End, + /// Truncate text from the middle, preserving the start and end. + Middle, } /// The GPUI line wrapper, used to wrap lines of text to a given width. @@ -179,11 +181,69 @@ impl LineWrapper { } } } + TruncateFrom::Middle => {} } None } + fn should_truncate_line_middle( + &mut self, + line: &str, + truncate_width: Pixels, + truncation_affix: &str, + ) -> Option<(usize, usize)> { + let suffix_width = truncation_affix + .chars() + .map(|c| self.width_for_char(c)) + .fold(px(0.0), |a, x| a + x); + + let total_width: Pixels = line + .chars() + .map(|c| self.width_for_char(c)) + .fold(px(0.0), |a, x| a + x); + + if total_width <= truncate_width { + return None; + } + + let content_budget = truncate_width - suffix_width; + if content_budget <= px(0.) { + return Some((0, line.len())); + } + + let front_budget = content_budget * (2.0 / 3.0); + let back_budget = content_budget - front_budget; + + let mut front_width = px(0.); + let mut front_end_ix = 0usize; + for (ix, c) in line.char_indices() { + let char_width = self.width_for_char(c); + if front_width + char_width > front_budget { + break; + } + front_width += char_width; + front_end_ix = ix + c.len_utf8(); + } + + let mut back_width = px(0.); + let mut back_start_ix = line.len(); + for (ix, c) in line.char_indices().rev() { + let char_width = self.width_for_char(c); + if back_width + char_width > back_budget { + break; + } + back_width += char_width; + back_start_ix = ix; + } + + if front_end_ix >= back_start_ix { + return Some((0, line.len())); + } + + Some((front_end_ix, back_start_ix)) + } + /// Truncate a line of text to the given width with this wrapper's font and font size. pub fn truncate_line<'a>( &mut self, @@ -193,6 +253,28 @@ impl LineWrapper { runs: &'a [TextRun], truncate_from: TruncateFrom, ) -> (SharedString, Cow<'a, [TextRun]>) { + if truncate_from == TruncateFrom::Middle { + if let Some((front_end_ix, back_start_ix)) = + self.should_truncate_line_middle(&line, truncate_width, truncation_affix) + { + let result = SharedString::from(format!( + "{}{truncation_affix}{}", + &line[..front_end_ix], + &line[back_start_ix..] + )); + let mut runs = runs.to_vec(); + update_runs_after_middle_truncation( + truncation_affix, + &mut runs, + front_end_ix, + back_start_ix, + ); + return (result, Cow::Owned(runs)); + } else { + return (line, Cow::Borrowed(runs)); + } + } + if let Some(truncate_ix) = self.should_truncate_line(&line, truncate_width, truncation_affix, truncate_from) { @@ -206,6 +288,7 @@ impl LineWrapper { line[..truncate_ix] .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation()) )), + TruncateFrom::Middle => unreachable!("Middle truncation is handled above"), }; let mut runs = runs.to_vec(); update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from); @@ -242,6 +325,9 @@ impl LineWrapper { truncate_from, ); } + if truncate_from == TruncateFrom::Middle { + return self.truncate_line(text, wrap_width, truncation_affix, runs, truncate_from); + } let affix_width: Pixels = truncation_affix .chars() @@ -448,7 +534,71 @@ fn update_runs_after_truncation( } } } + TruncateFrom::Middle => { + unreachable!("Middle truncation calls this function with TruncateFrom::End directly") + } + } +} + +fn update_runs_after_middle_truncation( + ellipsis: &str, + runs: &mut Vec, + front_end_ix: usize, + back_start_ix: usize, +) { + let original_runs = std::mem::take(runs); + let mut result_runs: Vec = Vec::with_capacity(original_runs.len()); + + // Front segment [0, front_end_ix) + ellipsis: walk forward until the run + // that straddles or ends at front_end_ix, then extend that run's length + // to include the ellipsis. + let mut front_remaining = front_end_ix; + let mut front_done = false; + for run in &original_runs { + if front_done { + break; + } + if run.len <= front_remaining { + result_runs.push(run.clone()); + front_remaining -= run.len; + } else { + let mut partial = run.clone(); + partial.len = front_remaining + ellipsis.len(); + result_runs.push(partial); + front_done = true; + } + } + if !front_done { + // front_end_ix landed exactly on a run boundary; append ellipsis to + // the last front run (or, if the front is empty, to the first back run). + if let Some(last) = result_runs.last_mut() { + last.len += ellipsis.len(); + } else if let Some(first) = original_runs.first() { + let mut affix_run = first.clone(); + affix_run.len = ellipsis.len(); + result_runs.push(affix_run); + } + } + + // Back segment [back_start_ix, original.len()): skip runs entirely in the + // removed middle, keep the rest. + let mut byte_pos = 0usize; + for run in &original_runs { + let run_end = byte_pos + run.len; + if run_end > back_start_ix { + if byte_pos < back_start_ix { + // Run straddles back_start_ix; keep only the tail. + let mut partial = run.clone(); + partial.len = run_end - back_start_ix; + result_runs.push(partial); + } else { + result_runs.push(run.clone()); + } + } + byte_pos = run_end; } + + *runs = result_runs; } /// A fragment of a line that can be wrapped. @@ -1275,6 +1425,81 @@ mod tests { ); } + #[test] + fn test_truncate_line_middle() { + let mut wrapper = build_wrapper(); + + // No truncation when text fits within a very wide budget. + let short_text = "hello world"; + let runs = generate_test_runs(&[short_text.len()]); + let (result, result_runs) = wrapper.truncate_line( + short_text.into(), + px(10000.), + "…", + &runs, + TruncateFrom::Middle, + ); + assert_eq!(result.as_ref(), short_text); + assert_eq!(result_runs.len(), 1); + assert_eq!(result_runs[0].len, short_text.len()); + + // Basic middle truncation: long string with px(100.) budget. + let long_text = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz"; + let runs = generate_test_runs(&[long_text.len()]); + let (result, _result_runs) = + wrapper.truncate_line(long_text.into(), px(100.), "…", &runs, TruncateFrom::Middle); + assert!( + result.contains('…'), + "Middle-truncated result should contain '…', got: '{}'", + result + ); + assert!( + result.chars().count() < long_text.chars().count(), + "Middle-truncated result should be shorter than original" + ); + assert_eq!( + result.chars().next(), + long_text.chars().next(), + "Result should start with the same first character as original" + ); + assert_eq!( + result.chars().last(), + long_text.chars().last(), + "Result should end with the same last character as original" + ); + + // Degenerate case: budget so narrow that middle truncation cannot find a valid split. + // Still show the truncation affix instead of returning the original overflowing text. + let text = "abcdef"; + let runs = generate_test_runs(&[text.len()]); + let (result, result_runs) = + wrapper.truncate_line(text.into(), px(1.), "…", &runs, TruncateFrom::Middle); + assert_eq!(result.as_ref(), "…"); + assert_eq!(result_runs.len(), 1); + assert_eq!(result_runs[0].len, "…".len()); + + // Run adjustment correctness: multiple runs across the string. + // Verify that the returned runs' lengths sum to result.len(). + let multi_run_text = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz"; + let run_lens = [20, 20, multi_run_text.len() - 40]; + let runs = generate_test_runs(&run_lens); + let (result, result_runs) = wrapper.truncate_line( + multi_run_text.into(), + px(100.), + "…", + &runs, + TruncateFrom::Middle, + ); + let total_run_len: usize = result_runs.iter().map(|r| r.len).sum(); + assert_eq!( + total_run_len, + result.len(), + "Sum of run lengths ({}) should equal result byte length ({})", + total_run_len, + result.len() + ); + } + #[test] fn test_multiline_truncation_trailing_newline() { let mut wrapper = build_wrapper(); diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 9f9f706780bbba..7edaaf46c75546 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -831,6 +831,7 @@ impl PickerDelegate for TabSwitcherDelegate { preview: tab_match.preview, deemphasized: false, max_title_len: Some(usize::MAX), + truncate_title_middle: true, }; let label = tab_match.item.tab_content(params, window, cx); @@ -875,7 +876,7 @@ impl PickerDelegate for TabSwitcherDelegate { .spacing(ListItemSpacing::Sparse) .inset(true) .toggle_state(selected) - .child(h_flex().w_full().child(label)) + .child(h_flex().w_full().min_w_0().overflow_hidden().child(label)) .start_slot::(icon) .map(|el| { if self.selected_index == ix { diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index 2cac321ab6443e..0dd28112e890b7 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -77,6 +77,12 @@ impl HighlightedLabel { self.base = self.base.truncate_start(); self } + + /// Truncates overflowing text with an ellipsis (`…`) in the middle if needed. + pub fn truncate_middle(mut self) -> Self { + self.base = self.base.truncate_middle(); + self + } } impl HighlightedLabel { diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index 8b98817b050f5c..264049d45fcf26 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -74,6 +74,12 @@ impl Label { self } + /// Truncates overflowing text with an ellipsis (`…`) in the middle if needed. + pub fn truncate_middle(mut self) -> Self { + self.base = self.base.truncate_middle(); + self + } + /// Wraps the text and truncates it with an ellipsis (`…`) at the end of /// the last visible line if it exceeds the given number of lines. pub fn line_clamp(mut self, lines: usize) -> Self { diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index 4218c5d1fe70fe..ebc6c9b8250708 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -89,6 +89,7 @@ pub struct LabelLike { single_line: bool, truncate: bool, truncate_start: bool, + truncate_middle: bool, } impl Default for LabelLike { @@ -115,6 +116,7 @@ impl LabelLike { single_line: false, truncate: false, truncate_start: false, + truncate_middle: false, } } } @@ -135,6 +137,13 @@ impl LabelLike { self } + /// Truncates overflowing text with an ellipsis (`…`) in the middle if needed. + /// Preserves the start and end of the text. Useful for filenames. + pub fn truncate_middle(mut self) -> Self { + self.truncate_middle = true; + self + } + /// Wraps the text and truncates it with an ellipsis (`…`) at the end of /// the last visible line if it exceeds the given number of lines. pub fn line_clamp(mut self, lines: usize) -> Self { @@ -262,6 +271,12 @@ impl RenderOnce for LabelLike { .whitespace_nowrap() .text_ellipsis_start() }) + .when(self.truncate_middle, |this| { + this.min_w_0() + .overflow_x_hidden() + .whitespace_nowrap() + .text_ellipsis_middle() + }) .text_color(color) .font_weight( self.weight diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index c2fbfc944632b7..9b4ad5dbbb67d6 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -135,6 +135,7 @@ pub struct TabContentParams { pub deemphasized: bool, /// Maximum character length for the title. None = use the item's own default (typically MAX_TAB_TITLE_LEN). pub max_title_len: Option, + pub truncate_title_middle: bool, } impl TabContentParams { diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index a3d56abf19b7b7..ea3091a6fffbcc 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -2795,6 +2795,7 @@ impl Pane { preview: is_preview, deemphasized: !self.has_focus(window, cx), max_title_len: None, + truncate_title_middle: false, }, window, cx, @@ -4929,6 +4930,7 @@ impl Render for DraggedTab { preview: false, deemphasized: false, max_title_len: None, + truncate_title_middle: false, }, window, cx, From dde7c1c07f026177338a8c1948c6591090ffba93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82nderson=20Q=2E?= Date: Thu, 11 Jun 2026 01:03:48 -0300 Subject: [PATCH 31/38] workspace: Add command to reset pane sizes (#59046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Exposes the existing recursive pane-size reset as a command-palette action, so it can be invoked outside of vim mode. The center pane grid's sizes can already be evened out by double-clicking a divider, or via `vim::ResetPaneSizes` (`ctrl-w =`) when vim mode is on, but there is no general action for it — so users not in vim mode have no discoverable way to undo a drifted split layout in one step. ## How it works `workspace::ResetPaneSizes` (palette: "workspace: reset pane sizes") calls the existing `Workspace::reset_pane_sizes`, which resets every `PaneAxis` in the center pane group to equal flexes, recursing into nested splits. The split structure is preserved — no panes are added, removed, or rearranged, only their sizes are equalized. This is the same path the vim `ctrl-w =` binding and the divider double-click already use. ## Out of scope - Docks (left/right/bottom panels) — they keep their existing `Reset Active Dock Size` / `Reset Open Docks Size` actions. - The terminal panel's internal splits — it is a separate panel, not part of the center pane group. - No default keybinding (palette-only); vim users keep `ctrl-w =`. ## Testing Added `test_reset_pane_sizes`: builds a nested split (a horizontal axis of three panes whose last child is a vertical split), skews every axis's flexes, dispatches the action, and asserts every axis returns to uniform sizes. Release Notes: - Added a `workspace: reset pane sizes` command that equalizes the sizes of all panes in the center group --- crates/workspace/src/workspace.rs | 75 +++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 5edc156371161b..06bfa4ac8121a7 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -303,6 +303,8 @@ actions!( ResetActiveDockSize, /// Resets all open docks to their default sizes. ResetOpenDocksSize, + /// Resets all panes in the center group to equal sizes, preserving the split layout. + ResetPaneSizes, /// Reloads the application Reload, /// Formats and saves the current file, regardless of the format_on_save setting. @@ -7651,6 +7653,11 @@ impl Workspace { } }, )) + .on_action(cx.listener( + |workspace: &mut Workspace, _: &ResetPaneSizes, _window, cx| { + workspace.reset_pane_sizes(cx); + }, + )) .on_action(cx.listener( |workspace: &mut Workspace, act: &IncreaseActiveDockSize, window, cx| { adjust_active_dock_size_by_px( @@ -12771,6 +12778,74 @@ mod tests { }); } + #[gpui::test] + async fn test_reset_pane_sizes(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + // A horizontal split of three panes whose last child is itself a vertical + // split, so equalizing has to recurse into the nested axis. + workspace.update_in(cx, |workspace, window, cx| { + let item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "1.txt", cx)]) + }); + workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx); + workspace.split_pane( + workspace.active_pane().clone(), + SplitDirection::Right, + window, + cx, + ); + workspace.split_pane( + workspace.active_pane().clone(), + SplitDirection::Right, + window, + cx, + ); + workspace.split_pane( + workspace.active_pane().clone(), + SplitDirection::Down, + window, + cx, + ); + }); + + let nested_axis = |workspace: &Workspace| { + let Member::Axis(top) = &workspace.center.root else { + panic!("expected the center to be a split axis"); + }; + let nested = top + .members + .iter() + .find_map(|member| match member { + Member::Axis(axis) => Some(axis.clone()), + Member::Pane(_) => None, + }) + .expect("expected a nested split axis"); + (top.clone(), nested) + }; + + // Skew every axis away from uniform sizes. + workspace.update(cx, |workspace, _| { + let (top, nested) = nested_axis(workspace); + *top.flexes.lock() = vec![1.6, 0.7, 0.7]; + *nested.flexes.lock() = vec![1.3, 0.7]; + }); + + cx.run_until_parked(); + cx.dispatch_action(ResetPaneSizes); + + workspace.update(cx, |workspace, _| { + let (top, nested) = nested_axis(workspace); + assert_eq!(*top.flexes.lock(), vec![1.0; top.members.len()]); + assert_eq!(*nested.flexes.lock(), vec![1.0; nested.members.len()]); + }); + } + #[gpui::test] async fn test_toggle_docks_and_panels(cx: &mut gpui::TestAppContext) { init_test(cx); From df35a07d138316a5e38fa75c2197208a22d2e2c3 Mon Sep 17 00:00:00 2001 From: Myself <107336861+totrytakeoff@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:04:36 +0800 Subject: [PATCH 32/38] Add action to select inside enclosing brackets (#59005) ## Summary - add an editor action for selecting the contents of the innermost enclosing bracket pair - register the action without assigning a default key binding - cover cursor, selection, nested bracket, no-op, and multi-cursor cases ## Tests - cargo fmt --check - cargo test -p editor test_select_inside_enclosing_bracket --lib - cargo test -p editor test_move_to_enclosing_bracket --lib Release Notes: - Added action to select the contents enclosed by brackets --- crates/editor/src/actions.rs | 2 + crates/editor/src/editor_tests.rs | 82 +++++++++++++++++++++++++++++++ crates/editor/src/element.rs | 1 + crates/editor/src/selection.rs | 37 ++++++++++++++ 4 files changed, 122 insertions(+) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index e4e280851d54ef..906f4b00f26398 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -773,6 +773,8 @@ actions!( SelectDown, /// Selects the enclosing symbol. SelectEnclosingSymbol, + /// Selects inside the innermost enclosing bracket pair. + SelectInsideEnclosingBracket, /// Selects to the start of the next larger syntax node. SelectToStartOfLargerSyntaxNode, /// Selects to the end of the next larger syntax node. diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index fdcf906d4d46a7..09613906743dd2 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -21266,6 +21266,88 @@ async fn test_move_to_enclosing_bracket(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_select_inside_enclosing_bracket(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_typescript(Default::default(), cx).await; + + #[track_caller] + fn assert_after_runs(before: &str, after: &str, runs: usize, cx: &mut EditorLspTestContext) { + let _state_context = cx.set_state(before); + cx.run_until_parked(); + for _ in 0..runs { + cx.update_editor(|editor, window, cx| { + editor.select_inside_enclosing_bracket(&SelectInsideEnclosingBracket, window, cx) + }); + } + cx.run_until_parked(); + cx.assert_editor_state(after); + } + + #[track_caller] + fn assert(before: &str, after: &str, cx: &mut EditorLspTestContext) { + assert_after_runs(before, after, 1, cx); + } + + assert("console.log(ˇvar);", "console.log(«varˇ»);", &mut cx); + assert("console.logˇ(var);", "console.log(«varˇ»);", &mut cx); + assert("console.log(var)ˇ;", "console.log(«varˇ»);", &mut cx); + assert( + "let numbers = [1, ˇ2, 3];", + "let numbers = [«1, 2, 3ˇ»];", + &mut cx, + ); + assert( + "const object = { foo: ˇbar };", + "const object = {« foo: bar ˇ»};", + &mut cx, + ); + assert( + r#"const doubleQuoted = "foo ˇbar";"#, + r#"const doubleQuoted = "«foo barˇ»";"#, + &mut cx, + ); + assert( + "const singleQuoted = 'foo ˇbar';", + "const singleQuoted = '«foo barˇ»';", + &mut cx, + ); + assert( + "const template = `foo ˇbar`;", + "const template = `«foo barˇ»`;", + &mut cx, + ); + assert( + "let result = foo(bar(ˇbaz));", + "let result = foo(bar(«bazˇ»));", + &mut cx, + ); + assert( + "let result = foo(«barˇ»(baz));", + "let result = foo(«bar(baz)ˇ»);", + &mut cx, + ); + assert_after_runs( + "let result = foo(bar(ˇbaz));", + "let result = foo(«bar(baz)ˇ»);", + 2, + &mut cx, + ); + assert_after_runs( + r#"let result = (xx[xxx{xxˇx}] xx"xxx"xx);"#, + r#"let result = («xx[xxx{xxx}] xx"xxx"xxˇ»);"#, + 3, + &mut cx, + ); + assert("let plain = ˇvalue;", "let plain = ˇvalue;", &mut cx); + assert( + "foo(ˇone); bar(ˇtwo);", + "foo(«oneˇ»); bar(«twoˇ»);", + &mut cx, + ); +} + #[gpui::test] async fn test_move_to_enclosing_bracket_in_markdown_code_block(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index e3d634b4a0a459..ec693e96f01689 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -334,6 +334,7 @@ impl EditorElement { register_action(editor, window, Editor::move_to_start_of_larger_syntax_node); register_action(editor, window, Editor::move_to_end_of_larger_syntax_node); register_action(editor, window, Editor::select_enclosing_symbol); + register_action(editor, window, Editor::select_inside_enclosing_bracket); register_action(editor, window, Editor::move_to_enclosing_bracket); register_action(editor, window, Editor::undo_selection); register_action(editor, window, Editor::redo_selection); diff --git a/crates/editor/src/selection.rs b/crates/editor/src/selection.rs index e6c5a8bb1fbe56..d3a4b80cd1e741 100644 --- a/crates/editor/src/selection.rs +++ b/crates/editor/src/selection.rs @@ -966,6 +966,43 @@ impl Editor { self.select_to_syntax_nodes(window, cx, true); } + pub fn select_inside_enclosing_bracket( + &mut self, + _: &SelectInsideEnclosingBracket, + window: &mut Window, + cx: &mut Context, + ) { + self.change_selections(Default::default(), window, cx, |s| { + s.move_offsets_with(&mut |snapshot, selection| { + let Some(enclosing_bracket_ranges) = + snapshot.enclosing_bracket_ranges(selection.start..selection.end) + else { + return; + }; + + let mut best = None; + let mut best_length = usize::MAX; + + for (open, close) in enclosing_bracket_ranges { + let inside = open.end..close.start; + if inside == (selection.start..selection.end) { + continue; + } + + let length = close.end - open.start; + if length < best_length { + best_length = length; + best = Some(inside); + } + } + + if let Some(inside) = best { + selection.set_head_tail(inside.end, inside.start, SelectionGoal::None); + } + }) + }); + } + pub fn move_to_enclosing_bracket( &mut self, _: &MoveToEnclosingBracket, From 7fd5ea4bf3d97826d96fb8e31426d18c459e8eb6 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:15:10 -0400 Subject: [PATCH 33/38] gpui: Fix list scroll events being reverted by pending scroll (#59002) This PR fixes some cases where scrolling is choppy due to a user inputting a scroll event while a list state is going through a remeasure. Currently, the list state would ignore the user's scroll and fall back to the pending scroll position, this PR fixes this by rebasing the pending scroll onto the user's new scroll position. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - agent_panel: Improve scroll smoothness while a response is streaming --- crates/gpui/src/elements/list.rs | 192 ++++++++++++++++++++++++++++--- 1 file changed, 178 insertions(+), 14 deletions(-) diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 725f517a122e40..28f47a6b7b0591 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -347,6 +347,7 @@ impl ListState { state.reset = true; state.measuring_behavior.reset(); state.logical_scroll_top = None; + state.pending_scroll = None; state.scrollbar_drag_start_height = None; state.items.summary().count }; @@ -546,10 +547,13 @@ impl ListState { cursor.seek(&Height(new_pixel_offset), Bias::Right); } - state.logical_scroll_top = Some(ListOffset { + let scroll_top = ListOffset { item_ix: cursor.start().count, offset_in_item: new_pixel_offset - cursor.start().height, - }); + }; + drop(cursor); + state.rebase_pending_scroll(scroll_top); + state.logical_scroll_top = Some(scroll_top); } /// Scroll the list to the very end (past the last item). @@ -561,6 +565,7 @@ impl ListState { pub fn scroll_to_end(&self) { let state = &mut *self.0.borrow_mut(); let item_count = state.items.summary().count; + state.pending_scroll = None; state.logical_scroll_top = Some(ListOffset { item_ix: item_count, offset_in_item: px(0.), @@ -613,6 +618,7 @@ impl ListState { state.follow_state.stop_following(); } + state.rebase_pending_scroll(scroll_top); state.logical_scroll_top = Some(scroll_top); } @@ -645,6 +651,7 @@ impl ListState { } } + state.rebase_pending_scroll(scroll_top); state.logical_scroll_top = Some(scroll_top); } @@ -784,6 +791,39 @@ impl ListState { } impl StateInner { + /// Re-anchor a pending scroll adjustment from a remeasure onto a newly set + /// scroll position, so it clamps to the remeasured item's new height on + /// the next layout instead of reverting the scroll. + fn rebase_pending_scroll(&mut self, scroll_top: ListOffset) { + let Some(pending) = self.pending_scroll.take() else { + return; + }; + if scroll_top.item_ix >= self.items.summary().count { + return; + } + + self.pending_scroll = match pending { + PendingScroll::Absolute { .. } => Some(PendingScroll::Absolute { + item_ix: scroll_top.item_ix, + offset: scroll_top.offset_in_item, + }), + PendingScroll::Proportional(_) => { + let mut cursor = self.items.cursor::(()); + cursor.seek(&Count(scroll_top.item_ix), Bias::Right); + cursor + .item() + .and_then(|item| item.size_hint()) + .filter(|size| size.height.0 > 0.0) + .map(|size| { + PendingScroll::Proportional(PendingScrollFraction { + item_ix: scroll_top.item_ix, + fraction: (scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0), + }) + }) + } + }; + } + fn max_scroll_offset(&self) -> Pixels { let bounds = self.last_layout_bounds.unwrap_or_default(); let height = self @@ -827,17 +867,21 @@ impl StateInner { .min(scroll_max); if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { + self.pending_scroll = None; self.logical_scroll_top = None; } else { let (start, ..) = self.items .find::((), &Height(new_scroll_top), Bias::Right); - let item_ix = start.count; - let offset_in_item = new_scroll_top - start.height; - self.logical_scroll_top = Some(ListOffset { - item_ix, - offset_in_item, - }); + let scroll_top = ListOffset { + item_ix: start.count, + offset_in_item: new_scroll_top - start.height, + }; + // The user's scroll supersedes the position stashed by a + // remeasure; re-anchor the pending adjustment so it doesn't revert + // this scroll on the next layout. + self.rebase_pending_scroll(scroll_top); + self.logical_scroll_top = Some(scroll_top); } if delta.y > px(0.) { @@ -1266,6 +1310,7 @@ impl StateInner { if dragged_to_end && matches!(self.follow_state, FollowState::Tail { .. }) { self.follow_state = FollowState::Tail { is_following: true }; let item_count = self.items.summary().count; + self.pending_scroll = None; self.logical_scroll_top = Some(ListOffset { item_ix: item_count, offset_in_item: px(0.), @@ -1276,18 +1321,19 @@ impl StateInner { self.follow_state.stop_following(); if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { + self.pending_scroll = None; self.logical_scroll_top = None; } else { let (start, _, _) = self.items .find::((), &Height(new_scroll_top), Bias::Right); - let item_ix = start.count; - let offset_in_item = new_scroll_top - start.height; - self.logical_scroll_top = Some(ListOffset { - item_ix, - offset_in_item, - }); + let scroll_top = ListOffset { + item_ix: start.count, + offset_in_item: new_scroll_top - start.height, + }; + self.rebase_pending_scroll(scroll_top); + self.logical_scroll_top = Some(scroll_top); } } } @@ -1978,6 +2024,124 @@ mod test { assert_eq!(offset.offset_in_item, px(40.)); } + #[gpui::test] + fn test_remeasure_then_scroll_does_not_revert_scroll_position(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(20, crate::ListAlignment::Top, px(10.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(100.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = { + let state = state.clone(); + cx.update(|_, cx| cx.new(|_| TestView(state))) + }; + + state.scroll_to(gpui::ListOffset { + item_ix: 5, + offset_in_item: px(40.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + state.remeasure_items(5..6); + + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(-30.))), + ..Default::default() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 5); + assert_eq!(offset.offset_in_item, px(70.)); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 5); + assert_eq!( + offset.offset_in_item, + px(70.), + "scrolling after a remeasure should not be reverted by the stale pending scroll" + ); + } + + #[gpui::test] + fn test_scroll_after_remeasure_clamps_to_shrunk_item_height(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let item_height = Rc::new(Cell::new(100usize)); + let state = ListState::new(20, crate::ListAlignment::Top, px(10.)); + + struct TestView { + state: ListState, + item_height: Rc>, + } + + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let height = self.item_height.get(); + list(self.state.clone(), move |index, _, _| { + let height = if index == 5 { height } else { 100 }; + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = { + let state = state.clone(); + let item_height = item_height.clone(); + cx.update(|_, cx| cx.new(|_| TestView { state, item_height })) + }; + + state.scroll_to(gpui::ListOffset { + item_ix: 5, + offset_in_item: px(40.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + // Item 5 shrinks from 100px to 50px and is remeasured... + item_height.set(50); + state.remeasure_items(5..6); + + // ...and then the user scrolls down by 30px before the next frame, + // landing at offset 70. + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(-30.))), + ..Default::default() + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + // The rebased pending scroll clamps the user's offset to the item's + // new height instead of leaving it pointing past the end of the item. + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 5); + assert_eq!(offset.offset_in_item, px(50.)); + } + #[gpui::test] fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); From 715cd42d36674891e92daf1932d7c655392e45c9 Mon Sep 17 00:00:00 2001 From: Nick Ebert Date: Wed, 10 Jun 2026 21:15:32 -0700 Subject: [PATCH 34/38] Fix shadow and gradient artifacts on transparent windows (#58981) On transparent or blurred window backgrounds, several agent panel surfaces and the multibuffer header rendered drop shadows and fade gradients that have no opaque surface to blend into, so they showed up as dark halos or colored patches: - agent message boxes and the activity bar (drop shadows) - diff-hunk Reject/Keep controls (drop shadow) - the diff review pane (double-painted `editor_background`) - agent thread-list rows, the conversation title edit affordance, and sidebar project headers (fade gradient) - the multibuffer buffer header (`editor_subheader_background` + sticky shadow) These are skipped when `window_background_appearance` isn't `Opaque`. Opaque windows are unchanged; on transparent windows the elements stay delineated by their borders and truncate text with an ellipsis instead of fading. ## Screenshots Before: Screenshot 2026-06-09 at 3 26 26
PM After: Screenshot 2026-06-09 at 3 26 29
PM Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed dark shadow and gradient artifacts in the agent panel and multibuffer headers when using a transparent or blurred window background --- crates/agent_ui/src/agent_diff.rs | 10 ++++-- crates/agent_ui/src/agent_panel.rs | 31 ++++++++++------- .../src/conversation_view/thread_view.rs | 34 +++++++++++++------ crates/editor/src/element/header.rs | 15 +++++--- crates/sidebar/src/sidebar.rs | 14 ++++++-- crates/ui/src/components/ai/thread_item.rs | 30 +++++++++++----- 6 files changed, 92 insertions(+), 42 deletions(-) diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index a4ae164c51ff48..316c7aaeeb54d1 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -684,7 +684,10 @@ impl Render for AgentDiffPane { .on_action(cx.listener(Self::reject)) .on_action(cx.listener(Self::reject_all)) .on_action(cx.listener(Self::keep_all)) - .bg(cx.theme().colors().editor_background) + // Only paint the background for the empty state. When the diff editor + // is shown it already paints `editor_background`; painting it again + // here double-composites into a darker patch on transparent windows. + .when(is_empty, |el| el.bg(cx.theme().colors().editor_background)) .flex() .items_center() .justify_center() @@ -756,6 +759,9 @@ fn render_diff_hunk_controls( cx: &mut App, ) -> AnyElement { let editor = editor.clone(); + // Drop shadows render as a dark halo on transparent windows. + let opaque_window = + cx.theme().window_background_appearance() == gpui::WindowBackgroundAppearance::Opaque; h_flex() .h(line_height) @@ -770,7 +776,7 @@ fn render_diff_hunk_controls( .bg(cx.theme().colors().editor_background) .gap_1() .block_mouse_except_scroll() - .shadow_md() + .when(opaque_window, |this| this.shadow_md()) .children(vec![ Button::new(("reject", row as u64), "Reject") .disabled(is_created_file) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index e376cf17bdbc52..4bf884c775d303 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -5463,6 +5463,10 @@ impl AgentPanel { .width(px(64.0)) .right(px(0.0)) .gradient_stop(0.75); + // The fade gradient renders as a visible patch on transparent windows + // (the title already truncates). + let opaque_window = + cx.theme().window_background_appearance() == gpui::WindowBackgroundAppearance::Opaque; h_flex() .key_context("TitleEditor") @@ -5474,19 +5478,20 @@ impl AgentPanel { .overflow_x_hidden() .child(content) .when(self.should_show_title_edit(window, cx), |this| { - this.child(gradient_overlay).child( - h_flex() - .visible_on_hover("title_editor") - .absolute() - .right_0() - .h_full() - .bg(cx.theme().colors().tab_bar_background) - .child( - IconButton::new("edit_tile", IconName::Pencil) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Edit Thread Title")), - ), - ) + this.when(opaque_window, |this| this.child(gradient_overlay)) + .child( + h_flex() + .visible_on_hover("title_editor") + .absolute() + .right_0() + .h_full() + .bg(cx.theme().colors().tab_bar_background) + .child( + IconButton::new("edit_tile", IconName::Pencil) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Edit Thread Title")), + ), + ) }) .into_any() } diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 6180ad43f7d80d..201d553ee2d853 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -2831,6 +2831,10 @@ impl ThreadView { let queue_expanded = self.queue_expanded; let max_content_width = AgentSettings::get_global(cx).max_content_width; + // Drop shadows have no opaque surface to blend into on a transparent + // window, so they render as a dark halo; only apply them when opaque. + let opaque_window = + cx.theme().window_background_appearance() == gpui::WindowBackgroundAppearance::Opaque; h_flex() .w_full() @@ -2848,13 +2852,15 @@ impl ThreadView { .border_b_0() .border_color(cx.theme().colors().border) .rounded_t_md() - .shadow(vec![gpui::BoxShadow { - color: gpui::black().opacity(0.12), - offset: point(px(1.), px(-1.)), - blur_radius: px(2.), - spread_radius: px(0.), - inset: false, - }]) + .when(opaque_window, |this| { + this.shadow(vec![gpui::BoxShadow { + color: gpui::black().opacity(0.12), + offset: point(px(1.), px(-1.)), + blur_radius: px(2.), + spread_radius: px(0.), + inset: false, + }]) + }) .when_some(awaiting_permission, |this, element| this.child(element)) .when( has_awaiting_permission @@ -5481,6 +5487,9 @@ impl ThreadView { let editing = self.editing_message == Some(entry_ix); let editor_focus = editor.focus_handle(cx).is_focused(window); let focus_border = cx.theme().colors().border_focused; + // Drop shadows render as a dark halo on transparent windows. + let opaque_window = cx.theme().window_background_appearance() + == gpui::WindowBackgroundAppearance::Opaque; let has_checkpoint_button = message .checkpoint @@ -5539,7 +5548,9 @@ impl ThreadView { .bg(cx.theme().colors().editor_background) .border_1() .when(is_indented, |this| { - this.py_2().px_2().shadow_sm() + this.py_2().px_2().when(opaque_window, |this| { + this.shadow_sm() + }) }) .border_color(cx.theme().colors().border) .map(|this| { @@ -5555,9 +5566,10 @@ impl ThreadView { if editing && !editor_focus { return this.border_dashed() } - this.shadow_md().hover(|s| { - s.border_color(focus_border.opacity(0.8)) - }) + this.when(opaque_window, |this| this.shadow_md()) + .hover(|s| { + s.border_color(focus_border.opacity(0.8)) + }) }) .text_xs() .child(editor.clone().into_any_element()) diff --git a/crates/editor/src/element/header.rs b/crates/editor/src/element/header.rs index 02dfebdfc313f1..052afe1de6ae30 100644 --- a/crates/editor/src/element/header.rs +++ b/crates/editor/src/element/header.rs @@ -8,8 +8,8 @@ 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, + ShapedLine, SharedString, Styled, TextAlign, Window, WindowBackgroundAppearance, div, fill, + linear_color_stop, linear_gradient, point, px, size, }; use language::language_settings::ShowWhitespaceSetting; use multi_buffer::{Anchor, ExcerptBoundaryInfo}; @@ -663,6 +663,11 @@ pub(crate) fn render_buffer_header( }; let focus_handle = editor_read.focus_handle(cx); let colors = cx.theme().colors(); + // On transparent windows `editor_subheader_background` stacks over the + // editor background into a darker bar (and the sticky shadow becomes a halo), + // so skip both unless the window is opaque. + let opaque_window = + cx.theme().window_background_appearance() == WindowBackgroundAppearance::Opaque; let header = div() .id(("buffer-header", buffer_id.to_proto())) @@ -678,7 +683,7 @@ pub(crate) fn render_buffer_header( .pr_2() .rounded_sm() .gap_1p5() - .when(is_sticky, |el| el.shadow_md()) + .when(is_sticky && opaque_window, |el| el.shadow_md()) .border_1() .map(|border| { let border_color = @@ -689,7 +694,9 @@ pub(crate) fn render_buffer_header( }; border.border_color(border_color) }) - .bg(colors.editor_subheader_background) + .when(opaque_window, |el| { + el.bg(colors.editor_subheader_background) + }) .hover(|style| style.bg(colors.element_hover)) .map(|header| { let editor = editor.clone(); diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 4f5f3d8d307b2f..300d40d518a57b 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -30,7 +30,8 @@ use feature_flags::{ use gpui::{ Action as _, AnyElement, App, ClickEvent, Context, DismissEvent, Entity, EntityId, FocusHandle, Focusable, KeyContext, ListState, Modifiers, Pixels, Render, SharedString, Task, TaskExt, - WeakEntity, Window, WindowHandle, linear_color_stop, linear_gradient, list, prelude::*, px, + WeakEntity, Window, WindowBackgroundAppearance, WindowHandle, linear_color_stop, + linear_gradient, list, prelude::*, px, }; use itertools::Itertools; use language_model::LanguageModelRegistry; @@ -2288,13 +2289,20 @@ impl Sidebar { let key_for_toggle = key.clone(); let key_for_focus = key.clone(); + // The fade gradient renders as a visible patch on transparent windows, + // so truncate the label instead. + let opaque_window = + cx.theme().window_background_appearance() == WindowBackgroundAppearance::Opaque; + let label = if highlight_positions.is_empty() { Label::new(label.clone()) .when(!is_active, |this| this.color(Color::Muted)) + .when(!opaque_window, |this| this.truncate()) .into_any_element() } else { HighlightedLabel::new(label.clone(), highlight_positions.to_vec()) .when(!is_active, |this| this.color(Color::Muted)) + .when(!opaque_window, |this| this.truncate()) .into_any_element() }; @@ -2402,12 +2410,12 @@ impl Sidebar { ) }), ) - .child(gradient_overlay()) + .children(opaque_window.then(|| gradient_overlay())) .child( h_flex() .gap_px() .pr_1p5() - .child(gradient_overlay()) + .children(opaque_window.then(|| gradient_overlay())) .child(self.render_new_thread_button(ix, id_prefix, key, &group_name, cx)) .child(self.render_project_header_ellipsis_menu( ix, diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 6f72405c82b505..caf36f41772880 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -1,7 +1,8 @@ use crate::{CommonAnimationExt, DiffStat, GradientFade, HighlightedLabel, Tooltip, prelude::*}; use gpui::{ - Animation, AnimationExt, ClickEvent, Hsla, MouseButton, SharedString, pulsating_between, + Animation, AnimationExt, ClickEvent, Hsla, MouseButton, SharedString, + WindowBackgroundAppearance, pulsating_between, }; use itertools::Itertools as _; use std::{path::PathBuf, sync::Arc, time::Duration}; @@ -250,6 +251,11 @@ impl ThreadItem { impl RenderOnce for ThreadItem { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let color = cx.theme().colors(); + // The fade gradient paints a solid color over the title to blend it into + // the row background, but a transparent window has no opaque surface to + // fade into, so it renders as a visible patch; truncate the title instead. + let opaque_window = + cx.theme().window_background_appearance() == WindowBackgroundAppearance::Opaque; let sidebar_base_bg = color .title_bar_background .blend(color.panel_background.opacity(0.25)); @@ -365,10 +371,12 @@ impl RenderOnce for ThreadItem { } else if highlight_positions.is_empty() { Label::new(title) .when_some(self.title_label_color, |label, color| label.color(color)) + .when(!opaque_window, |label| label.truncate()) .into_any_element() } else { HighlightedLabel::new(title, highlight_positions) .when_some(self.title_label_color, |label, color| label.color(color)) + .when(!opaque_window, |label| label.truncate()) .into_any_element() }; @@ -449,20 +457,24 @@ impl RenderOnce for ThreadItem { .child(icon) .child(title_label), ) - .when(self.is_truncated, |this| this.child(gradient_overlay)) + .when(self.is_truncated && opaque_window, |this| { + this.child(gradient_overlay) + }) .when(self.hovered, |this| { this.when_some(self.action_slot, |this, slot| { - let overlay = GradientFade::new(base_bg, hover_bg, hover_bg) - .width(px(120.0)) - .right(px(8.)) - .gradient_stop(0.90) - .group_name("thread-item"); - this.child( h_flex() .relative() .pr_1p5() - .child(overlay) + .when(opaque_window, |this| { + this.child( + GradientFade::new(base_bg, hover_bg, hover_bg) + .width(px(120.0)) + .right(px(8.)) + .gradient_stop(0.90) + .group_name("thread-item"), + ) + }) .child(slot) .on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation() From 81e57f0cf14508bebe5a1c9c1af4d349c8a721f3 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 11 Jun 2026 14:38:44 +0800 Subject: [PATCH 35/38] gpui: add WindowOptions::host_window_handle for hosting windows in external native views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows a GPUI window to render into (and receive input from) an existing native view instead of creating an OS window of its own — e.g. hosting GPUI content inside a shown NSPopover, an NSStatusItem, or an existing AppKit application. macOS implementation: - The GPUI view is added to the host view as an autoresizing subview. - Input positions are always local to the GPUI view: event locations are shifted by the view's origin within its window (a no-op for regular windows, whose view fills the window). - The display link follows ownership: occlusion-based gating only applies to windows we own and receive notifications for. - content_size is the GPUI view's own bounds. - On drop, a hosted window detaches its view instead of closing the host window it does not own. Windows (WS_CHILD) and X11 (child window) implementations follow in subsequent commits; Wayland returns an error for now. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 3 + crates/gpui/Cargo.toml | 24 ++ crates/gpui/examples/hosted_popover.rs | 373 +++++++++++++++++++++++++ crates/gpui/examples/hosted_window.rs | 172 ++++++++++++ crates/gpui/src/platform.rs | 26 +- crates/gpui/src/window.rs | 2 + crates/gpui_macos/src/platform.rs | 6 + crates/gpui_macos/src/window.rs | 289 +++++++++++++++++-- 8 files changed, 876 insertions(+), 19 deletions(-) create mode 100644 crates/gpui/examples/hosted_popover.rs create mode 100644 crates/gpui/examples/hosted_window.rs diff --git a/Cargo.lock b/Cargo.lock index dbae254fb27866..4bfe52c210c86b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7764,6 +7764,8 @@ dependencies = [ "num_cpus", "objc", "objc2 0.6.3", + "objc2-app-kit 0.3.1", + "objc2-foundation 0.3.2", "objc2-metal 0.3.2", "parking", "parking_lot", @@ -11848,6 +11850,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ + "bitflags 2.10.0", "objc2 0.6.3", "objc2-foundation 0.3.2", ] diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 2f9c47dbef9935..5a884482de5bd6 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -164,8 +164,24 @@ unicode-segmentation = { workspace = true } [target.'cfg(not(target_family = "wasm"))'.dev-dependencies] http_client = { workspace = true, features = ["test-support"] } +raw-window-handle = "0.6" reqwest_client = { workspace = true, features = ["test-support"] } +# For the hosted_popover / hosted_window examples, which build native AppKit +# hosts (NSPopover, NSWindow) for GPUI windows. +[target.'cfg(target_os = "macos")'.dev-dependencies] +objc2 = "0.6" +objc2-app-kit = { workspace = true, features = [ + "NSControl", + "NSPopover", + "NSResponder", + "NSTextField", + "NSView", + "NSViewController", + "NSWindow", +] } +objc2-foundation = { workspace = true } + [target.'cfg(target_family = "wasm")'.dev-dependencies] wasm-bindgen = { workspace = true } gpui_web.workspace = true @@ -186,6 +202,14 @@ cbindgen = { version = "0.28.0", default-features = false } name = "hello_world" path = "examples/hello_world.rs" +[[example]] +name = "hosted_popover" +path = "examples/hosted_popover.rs" + +[[example]] +name = "hosted_window" +path = "examples/hosted_window.rs" + [[example]] name = "move_entity_between_windows" path = "examples/move_entity_between_windows.rs" diff --git a/crates/gpui/examples/hosted_popover.rs b/crates/gpui/examples/hosted_popover.rs new file mode 100644 index 00000000000000..c0a4fedd9f8e31 --- /dev/null +++ b/crates/gpui/examples/hosted_popover.rs @@ -0,0 +1,373 @@ +//! Renders GPUI content inside a native macOS `NSPopover`, using +//! `WindowOptions::host_window_handle`. +//! +//! The popover supplies the system chrome — arrow, vibrant backdrop, show +//! animation, and transient dismissal (click outside to close) — while its +//! content is an ordinary GPUI window: any element renders, and input works +//! exactly as in a regular window. +//! +//! Run with: `cargo run -p gpui --example hosted_popover` (macOS only). + +#![cfg_attr(target_family = "wasm", no_main)] + +#[cfg(target_os = "macos")] +mod example { + use gpui::{ + App, Bounds, Context, FocusHandle, FontWeight, KeyBinding, MouseButton, Pixels, + SharedString, Size, Window, WindowBackgroundAppearance, WindowBounds, WindowOptions, + actions, canvas, div, point, prelude::*, px, rgb, rgba, size, + }; + use std::cell::Cell; + use std::rc::Rc; + + actions!(hosted_popover, [NewDocument, OpenRecent, Share]); + + const KEY_CONTEXT: &str = "HostedPopover"; + use gpui_platform::application; + use objc2::MainThreadMarker; + use objc2_app_kit::{NSPopover, NSPopoverBehavior, NSView, NSViewController}; + use objc2_foundation::{NSPoint, NSRect, NSRectEdge, NSSize}; + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use std::ptr::NonNull; + use std::time::Duration; + + /// Content rendered inside the popover — a regular GPUI view, styled like a + /// native quick-actions panel. No opaque background is painted, so the + /// popover's vibrant backdrop shows through. The rows' keyboard shortcuts + /// are real GPUI key bindings, demonstrating that hosted windows receive + /// keyboard input. + struct PopoverContent { + focus_handle: FocusHandle, + last_action: Option<&'static str>, + } + + impl PopoverContent { + fn trigger(&mut self, label: &'static str, cx: &mut Context) { + self.last_action = Some(label); + cx.notify(); + } + + fn row( + &self, + id: &'static str, + swatch: u32, + label: &'static str, + shortcut: &'static str, + cx: &mut Context, + ) -> impl IntoElement { + div() + .id(SharedString::new_static(id)) + .flex() + .items_center() + .gap_2() + .px_2() + .h(px(28.)) + .rounded_md() + .hover(|style| style.bg(rgba(0x3b82f626))) + .active(|style| style.bg(rgba(0x3b82f640))) + .child(div().size(px(14.)).rounded_sm().bg(rgb(swatch))) + .child(div().flex_1().text_size(px(13.)).child(label)) + .child( + div() + .text_size(px(11.)) + .text_color(rgba(0x3c3c4366)) + .child(shortcut), + ) + .on_click(cx.listener(move |this, _, _, cx| this.trigger(label, cx))) + } + + fn divider(&self) -> impl IntoElement { + div().h(px(1.)).w_full().bg(rgba(0x3c3c431f)) + } + } + + impl Render for PopoverContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .flex() + .flex_col() + .gap_2() + .p_3() + .size_full() + .text_color(rgba(0x000000d9)) + .key_context(KEY_CONTEXT) + .track_focus(&self.focus_handle) + .on_action(cx.listener(|this, _: &NewDocument, _, cx| { + this.trigger("New Document", cx) + })) + .on_action( + cx.listener(|this, _: &OpenRecent, _, cx| this.trigger("Open Recent", cx)), + ) + .on_action(cx.listener(|this, _: &Share, _, cx| this.trigger("Share…", cx))) + .child( + div() + .px_2() + .flex() + .flex_col() + .child( + div() + .text_size(px(14.)) + .font_weight(FontWeight::SEMIBOLD) + .child("Quick Actions"), + ) + .child( + div() + .text_size(px(11.)) + .text_color(rgba(0x3c3c4380)) + .child("Rendered by GPUI inside an NSPopover"), + ), + ) + .child(self.divider()) + .child( + div() + .flex() + .flex_col() + .child(self.row("new", 0x3b82f6, "New Document", "⌘N", cx)) + .child(self.row("open", 0x22c55e, "Open Recent", "⌘O", cx)) + .child(self.row("share", 0xf59e0b, "Share…", "⇧⌘S", cx)), + ) + .child(self.divider()) + .child( + div() + .px_2() + .flex() + .items_center() + .child( + div() + .flex_1() + .text_size(px(12.)) + .text_color(rgba(0x3c3c4380)) + .child(match self.last_action { + Some(label) => format!("Last action: {label}"), + None => "Click a row or press a shortcut".to_string(), + }), + ) + .child( + div() + .id("reset") + .px_2p5() + .py_1() + .rounded_md() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .bg(rgba(0x7878801f)) + .text_color(rgba(0x000000d9)) + .hover(|style| style.bg(rgba(0x78788033))) + .active(|style| style.bg(rgba(0x78788047))) + .child("Reset") + .on_click(cx.listener(|this, _, _, cx| { + this.last_action = None; + cx.notify(); + })), + ), + ) + } + } + + /// The main window: a trigger that opens the native popover. + struct MainView { + /// The trigger's bounds, captured at layout so the popover can anchor + /// to the button itself. + trigger_bounds: Rc>>, + } + + impl Render for MainView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let capture = self.trigger_bounds.clone(); + let anchor = self.trigger_bounds.clone(); + div() + .flex() + .flex_col() + .gap_2() + .size_full() + .justify_center() + .items_center() + .bg(rgb(0xf5f5f7)) + .child( + div() + .relative() + .child( + div() + .id("trigger") + .px_4() + .py_2() + .rounded_lg() + .bg(rgb(0x1d1d1f)) + .text_color(gpui::white()) + .font_weight(FontWeight::MEDIUM) + .hover(|style| style.bg(rgb(0x2c2c2e))) + .active(|style| style.bg(rgb(0x3a3a3c))) + .child("Open native popover") + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + open_popover(anchor.get(), window, cx); + }), + ) + .child( + // Records the trigger's window-relative bounds. + canvas(move |bounds, _, _| capture.set(bounds), |_, _, _, _| {}) + .absolute() + .inset_0(), + ), + ) + .child( + div() + .text_size(px(12.)) + .text_color(rgba(0x3c3c4366)) + .child("System arrow & vibrancy — content rendered by GPUI"), + ) + } + } + + /// Shows an `NSPopover` anchored to `anchor` (the trigger's window-relative + /// bounds) and opens a GPUI window hosted inside its content view. + fn open_popover(anchor: Bounds, window: &mut Window, cx: &mut App) { + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + // The popover is anchored to the triggering window's content view. + let Ok(handle) = HasWindowHandle::window_handle(window) else { + return; + }; + let RawWindowHandle::AppKit(parent) = handle.as_raw() else { + return; + }; + // SAFETY: the pointer comes from the live window's AppKit handle and is + // only used synchronously while the window is alive. + let parent_view: &NSView = unsafe { &*parent.ns_view.as_ptr().cast() }; + + let content_size: Size = size(px(320.), px(208.)); + + // Native popover shell: a plain container view in a view controller. + // SAFETY: all objects are created and used on the main thread, and the + // popover/controller/container are kept alive until the popover closes. + let (popover, controller, container) = unsafe { + let container = NSView::new(mtm); + container.setFrameSize(NSSize::new( + f32::from(content_size.width) as f64, + f32::from(content_size.height) as f64, + )); + let controller = NSViewController::new(mtm); + controller.setView(&container); + + let popover = NSPopover::new(mtm); + popover.setBehavior(NSPopoverBehavior::Transient); + popover.setContentViewController(Some(&controller)); + + // GPUI's anchor bounds are top-left based; the (non-flipped) + // positioning view is bottom-left based, so flip y against its + // height. + let parent_height = parent_view.bounds().size.height; + let rect = NSRect::new( + NSPoint::new( + f32::from(anchor.origin.x) as f64, + parent_height + - (f32::from(anchor.origin.y) + f32::from(anchor.size.height)) as f64, + ), + NSSize::new( + f32::from(anchor.size.width) as f64, + f32::from(anchor.size.height) as f64, + ), + ); + popover.showRelativeToRect_ofView_preferredEdge(rect, parent_view, NSRectEdge::MinY); + (popover, controller, container) + }; + + // The container is now installed in the popover's window; render a GPUI + // window into it. + let container_ptr = NonNull::new(objc2::rc::Retained::as_ptr(&container) as *mut _) + .expect("container view pointer is non-null"); + let gpui_window = cx + .open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: point(px(0.), px(0.)), + size: content_size, + })), + window_background: WindowBackgroundAppearance::Transparent, + host_window_handle: Some(RawWindowHandle::AppKit( + raw_window_handle::AppKitWindowHandle::new(container_ptr), + )), + ..Default::default() + }, + |window, cx| { + let content = cx.new(|cx| PopoverContent { + focus_handle: cx.focus_handle(), + last_action: None, + }); + let focus_handle = content.read(cx).focus_handle.clone(); + window.focus(&focus_handle, cx); + content + }, + ) + .expect("failed to open hosted window"); + + // Let the popover's window receive keyboard input immediately, so the + // shortcuts work without clicking inside first. + // SAFETY: main thread; the container was just installed in the + // popover's window by `show...` above. + unsafe { + if let Some(popover_window) = container.window() { + popover_window.makeKeyWindow(); + } + } + + // When the popover is dismissed (e.g. by clicking outside), close the + // hosted GPUI window. Production code would use an `NSPopoverDelegate`; + // polling keeps this example small. + cx.spawn(async move |cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(50)) + .await; + // SAFETY: checked on the main thread; the popover is kept alive + // by this future. + if !unsafe { popover.isShown() } { + break; + } + } + gpui_window + .update(cx, |_, window, _| window.remove_window()) + .ok(); + drop((popover, controller, container)); + }) + .detach(); + } + + pub fn run() { + application().run(|cx: &mut App| { + cx.bind_keys([ + KeyBinding::new("cmd-n", NewDocument, Some(KEY_CONTEXT)), + KeyBinding::new("cmd-o", OpenRecent, Some(KEY_CONTEXT)), + KeyBinding::new("shift-cmd-s", Share, Some(KEY_CONTEXT)), + ]); + let bounds = Bounds::centered(None, size(px(500.), px(320.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| { + cx.new(|_| MainView { + trigger_bounds: Rc::new(Cell::new(Bounds::default())), + }) + }, + ) + .unwrap(); + cx.activate(true); + }); + } +} + +#[cfg(not(target_family = "wasm"))] +fn main() { + #[cfg(target_os = "macos")] + example::run(); + #[cfg(not(target_os = "macos"))] + println!("This example demonstrates hosting GPUI inside an NSPopover and is macOS-only."); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + println!("This example is macOS-only."); +} diff --git a/crates/gpui/examples/hosted_window.rs b/crates/gpui/examples/hosted_window.rs new file mode 100644 index 00000000000000..425a531cf22986 --- /dev/null +++ b/crates/gpui/examples/hosted_window.rs @@ -0,0 +1,172 @@ +//! Embeds a GPUI window inside a window created with the platform's native +//! APIs, using `WindowOptions::host_window_handle` — the building block for +//! adopting GPUI incrementally inside an existing native application. +//! +//! The window itself, its title bar, and the label at the top are plain +//! AppKit; the interactive area below is rendered and driven entirely by GPUI. +//! +//! Run with: `cargo run -p gpui --example hosted_window` (macOS today; +//! Windows/X11 hosts work the same way through `host_window_handle`). + +#![cfg_attr(target_family = "wasm", no_main)] + +#[cfg(target_os = "macos")] +mod example { + use gpui::{ + App, Bounds, Context, Window, WindowBounds, WindowOptions, div, point, prelude::*, px, + rgb, size, + }; + use gpui_platform::application; + use objc2::{MainThreadMarker, MainThreadOnly}; + use objc2_app_kit::{ + NSAutoresizingMaskOptions, NSBackingStoreType, NSTextField, NSView, NSWindow, + NSWindowStyleMask, + }; + use objc2_foundation::{NSPoint, NSRect, NSSize, NSString}; + use raw_window_handle::{AppKitWindowHandle, RawWindowHandle}; + use std::ptr::NonNull; + + const WIDTH: f64 = 560.; + const HEIGHT: f64 = 400.; + const HEADER: f64 = 48.; + + /// The GPUI content embedded in the native window. + struct Embedded { + clicks: usize, + } + + impl Render for Embedded { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .flex() + .flex_col() + .gap_3() + .p_4() + .size_full() + .bg(rgb(0x14161b)) + .text_color(rgb(0xe6e6e6)) + .child(div().text_xl().child("This area is GPUI")) + .child(div().text_sm().text_color(rgb(0x9a9a9a)).child( + "Rendered into a plain NSView of an AppKit window via \ + WindowOptions::host_window_handle.", + )) + .child( + div() + .id("counter") + .px_3() + .py_2() + .rounded_md() + .bg(rgb(0x3b82f6)) + .text_color(gpui::white()) + .child(format!("Clicked {} times", self.clicks)) + .on_click(cx.listener(|this, _, _, cx| { + this.clicks += 1; + cx.notify(); + })), + ) + .child( + div().flex().gap_2().children((0..6).map(|i| { + div() + .id(i) + .size_8() + .rounded_md() + .bg(rgb(0x2b2f3a)) + .hover(|style| style.bg(rgb(0x3b82f6))) + })), + ) + } + } + + pub fn run() { + application().run(|cx: &mut App| { + let mtm = MainThreadMarker::new().expect("must run on the main thread"); + + // --- Plain AppKit: a native window with a native label. --- + // SAFETY: all objects are created and used on the main thread and + // are kept alive for the lifetime of the process (see the + // `mem::forget` below). + let (native_window, host_view, label) = unsafe { + let rect = NSRect::new(NSPoint::new(200., 200.), NSSize::new(WIDTH, HEIGHT)); + let style = NSWindowStyleMask::Titled + | NSWindowStyleMask::Closable + | NSWindowStyleMask::Miniaturizable; + let native_window = NSWindow::initWithContentRect_styleMask_backing_defer( + NSWindow::alloc(mtm), + rect, + style, + NSBackingStoreType::Buffered, + false, + ); + native_window.setTitle(&NSString::from_str("Native AppKit window")); + let content_view = native_window + .contentView() + .expect("native window has a content view"); + + let label = NSTextField::labelWithString( + &NSString::from_str("This label and window are plain AppKit ↓ below is GPUI"), + mtm, + ); + label.setFrame(NSRect::new( + NSPoint::new(16., HEIGHT - HEADER + 14.), + NSSize::new(WIDTH - 32., 20.), + )); + content_view.addSubview(&label); + + // The host view GPUI renders into: the area below the header. + let host_view = NSView::new(mtm); + host_view.setFrame(NSRect::new( + NSPoint::new(0., 0.), + NSSize::new(WIDTH, HEIGHT - HEADER), + )); + host_view.setAutoresizingMask( + NSAutoresizingMaskOptions::ViewWidthSizable + | NSAutoresizingMaskOptions::ViewHeightSizable, + ); + content_view.addSubview(&host_view); + + native_window.makeKeyAndOrderFront(None); + (native_window, host_view, label) + }; + + // --- GPUI: render into the host view. --- + let host_ptr = NonNull::new(objc2::rc::Retained::as_ptr(&host_view) as *mut _) + .expect("host view pointer is non-null"); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: point(px(0.), px(0.)), + size: size(px(WIDTH as f32), px((HEIGHT - HEADER) as f32)), + })), + host_window_handle: Some(RawWindowHandle::AppKit(AppKitWindowHandle::new( + host_ptr, + ))), + ..Default::default() + }, + |_, cx| cx.new(|_| Embedded { clicks: 0 }), + ) + .expect("failed to open hosted window"); + + // The native window lives for the rest of the process. + std::mem::forget((native_window, host_view, label)); + + cx.activate(true); + }); + } +} + +#[cfg(not(target_family = "wasm"))] +fn main() { + #[cfg(target_os = "macos")] + example::run(); + #[cfg(not(target_os = "macos"))] + println!( + "This example currently builds its native host with AppKit and is macOS-only; \ + Windows/X11 hosts work the same way through WindowOptions::host_window_handle." + ); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + println!("This example is macOS-only."); +} diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 60355b8e2446bd..359e12ca7f9e05 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -48,7 +48,7 @@ use futures::channel::oneshot; use image::RgbaImage; use image::codecs::gif::GifDecoder; use image::{AnimationDecoder as _, Frame}; -use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; +use raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawWindowHandle}; use scheduler::Instant; pub use scheduler::RunnableMeta; use schemars::JsonSchema; @@ -1525,6 +1525,24 @@ pub struct WindowOptions { /// Tab group name, allows opening the window as a native tab on macOS 10.12+. Windows with the same tabbing identifier will be grouped together. pub tabbing_identifier: Option, + + /// If set, render this window into the given existing native view instead + /// of creating an operating-system window of its own. The window draws + /// into, and receives input from, the host view; input positions are + /// always local to the GPUI view. The caller retains ownership of the host + /// view and its window, and must close this window before the host goes + /// away. Options that configure an OS window (`titlebar`, `kind`, + /// `is_movable`, etc.) are ignored. + /// + /// Platform-specific: + /// - macOS: an `AppKit` handle whose `ns_view` is installed in a window; + /// the GPUI view is added as an autoresizing subview (e.g. to host GPUI + /// content inside a shown `NSPopover`, an `NSStatusItem`, or an existing + /// AppKit application). + /// - Windows: a `Win32` handle; a `WS_CHILD` window is created inside it. + /// - X11: an `Xcb`/`Xlib` handle; a child X window is created inside it. + /// - Wayland: not yet supported (`open_window` returns an error). + pub host_window_handle: Option, } /// The variables that can be configured when creating a new window @@ -1579,6 +1597,10 @@ pub struct WindowParams { pub window_min_size: Option>, #[cfg(target_os = "macos")] pub tabbing_identifier: Option, + + /// If set, render into this existing native view instead of creating an OS + /// window (see [`WindowOptions::host_window_handle`]). + pub host_window_handle: Option, } /// Represents the status of how a window should be opened. @@ -1638,6 +1660,7 @@ impl Default for WindowOptions { window_min_size: None, window_decorations: None, tabbing_identifier: None, + host_window_handle: None, } } } @@ -1679,6 +1702,7 @@ pub enum WindowKind { Dialog, } + /// The appearance of the window, as defined by the operating system. /// /// On macOS, this corresponds to named [`NSAppearance`](https://developer.apple.com/documentation/appkit/nsappearance) diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index d5fce7a0aafb81..3631b1a6956691 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1301,6 +1301,7 @@ impl Window { icon, #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] tabbing_identifier, + host_window_handle, } = options; let window_bounds = window_bounds.unwrap_or_else(|| default_bounds(display_id, cx)); @@ -1320,6 +1321,7 @@ impl Window { icon, #[cfg(target_os = "macos")] tabbing_identifier, + host_window_handle, }, )?; diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 87346991d64750..b34c2d57b54051 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -627,6 +627,12 @@ impl Platform for MacPlatform { handle: AnyWindowHandle, options: WindowParams, ) -> Result> { + if let Some(host) = options.host_window_handle { + anyhow::ensure!( + matches!(host, raw_window_handle::RawWindowHandle::AppKit(_)), + "host_window_handle on macOS must be an AppKit handle" + ); + } let (cursor_visible, foreground_executor, background_executor, renderer_context) = { let guard = self.0.lock(); ( diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 51adfe50b321b7..4d5677ec2c705c 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -27,7 +27,8 @@ use dispatch2::DispatchQueue; use gpui::{ AnyWindowHandle, BackgroundExecutor, Bounds, Capslock, CursorStyle, ExternalPaths, FileDropEvent, ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, - MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, + MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, Pixels, + PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, SharedString, Size, SystemWindowTab, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, WindowParams, point, @@ -510,6 +511,10 @@ struct MacWindowState { accesskit_adapter: Option, // The parent window if this window is a sheet (Dialog kind) sheet_parent: Option, + // False when the GPUI view is hosted inside an external window we don't own + // (e.g. an NSPopover's internal window): we must not close that window, and + // we receive none of its notifications. + owns_native_window: bool, } impl MacWindowState { @@ -567,10 +572,17 @@ impl MacWindowState { fn start_display_link(&mut self) { self.stop_display_link(); unsafe { - if !self - .native_window - .occlusionState() - .contains(NSWindowOcclusionState::NSWindowOcclusionStateVisible) + // Only honor the occlusion check for windows we own: we receive + // their occlusion notifications and restart the link when they + // become visible again. For views hosted in an external window + // (e.g. an NSPopover's internal window) we get no such + // notifications — and the host may not report itself visible right + // after being shown — so the link must keep running. + if self.owns_native_window + && !self + .native_window + .occlusionState() + .contains(NSWindowOcclusionState::NSWindowOcclusionStateVisible) { return; } @@ -633,8 +645,12 @@ impl MacWindowState { } fn content_size(&self) -> Size { + // The GPUI view's own bounds. For regular windows this equals the window + // content view's frame (the view fills it); for views hosted in an + // external window (whose content view isn't ours) it is the only + // correct answer. let NSSize { width, height, .. } = - unsafe { NSView::frame(self.native_window.contentView()) }.size; + unsafe { NSView::bounds(self.native_view.as_ptr()) }.size; size(px(width as f32), px(height as f32)) } @@ -678,6 +694,7 @@ impl MacWindow { display_id, window_min_size, tabbing_identifier, + host_window_handle, .. }: WindowParams, cursor_visible: Arc, @@ -685,6 +702,18 @@ impl MacWindow { background_executor: BackgroundExecutor, renderer_context: renderer::Context, ) -> Self { + // Non-AppKit handles are rejected by `MacPlatform::open_window`. + if let Some(rwh::RawWindowHandle::AppKit(host)) = host_window_handle { + return Self::open_in_external_view( + handle, + host.ns_view.as_ptr() as id, + bounds, + cursor_visible, + foreground_executor, + background_executor, + renderer_context, + ); + } unsafe { let pool = NSAutoreleasePool::new(nil); @@ -839,6 +868,7 @@ impl MacWindow { closed: Arc::new(AtomicBool::new(false)), accesskit_adapter: None, sheet_parent: None, + owns_native_window: true, }))); (*native_window).set_ivar( @@ -1005,6 +1035,111 @@ impl MacWindow { } } + /// Open a window whose content is rendered by GPUI into an existing, + /// already-on-screen external `NSView` (e.g. the content view of a shown + /// `NSPopover`), instead of creating an `NSWindow` of our own. The GPUI view + /// is added as an autoresizing subview; rendering, input, and frame driving + /// all work as for a regular window. The caller owns the host view/window + /// lifecycle and must close this window when the host goes away. + fn open_in_external_view( + handle: AnyWindowHandle, + external_view: id, + bounds: Bounds, + cursor_visible: Arc, + foreground_executor: ForegroundExecutor, + background_executor: BackgroundExecutor, + renderer_context: renderer::Context, + ) -> Self { + unsafe { + let pool = NSAutoreleasePool::new(nil); + + let host_window: id = msg_send![external_view, window]; + assert!( + !host_window.is_null(), + "external view must already be installed in a window" + ); + + let native_view: id = msg_send![VIEW_CLASS, alloc]; + let native_view = + NSView::initWithFrame_(native_view, NSView::bounds(external_view)); + assert!(!native_view.is_null()); + + let window = Self(Arc::new(Mutex::new(MacWindowState { + handle, + foreground_executor, + background_executor, + native_window: host_window, + native_view: NonNull::new_unchecked(native_view), + blurred_view: None, + background_appearance: WindowBackgroundAppearance::Transparent, + cursor_style: CursorStyle::Arrow, + cursor_visible, + display_link: None, + renderer: renderer::new_renderer( + renderer_context, + host_window as *mut _, + native_view as *mut _, + bounds.size.map(|pixels| pixels.as_f32()), + true, + ), + request_frame_callback: None, + event_callback: None, + activate_callback: None, + resize_callback: None, + moved_callback: None, + should_close_callback: None, + close_callback: None, + appearance_changed_callback: None, + input_handler: None, + last_key_equivalent: None, + synthetic_drag_counter: 0, + traffic_light_position: None, + transparent_titlebar: true, + previous_modifiers_changed_event: None, + keystroke_for_do_command: None, + do_command_handled: None, + external_files_dragged: false, + first_mouse: false, + fullscreen_restore_bounds: Bounds::default(), + move_tab_to_new_window_callback: None, + merge_all_windows_callback: None, + select_next_tab_callback: None, + select_previous_tab_callback: None, + toggle_tab_bar_callback: None, + activated_least_once: false, + closed: Arc::new(AtomicBool::new(false)), + accesskit_adapter: None, + sheet_parent: None, + owns_native_window: false, + }))); + + (*native_view).set_ivar( + WINDOW_STATE_IVAR, + Arc::into_raw(window.0.clone()) as *const c_void, + ); + + native_view.setAutoresizingMask_(NSViewWidthSizable | NSViewHeightSizable); + native_view.setWantsBestResolutionOpenGLSurface_(YES); + native_view.setWantsLayer(YES); + let _: () = msg_send![ + native_view, + setLayerContentsRedrawPolicy: NSViewLayerContentsRedrawDuringViewResize + ]; + + NSView::addSubview_(external_view, native_view.autorelease()); + let _: BOOL = msg_send![host_window, makeFirstResponder: native_view]; + + window.0.lock().start_display_link(); + + // Align the layer's contentsScale and drawable size to the host + // window's backing scale. + update_window_scale_factor(&window.0); + + pool.drain(); + window + } + } + pub fn active_window() -> Option { unsafe { let app = NSApplication::sharedApplication(nil); @@ -1074,20 +1209,30 @@ impl Drop for MacWindow { let mut this = self.0.lock(); this.renderer.destroy(); let window = this.native_window; + let owns_native_window = this.owns_native_window; + let native_view = this.native_view.as_ptr(); let sheet_parent = this.sheet_parent.take(); this.display_link.take(); - unsafe { - this.native_window.setDelegate_(nil); + // An externally hosted view lives in a window we don't own — don't touch + // that window's delegate or close it; just detach our view. + if owns_native_window { + unsafe { + this.native_window.setDelegate_(nil); + } } this.input_handler.take(); this.foreground_executor .spawn(async move { unsafe { - if let Some(parent) = sheet_parent { - let _: () = msg_send![parent, endSheet: window]; + if owns_native_window { + if let Some(parent) = sheet_parent { + let _: () = msg_send![parent, endSheet: window]; + } + window.close(); + window.autorelease(); + } else { + let _: () = msg_send![native_view, removeFromSuperview]; } - window.close(); - window.autorelease(); } }) .detach(); @@ -1229,13 +1374,24 @@ impl PlatformWindow for MacWindow { } fn mouse_position(&self) -> Point { - let position = unsafe { - self.0 - .lock() - .native_window - .mouseLocationOutsideOfEventStream() + let lock = self.0.lock(); + let position = unsafe { lock.native_window.mouseLocationOutsideOfEventStream() }; + // Window coordinates -> view-local coordinates. A no-op for regular + // windows (whose view sits at the window origin), required for views + // hosted inside a larger external window. + let origin: NSPoint = unsafe { + msg_send![ + lock.native_view.as_ptr(), + convertPoint: NSPoint::new(0., 0.) + toView: nil + ] }; - convert_mouse_position(position, self.content_size().height) + let height = px(unsafe { NSView::bounds(lock.native_view.as_ptr()) }.size.height as f32); + drop(lock); + let mut position = convert_mouse_position(position, height); + position.x -= px(origin.x as f32); + position.y += px(origin.y as f32); + position } fn modifiers(&self) -> Modifiers { @@ -2154,6 +2310,103 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { let event = unsafe { platform_input_from_native(native_event, Some(window_height)) }; if let Some(mut event) = event { + // Event locations are window-relative, while GPUI positions are relative + // to its view. For regular windows the view sits at the window origin so + // this is a no-op, but when the view is hosted inside a larger external + // window (e.g. an NSPopover's internal window, which insets the content + // for its arrow/chrome) the positions must be shifted into view-local + // space: with the view's bottom-left corner at (ox, oy) in window + // coords, the correction is x -= ox, y += oy (y was already flipped + // against the view height). + { + let origin: NSPoint = unsafe { + msg_send![ + lock.native_view.as_ptr(), + convertPoint: NSPoint::new(0., 0.) + toView: nil + ] + }; + // Mouse events can carry locations in a different window's + // coordinate space than ours: a hosted view's window may be an + // attached child window (e.g. an NSPopover's), whose mouse-moved + // events flow through the parent window's event stream. Convert + // such locations into our window's space via screen coordinates and + // fold the difference into the correction. Zero for events that + // already belong to our window. + let mut delta = NSPoint::new(0., 0.); + if !lock.owns_native_window { + let event_window: id = unsafe { msg_send![native_event, window] }; + if event_window != lock.native_window { + unsafe { + let raw = native_event.locationInWindow(); + let screen: NSPoint = if event_window.is_null() { + raw + } else { + msg_send![event_window, convertPointToScreen: raw] + }; + let local: NSPoint = + msg_send![lock.native_window, convertPointFromScreen: screen]; + delta = NSPoint::new(local.x - raw.x, local.y - raw.y); + } + } + } + let dx = px((origin.x - delta.x) as f32); + let dy = px((origin.y - delta.y) as f32); + match &mut event { + PlatformInput::MouseDown(e) => { + e.position.x -= dx; + e.position.y += dy; + } + PlatformInput::MouseUp(e) => { + e.position.x -= dx; + e.position.y += dy; + } + PlatformInput::MouseMove(e) => { + e.position.x -= dx; + e.position.y += dy; + } + PlatformInput::MouseExited(e) => { + e.position.x -= dx; + e.position.y += dy; + } + PlatformInput::ScrollWheel(e) => { + e.position.x -= dx; + e.position.y += dy; + } + PlatformInput::Pinch(e) => { + e.position.x -= dx; + e.position.y += dy; + } + PlatformInput::MousePressure(e) => { + e.position.x -= dx; + e.position.y += dy; + } + _ => {} + } + + // For hosted views, a hover move over the host window's chrome + // (outside the view) would otherwise leave a stale hover state — + // there is no tracking area to deliver mouse-exited. Convert it. + if !lock.owns_native_window + && let PlatformInput::MouseMove(e) = &event + && e.pressed_button.is_none() + { + let bounds = unsafe { NSView::bounds(lock.native_view.as_ptr()) }; + let (width, height) = (px(bounds.size.width as f32), px(bounds.size.height as f32)); + if e.position.x < px(0.) + || e.position.y < px(0.) + || e.position.x > width + || e.position.y > height + { + event = PlatformInput::MouseExited(MouseExitEvent { + position: e.position, + pressed_button: None, + modifiers: e.modifiers, + }); + } + } + } + // AppKit unhides the cursor on the next mouse movement; mirror that here. if matches!( event, From 755b30a9845c9b389606c85be85adec22cde85a6 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 11 Jun 2026 16:23:10 +0800 Subject: [PATCH 36/38] Update example --- crates/gpui/Cargo.toml | 9 +- crates/gpui/examples/hosted_popover.rs | 373 ------------- crates/gpui/examples/hosted_window.rs | 742 ++++++++++++++++++++++--- crates/gpui_macos/src/window.rs | 78 +-- 4 files changed, 723 insertions(+), 479 deletions(-) delete mode 100644 crates/gpui/examples/hosted_popover.rs diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 5a884482de5bd6..1ead9963de1a32 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -172,12 +172,17 @@ reqwest_client = { workspace = true, features = ["test-support"] } [target.'cfg(target_os = "macos")'.dev-dependencies] objc2 = "0.6" objc2-app-kit = { workspace = true, features = [ + "NSButton", "NSControl", + "NSDatePicker", + "NSDatePickerCell", "NSPopover", "NSResponder", + "NSSearchField", "NSTextField", "NSView", "NSViewController", + "NSVisualEffectView", "NSWindow", ] } objc2-foundation = { workspace = true } @@ -202,10 +207,6 @@ cbindgen = { version = "0.28.0", default-features = false } name = "hello_world" path = "examples/hello_world.rs" -[[example]] -name = "hosted_popover" -path = "examples/hosted_popover.rs" - [[example]] name = "hosted_window" path = "examples/hosted_window.rs" diff --git a/crates/gpui/examples/hosted_popover.rs b/crates/gpui/examples/hosted_popover.rs deleted file mode 100644 index c0a4fedd9f8e31..00000000000000 --- a/crates/gpui/examples/hosted_popover.rs +++ /dev/null @@ -1,373 +0,0 @@ -//! Renders GPUI content inside a native macOS `NSPopover`, using -//! `WindowOptions::host_window_handle`. -//! -//! The popover supplies the system chrome — arrow, vibrant backdrop, show -//! animation, and transient dismissal (click outside to close) — while its -//! content is an ordinary GPUI window: any element renders, and input works -//! exactly as in a regular window. -//! -//! Run with: `cargo run -p gpui --example hosted_popover` (macOS only). - -#![cfg_attr(target_family = "wasm", no_main)] - -#[cfg(target_os = "macos")] -mod example { - use gpui::{ - App, Bounds, Context, FocusHandle, FontWeight, KeyBinding, MouseButton, Pixels, - SharedString, Size, Window, WindowBackgroundAppearance, WindowBounds, WindowOptions, - actions, canvas, div, point, prelude::*, px, rgb, rgba, size, - }; - use std::cell::Cell; - use std::rc::Rc; - - actions!(hosted_popover, [NewDocument, OpenRecent, Share]); - - const KEY_CONTEXT: &str = "HostedPopover"; - use gpui_platform::application; - use objc2::MainThreadMarker; - use objc2_app_kit::{NSPopover, NSPopoverBehavior, NSView, NSViewController}; - use objc2_foundation::{NSPoint, NSRect, NSRectEdge, NSSize}; - use raw_window_handle::{HasWindowHandle, RawWindowHandle}; - use std::ptr::NonNull; - use std::time::Duration; - - /// Content rendered inside the popover — a regular GPUI view, styled like a - /// native quick-actions panel. No opaque background is painted, so the - /// popover's vibrant backdrop shows through. The rows' keyboard shortcuts - /// are real GPUI key bindings, demonstrating that hosted windows receive - /// keyboard input. - struct PopoverContent { - focus_handle: FocusHandle, - last_action: Option<&'static str>, - } - - impl PopoverContent { - fn trigger(&mut self, label: &'static str, cx: &mut Context) { - self.last_action = Some(label); - cx.notify(); - } - - fn row( - &self, - id: &'static str, - swatch: u32, - label: &'static str, - shortcut: &'static str, - cx: &mut Context, - ) -> impl IntoElement { - div() - .id(SharedString::new_static(id)) - .flex() - .items_center() - .gap_2() - .px_2() - .h(px(28.)) - .rounded_md() - .hover(|style| style.bg(rgba(0x3b82f626))) - .active(|style| style.bg(rgba(0x3b82f640))) - .child(div().size(px(14.)).rounded_sm().bg(rgb(swatch))) - .child(div().flex_1().text_size(px(13.)).child(label)) - .child( - div() - .text_size(px(11.)) - .text_color(rgba(0x3c3c4366)) - .child(shortcut), - ) - .on_click(cx.listener(move |this, _, _, cx| this.trigger(label, cx))) - } - - fn divider(&self) -> impl IntoElement { - div().h(px(1.)).w_full().bg(rgba(0x3c3c431f)) - } - } - - impl Render for PopoverContent { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .flex() - .flex_col() - .gap_2() - .p_3() - .size_full() - .text_color(rgba(0x000000d9)) - .key_context(KEY_CONTEXT) - .track_focus(&self.focus_handle) - .on_action(cx.listener(|this, _: &NewDocument, _, cx| { - this.trigger("New Document", cx) - })) - .on_action( - cx.listener(|this, _: &OpenRecent, _, cx| this.trigger("Open Recent", cx)), - ) - .on_action(cx.listener(|this, _: &Share, _, cx| this.trigger("Share…", cx))) - .child( - div() - .px_2() - .flex() - .flex_col() - .child( - div() - .text_size(px(14.)) - .font_weight(FontWeight::SEMIBOLD) - .child("Quick Actions"), - ) - .child( - div() - .text_size(px(11.)) - .text_color(rgba(0x3c3c4380)) - .child("Rendered by GPUI inside an NSPopover"), - ), - ) - .child(self.divider()) - .child( - div() - .flex() - .flex_col() - .child(self.row("new", 0x3b82f6, "New Document", "⌘N", cx)) - .child(self.row("open", 0x22c55e, "Open Recent", "⌘O", cx)) - .child(self.row("share", 0xf59e0b, "Share…", "⇧⌘S", cx)), - ) - .child(self.divider()) - .child( - div() - .px_2() - .flex() - .items_center() - .child( - div() - .flex_1() - .text_size(px(12.)) - .text_color(rgba(0x3c3c4380)) - .child(match self.last_action { - Some(label) => format!("Last action: {label}"), - None => "Click a row or press a shortcut".to_string(), - }), - ) - .child( - div() - .id("reset") - .px_2p5() - .py_1() - .rounded_md() - .text_size(px(12.)) - .font_weight(FontWeight::MEDIUM) - .bg(rgba(0x7878801f)) - .text_color(rgba(0x000000d9)) - .hover(|style| style.bg(rgba(0x78788033))) - .active(|style| style.bg(rgba(0x78788047))) - .child("Reset") - .on_click(cx.listener(|this, _, _, cx| { - this.last_action = None; - cx.notify(); - })), - ), - ) - } - } - - /// The main window: a trigger that opens the native popover. - struct MainView { - /// The trigger's bounds, captured at layout so the popover can anchor - /// to the button itself. - trigger_bounds: Rc>>, - } - - impl Render for MainView { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - let capture = self.trigger_bounds.clone(); - let anchor = self.trigger_bounds.clone(); - div() - .flex() - .flex_col() - .gap_2() - .size_full() - .justify_center() - .items_center() - .bg(rgb(0xf5f5f7)) - .child( - div() - .relative() - .child( - div() - .id("trigger") - .px_4() - .py_2() - .rounded_lg() - .bg(rgb(0x1d1d1f)) - .text_color(gpui::white()) - .font_weight(FontWeight::MEDIUM) - .hover(|style| style.bg(rgb(0x2c2c2e))) - .active(|style| style.bg(rgb(0x3a3a3c))) - .child("Open native popover") - .on_mouse_down(MouseButton::Left, move |_, window, cx| { - open_popover(anchor.get(), window, cx); - }), - ) - .child( - // Records the trigger's window-relative bounds. - canvas(move |bounds, _, _| capture.set(bounds), |_, _, _, _| {}) - .absolute() - .inset_0(), - ), - ) - .child( - div() - .text_size(px(12.)) - .text_color(rgba(0x3c3c4366)) - .child("System arrow & vibrancy — content rendered by GPUI"), - ) - } - } - - /// Shows an `NSPopover` anchored to `anchor` (the trigger's window-relative - /// bounds) and opens a GPUI window hosted inside its content view. - fn open_popover(anchor: Bounds, window: &mut Window, cx: &mut App) { - let Some(mtm) = MainThreadMarker::new() else { - return; - }; - // The popover is anchored to the triggering window's content view. - let Ok(handle) = HasWindowHandle::window_handle(window) else { - return; - }; - let RawWindowHandle::AppKit(parent) = handle.as_raw() else { - return; - }; - // SAFETY: the pointer comes from the live window's AppKit handle and is - // only used synchronously while the window is alive. - let parent_view: &NSView = unsafe { &*parent.ns_view.as_ptr().cast() }; - - let content_size: Size = size(px(320.), px(208.)); - - // Native popover shell: a plain container view in a view controller. - // SAFETY: all objects are created and used on the main thread, and the - // popover/controller/container are kept alive until the popover closes. - let (popover, controller, container) = unsafe { - let container = NSView::new(mtm); - container.setFrameSize(NSSize::new( - f32::from(content_size.width) as f64, - f32::from(content_size.height) as f64, - )); - let controller = NSViewController::new(mtm); - controller.setView(&container); - - let popover = NSPopover::new(mtm); - popover.setBehavior(NSPopoverBehavior::Transient); - popover.setContentViewController(Some(&controller)); - - // GPUI's anchor bounds are top-left based; the (non-flipped) - // positioning view is bottom-left based, so flip y against its - // height. - let parent_height = parent_view.bounds().size.height; - let rect = NSRect::new( - NSPoint::new( - f32::from(anchor.origin.x) as f64, - parent_height - - (f32::from(anchor.origin.y) + f32::from(anchor.size.height)) as f64, - ), - NSSize::new( - f32::from(anchor.size.width) as f64, - f32::from(anchor.size.height) as f64, - ), - ); - popover.showRelativeToRect_ofView_preferredEdge(rect, parent_view, NSRectEdge::MinY); - (popover, controller, container) - }; - - // The container is now installed in the popover's window; render a GPUI - // window into it. - let container_ptr = NonNull::new(objc2::rc::Retained::as_ptr(&container) as *mut _) - .expect("container view pointer is non-null"); - let gpui_window = cx - .open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(Bounds { - origin: point(px(0.), px(0.)), - size: content_size, - })), - window_background: WindowBackgroundAppearance::Transparent, - host_window_handle: Some(RawWindowHandle::AppKit( - raw_window_handle::AppKitWindowHandle::new(container_ptr), - )), - ..Default::default() - }, - |window, cx| { - let content = cx.new(|cx| PopoverContent { - focus_handle: cx.focus_handle(), - last_action: None, - }); - let focus_handle = content.read(cx).focus_handle.clone(); - window.focus(&focus_handle, cx); - content - }, - ) - .expect("failed to open hosted window"); - - // Let the popover's window receive keyboard input immediately, so the - // shortcuts work without clicking inside first. - // SAFETY: main thread; the container was just installed in the - // popover's window by `show...` above. - unsafe { - if let Some(popover_window) = container.window() { - popover_window.makeKeyWindow(); - } - } - - // When the popover is dismissed (e.g. by clicking outside), close the - // hosted GPUI window. Production code would use an `NSPopoverDelegate`; - // polling keeps this example small. - cx.spawn(async move |cx| { - loop { - cx.background_executor() - .timer(Duration::from_millis(50)) - .await; - // SAFETY: checked on the main thread; the popover is kept alive - // by this future. - if !unsafe { popover.isShown() } { - break; - } - } - gpui_window - .update(cx, |_, window, _| window.remove_window()) - .ok(); - drop((popover, controller, container)); - }) - .detach(); - } - - pub fn run() { - application().run(|cx: &mut App| { - cx.bind_keys([ - KeyBinding::new("cmd-n", NewDocument, Some(KEY_CONTEXT)), - KeyBinding::new("cmd-o", OpenRecent, Some(KEY_CONTEXT)), - KeyBinding::new("shift-cmd-s", Share, Some(KEY_CONTEXT)), - ]); - let bounds = Bounds::centered(None, size(px(500.), px(320.)), cx); - cx.open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - ..Default::default() - }, - |_, cx| { - cx.new(|_| MainView { - trigger_bounds: Rc::new(Cell::new(Bounds::default())), - }) - }, - ) - .unwrap(); - cx.activate(true); - }); - } -} - -#[cfg(not(target_family = "wasm"))] -fn main() { - #[cfg(target_os = "macos")] - example::run(); - #[cfg(not(target_os = "macos"))] - println!("This example demonstrates hosting GPUI inside an NSPopover and is macOS-only."); -} - -#[cfg(target_family = "wasm")] -#[wasm_bindgen::prelude::wasm_bindgen(start)] -pub fn start() { - println!("This example is macOS-only."); -} diff --git a/crates/gpui/examples/hosted_window.rs b/crates/gpui/examples/hosted_window.rs index 425a531cf22986..5aea9513ff52b5 100644 --- a/crates/gpui/examples/hosted_window.rs +++ b/crates/gpui/examples/hosted_window.rs @@ -1,9 +1,19 @@ -//! Embeds a GPUI window inside a window created with the platform's native +//! Embeds GPUI windows inside an application built with the platform's native //! APIs, using `WindowOptions::host_window_handle` — the building block for //! adopting GPUI incrementally inside an existing native application. //! -//! The window itself, its title bar, and the label at the top are plain -//! AppKit; the interactive area below is rendered and driven entirely by GPUI. +//! The scenario: a mail-style AppKit application, demonstrating every way the +//! two UI stacks compose: +//! +//! - The window shell, vibrant sidebar, search field, and navigation are +//! plain AppKit; the message pane is a hosted GPUI window. +//! - A second, component-sized GPUI window (the account card) is hosted +//! inside the native sidebar. +//! - "More ▾" opens a system `NSPopover` (arrow, vibrancy, transient +//! dismissal) whose content is a third hosted GPUI window — with working +//! GPUI keyboard shortcuts. +//! - Native controls layer *above* GPUI (the Address field), and their input +//! streams into GPUI state (the banner mirrors search & address live). //! //! Run with: `cargo run -p gpui --example hosted_window` (macOS today; //! Windows/X11 hosts work the same way through `host_window_handle`). @@ -13,80 +23,543 @@ #[cfg(target_os = "macos")] mod example { use gpui::{ - App, Bounds, Context, Window, WindowBounds, WindowOptions, div, point, prelude::*, px, - rgb, size, + App, Bounds, Context, FocusHandle, FontWeight, KeyBinding, Pixels, SharedString, Window, + WindowBackgroundAppearance, WindowBounds, WindowOptions, actions, canvas, div, point, + prelude::*, px, rgb, rgba, size, }; use gpui_platform::application; - use objc2::{MainThreadMarker, MainThreadOnly}; + use objc2::rc::Retained; + use objc2::runtime::NSObject; + use objc2::{AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send}; use objc2_app_kit::{ - NSAutoresizingMaskOptions, NSBackingStoreType, NSTextField, NSView, NSWindow, - NSWindowStyleMask, + NSAutoresizingMaskOptions, NSBackingStoreType, NSControl, NSPopover, NSPopoverBehavior, + NSSearchField, NSTextField, NSView, NSViewController, NSVisualEffectBlendingMode, + NSVisualEffectMaterial, NSVisualEffectView, NSWindow, NSWindowStyleMask, }; - use objc2_foundation::{NSPoint, NSRect, NSSize, NSString}; - use raw_window_handle::{AppKitWindowHandle, RawWindowHandle}; + use objc2_foundation::{NSNotification, NSPoint, NSRect, NSRectEdge, NSSize, NSString}; + use raw_window_handle::{AppKitWindowHandle, HasWindowHandle, RawWindowHandle}; + use std::cell::Cell; use std::ptr::NonNull; + use std::rc::Rc; + use std::sync::mpsc; + use std::time::Duration; + + actions!(hosted_window, [NewDocument, OpenRecent, Share]); - const WIDTH: f64 = 560.; - const HEIGHT: f64 = 400.; - const HEADER: f64 = 48.; + const KEY_CONTEXT: &str = "QuickActions"; - /// The GPUI content embedded in the native window. - struct Embedded { - clicks: usize, + /// Ivars for [`SearchHandler`]: forwards the native search field's text. + struct SearchHandlerIvars { + tx: mpsc::Sender, } - impl Render for Embedded { + define_class!( + // Delegate of the native NSSearchField: forwards every text change to + // the GPUI side over a channel — native input driving GPUI state. + #[unsafe(super(NSObject))] + #[name = "HostedWindowSearchHandler"] + #[ivars = SearchHandlerIvars] + struct SearchHandler; + + impl SearchHandler { + #[unsafe(method(controlTextDidChange:))] + fn control_text_did_change(&self, notification: &NSNotification) { + let text = unsafe { + notification + .object() + .and_then(|object| object.downcast::().ok()) + .map(|control| control.stringValue().to_string()) + }; + if let Some(text) = text { + let _ = self.ivars().tx.send(text); + } + } + } + ); + + impl SearchHandler { + fn new(tx: mpsc::Sender) -> Retained { + let this = Self::alloc().set_ivars(SearchHandlerIvars { tx }); + unsafe { msg_send![super(this), init] } + } + } + + const WIDTH: f64 = 760.; + const HEIGHT: f64 = 480.; + const SIDEBAR: f64 = 200.; + + /// A small account card embedded at the bottom of the *native* sidebar — + /// a second hosted GPUI window, showing that GPUI embeds at any + /// granularity: a whole pane or a single component. + struct AccountCard; + + impl Render for AccountCard { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .flex() + .items_center() + .gap_2() + .size_full() + .p_2() + .rounded_lg() + .bg(rgba(0x7878801a)) + .text_color(rgba(0x000000d9)) + .child( + div() + .size(px(28.)) + .rounded_full() + .bg(rgb(0x10b981)) + .flex() + .items_center() + .justify_center() + .text_color(gpui::white()) + .text_size(px(11.)) + .font_weight(FontWeight::SEMIBOLD) + .child("JL"), + ) + .child( + div() + .flex() + .flex_col() + .flex_1() + .child( + div() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .child("Jason — GPUI card"), + ) + .child( + div() + .h(px(4.)) + .w_full() + .mt_1() + .rounded_full() + .bg(rgba(0x3c3c4326)) + .child(div().h_full().w_2_3().rounded_full().bg(rgb(0x10b981))), + ), + ) + } + } + + /// Quick-actions panel rendered inside a native `NSPopover` — a third + /// hosted GPUI window, in a *system-owned* container this time (arrow, + /// vibrancy, transient dismissal). The shortcuts are real GPUI key + /// bindings, exercising hosted keyboard input. + struct QuickActions { + focus_handle: FocusHandle, + last_action: Option<&'static str>, + } + + impl QuickActions { + fn trigger(&mut self, label: &'static str, cx: &mut Context) { + self.last_action = Some(label); + cx.notify(); + } + + fn row( + &self, + id: &'static str, + swatch: u32, + label: &'static str, + shortcut: &'static str, + cx: &mut Context, + ) -> impl IntoElement { + div() + .id(SharedString::new_static(id)) + .flex() + .items_center() + .gap_2() + .px_2() + .h(px(28.)) + .rounded_md() + .hover(|style| style.bg(rgba(0x3b82f626))) + .active(|style| style.bg(rgba(0x3b82f640))) + .child(div().size(px(14.)).rounded_sm().bg(rgb(swatch))) + .child(div().flex_1().text_size(px(13.)).child(label)) + .child( + div() + .text_size(px(11.)) + .text_color(rgba(0x3c3c4366)) + .child(shortcut), + ) + .on_click(cx.listener(move |this, _, _, cx| this.trigger(label, cx))) + } + } + + impl Render for QuickActions { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .flex() .flex_col() - .gap_3() - .p_4() + .gap_2() + .p_3() .size_full() - .bg(rgb(0x14161b)) - .text_color(rgb(0xe6e6e6)) - .child(div().text_xl().child("This area is GPUI")) - .child(div().text_sm().text_color(rgb(0x9a9a9a)).child( - "Rendered into a plain NSView of an AppKit window via \ - WindowOptions::host_window_handle.", - )) + .text_color(rgba(0x000000d9)) + .key_context(KEY_CONTEXT) + .track_focus(&self.focus_handle) + .on_action(cx.listener(|this, _: &NewDocument, _, cx| { + this.trigger("New Document", cx) + })) + .on_action( + cx.listener(|this, _: &OpenRecent, _, cx| this.trigger("Open Recent", cx)), + ) + .on_action(cx.listener(|this, _: &Share, _, cx| this.trigger("Share…", cx))) .child( div() - .id("counter") - .px_3() - .py_2() - .rounded_md() - .bg(rgb(0x3b82f6)) - .text_color(gpui::white()) - .child(format!("Clicked {} times", self.clicks)) - .on_click(cx.listener(|this, _, _, cx| { - this.clicks += 1; - cx.notify(); - })), + .flex() + .flex_col() + .child(self.row("new", 0x3b82f6, "New Document", "⌘N", cx)) + .child(self.row("open", 0x22c55e, "Open Recent", "⌘O", cx)) + .child(self.row("share", 0xf59e0b, "Share…", "⇧⌘S", cx)), ) + .child(div().h(px(1.)).w_full().bg(rgba(0x3c3c431f))) .child( - div().flex().gap_2().children((0..6).map(|i| { - div() - .id(i) - .size_8() - .rounded_md() - .bg(rgb(0x2b2f3a)) - .hover(|style| style.bg(rgb(0x3b82f6))) + div() + .px_2() + .text_size(px(11.)) + .text_color(rgba(0x3c3c4380)) + .child(match self.last_action { + Some(label) => format!("Last action: {label}"), + None => "Click a row or press a shortcut".to_string(), + }), + ) + } + } + + /// Shows an `NSPopover` anchored to `anchor` (bounds within the message + /// pane's GPUI window) and hosts a [`QuickActions`] GPUI window inside it. + fn open_quick_actions(anchor: Bounds, window: &mut Window, cx: &mut App) { + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + // Anchor to the message pane's own (hosted) GPUI view. + let Ok(handle) = HasWindowHandle::window_handle(window) else { + return; + }; + let RawWindowHandle::AppKit(parent) = handle.as_raw() else { + return; + }; + // SAFETY: the pointer comes from the live window's AppKit handle and is + // only used synchronously while the window is alive. + let parent_view: &NSView = unsafe { &*parent.ns_view.as_ptr().cast() }; + + let content_size = size(px(280.), px(164.)); + + // SAFETY: main thread; the popover objects are kept alive by the + // dismissal task below. + let (popover, controller, container) = unsafe { + let container = NSView::new(mtm); + container.setFrameSize(NSSize::new( + f32::from(content_size.width) as f64, + f32::from(content_size.height) as f64, + )); + let controller = NSViewController::new(mtm); + controller.setView(&container); + + let popover = NSPopover::new(mtm); + popover.setBehavior(NSPopoverBehavior::Transient); + popover.setContentViewController(Some(&controller)); + + // GPUI bounds are top-left based; the (non-flipped) view is + // bottom-left based, so flip y against its height. + let parent_height = parent_view.bounds().size.height; + let rect = NSRect::new( + NSPoint::new( + f32::from(anchor.origin.x) as f64, + parent_height + - (f32::from(anchor.origin.y) + f32::from(anchor.size.height)) as f64, + ), + NSSize::new( + f32::from(anchor.size.width) as f64, + f32::from(anchor.size.height) as f64, + ), + ); + popover.showRelativeToRect_ofView_preferredEdge(rect, parent_view, NSRectEdge::MinY); + (popover, controller, container) + }; + + let container_ptr = NonNull::new(Retained::as_ptr(&container) as *mut _) + .expect("container view pointer is non-null"); + let gpui_window = cx + .open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: point(px(0.), px(0.)), + size: content_size, })), + window_background: WindowBackgroundAppearance::Transparent, + host_window_handle: Some(RawWindowHandle::AppKit(AppKitWindowHandle::new( + container_ptr, + ))), + ..Default::default() + }, + |window, cx| { + let content = cx.new(|cx| QuickActions { + focus_handle: cx.focus_handle(), + last_action: None, + }); + let focus_handle = content.read(cx).focus_handle.clone(); + window.focus(&focus_handle, cx); + content + }, + ) + .expect("failed to open hosted popover window"); + + // Let the popover receive keyboard input immediately, so the shortcuts + // work without clicking inside first. + // SAFETY: main thread; the container was installed by `show...` above. + unsafe { + if let Some(popover_window) = container.window() { + popover_window.makeKeyWindow(); + } + } + + // When the popover is dismissed (e.g. by clicking outside), close the + // hosted GPUI window. Production code would use an `NSPopoverDelegate`; + // polling keeps this example small. + cx.spawn(async move |cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(50)) + .await; + // SAFETY: checked on the main thread; the popover is kept alive + // by this future. + if !unsafe { popover.isShown() } { + break; + } + } + gpui_window + .update(cx, |_, window, _| window.remove_window()) + .ok(); + drop((popover, controller, container)); + }) + .detach(); + } + + /// The content pane — a mail message view, rendered entirely by GPUI. Its + /// `query` mirrors the native sidebar's NSSearchField in real time. + struct MessagePane { + replied: bool, + query: String, + address: String, + /// The "More" button's bounds, captured at layout so the native popover + /// can anchor to it. + more_bounds: Rc>>, + } + + /// A small pill mirroring a native field's value. + fn mirror_chip(value: &str, empty_hint: &str) -> impl IntoElement { + let empty = value.is_empty(); + div() + .px_2() + .py_0p5() + .rounded_full() + .bg(if empty { + rgba(0x7878801f) + } else { + rgba(0x3b82f626) + }) + .text_color(if empty { + rgba(0x3c3c4366) + } else { + rgba(0x1d4ed8ff) + }) + .child(if empty { + empty_hint.to_string() + } else { + value.to_string() + }) + } + + impl Render for MessagePane { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .flex() + .flex_col() + .size_full() + .bg(gpui::white()) + .text_color(rgba(0x000000d9)) + // Banner: live mirror of the native search field — native input + // flowing into GPUI state. + .child( + div() + .flex() + .items_center() + .gap_2() + .px_6() + .py_2() + .bg(rgba(0x78788014)) + .border_b_1() + .border_color(rgba(0x3c3c431f)) + .text_size(px(11.)) + .text_color(rgba(0x3c3c4380)) + .child("Search:") + .child(mirror_chip(&self.query, "type in the sidebar…")) + .child(div().w_2()) + .child("Address:") + .child(mirror_chip(&self.address, "type below…")), + ) + .child( + div() + .flex() + .flex_col() + .flex_1() + .p_6() + .gap_4() + // Sender row. + .child( + div() + .flex() + .items_center() + .gap_3() + .child( + div() + .size(px(36.)) + .rounded_full() + .bg(rgb(0x6366f1)) + .flex() + .items_center() + .justify_center() + .text_color(gpui::white()) + .text_size(px(14.)) + .font_weight(FontWeight::SEMIBOLD) + .child("AC"), + ) + .child( + div() + .flex() + .flex_col() + .child( + div() + .text_size(px(13.)) + .font_weight(FontWeight::SEMIBOLD) + .child("Alex Chen"), + ) + .child( + div() + .text_size(px(11.)) + .text_color(rgba(0x3c3c4380)) + .child("alex@example.com · 9:41 AM"), + ), + ), ) + // Subject. + .child( + div() + .text_size(px(20.)) + .font_weight(FontWeight::SEMIBOLD) + .child("Migrating our content pane to GPUI"), + ) + // Body. + .child( + div() + .flex() + .flex_col() + .gap_2() + .text_size(px(13.)) + .text_color(rgba(0x000000b3)) + .child( + "The window, the vibrant sidebar, and the search field on the left \ + are still plain AppKit. This message pane is a GPUI window hosted \ + in a sub-view via WindowOptions::host_window_handle.", + ) + .child( + "That lets an existing native application adopt GPUI one pane at a \ + time — same window, two UI stacks, one input story.", + ), + ) + .child(div().flex_1()) + // Reserved row: the native NSTextField "Address" (layered above + // this GPUI window by AppKit) sits here. + .child(div().h(px(34.))) + .child(div().h(px(1.)).w_full().bg(rgba(0x3c3c431f))) + // Actions. + .child( + div() + .flex() + .gap_2() + .child( + div() + .id("reply") + .px_4() + .py_1p5() + .rounded_md() + .text_size(px(13.)) + .font_weight(FontWeight::MEDIUM) + .bg(rgb(0x1d1d1f)) + .text_color(gpui::white()) + .hover(|style| style.bg(rgb(0x2c2c2e))) + .active(|style| style.bg(rgb(0x3a3a3c))) + .child(if self.replied { "Replied ✓" } else { "Reply" }) + .on_click(cx.listener(|this, _, _, cx| { + this.replied = true; + cx.notify(); + })), + ) + .child( + div() + .id("forward") + .px_4() + .py_1p5() + .rounded_md() + .text_size(px(13.)) + .font_weight(FontWeight::MEDIUM) + .bg(rgba(0x7878801f)) + .hover(|style| style.bg(rgba(0x78788033))) + .active(|style| style.bg(rgba(0x78788047))) + .child("Forward"), + ) + .child({ + let capture = self.more_bounds.clone(); + let anchor = self.more_bounds.clone(); + div() + .relative() + .child( + div() + .id("more") + .px_4() + .py_1p5() + .rounded_md() + .text_size(px(13.)) + .font_weight(FontWeight::MEDIUM) + .bg(rgba(0x7878801f)) + .hover(|style| style.bg(rgba(0x78788033))) + .active(|style| style.bg(rgba(0x78788047))) + .child("More ▾") + .on_click(move |_, window, cx| { + open_quick_actions(anchor.get(), window, cx); + }), + ) + .child( + // Records the button's bounds for anchoring. + canvas( + move |bounds, _, _| capture.set(bounds), + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ) + }), + )) } } pub fn run() { application().run(|cx: &mut App| { + cx.bind_keys([ + KeyBinding::new("cmd-n", NewDocument, Some(KEY_CONTEXT)), + KeyBinding::new("cmd-o", OpenRecent, Some(KEY_CONTEXT)), + KeyBinding::new("shift-cmd-s", Share, Some(KEY_CONTEXT)), + ]); let mtm = MainThreadMarker::new().expect("must run on the main thread"); - // --- Plain AppKit: a native window with a native label. --- - // SAFETY: all objects are created and used on the main thread and - // are kept alive for the lifetime of the process (see the - // `mem::forget` below). - let (native_window, host_view, label) = unsafe { - let rect = NSRect::new(NSPoint::new(200., 200.), NSSize::new(WIDTH, HEIGHT)); + let (search_tx, search_rx) = mpsc::channel::(); + + // --- Plain AppKit: window + vibrant sidebar + search + navigation. --- + // SAFETY: all objects are created and used on the main thread and are + // kept alive for the lifetime of the process (`mem::forget` below). + let (native_window, host_view, card_host, native_sidebar) = unsafe { + let rect = NSRect::new(NSPoint::new(160., 160.), NSSize::new(WIDTH, HEIGHT)); let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable | NSWindowStyleMask::Miniaturizable; @@ -97,26 +570,63 @@ mod example { NSBackingStoreType::Buffered, false, ); - native_window.setTitle(&NSString::from_str("Native AppKit window")); + native_window.setTitle(&NSString::from_str("Inbox — AppKit shell, GPUI content")); let content_view = native_window .contentView() .expect("native window has a content view"); + let content_height = content_view.bounds().size.height; - let label = NSTextField::labelWithString( - &NSString::from_str("This label and window are plain AppKit ↓ below is GPUI"), - mtm, - ); - label.setFrame(NSRect::new( - NSPoint::new(16., HEIGHT - HEADER + 14.), - NSSize::new(WIDTH - 32., 20.), + // Vibrant sidebar. + let sidebar = NSVisualEffectView::new(mtm); + sidebar.setMaterial(NSVisualEffectMaterial::Sidebar); + sidebar.setBlendingMode(NSVisualEffectBlendingMode::BehindWindow); + sidebar.setFrame(NSRect::new( + NSPoint::new(0., 0.), + NSSize::new(SIDEBAR, content_height), + )); + sidebar.setAutoresizingMask(NSAutoresizingMaskOptions::ViewHeightSizable); + content_view.addSubview(&sidebar); + + // Native search field. + let search = NSSearchField::new(mtm); + search.setFrame(NSRect::new( + NSPoint::new(12., content_height - 40.), + NSSize::new(SIDEBAR - 24., 28.), )); - content_view.addSubview(&label); + search.setPlaceholderString(Some(&NSString::from_str("Search"))); + sidebar.addSubview(&search); - // The host view GPUI renders into: the area below the header. + // Forward the search field's text changes to GPUI. + let search_handler = SearchHandler::new(search_tx); + let _: () = msg_send![&*search, setDelegate: &*search_handler]; + + // Navigation list. + for (index, item) in ["📥 Inbox", "📤 Sent", "📝 Drafts", "🗂 Archive"] + .iter() + .enumerate() + { + let label = NSTextField::labelWithString(&NSString::from_str(item), mtm); + label.setFrame(NSRect::new( + NSPoint::new(16., content_height - 76. - index as f64 * 30.), + NSSize::new(SIDEBAR - 32., 20.), + )); + sidebar.addSubview(&label); + } + + // A small host at the bottom of the *native* sidebar for a + // GPUI-rendered account card. + let card_host = NSView::new(mtm); + card_host.setFrame(NSRect::new( + NSPoint::new(12., 12.), + NSSize::new(SIDEBAR - 24., 52.), + )); + sidebar.addSubview(&card_host); + + // The pane GPUI renders into: everything right of the sidebar. let host_view = NSView::new(mtm); host_view.setFrame(NSRect::new( - NSPoint::new(0., 0.), - NSSize::new(WIDTH, HEIGHT - HEADER), + NSPoint::new(SIDEBAR, 0.), + NSSize::new(WIDTH - SIDEBAR, content_height), )); host_view.setAutoresizingMask( NSAutoresizingMaskOptions::ViewWidthSizable @@ -125,29 +635,121 @@ mod example { content_view.addSubview(&host_view); native_window.makeKeyAndOrderFront(None); - (native_window, host_view, label) + ( + native_window, + host_view, + card_host, + (sidebar, search, search_handler), + ) }; - // --- GPUI: render into the host view. --- - let host_ptr = NonNull::new(objc2::rc::Retained::as_ptr(&host_view) as *mut _) + // --- GPUI: render the message pane into the host view. --- + let host_size = unsafe { NSView::bounds(&host_view) }.size; + let host_ptr = NonNull::new(Retained::as_ptr(&host_view) as *mut _) .expect("host view pointer is non-null"); + let pane = cx + .open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: point(px(0.), px(0.)), + size: size(px(host_size.width as f32), px(host_size.height as f32)), + })), + host_window_handle: Some(RawWindowHandle::AppKit( + AppKitWindowHandle::new(host_ptr), + )), + ..Default::default() + }, + |_, cx| { + cx.new(|_| MessagePane { + replied: false, + query: String::new(), + address: String::new(), + more_bounds: Rc::new(Cell::new(Bounds::default())), + }) + }, + ) + .expect("failed to open hosted window"); + + // --- GPUI: a second hosted window — the sidebar's account card. --- + let card_size = unsafe { NSView::bounds(&card_host) }.size; + let card_ptr = NonNull::new(Retained::as_ptr(&card_host) as *mut _) + .expect("card host pointer is non-null"); cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds { origin: point(px(0.), px(0.)), - size: size(px(WIDTH as f32), px((HEIGHT - HEADER) as f32)), + size: size(px(card_size.width as f32), px(card_size.height as f32)), })), host_window_handle: Some(RawWindowHandle::AppKit(AppKitWindowHandle::new( - host_ptr, + card_ptr, ))), + // Let the sidebar's vibrancy show through the card. + window_background: gpui::WindowBackgroundAppearance::Transparent, ..Default::default() }, - |_, cx| cx.new(|_| Embedded { clicks: 0 }), + |_, cx| cx.new(|_| AccountCard), ) - .expect("failed to open hosted window"); + .expect("failed to open hosted card window"); + + // --- Native above GPUI: an "Address" text field layered over the + // GPUI pane. AppKit's view hierarchy makes this trivial — add the + // control as a later subview of the host view, above the GPUI view. + // It receives its own input; everything around it goes to GPUI, and + // its text streams into the GPUI pane like the search field's. + // SAFETY: main thread; the field is kept alive by the forget below. + let (addr_tx, addr_rx) = mpsc::channel::(); + let address_field = unsafe { + let field = NSTextField::new(mtm); + field.setPlaceholderString(Some(&NSString::from_str( + "Address — native NSTextField over GPUI", + ))); + field.setFrame(NSRect::new( + NSPoint::new(24., 92.), + NSSize::new(host_size.width - 48., 26.), + )); + let handler = SearchHandler::new(addr_tx); + let _: () = msg_send![&*field, setDelegate: &*handler]; + host_view.addSubview(&field); + (field, handler) + }; + + // Mirror the native search field's text into the GPUI pane. + cx.spawn(async move |cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(50)) + .await; + let mut latest_query = None; + while let Ok(text) = search_rx.try_recv() { + latest_query = Some(text); + } + let mut latest_address = None; + while let Ok(text) = addr_rx.try_recv() { + latest_address = Some(text); + } + if latest_query.is_some() || latest_address.is_some() { + let _ = pane.update(cx, |pane, _, cx| { + if let Some(text) = latest_query { + pane.query = text; + } + if let Some(text) = latest_address { + pane.address = text; + } + cx.notify(); + }); + } + } + }) + .detach(); // The native window lives for the rest of the process. - std::mem::forget((native_window, host_view, label)); + std::mem::forget(( + native_window, + host_view, + card_host, + native_sidebar, + address_field, + )); cx.activate(true); }); diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 4d5677ec2c705c..099a0df7b625be 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -645,12 +645,15 @@ impl MacWindowState { } fn content_size(&self) -> Size { - // The GPUI view's own bounds. For regular windows this equals the window - // content view's frame (the view fills it); for views hosted in an - // external window (whose content view isn't ours) it is the only - // correct answer. + if !self.owns_native_window { + // Hosted views: the host window's content view isn't ours — use the + // GPUI view's own bounds. + let NSSize { width, height, .. } = + unsafe { NSView::bounds(self.native_view.as_ptr()) }.size; + return size(px(width as f32), px(height as f32)); + } let NSSize { width, height, .. } = - unsafe { NSView::bounds(self.native_view.as_ptr()) }.size; + unsafe { NSView::frame(self.native_window.contentView()) }.size; size(px(width as f32), px(height as f32)) } @@ -1375,23 +1378,27 @@ impl PlatformWindow for MacWindow { fn mouse_position(&self) -> Point { let lock = self.0.lock(); + if !lock.owns_native_window { + // Hosted views: window coordinates -> view-local coordinates (the + // view can be inset anywhere within the host window). + let position = unsafe { lock.native_window.mouseLocationOutsideOfEventStream() }; + let origin: NSPoint = unsafe { + msg_send![ + lock.native_view.as_ptr(), + convertPoint: NSPoint::new(0., 0.) + toView: nil + ] + }; + let height = + px(unsafe { NSView::bounds(lock.native_view.as_ptr()) }.size.height as f32); + let mut position = convert_mouse_position(position, height); + position.x -= px(origin.x as f32); + position.y += px(origin.y as f32); + return position; + } let position = unsafe { lock.native_window.mouseLocationOutsideOfEventStream() }; - // Window coordinates -> view-local coordinates. A no-op for regular - // windows (whose view sits at the window origin), required for views - // hosted inside a larger external window. - let origin: NSPoint = unsafe { - msg_send![ - lock.native_view.as_ptr(), - convertPoint: NSPoint::new(0., 0.) - toView: nil - ] - }; - let height = px(unsafe { NSView::bounds(lock.native_view.as_ptr()) }.size.height as f32); drop(lock); - let mut position = convert_mouse_position(position, height); - position.x -= px(origin.x as f32); - position.y += px(origin.y as f32); - position + convert_mouse_position(position, self.content_size().height) } fn modifiers(&self) -> Modifiers { @@ -1905,6 +1912,14 @@ impl PlatformWindow for MacWindow { fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { let mut lock = self.0.lock(); + // The accessibility adapter subclasses the NSWindow. A hosted view's + // window belongs to the host application (and may host several GPUI + // windows) — don't subclass it. Accessibility for hosted windows is + // future work. + if !lock.owns_native_window { + return; + } + let activation_handler = A11yActivationHandler { callback: callbacks.activation, }; @@ -2310,15 +2325,15 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { let event = unsafe { platform_input_from_native(native_event, Some(window_height)) }; if let Some(mut event) = event { - // Event locations are window-relative, while GPUI positions are relative - // to its view. For regular windows the view sits at the window origin so - // this is a no-op, but when the view is hosted inside a larger external - // window (e.g. an NSPopover's internal window, which insets the content - // for its arrow/chrome) the positions must be shifted into view-local + // Hosted views only: event locations are window-relative, while GPUI + // positions are relative to its view. A regular window's view sits at + // the window origin, but a hosted view can be inset anywhere within an + // external window (e.g. an NSPopover's internal window insets the + // content for its arrow/chrome), so shift positions into view-local // space: with the view's bottom-left corner at (ox, oy) in window // coords, the correction is x -= ox, y += oy (y was already flipped // against the view height). - { + if !lock.owns_native_window { let origin: NSPoint = unsafe { msg_send![ lock.native_view.as_ptr(), @@ -2334,7 +2349,7 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { // fold the difference into the correction. Zero for events that // already belong to our window. let mut delta = NSPoint::new(0., 0.); - if !lock.owns_native_window { + { let event_window: id = unsafe { msg_send![native_event, window] }; if event_window != lock.native_window { unsafe { @@ -2384,11 +2399,10 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { _ => {} } - // For hosted views, a hover move over the host window's chrome - // (outside the view) would otherwise leave a stale hover state — - // there is no tracking area to deliver mouse-exited. Convert it. - if !lock.owns_native_window - && let PlatformInput::MouseMove(e) = &event + // A hover move over the host window's chrome (outside the view) + // would otherwise leave a stale hover state — there is no tracking + // area to deliver mouse-exited. Convert it. + if let PlatformInput::MouseMove(e) = &event && e.pressed_button.is_none() { let bounds = unsafe { NSView::bounds(lock.native_view.as_ptr()) }; From bf1085ac6d393d9858e204d277ed473e42bd2b7f Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 11 Jun 2026 17:31:12 +0800 Subject: [PATCH 37/38] . --- crates/gpui/Cargo.toml | 4 - crates/gpui/examples/hosted_window.rs | 1055 ++++++++++++++----------- 2 files changed, 577 insertions(+), 482 deletions(-) diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 1ead9963de1a32..7568f2c4e81f53 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -172,17 +172,13 @@ reqwest_client = { workspace = true, features = ["test-support"] } [target.'cfg(target_os = "macos")'.dev-dependencies] objc2 = "0.6" objc2-app-kit = { workspace = true, features = [ - "NSButton", "NSControl", - "NSDatePicker", - "NSDatePickerCell", "NSPopover", "NSResponder", "NSSearchField", "NSTextField", "NSView", "NSViewController", - "NSVisualEffectView", "NSWindow", ] } objc2-foundation = { workspace = true } diff --git a/crates/gpui/examples/hosted_window.rs b/crates/gpui/examples/hosted_window.rs index 5aea9513ff52b5..d11d4185357aca 100644 --- a/crates/gpui/examples/hosted_window.rs +++ b/crates/gpui/examples/hosted_window.rs @@ -1,19 +1,22 @@ -//! Embeds GPUI windows inside an application built with the platform's native -//! APIs, using `WindowOptions::host_window_handle` — the building block for -//! adopting GPUI incrementally inside an existing native application. +//! Native macOS window tabs hosting GPU-rendered GPUI surfaces, built on +//! `WindowOptions::host_window_handle` — the building block for adopting GPUI +//! incrementally inside an existing native application. //! -//! The scenario: a mail-style AppKit application, demonstrating every way the -//! two UI stacks compose: +//! The contrast is the point: the windows, the system tab bar (the same +//! native tabs Terminal, Safari, or Ghostty use), and the search field are +//! plain AppKit — while everything inside each tab is a hosted GPUI window +//! animating at the display's refresh rate: gradient-lit metric cards, a live +//! equalizer chart, layered showcase tiles. Content AppKit views don't draw, +//! inside windows AppKit owns. //! -//! - The window shell, vibrant sidebar, search field, and navigation are -//! plain AppKit; the message pane is a hosted GPUI window. -//! - A second, component-sized GPUI window (the account card) is hosted -//! inside the native sidebar. -//! - "More ▾" opens a system `NSPopover` (arrow, vibrancy, transient -//! dismissal) whose content is a third hosted GPUI window — with working -//! GPUI keyboard shortcuts. -//! - Native controls layer *above* GPUI (the Address field), and their input -//! streams into GPUI state (the banner mirrors search & address live). +//! Every seam between the two worlds is wired up: +//! +//! - Two native windows are merged into one tab group — switch tabs natively, +//! or with ⌘1/⌘2: GPUI key bindings that select the *native* tab. +//! - The native search field's text streams into GPUI live, filtering the +//! metric cards. +//! - "Details ▾" opens a system `NSPopover` (arrow, vibrancy, transient +//! dismissal) hosting another GPUI window — with a working ⏎ key binding. //! //! Run with: `cargo run -p gpui --example hosted_window` (macOS today; //! Windows/X11 hosts work the same way through `host_window_handle`). @@ -23,18 +26,17 @@ #[cfg(target_os = "macos")] mod example { use gpui::{ - App, Bounds, Context, FocusHandle, FontWeight, KeyBinding, Pixels, SharedString, Window, - WindowBackgroundAppearance, WindowBounds, WindowOptions, actions, canvas, div, point, - prelude::*, px, rgb, rgba, size, + Animation, AnimationExt as _, App, Bounds, Context, FocusHandle, FontWeight, KeyBinding, + Pixels, Window, WindowBackgroundAppearance, WindowBounds, WindowOptions, actions, canvas, + div, linear_color_stop, linear_gradient, point, prelude::*, px, rgb, rgba, size, }; use gpui_platform::application; use objc2::rc::Retained; use objc2::runtime::NSObject; use objc2::{AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send}; use objc2_app_kit::{ - NSAutoresizingMaskOptions, NSBackingStoreType, NSControl, NSPopover, NSPopoverBehavior, - NSSearchField, NSTextField, NSView, NSViewController, NSVisualEffectBlendingMode, - NSVisualEffectMaterial, NSVisualEffectView, NSWindow, NSWindowStyleMask, + NSBackingStoreType, NSControl, NSPopover, NSPopoverBehavior, NSSearchField, NSView, + NSViewController, NSWindow, NSWindowOrderingMode, NSWindowStyleMask, }; use objc2_foundation::{NSNotification, NSPoint, NSRect, NSRectEdge, NSSize, NSString}; use raw_window_handle::{AppKitWindowHandle, HasWindowHandle, RawWindowHandle}; @@ -44,11 +46,19 @@ mod example { use std::sync::mpsc; use std::time::Duration; - actions!(hosted_window, [NewDocument, OpenRecent, Share]); + actions!(hosted_window, [SelectTab1, SelectTab2, DismissInfo]); + + const APP_CONTEXT: &str = "HostedDemo"; + const POPOVER_CONTEXT: &str = "InfoPopover"; - const KEY_CONTEXT: &str = "QuickActions"; + const WIDTH: f64 = 760.; + const HEIGHT: f64 = 520.; - /// Ivars for [`SearchHandler`]: forwards the native search field's text. + // ------------------------------------------------------------------------ + // AppKit glue: the search-field delegate. + // ------------------------------------------------------------------------ + + /// Ivars for [`SearchHandler`]: forwards the native field's text. struct SearchHandlerIvars { tx: mpsc::Sender, } @@ -84,109 +94,18 @@ mod example { } } - const WIDTH: f64 = 760.; - const HEIGHT: f64 = 480.; - const SIDEBAR: f64 = 200.; + // ------------------------------------------------------------------------ + // GPUI: the info popover content. + // ------------------------------------------------------------------------ - /// A small account card embedded at the bottom of the *native* sidebar — - /// a second hosted GPUI window, showing that GPUI embeds at any - /// granularity: a whole pane or a single component. - struct AccountCard; - - impl Render for AccountCard { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div() - .flex() - .items_center() - .gap_2() - .size_full() - .p_2() - .rounded_lg() - .bg(rgba(0x7878801a)) - .text_color(rgba(0x000000d9)) - .child( - div() - .size(px(28.)) - .rounded_full() - .bg(rgb(0x10b981)) - .flex() - .items_center() - .justify_center() - .text_color(gpui::white()) - .text_size(px(11.)) - .font_weight(FontWeight::SEMIBOLD) - .child("JL"), - ) - .child( - div() - .flex() - .flex_col() - .flex_1() - .child( - div() - .text_size(px(12.)) - .font_weight(FontWeight::MEDIUM) - .child("Jason — GPUI card"), - ) - .child( - div() - .h(px(4.)) - .w_full() - .mt_1() - .rounded_full() - .bg(rgba(0x3c3c4326)) - .child(div().h_full().w_2_3().rounded_full().bg(rgb(0x10b981))), - ), - ) - } - } - - /// Quick-actions panel rendered inside a native `NSPopover` — a third - /// hosted GPUI window, in a *system-owned* container this time (arrow, - /// vibrancy, transient dismissal). The shortcuts are real GPUI key - /// bindings, exercising hosted keyboard input. - struct QuickActions { + /// Info panel hosted inside a native `NSPopover`. ⏎ is a real GPUI key + /// binding — hosted windows receive keyboard input. + struct InfoPopover { focus_handle: FocusHandle, - last_action: Option<&'static str>, + acknowledged: bool, } - impl QuickActions { - fn trigger(&mut self, label: &'static str, cx: &mut Context) { - self.last_action = Some(label); - cx.notify(); - } - - fn row( - &self, - id: &'static str, - swatch: u32, - label: &'static str, - shortcut: &'static str, - cx: &mut Context, - ) -> impl IntoElement { - div() - .id(SharedString::new_static(id)) - .flex() - .items_center() - .gap_2() - .px_2() - .h(px(28.)) - .rounded_md() - .hover(|style| style.bg(rgba(0x3b82f626))) - .active(|style| style.bg(rgba(0x3b82f640))) - .child(div().size(px(14.)).rounded_sm().bg(rgb(swatch))) - .child(div().flex_1().text_size(px(13.)).child(label)) - .child( - div() - .text_size(px(11.)) - .text_color(rgba(0x3c3c4366)) - .child(shortcut), - ) - .on_click(cx.listener(move |this, _, _, cx| this.trigger(label, cx))) - } - } - - impl Render for QuickActions { + impl Render for InfoPopover { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .flex() @@ -195,44 +114,49 @@ mod example { .p_3() .size_full() .text_color(rgba(0x000000d9)) - .key_context(KEY_CONTEXT) + .key_context(POPOVER_CONTEXT) .track_focus(&self.focus_handle) - .on_action(cx.listener(|this, _: &NewDocument, _, cx| { - this.trigger("New Document", cx) + .on_action(cx.listener(|this, _: &DismissInfo, _, cx| { + this.acknowledged = true; + cx.notify(); })) - .on_action( - cx.listener(|this, _: &OpenRecent, _, cx| this.trigger("Open Recent", cx)), - ) - .on_action(cx.listener(|this, _: &Share, _, cx| this.trigger("Share…", cx))) .child( div() - .flex() - .flex_col() - .child(self.row("new", 0x3b82f6, "New Document", "⌘N", cx)) - .child(self.row("open", 0x22c55e, "Open Recent", "⌘O", cx)) - .child(self.row("share", 0xf59e0b, "Share…", "⇧⌘S", cx)), + .text_size(px(13.)) + .font_weight(FontWeight::SEMIBOLD) + .child("This panel is GPUI too"), ) - .child(div().h(px(1.)).w_full().bg(rgba(0x3c3c431f))) .child( div() - .px_2() .text_size(px(11.)) .text_color(rgba(0x3c3c4380)) - .child(match self.last_action { - Some(label) => format!("Last action: {label}"), - None => "Click a row or press a shortcut".to_string(), + .child( + "A GPUI window hosted inside a system NSPopover — arrow, vibrancy \ + and transient dismissal are AppKit; the content is GPUI. Press ⏎.", + ), + ) + .child(div().flex_1()) + .child( + div() + .flex() + .justify_end() + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .child(if self.acknowledged { + "⏎ received by GPUI ✓" + } else { + "waiting for ⏎ …" }), ) } } - /// Shows an `NSPopover` anchored to `anchor` (bounds within the message - /// pane's GPUI window) and hosts a [`QuickActions`] GPUI window inside it. - fn open_quick_actions(anchor: Bounds, window: &mut Window, cx: &mut App) { + /// Shows an `NSPopover` anchored to `anchor` (bounds within the hosting + /// GPUI window) and hosts an [`InfoPopover`] GPUI window inside it. + fn open_info_popover(anchor: Bounds, window: &mut Window, cx: &mut App) { let Some(mtm) = MainThreadMarker::new() else { return; }; - // Anchor to the message pane's own (hosted) GPUI view. let Ok(handle) = HasWindowHandle::window_handle(window) else { return; }; @@ -243,7 +167,7 @@ mod example { // only used synchronously while the window is alive. let parent_view: &NSView = unsafe { &*parent.ns_view.as_ptr().cast() }; - let content_size = size(px(280.), px(164.)); + let content_size = size(px(280.), px(124.)); // SAFETY: main thread; the popover objects are kept alive by the // dismissal task below. @@ -294,9 +218,9 @@ mod example { ..Default::default() }, |window, cx| { - let content = cx.new(|cx| QuickActions { + let content = cx.new(|cx| InfoPopover { focus_handle: cx.focus_handle(), - last_action: None, + acknowledged: false, }); let focus_handle = content.read(cx).focus_handle.clone(); window.focus(&focus_handle, cx); @@ -305,8 +229,7 @@ mod example { ) .expect("failed to open hosted popover window"); - // Let the popover receive keyboard input immediately, so the shortcuts - // work without clicking inside first. + // Let the popover receive keyboard input immediately. // SAFETY: main thread; the container was installed by `show...` above. unsafe { if let Some(popover_window) = container.window() { @@ -314,9 +237,9 @@ mod example { } } - // When the popover is dismissed (e.g. by clicking outside), close the - // hosted GPUI window. Production code would use an `NSPopoverDelegate`; - // polling keeps this example small. + // When the popover is dismissed, close the hosted GPUI window. + // Production code would use an `NSPopoverDelegate`; polling keeps this + // example small. cx.spawn(async move |cx| { loop { cx.background_executor() @@ -336,202 +259,250 @@ mod example { .detach(); } - /// The content pane — a mail message view, rendered entirely by GPUI. Its - /// `query` mirrors the native sidebar's NSSearchField in real time. - struct MessagePane { - replied: bool, + // ------------------------------------------------------------------------ + // GPUI: the GPU-rendered tab contents. + // ------------------------------------------------------------------------ + + struct Metric { + label: &'static str, + value: &'static str, + accent: u32, + } + + // The shadcn default chart palette. + const CHART_1: u32 = 0xe76e50; + const CHART_2: u32 = 0x2a9d90; + const CHART_4: u32 = 0xe8c468; + + const METRICS: [Metric; 3] = [ + Metric { + label: "Frame time", + value: "8.3 ms", + accent: CHART_2, + }, + Metric { + label: "Draw calls", + value: "1,284", + accent: CHART_1, + }, + Metric { + label: "Layers", + value: "96", + accent: CHART_4, + }, + ]; + + #[derive(Clone, Copy, PartialEq)] + enum Pane { + Dashboard, + Showcase, + } + + struct DemoApp { + focus_handle: FocusHandle, + pane: Pane, + /// Mirrors the native NSSearchField in real time; filters the cards. query: String, - address: String, - /// The "More" button's bounds, captured at layout so the native popover - /// can anchor to it. - more_bounds: Rc>>, + /// Both native windows of the tab group, for ⌘1/⌘2 (GPUI key bindings + /// selecting the *native* tab). + tab_windows: (usize, usize), + info_anchor: Rc>>, + } + + /// shadcn-style box shadows — much subtler than the tailwind presets. + trait ShadcnShadow: Styled + Sized { + /// `shadow-xs`: 0 1px 2px rgb(0 0 0 / 0.05) + fn shadow_card(mut self) -> Self { + self.style().box_shadow = Some(vec![gpui::BoxShadow { + color: gpui::hsla(0., 0., 0., 0.05), + offset: point(px(0.), px(1.)), + blur_radius: px(2.), + spread_radius: px(0.), + inset: false, + }]); + self + } + + /// `shadow-md`: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) + fn shadow_card_hover(mut self) -> Self { + self.style().box_shadow = Some(vec![ + gpui::BoxShadow { + color: gpui::hsla(0., 0., 0., 0.1), + offset: point(px(0.), px(4.)), + blur_radius: px(6.), + spread_radius: px(-1.), + inset: false, + }, + gpui::BoxShadow { + color: gpui::hsla(0., 0., 0., 0.1), + offset: point(px(0.), px(2.)), + blur_radius: px(4.), + spread_radius: px(-2.), + inset: false, + }, + ]); + self + } } - /// A small pill mirroring a native field's value. - fn mirror_chip(value: &str, empty_hint: &str) -> impl IntoElement { - let empty = value.is_empty(); + impl ShadcnShadow for T {} + + /// A small outline badge (shadcn-style) labelling which UI stack draws a + /// region, with a colored dot. + fn stack_badge(color: u32, label: &'static str) -> impl IntoElement { div() + .flex() + .items_center() + .gap_1p5() .px_2() .py_0p5() - .rounded_full() - .bg(if empty { - rgba(0x7878801f) - } else { - rgba(0x3b82f626) - }) - .text_color(if empty { - rgba(0x3c3c4366) - } else { - rgba(0x1d4ed8ff) - }) - .child(if empty { - empty_hint.to_string() - } else { - value.to_string() - }) + .rounded_md() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(gpui::white()) + .text_size(px(11.)) + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(0x71717a)) + .child(div().size(px(6.)).rounded_full().bg(rgb(color))) + .child(label) } - impl Render for MessagePane { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + impl DemoApp { + /// Select a native window tab from a GPUI key binding (GPUI → AppKit + /// control). + fn select_tab(&mut self, index: usize, _cx: &mut Context) { + let window = if index == 0 { + self.tab_windows.0 + } else { + self.tab_windows.1 + } as *const NSWindow; + if !window.is_null() { + // SAFETY: main thread; both windows outlive the process (they + // are forgotten in `run`). + unsafe { + (*window).makeKeyAndOrderFront(None); + } + } + } + + fn metric_card(&self, metric: &Metric) -> impl IntoElement { + let dimmed = !self.query.is_empty() + && !metric + .label + .to_lowercase() + .contains(&self.query.to_lowercase()); div() - .flex() - .flex_col() - .size_full() + .flex_1() + .h(px(92.)) + .rounded_lg() + .border_1() + .border_color(rgb(0xe4e4e7)) .bg(gpui::white()) - .text_color(rgba(0x000000d9)) - // Banner: live mirror of the native search field — native input - // flowing into GPUI state. - .child( - div() - .flex() - .items_center() - .gap_2() - .px_6() - .py_2() - .bg(rgba(0x78788014)) - .border_b_1() - .border_color(rgba(0x3c3c431f)) - .text_size(px(11.)) - .text_color(rgba(0x3c3c4380)) - .child("Search:") - .child(mirror_chip(&self.query, "type in the sidebar…")) - .child(div().w_2()) - .child("Address:") - .child(mirror_chip(&self.address, "type below…")), - ) - .child( - div() + .p_4() .flex() .flex_col() - .flex_1() - .p_6() - .gap_4() - // Sender row. + .justify_between() + .shadow_card() + .opacity(if dimmed { 0.35 } else { 1. }) .child( div() .flex() .items_center() - .gap_3() + .gap_1p5() + .child(div().size(px(8.)).rounded_full().bg(rgb(metric.accent))) .child( div() - .size(px(36.)) - .rounded_full() - .bg(rgb(0x6366f1)) - .flex() - .items_center() - .justify_center() - .text_color(gpui::white()) - .text_size(px(14.)) - .font_weight(FontWeight::SEMIBOLD) - .child("AC"), - ) - .child( - div() - .flex() - .flex_col() - .child( - div() - .text_size(px(13.)) - .font_weight(FontWeight::SEMIBOLD) - .child("Alex Chen"), - ) - .child( - div() - .text_size(px(11.)) - .text_color(rgba(0x3c3c4380)) - .child("alex@example.com · 9:41 AM"), - ), + .text_size(px(12.)) + .text_color(rgb(0x71717a)) + .child(metric.label), ), ) - // Subject. .child( div() - .text_size(px(20.)) + .text_size(px(24.)) .font_weight(FontWeight::SEMIBOLD) - .child("Migrating our content pane to GPUI"), + .text_color(rgb(0x09090b)) + .child(metric.value), ) - // Body. - .child( + } + + /// A live equalizer: every bar animates continuously, driven by GPUI's + /// animation system at the display refresh rate. + fn equalizer(&self) -> impl IntoElement { + div() + .h(px(180.)) + .rounded_lg() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(gpui::white()) + .shadow_card() + .p_4() + .flex() + .items_end() + .gap(px(5.)) + .children((0..44usize).map(|i| { div() - .flex() - .flex_col() - .gap_2() - .text_size(px(13.)) - .text_color(rgba(0x000000b3)) - .child( - "The window, the vibrant sidebar, and the search field on the left \ - are still plain AppKit. This message pane is a GPUI window hosted \ - in a sub-view via WindowOptions::host_window_handle.", + .flex_1() + .rounded_sm() + .bg(linear_gradient( + 0., + linear_color_stop(rgb(CHART_2), 0.), + linear_color_stop(rgba(0x2a9d9066), 1.), + )) + .with_animation( + ("bar", i), + Animation::new(Duration::from_millis(2400)).repeat(), + move |bar, delta| { + let phase = delta * std::f32::consts::TAU; + let wave = (phase + i as f32 * 0.45).sin() * 0.5 + 0.5; + let ripple = (phase * 2. + i as f32 * 0.9).cos() * 0.5 + 0.5; + let level = 0.15 + 0.85 * (0.65 * wave + 0.35 * ripple); + bar.h(px(8. + 140. * level)) + }, ) - .child( - "That lets an existing native application adopt GPUI one pane at a \ - time — same window, two UI stacks, one input story.", - ), - ) - .child(div().flex_1()) - // Reserved row: the native NSTextField "Address" (layered above - // this GPUI window by AppKit) sits here. - .child(div().h(px(34.))) - .child(div().h(px(1.)).w_full().bg(rgba(0x3c3c431f))) - // Actions. + })) + } + + fn pane_dashboard(&self, _cx: &mut Context) -> gpui::AnyElement { + let capture = self.info_anchor.clone(); + let anchor = self.info_anchor.clone(); + div() + .flex() + .flex_col() + .gap_3() + // Title row; the native search field floats over its right end. .child( div() .flex() - .gap_2() - .child( - div() - .id("reply") - .px_4() - .py_1p5() - .rounded_md() - .text_size(px(13.)) - .font_weight(FontWeight::MEDIUM) - .bg(rgb(0x1d1d1f)) - .text_color(gpui::white()) - .hover(|style| style.bg(rgb(0x2c2c2e))) - .active(|style| style.bg(rgb(0x3a3a3c))) - .child(if self.replied { "Replied ✓" } else { "Reply" }) - .on_click(cx.listener(|this, _, _, cx| { - this.replied = true; - cx.notify(); - })), - ) + .items_center() + .gap_3() .child( div() - .id("forward") - .px_4() - .py_1p5() - .rounded_md() - .text_size(px(13.)) - .font_weight(FontWeight::MEDIUM) - .bg(rgba(0x7878801f)) - .hover(|style| style.bg(rgba(0x78788033))) - .active(|style| style.bg(rgba(0x78788047))) - .child("Forward"), + .text_size(px(15.)) + .font_weight(FontWeight::SEMIBOLD) + .child("Live Metrics"), ) .child({ - let capture = self.more_bounds.clone(); - let anchor = self.more_bounds.clone(); div() .relative() .child( div() - .id("more") - .px_4() - .py_1p5() + .id("details") + .px_2p5() + .py_0p5() .rounded_md() - .text_size(px(13.)) + .text_size(px(12.)) .font_weight(FontWeight::MEDIUM) - .bg(rgba(0x7878801f)) - .hover(|style| style.bg(rgba(0x78788033))) - .active(|style| style.bg(rgba(0x78788047))) - .child("More ▾") + .bg(rgb(0xf4f4f5)) + .text_color(rgb(0x18181b)) + .hover(|style| style.bg(rgb(0xe4e4e7))) + .active(|style| style.bg(rgb(0xd4d4d8))) + .child("Details ▾") .on_click(move |_, window, cx| { - open_quick_actions(anchor.get(), window, cx); + open_info_popover(anchor.get(), window, cx); }), ) .child( - // Records the button's bounds for anchoring. canvas( move |bounds, _, _| capture.set(bounds), |_, _, _, _| {}, @@ -539,181 +510,324 @@ mod example { .absolute() .inset_0(), ) - }), - )) + }) + .child(div().flex_1()) + // Reserved for the native search field floating above. + .child(div().w(px(200.)).h(px(26.))), + ) + // Annotation row: what is AppKit, what is GPUI. + .child( + div() + .flex() + .items_center() + .gap_2() + .child(stack_badge(CHART_1, "▲ system tab bar — AppKit")) + .child( + div() + .flex_1() + .text_size(px(11.)) + .text_color(rgb(0xa1a1aa)) + .child(if self.query.is_empty() { + "⌘1/⌘2 switch the native tabs via GPUI key bindings." + .to_string() + } else { + format!("native search → GPUI filter: “{}”", self.query) + }), + ) + .child( + div() + .w(px(200.)) + .flex() + .justify_center() + .child(stack_badge(CHART_1, "NSSearchField — AppKit ▲")), + ), + ) + .child( + div() + .flex() + .gap_3() + .children(METRICS.iter().map(|metric| self.metric_card(metric))), + ) + .child(self.equalizer()) + .into_any_element() } - } - pub fn run() { - application().run(|cx: &mut App| { - cx.bind_keys([ - KeyBinding::new("cmd-n", NewDocument, Some(KEY_CONTEXT)), - KeyBinding::new("cmd-o", OpenRecent, Some(KEY_CONTEXT)), - KeyBinding::new("shift-cmd-s", Share, Some(KEY_CONTEXT)), - ]); - let mtm = MainThreadMarker::new().expect("must run on the main thread"); + fn pane_showcase(&self, _cx: &mut Context) -> gpui::AnyElement { + const TILES: [(u32, u32, &str); 6] = [ + (0x6366f1, 0x8b5cf6, "Gradients"), + (0x0ea5e9, 0x6366f1, "Shadows"), + (0x14b8a6, 0x0ea5e9, "Layers"), + (0x8b5cf6, 0xd946ef, "Hover states"), + (0x64748b, 0x334155, "Typography"), + (0x1e293b, 0x0f172a, "Animation"), + ]; + + fn tile(index: usize, from: u32, to: u32, label: &'static str) -> impl IntoElement { + div() + .id(index) + .flex_1() + .rounded_lg() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(gpui::white()) + .shadow_card() + .p_2() + .flex() + .flex_col() + .gap_2() + .hover(|style| style.shadow_card_hover()) + .child(div().h(px(64.)).rounded_md().bg(linear_gradient( + 35. + index as f32 * 55., + linear_color_stop(rgb(from), 0.), + linear_color_stop(rgb(to), 1.), + ))) + .child( + div() + .px_1() + .pb_1() + .text_size(px(13.)) + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(0x09090b)) + .child(label), + ) + } - let (search_tx, search_rx) = mpsc::channel::(); + div() + .flex() + .flex_col() + .gap_3() + .child( + div() + .text_size(px(11.)) + .text_color(rgb(0xa1a1aa)) + .child( + "Gradients, shadows, hover states, and per-frame animation — \ + rendered by GPUI inside a native window tab.", + ), + ) + .child( + div() + .flex() + .gap_3() + .children((0..3).map(|i| tile(i, TILES[i].0, TILES[i].1, TILES[i].2))), + ) + .child( + div() + .flex() + .gap_3() + .children((3..6).map(|i| tile(i, TILES[i].0, TILES[i].1, TILES[i].2))), + ) + .child( + div() + .h(px(48.)) + .rounded_lg() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(gpui::white()) + .shadow_card() + .flex() + .gap_2() + .items_center() + .justify_center() + .children((0..7usize).map(|i| { + div() + .rounded_full() + .bg(rgb(CHART_2)) + .with_animation( + ("dot", i), + Animation::new(Duration::from_millis(1400)).repeat(), + move |dot, delta| { + let phase = delta * std::f32::consts::TAU; + let pulse = ((phase + i as f32 * 0.8).sin() * 0.5 + 0.5) + .powf(1.5); + dot.size(px(6. + 8. * pulse)) + }, + ) + })), + ) + .into_any_element() + } + } - // --- Plain AppKit: window + vibrant sidebar + search + navigation. --- - // SAFETY: all objects are created and used on the main thread and are - // kept alive for the lifetime of the process (`mem::forget` below). - let (native_window, host_view, card_host, native_sidebar) = unsafe { - let rect = NSRect::new(NSPoint::new(160., 160.), NSSize::new(WIDTH, HEIGHT)); - let style = NSWindowStyleMask::Titled - | NSWindowStyleMask::Closable - | NSWindowStyleMask::Miniaturizable; - let native_window = NSWindow::initWithContentRect_styleMask_backing_defer( - NSWindow::alloc(mtm), - rect, - style, - NSBackingStoreType::Buffered, - false, - ); - native_window.setTitle(&NSString::from_str("Inbox — AppKit shell, GPUI content")); - let content_view = native_window - .contentView() - .expect("native window has a content view"); - let content_height = content_view.bounds().size.height; - - // Vibrant sidebar. - let sidebar = NSVisualEffectView::new(mtm); - sidebar.setMaterial(NSVisualEffectMaterial::Sidebar); - sidebar.setBlendingMode(NSVisualEffectBlendingMode::BehindWindow); - sidebar.setFrame(NSRect::new( - NSPoint::new(0., 0.), - NSSize::new(SIDEBAR, content_height), - )); - sidebar.setAutoresizingMask(NSAutoresizingMaskOptions::ViewHeightSizable); - content_view.addSubview(&sidebar); + impl Render for DemoApp { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .relative() + .flex() + .flex_col() + .size_full() + .bg(gpui::white()) + .p_6() + .text_color(rgb(0x09090b)) + .key_context(APP_CONTEXT) + .track_focus(&self.focus_handle) + .on_action(cx.listener(|this, _: &SelectTab1, _, cx| this.select_tab(0, cx))) + .on_action(cx.listener(|this, _: &SelectTab2, _, cx| this.select_tab(1, cx))) + .child(match self.pane { + Pane::Dashboard => self.pane_dashboard(cx), + Pane::Showcase => self.pane_showcase(cx), + }) + .child( + div() + .absolute() + .bottom_3() + .right_3() + .child(stack_badge(CHART_2, "this whole pane — GPUI")), + ) + } + } - // Native search field. + // ------------------------------------------------------------------------ + // The native shell: two windows merged into one native tab group. + // ------------------------------------------------------------------------ + + /// Builds one native window with a hosted GPUI surface, returning the + /// window and the GPUI view's handle for later wiring. + fn build_tab_window( + cx: &mut App, + mtm: MainThreadMarker, + title: &str, + pane: Pane, + with_search: Option>, + ) -> (Retained, gpui::WindowHandle) { + // --- Plain AppKit: the window (plus, for the dashboard, a native + // search field layered above the GPUI surface). --- + // SAFETY: all objects are created and used on the main thread and are + // kept alive for the lifetime of the process (`mem::forget` in `run`). + let (native_window, host_view) = unsafe { + let rect = NSRect::new(NSPoint::new(180., 160.), NSSize::new(WIDTH, HEIGHT)); + let style = NSWindowStyleMask::Titled + | NSWindowStyleMask::Closable + | NSWindowStyleMask::Miniaturizable + | NSWindowStyleMask::Resizable; + let native_window = NSWindow::initWithContentRect_styleMask_backing_defer( + NSWindow::alloc(mtm), + rect, + style, + NSBackingStoreType::Buffered, + false, + ); + native_window.setTitle(&NSString::from_str(title)); + + let content_view = native_window + .contentView() + .expect("native window has a content view"); + let content_bounds = content_view.bounds().size; + + // The surface GPUI renders into: the whole content area. It + // autoresizes, so it follows the content view when the native tab + // bar appears. + let host_view = NSView::new(mtm); + host_view.setFrame(NSRect::new( + NSPoint::new(0., 0.), + NSSize::new(content_bounds.width, content_bounds.height), + )); + host_view.setAutoresizingMask( + objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable + | objc2_app_kit::NSAutoresizingMaskOptions::ViewHeightSizable, + ); + content_view.addSubview(&host_view); + + if let Some(tx) = with_search { + // A native search field, layered above the GPUI surface in the + // pane's header row; pinned to the content view's top edge. let search = NSSearchField::new(mtm); search.setFrame(NSRect::new( - NSPoint::new(12., content_height - 40.), - NSSize::new(SIDEBAR - 24., 28.), + NSPoint::new( + content_bounds.width - 24. - 200., + content_bounds.height - 24. - 25., + ), + NSSize::new(200., 26.), )); - search.setPlaceholderString(Some(&NSString::from_str("Search"))); - sidebar.addSubview(&search); - - // Forward the search field's text changes to GPUI. - let search_handler = SearchHandler::new(search_tx); + search.setAutoresizingMask( + objc2_app_kit::NSAutoresizingMaskOptions::ViewMinYMargin + | objc2_app_kit::NSAutoresizingMaskOptions::ViewMinXMargin, + ); + search.setPlaceholderString(Some(&NSString::from_str("Filter metrics"))); + let search_handler = SearchHandler::new(tx); let _: () = msg_send![&*search, setDelegate: &*search_handler]; + content_view.addSubview(&search); + // Both live for the rest of the process. + std::mem::forget((search, search_handler)); + } - // Navigation list. - for (index, item) in ["📥 Inbox", "📤 Sent", "📝 Drafts", "🗂 Archive"] - .iter() - .enumerate() - { - let label = NSTextField::labelWithString(&NSString::from_str(item), mtm); - label.setFrame(NSRect::new( - NSPoint::new(16., content_height - 76. - index as f64 * 30.), - NSSize::new(SIDEBAR - 32., 20.), - )); - sidebar.addSubview(&label); - } - - // A small host at the bottom of the *native* sidebar for a - // GPUI-rendered account card. - let card_host = NSView::new(mtm); - card_host.setFrame(NSRect::new( - NSPoint::new(12., 12.), - NSSize::new(SIDEBAR - 24., 52.), - )); - sidebar.addSubview(&card_host); - - // The pane GPUI renders into: everything right of the sidebar. - let host_view = NSView::new(mtm); - host_view.setFrame(NSRect::new( - NSPoint::new(SIDEBAR, 0.), - NSSize::new(WIDTH - SIDEBAR, content_height), - )); - host_view.setAutoresizingMask( - NSAutoresizingMaskOptions::ViewWidthSizable - | NSAutoresizingMaskOptions::ViewHeightSizable, - ); - content_view.addSubview(&host_view); - - native_window.makeKeyAndOrderFront(None); - ( - native_window, - host_view, - card_host, - (sidebar, search, search_handler), - ) - }; - - // --- GPUI: render the message pane into the host view. --- - let host_size = unsafe { NSView::bounds(&host_view) }.size; - let host_ptr = NonNull::new(Retained::as_ptr(&host_view) as *mut _) - .expect("host view pointer is non-null"); - let pane = cx - .open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(Bounds { - origin: point(px(0.), px(0.)), - size: size(px(host_size.width as f32), px(host_size.height as f32)), - })), - host_window_handle: Some(RawWindowHandle::AppKit( - AppKitWindowHandle::new(host_ptr), - )), - ..Default::default() - }, - |_, cx| { - cx.new(|_| MessagePane { - replied: false, - query: String::new(), - address: String::new(), - more_bounds: Rc::new(Cell::new(Bounds::default())), - }) - }, - ) - .expect("failed to open hosted window"); + (native_window, host_view) + }; - // --- GPUI: a second hosted window — the sidebar's account card. --- - let card_size = unsafe { NSView::bounds(&card_host) }.size; - let card_ptr = NonNull::new(Retained::as_ptr(&card_host) as *mut _) - .expect("card host pointer is non-null"); - cx.open_window( + // --- GPUI: render the pane into the host view. --- + let host_size = unsafe { NSView::bounds(&host_view) }.size; + let host_ptr = NonNull::new(Retained::as_ptr(&host_view) as *mut _) + .expect("host view pointer is non-null"); + let gpui_window = cx + .open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds { origin: point(px(0.), px(0.)), - size: size(px(card_size.width as f32), px(card_size.height as f32)), + size: size(px(host_size.width as f32), px(host_size.height as f32)), })), host_window_handle: Some(RawWindowHandle::AppKit(AppKitWindowHandle::new( - card_ptr, + host_ptr, ))), - // Let the sidebar's vibrancy show through the card. - window_background: gpui::WindowBackgroundAppearance::Transparent, ..Default::default() }, - |_, cx| cx.new(|_| AccountCard), + |window, cx| { + let app = cx.new(|cx| DemoApp { + focus_handle: cx.focus_handle(), + pane, + query: String::new(), + tab_windows: (0, 0), + info_anchor: Rc::new(Cell::new(Bounds::default())), + }); + let focus_handle = app.read(cx).focus_handle.clone(); + window.focus(&focus_handle, cx); + app + }, ) - .expect("failed to open hosted card window"); - - // --- Native above GPUI: an "Address" text field layered over the - // GPUI pane. AppKit's view hierarchy makes this trivial — add the - // control as a later subview of the host view, above the GPUI view. - // It receives its own input; everything around it goes to GPUI, and - // its text streams into the GPUI pane like the search field's. - // SAFETY: main thread; the field is kept alive by the forget below. - let (addr_tx, addr_rx) = mpsc::channel::(); - let address_field = unsafe { - let field = NSTextField::new(mtm); - field.setPlaceholderString(Some(&NSString::from_str( - "Address — native NSTextField over GPUI", - ))); - field.setFrame(NSRect::new( - NSPoint::new(24., 92.), - NSSize::new(host_size.width - 48., 26.), - )); - let handler = SearchHandler::new(addr_tx); - let _: () = msg_send![&*field, setDelegate: &*handler]; - host_view.addSubview(&field); - (field, handler) - }; + .expect("failed to open hosted window"); - // Mirror the native search field's text into the GPUI pane. + // The host view lives for the rest of the process. + std::mem::forget(host_view); + (native_window, gpui_window) + } + + pub fn run() { + application().run(|cx: &mut App| { + cx.bind_keys([ + KeyBinding::new("cmd-1", SelectTab1, Some(APP_CONTEXT)), + KeyBinding::new("cmd-2", SelectTab2, Some(APP_CONTEXT)), + KeyBinding::new("enter", DismissInfo, Some(POPOVER_CONTEXT)), + ]); + let mtm = MainThreadMarker::new().expect("must run on the main thread"); + + let (search_tx, search_rx) = mpsc::channel::(); + + let (dashboard_window, dashboard_app) = + build_tab_window(cx, mtm, "Dashboard", Pane::Dashboard, Some(search_tx)); + let (showcase_window, showcase_app) = + build_tab_window(cx, mtm, "Showcase", Pane::Showcase, None); + + // Merge the two native windows into one tab group — the same + // native tab bar Terminal, Safari, or Ghostty use. + // SAFETY: main thread; both windows are alive (forgotten below). + unsafe { + dashboard_window.makeKeyAndOrderFront(None); + dashboard_window + .addTabbedWindow_ordered(&showcase_window, NSWindowOrderingMode::Above); + dashboard_window.makeKeyAndOrderFront(None); + } + + // Tell both panes about the native windows, for ⌘1/⌘2. + let tab_windows = ( + Retained::as_ptr(&dashboard_window) as usize, + Retained::as_ptr(&showcase_window) as usize, + ); + for handle in [&dashboard_app, &showcase_app] { + let _ = handle.update(cx, |app, _, _| { + app.tab_windows = tab_windows; + }); + } + + // Stream the native search field's text into the dashboard pane. cx.spawn(async move |cx| { loop { cx.background_executor() @@ -723,18 +837,9 @@ mod example { while let Ok(text) = search_rx.try_recv() { latest_query = Some(text); } - let mut latest_address = None; - while let Ok(text) = addr_rx.try_recv() { - latest_address = Some(text); - } - if latest_query.is_some() || latest_address.is_some() { - let _ = pane.update(cx, |pane, _, cx| { - if let Some(text) = latest_query { - pane.query = text; - } - if let Some(text) = latest_address { - pane.address = text; - } + if let Some(text) = latest_query { + let _ = dashboard_app.update(cx, |app, _, cx| { + app.query = text; cx.notify(); }); } @@ -742,14 +847,8 @@ mod example { }) .detach(); - // The native window lives for the rest of the process. - std::mem::forget(( - native_window, - host_view, - card_host, - native_sidebar, - address_field, - )); + // The native windows live for the rest of the process. + std::mem::forget((dashboard_window, showcase_window)); cx.activate(true); }); From 134443203126d317c9af6aab7e962e87f5e2ceff Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 12 Jun 2026 09:33:43 +0800 Subject: [PATCH 38/38] . --- crates/gpui/examples/hosted_window.rs | 422 ++++++++++++++++---------- crates/gpui_macos/src/window.rs | 67 +++- 2 files changed, 312 insertions(+), 177 deletions(-) diff --git a/crates/gpui/examples/hosted_window.rs b/crates/gpui/examples/hosted_window.rs index d11d4185357aca..dad9542d1e97da 100644 --- a/crates/gpui/examples/hosted_window.rs +++ b/crates/gpui/examples/hosted_window.rs @@ -46,7 +46,7 @@ mod example { use std::sync::mpsc; use std::time::Duration; - actions!(hosted_window, [SelectTab1, SelectTab2, DismissInfo]); + actions!(hosted_window, [SelectTab1, SelectTab2, DismissInfo, ToggleFeature]); const APP_CONTEXT: &str = "HostedDemo"; const POPOVER_CONTEXT: &str = "InfoPopover"; @@ -98,54 +98,148 @@ mod example { // GPUI: the info popover content. // ------------------------------------------------------------------------ - /// Info panel hosted inside a native `NSPopover`. ⏎ is a real GPUI key - /// binding — hosted windows receive keyboard input. + /// Info panel hosted inside a native `NSPopover`. + /// Demonstrates click, hover, and keyboard input inside a hosted window. struct InfoPopover { focus_handle: FocusHandle, - acknowledged: bool, + enter_pressed: bool, + toggle_on: bool, + hover_count: u32, } impl Render for InfoPopover { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let toggle_on = self.toggle_on; + let hover_count = self.hover_count; + div() .flex() .flex_col() .gap_2() .p_3() .size_full() - .text_color(rgba(0x000000d9)) + .text_color(rgb(0x09090b)) .key_context(POPOVER_CONTEXT) .track_focus(&self.focus_handle) .on_action(cx.listener(|this, _: &DismissInfo, _, cx| { - this.acknowledged = true; + this.enter_pressed = true; + cx.notify(); + })) + .on_action(cx.listener(|this, _: &ToggleFeature, _, cx| { + this.toggle_on = !this.toggle_on; cx.notify(); })) + // header .child( div() - .text_size(px(13.)) - .font_weight(FontWeight::SEMIBOLD) - .child("This panel is GPUI too"), + .flex() + .items_center() + .justify_between() + .child( + div() + .text_size(px(13.)) + .font_weight(FontWeight::SEMIBOLD) + .child("NSPopover + GPUI"), + ) + .child( + div() + .text_size(px(11.)) + .text_color(rgb(0x52525b)) + .child("⏎ · ⌘K"), + ), ) + // description .child( div() .text_size(px(11.)) - .text_color(rgba(0x3c3c4380)) + .text_color(rgb(0x52525b)) + .child("Arrow, vibrancy & transient dismissal are AppKit. Interaction below is GPUI."), + ) + // interactive row + .child( + div() + .flex() + .items_center() + .gap_2() + .mt_1() + // click button + .child( + div() + .id("popover-btn") + .flex() + .items_center() + .px_2p5() + .py_1() + .rounded_md() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(rgb(0xfafafa)) + .text_size(px(12.)) + .cursor_pointer() + .hover(|s| s.bg(rgb(0xf4f4f5)).border_color(rgb(0xd4d4d8))) + .active(|s| s.bg(rgb(0xe4e4e7))) + .on_mouse_move(cx.listener(|this, _, _, cx| { + this.hover_count += 1; + cx.notify(); + })) + .on_click(cx.listener(|this, _, _, cx| { + this.toggle_on = !this.toggle_on; + cx.notify(); + })) + .child(if toggle_on { "● On" } else { "○ Off" }), + ) + // ⌘K toggle + .child( + div() + .id("popover-cmd-k") + .flex() + .items_center() + .px_2p5() + .py_1() + .rounded_md() + .border_1() + .border_color(rgb(0xe4e4e7)) + .bg(rgb(0xfafafa)) + .text_size(px(12.)) + .cursor_pointer() + .hover(|s| s.bg(rgb(0xf4f4f5))) + .active(|s| s.bg(rgb(0xe4e4e7))) + .on_click(cx.listener(|this, _, _, cx| { + this.enter_pressed = !this.enter_pressed; + cx.notify(); + })) + .child(if self.enter_pressed { "⌘K ✓" } else { "⌘K —" }), + ) .child( - "A GPUI window hosted inside a system NSPopover — arrow, vibrancy \ - and transient dismissal are AppKit; the content is GPUI. Press ⏎.", + div() + .flex_1() + .text_size(px(11.)) + .text_color(rgb(0x71717a)) + .text_align(gpui::TextAlign::Right) + .child(format!("hover moves: {hover_count}")), ), ) - .child(div().flex_1()) + // status row .child( div() .flex() - .justify_end() - .text_size(px(12.)) - .font_weight(FontWeight::MEDIUM) - .child(if self.acknowledged { - "⏎ received by GPUI ✓" + .items_center() + .gap_1() + .text_size(px(11.)) + .text_color(rgb(0x71717a)) + .child( + div() + .w(px(6.)) + .h(px(6.)) + .rounded_full() + .bg(if toggle_on { rgb(CHART_2) } else { rgb(0xd4d4d8) }), + ) + .child(if toggle_on { "feature enabled" } else { "feature disabled" }) + .child(div().flex_1()) + .child(if self.enter_pressed { + "⏎ received ✓" } else { - "waiting for ⏎ …" + "press ⏎ to confirm" }), ) } @@ -220,7 +314,9 @@ mod example { |window, cx| { let content = cx.new(|cx| InfoPopover { focus_handle: cx.focus_handle(), - acknowledged: false, + enter_pressed: false, + toggle_on: false, + hover_count: 0, }); let focus_handle = content.read(cx).focus_handle.clone(); window.focus(&focus_handle, cx); @@ -309,44 +405,6 @@ mod example { info_anchor: Rc>>, } - /// shadcn-style box shadows — much subtler than the tailwind presets. - trait ShadcnShadow: Styled + Sized { - /// `shadow-xs`: 0 1px 2px rgb(0 0 0 / 0.05) - fn shadow_card(mut self) -> Self { - self.style().box_shadow = Some(vec![gpui::BoxShadow { - color: gpui::hsla(0., 0., 0., 0.05), - offset: point(px(0.), px(1.)), - blur_radius: px(2.), - spread_radius: px(0.), - inset: false, - }]); - self - } - - /// `shadow-md`: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) - fn shadow_card_hover(mut self) -> Self { - self.style().box_shadow = Some(vec![ - gpui::BoxShadow { - color: gpui::hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(4.)), - blur_radius: px(6.), - spread_radius: px(-1.), - inset: false, - }, - gpui::BoxShadow { - color: gpui::hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(2.)), - blur_radius: px(4.), - spread_radius: px(-2.), - inset: false, - }, - ]); - self - } - } - - impl ShadcnShadow for T {} - /// A small outline badge (shadcn-style) labelling which UI stack draws a /// region, with a colored dot. fn stack_badge(color: u32, label: &'static str) -> impl IntoElement { @@ -362,7 +420,7 @@ mod example { .bg(gpui::white()) .text_size(px(11.)) .font_weight(FontWeight::MEDIUM) - .text_color(rgb(0x71717a)) + .text_color(rgb(0x52525b)) .child(div().size(px(6.)).rounded_full().bg(rgb(color))) .child(label) } @@ -402,7 +460,7 @@ mod example { .flex() .flex_col() .justify_between() - .shadow_card() + .opacity(if dimmed { 0.35 } else { 1. }) .child( div() @@ -413,7 +471,7 @@ mod example { .child( div() .text_size(px(12.)) - .text_color(rgb(0x71717a)) + .text_color(rgb(0x52525b)) .child(metric.label), ), ) @@ -435,7 +493,7 @@ mod example { .border_1() .border_color(rgb(0xe4e4e7)) .bg(gpui::white()) - .shadow_card() + .p_4() .flex() .items_end() @@ -444,11 +502,7 @@ mod example { div() .flex_1() .rounded_sm() - .bg(linear_gradient( - 0., - linear_color_stop(rgb(CHART_2), 0.), - linear_color_stop(rgba(0x2a9d9066), 1.), - )) + .bg(rgb(0x09090b)) .with_animation( ("bar", i), Animation::new(Duration::from_millis(2400)).repeat(), @@ -526,7 +580,7 @@ mod example { div() .flex_1() .text_size(px(11.)) - .text_color(rgb(0xa1a1aa)) + .text_color(rgb(0x71717a)) .child(if self.query.is_empty() { "⌘1/⌘2 switch the native tabs via GPUI key bindings." .to_string() @@ -553,16 +607,21 @@ mod example { } fn pane_showcase(&self, _cx: &mut Context) -> gpui::AnyElement { - const TILES: [(u32, u32, &str); 6] = [ - (0x6366f1, 0x8b5cf6, "Gradients"), - (0x0ea5e9, 0x6366f1, "Shadows"), - (0x14b8a6, 0x0ea5e9, "Layers"), - (0x8b5cf6, 0xd946ef, "Hover states"), - (0x64748b, 0x334155, "Typography"), - (0x1e293b, 0x0f172a, "Animation"), - ]; + fn gpui_badge() -> impl IntoElement { + div() + .flex() + .items_center() + .px_1p5() + .py_0p5() + .rounded_sm() + .border_1() + .border_color(rgb(0xe4e4e7)) + .text_size(px(9.)) + .text_color(rgb(0xa1a1aa)) + .child("GPUI") + } - fn tile(index: usize, from: u32, to: u32, label: &'static str) -> impl IntoElement { + fn card(index: usize, label: &'static str, content: impl IntoElement) -> impl IntoElement { div() .id(index) .flex_1() @@ -570,77 +629,110 @@ mod example { .border_1() .border_color(rgb(0xe4e4e7)) .bg(gpui::white()) - .shadow_card() - .p_2() + .p_3() .flex() .flex_col() .gap_2() - .hover(|style| style.shadow_card_hover()) - .child(div().h(px(64.)).rounded_md().bg(linear_gradient( - 35. + index as f32 * 55., - linear_color_stop(rgb(from), 0.), - linear_color_stop(rgb(to), 1.), - ))) + .child(content) .child( div() - .px_1() - .pb_1() - .text_size(px(13.)) - .font_weight(FontWeight::MEDIUM) - .text_color(rgb(0x09090b)) - .child(label), + .flex() + .items_center() + .justify_between() + .mt_auto() + .child( + div() + .text_size(px(11.)) + .text_color(rgb(0xa1a1aa)) + .child(label), + ) + .child(gpui_badge()), ) } + let gradient_card = card( + 0, + "Gradient", + div().h(px(56.)).rounded_md().bg(linear_gradient( + 145., + linear_color_stop(rgb(0x3b82f6), 0.), + linear_color_stop(rgb(0x6366f1), 1.), + )), + ) + .into_any_element(); + + let text_items: [(&str, &str, FontWeight, Pixels); 5] = [ + ("Semibold", "The quick brown fox", FontWeight::SEMIBOLD, px(14.)), + ("Medium", "jumps over the lazy dog", FontWeight::MEDIUM, px(13.)), + ("Normal", "0123456789 — !@#$%", FontWeight::NORMAL, px(12.)), + ("Light", "Aa Bb Cc Dd Ee Ff Gg", FontWeight::LIGHT, px(12.)), + ("Bold", "GPUI renders text", FontWeight::BOLD, px(15.)), + ]; + + let mut text_cards: Vec = text_items + .iter() + .enumerate() + .map(|(i, (label, sample, weight, size))| { + card( + i + 1, + label, + div() + .text_size(*size) + .font_weight(*weight) + .text_color(rgb(0x09090b)) + .child(*sample), + ) + .into_any_element() + }) + .collect(); + div() .flex() .flex_col() .gap_3() - .child( - div() - .text_size(px(11.)) - .text_color(rgb(0xa1a1aa)) - .child( - "Gradients, shadows, hover states, and per-frame animation — \ - rendered by GPUI inside a native window tab.", - ), - ) .child( div() .flex() .gap_3() - .children((0..3).map(|i| tile(i, TILES[i].0, TILES[i].1, TILES[i].2))), + .child(gradient_card) + .child(text_cards.remove(0)) + .child(text_cards.remove(0)), ) .child( div() .flex() .gap_3() - .children((3..6).map(|i| tile(i, TILES[i].0, TILES[i].1, TILES[i].2))), + .child(text_cards.remove(0)) + .child(text_cards.remove(0)) + .child(text_cards.remove(0)), ) .child( + // Staggered activity bars — GPUI per-element animation at display rate. div() - .h(px(48.)) + .h(px(40.)) .rounded_lg() .border_1() .border_color(rgb(0xe4e4e7)) .bg(gpui::white()) - .shadow_card() + .px_4() .flex() - .gap_2() + .gap(px(4.)) .items_center() - .justify_center() - .children((0..7usize).map(|i| { + .children((0..18usize).map(|i| { div() - .rounded_full() - .bg(rgb(CHART_2)) + .w(px(6.)) + .rounded_sm() + .bg(rgb(0x09090b)) .with_animation( - ("dot", i), - Animation::new(Duration::from_millis(1400)).repeat(), - move |dot, delta| { + ("act", i), + Animation::new(Duration::from_millis(1200)).repeat(), + move |bar, delta| { let phase = delta * std::f32::consts::TAU; - let pulse = ((phase + i as f32 * 0.8).sin() * 0.5 + 0.5) - .powf(1.5); - dot.size(px(6. + 8. * pulse)) + let h = 6. + + 18. + * ((phase + i as f32 * 0.55).sin() * 0.5 + 0.5) + .powf(2.0); + bar.h(px(h)) }, ) })), @@ -690,11 +782,10 @@ mod example { pane: Pane, with_search: Option>, ) -> (Retained, gpui::WindowHandle) { - // --- Plain AppKit: the window (plus, for the dashboard, a native - // search field layered above the GPUI surface). --- + // --- Plain AppKit: the window. --- // SAFETY: all objects are created and used on the main thread and are // kept alive for the lifetime of the process (`mem::forget` in `run`). - let (native_window, host_view) = unsafe { + let native_window = unsafe { let rect = NSRect::new(NSPoint::new(180., 160.), NSSize::new(WIDTH, HEIGHT)); let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable @@ -708,65 +799,32 @@ mod example { false, ); native_window.setTitle(&NSString::from_str(title)); + native_window + }; - let content_view = native_window + // Pass the window's contentView directly as the host. open_window + // detects external_view == contentView and promotes the GPUI native + // view to be the contentView via setContentView:, giving it the same + // CA-transaction resize path as a regular GPUI window — no edge jitter. + let (content_ptr, content_bounds) = unsafe { + let cv = native_window .contentView() .expect("native window has a content view"); - let content_bounds = content_view.bounds().size; - - // The surface GPUI renders into: the whole content area. It - // autoresizes, so it follows the content view when the native tab - // bar appears. - let host_view = NSView::new(mtm); - host_view.setFrame(NSRect::new( - NSPoint::new(0., 0.), - NSSize::new(content_bounds.width, content_bounds.height), - )); - host_view.setAutoresizingMask( - objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable - | objc2_app_kit::NSAutoresizingMaskOptions::ViewHeightSizable, - ); - content_view.addSubview(&host_view); - - if let Some(tx) = with_search { - // A native search field, layered above the GPUI surface in the - // pane's header row; pinned to the content view's top edge. - let search = NSSearchField::new(mtm); - search.setFrame(NSRect::new( - NSPoint::new( - content_bounds.width - 24. - 200., - content_bounds.height - 24. - 25., - ), - NSSize::new(200., 26.), - )); - search.setAutoresizingMask( - objc2_app_kit::NSAutoresizingMaskOptions::ViewMinYMargin - | objc2_app_kit::NSAutoresizingMaskOptions::ViewMinXMargin, - ); - search.setPlaceholderString(Some(&NSString::from_str("Filter metrics"))); - let search_handler = SearchHandler::new(tx); - let _: () = msg_send![&*search, setDelegate: &*search_handler]; - content_view.addSubview(&search); - // Both live for the rest of the process. - std::mem::forget((search, search_handler)); - } - - (native_window, host_view) + let bounds = cv.bounds().size; + let ptr = NonNull::new(Retained::as_ptr(&cv) as *mut _) + .expect("content view pointer is non-null"); + (ptr, bounds) }; - // --- GPUI: render the pane into the host view. --- - let host_size = unsafe { NSView::bounds(&host_view) }.size; - let host_ptr = NonNull::new(Retained::as_ptr(&host_view) as *mut _) - .expect("host view pointer is non-null"); let gpui_window = cx .open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds { origin: point(px(0.), px(0.)), - size: size(px(host_size.width as f32), px(host_size.height as f32)), + size: size(px(content_bounds.width as f32), px(content_bounds.height as f32)), })), host_window_handle: Some(RawWindowHandle::AppKit(AppKitWindowHandle::new( - host_ptr, + content_ptr, ))), ..Default::default() }, @@ -785,8 +843,37 @@ mod example { ) .expect("failed to open hosted window"); - // The host view lives for the rest of the process. - std::mem::forget(host_view); + // After open_window, the GPUI native view is the window's contentView. + // Add the native search field as a subview of it so it layers on top. + if let Some(tx) = with_search { + unsafe { + let gpui_view = native_window + .contentView() + .expect("contentView is now the GPUI native view"); + let gpui_bounds = gpui_view.bounds().size; + // A native search field in the pane's header row; pinned to + // the top-right corner and follows resize via autoresizing. + let search = NSSearchField::new(mtm); + search.setFrame(NSRect::new( + NSPoint::new( + gpui_bounds.width - 24. - 200., + gpui_bounds.height - 24. - 25., + ), + NSSize::new(200., 26.), + )); + search.setAutoresizingMask( + objc2_app_kit::NSAutoresizingMaskOptions::ViewMinYMargin + | objc2_app_kit::NSAutoresizingMaskOptions::ViewMinXMargin, + ); + search.setPlaceholderString(Some(&NSString::from_str("Filter metrics"))); + let search_handler = SearchHandler::new(tx); + let _: () = msg_send![&*search, setDelegate: &*search_handler]; + gpui_view.addSubview(&search); + // Both live for the rest of the process. + std::mem::forget((search, search_handler)); + } + } + (native_window, gpui_window) } @@ -796,6 +883,7 @@ mod example { KeyBinding::new("cmd-1", SelectTab1, Some(APP_CONTEXT)), KeyBinding::new("cmd-2", SelectTab2, Some(APP_CONTEXT)), KeyBinding::new("enter", DismissInfo, Some(POPOVER_CONTEXT)), + KeyBinding::new("cmd-k", ToggleFeature, Some(POPOVER_CONTEXT)), ]); let mtm = MainThreadMarker::new().expect("must run on the main thread"); diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 099a0df7b625be..e7ce036467b7fa 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -515,6 +515,10 @@ struct MacWindowState { // (e.g. an NSPopover's internal window): we must not close that window, and // we receive none of its notifications. owns_native_window: bool, + // Temporarily set during set_frame_size so that content_size() returns the + // incoming new size while resize_callback runs (before super setFrameSize: + // has updated the NSView frame). Cleared immediately after the callback. + pending_content_size: Option>, } impl MacWindowState { @@ -645,6 +649,9 @@ impl MacWindowState { } fn content_size(&self) -> Size { + if let Some(pending) = self.pending_content_size { + return pending; + } if !self.owns_native_window { // Hosted views: the host window's content view isn't ours — use the // GPUI view's own bounds. @@ -872,6 +879,7 @@ impl MacWindow { accesskit_adapter: None, sheet_parent: None, owns_native_window: true, + pending_content_size: None, }))); (*native_window).set_ivar( @@ -1114,6 +1122,7 @@ impl MacWindow { accesskit_adapter: None, sheet_parent: None, owns_native_window: false, + pending_content_size: None, }))); (*native_view).set_ivar( @@ -1129,7 +1138,19 @@ impl MacWindow { setLayerContentsRedrawPolicy: NSViewLayerContentsRedrawDuringViewResize ]; - NSView::addSubview_(external_view, native_view.autorelease()); + // If the external view is the window's current content view, promote our + // GPUI view to be the content view directly. This puts setFrameSize: in + // the same CA-transaction path as a regular GPUI window, eliminating the + // sub-pixel edge jitter that occurs when rendering from a sub-view during + // live resize. When the external view is not the content view (e.g. inside + // an NSPopover's content view hierarchy), fall back to adding as a subview. + let window_content_view: id = msg_send![host_window, contentView]; + let native_view_autorelease = native_view.autorelease(); + if window_content_view == external_view { + let _: () = msg_send![host_window, setContentView: native_view_autorelease]; + } else { + NSView::addSubview_(external_view, native_view_autorelease); + } let _: BOOL = msg_send![host_window, makeFirstResponder: native_view]; window.0.lock().start_display_link(); @@ -2787,7 +2808,7 @@ extern "C" fn set_frame_size(this: &Object, _: Sel, size: NSSize) { } let window_state = unsafe { get_window_state(this) }; - let mut lock = window_state.as_ref().lock(); + let lock = window_state.as_ref().lock(); let new_size = convert(size); let old_size = unsafe { @@ -2799,21 +2820,47 @@ extern "C" fn set_frame_size(this: &Object, _: Sel, size: NSSize) { return; } - unsafe { - let _: () = msg_send![super(this, class!(NSView)), setFrameSize: size]; - } + drop(lock); + // Update the drawable size and GPUI viewport before the bounds change so + // that any displayLayer: call triggered during or after super finds the + // renderer and layout already consistent with the new size. + let mut lock = window_state.as_ref().lock(); let scale_factor = lock.scale_factor(); let drawable_size = new_size.to_device_pixels(scale_factor); lock.renderer.update_drawable_size(drawable_size); if let Some(mut callback) = lock.resize_callback.take() { - let content_size = lock.content_size(); - let scale_factor = lock.scale_factor(); + lock.pending_content_size = Some(new_size); drop(lock); - callback(content_size, scale_factor); - window_state.lock().resize_callback = Some(callback); - }; + callback(new_size, scale_factor); + let mut lock = window_state.as_ref().lock(); + lock.pending_content_size = None; + lock.resize_callback = Some(callback); + drop(lock); + } else { + drop(lock); + } + + unsafe { + let _: () = msg_send![super(this, class!(NSView)), setFrameSize: size]; + } + + // For hosted windows (external_view == contentView, i.e. the GPUI view is + // promoted to be the NSWindow's contentView), force a synchronous render + // immediately after the bounds change. With presentsWithTransaction=true + // set inside display_layer, drawable.present() is deferred into the + // current CA transaction — the same one that just received the bounds + // change from super above — so both land at the same vsync with no + // blank-strip flash on expanding edges. + // + // This path is safe for all windows: for non-hosted windows the display + // link already handles rendering and the extra synchronous draw is a + // redundant but harmless fast-path. + unsafe { + let layer: id = msg_send![this, layer]; + let _: () = msg_send![layer, display]; + } } extern "C" fn display_layer(this: &Object, _: Sel, _: id) {