From 4aceec16025bce611593bd3b27e40a081b6f88af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Wed, 30 Sep 2026 18:50:07 -0300 Subject: [PATCH 01/74] Fix file tree edge fades at scroll boundaries (#665) --- crates/ui/src/files/tree.rs | 66 +++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/crates/ui/src/files/tree.rs b/crates/ui/src/files/tree.rs index 1711f78e4f..a6c92749a9 100644 --- a/crates/ui/src/files/tree.rs +++ b/crates/ui/src/files/tree.rs @@ -114,6 +114,13 @@ fn tree_rail_geometry( Some((metrics, bounds.top())) } +fn tree_scroll_overflow(list: &gpui::ListState) -> (bool, bool) { + // ListState reports a negative offset as content scrolls upward. + let offset = -f32::from(list.scroll_px_offset_for_scrollbar().y); + let max = f32::from(list.max_offset_for_scrollbar().y); + (offset > 0.5, offset < max - 0.5) +} + /// The file tree's share of the shared floating rail: it owns no scroll /// handle, so geometry comes from the list state's own accessors and drags /// land through the list state's scrollbar offset setter. @@ -211,11 +218,7 @@ impl FilesSurface { .min_h_0() .with_sizing_behavior(ListSizingBehavior::Auto), ) - .fade_overflow_y_with(move |_| { - let offset = f32::from(overflow.scroll_px_offset_for_scrollbar().y); - let max = f32::from(overflow.max_offset_for_scrollbar().y); - (offset > 0.5, offset < max - 0.5) - }) + .fade_overflow_y_with(move |_| tree_scroll_overflow(&overflow)) }) .children(scrollbar) .into_any_element() @@ -531,7 +534,7 @@ fn status_row( mod tests { use super::super::model::VisibleTreeRow; use super::*; - use gpui::{ListAlignment, ListOffset, ListState}; + use gpui::{AppContext, ListAlignment, ListOffset, ListState}; fn rows(paths: &[&str]) -> Vec { paths @@ -544,6 +547,57 @@ mod tests { .collect() } + #[gpui::test] + fn tree_fades_only_at_edges_with_hidden_rows(cx: &mut gpui::TestAppContext) { + struct TestTree(ListState); + impl gpui::Render for TestTree { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(TREE_ROW_HEIGHT)).w_full().into_any_element() + }) + .w_full() + .h_full() + } + } + + let cx = cx.add_empty_window(); + let state = ListState::new(10, ListAlignment::Top, px(0.0)).measure_all(); + let view = cx.update(|_, cx| cx.new(|_| TestTree(state.clone()))); + let mut draw = |height| { + cx.draw( + gpui::point(px(0.0), px(0.0)), + gpui::size(px(200.0), px(height)), + |_, _| view.clone().into_any_element(), + ); + }; + + draw(100.0); + assert_eq!(state.max_offset_for_scrollbar().y, px(170.0)); + assert_eq!(tree_scroll_overflow(&state), (false, true)); + + state.set_offset_from_scrollbar(gpui::point(px(0.0), px(-85.0))); + draw(100.0); + assert_eq!(tree_scroll_overflow(&state), (true, true)); + + state.scroll_to_reveal_item(9); + draw(100.0); + assert_eq!(tree_scroll_overflow(&state), (true, false)); + let last = state.bounds_for_item(9).unwrap(); + let viewport = state.viewport_bounds(); + assert!(last.top() >= viewport.top() && last.bottom() <= viewport.bottom()); + + // A taller viewport or removed rows clamps scrolling during layout; + // the fade must disappear in that same frame. + draw(400.0); + assert_eq!(tree_scroll_overflow(&state), (false, false)); + draw(100.0); + state.scroll_to_reveal_item(9); + draw(100.0); + state.splice(2..10, 0); + draw(100.0); + assert_eq!(tree_scroll_overflow(&state), (false, false)); + } + #[test] fn directory_load_keeps_the_viewport_on_the_same_path() { let old = rows(&["a", "src", "z"]); From 6641095b5b5d1bd682da58b4939334f03dbf3bc6 Mon Sep 17 00:00:00 2001 From: Daniel Katulevskiy <84909978+katulevskiy@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:01:28 -0700 Subject: [PATCH 02/74] Fix Markdown drag selection across table columns (#681) --- crates/ui/src/markdown/render.rs | 255 ++++++++++++++++++++++++++++++- 1 file changed, 248 insertions(+), 7 deletions(-) diff --git a/crates/ui/src/markdown/render.rs b/crates/ui/src/markdown/render.rs index 9dced2fb3b..f92e30bc9a 100644 --- a/crates/ui/src/markdown/render.rs +++ b/crates/ui/src/markdown/render.rs @@ -1509,18 +1509,27 @@ pub fn selection_surface_reset(prefix: String) -> impl IntoElement { } /// `(element index, byte offset)` for a window position: the registered -/// element whose vertical band contains it, else the nearest by vertical -/// distance (a drag past the gutter or between blocks clamps sensibly). +/// element whose bounds contain it, else the nearest horizontally within the +/// closest vertical band. Vertical distance stays primary so dragging beyond +/// a short paragraph's edge keeps selecting that line. Horizontal distance +/// distinguishes side-by-side text such as table cells and their padding. fn registry_point(position: gpui::Point) -> Option<(usize, usize)> { REGISTRY.with(|r| { let reg = r.borrow(); let anchor = super::selection::anchor_key().unwrap_or_default(); - let mut best: Option<(usize, f32)> = None; + let mut best: Option<(usize, f32, f32)> = None; for (ei, entry) in reg.iter().enumerate() { if selection_scope(&entry.key) != selection_scope(&anchor) { continue; } let b = entry.layout.bounds(); + let dx = if position.x < b.left() { + f32::from(b.left() - position.x) + } else if position.x > b.right() { + f32::from(position.x - b.right()) + } else { + 0.0 + }; let dy = if position.y < b.top() { f32::from(b.top() - position.y) } else if position.y > b.bottom() { @@ -1528,14 +1537,14 @@ fn registry_point(position: gpui::Point) -> Option<(usize, usize)> } else { 0.0 }; - if best.is_none_or(|(_, d)| dy < d) { - best = Some((ei, dy)); + if best.is_none_or(|(_, best_dy, best_dx)| (dy, dx) < (best_dy, best_dx)) { + best = Some((ei, dy, dx)); } - if dy == 0.0 { + if dy == 0.0 && dx == 0.0 { break; } } - let (ei, _) = best?; + let (ei, _, _) = best?; let ix = match reg[ei].layout.index_for_position(position) { Ok(ix) | Err(ix) => ix, }; @@ -2559,6 +2568,238 @@ mod tests { } } + struct MarkdownSelectionHarness; + + impl Render for MarkdownSelectionHarness { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::of(cx).clone(); + let source = "# Heading\n\nBefore **bold** and *italic* with `code`.\n\n\ + | Agent | Branch | Target |\n\ + | --- | --- | --- |\n\ + | linux | `lab-linux` | Linux jobs, ten minutes or less. This long target wraps across several visual lines in its cell. |\n\ + | macos | `lab-macos` | macOS jobs |\n\n\ + > A **quoted** paragraph.\n\n\ + - A list with `inline code`.\n\n\ + After the table."; + div() + .size_full() + .child(selection_frame_reset()) + .child(render_tree( + &parse_full(source), + &RenderOptions::settled("markdown-selection-test".into()), + &theme, + window, + &|_| None, + )) + } + } + + fn selection_key_for_text(text: &str) -> String { + REGISTRY.with(|registry| { + registry + .borrow() + .iter() + .find(|entry| entry.text.as_ref() == text) + .unwrap_or_else(|| panic!("missing painted text: {text}")) + .key + .to_string() + }) + } + + fn selection_position(key: &str, ix: usize) -> gpui::Point { + let (_, layout, _) = selection_test_snapshot(key); + layout.position_for_index(ix).expect("text position") + + point(px(0.1), layout.line_height() / 2.0) + } + + #[gpui::test] + fn markdown_drag_tracks_each_table_column_and_wrapped_cell(cx: &mut TestAppContext) { + let _selection = super::super::selection::test_state_lock(); + cx.update(|cx| cx.set_global(Theme::dark())); + let (_, cx) = cx.add_window_view(|_, _| MarkdownSelectionHarness); + cx.simulate_resize(size(px(560.0), px(800.0))); + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(); + }); + + let target = "Linux jobs, ten minutes or less. This long target wraps across several visual lines in its cell."; + let target_key = selection_key_for_text(target); + assert!(selection_test_bounds(&target_key).size.height > px(MD_LINE_HEIGHT)); + // Every column must track the pointer, including later visual lines + // in a wrapped cell, and a drag must contract and reverse normally. + for text in ["linux", "lab-linux", target, "lab-macos", "macOS jobs"] { + let key = selection_key_for_text(text); + let start = selection_position(&key, 1); + let end = selection_position(&key, text.len() - 1); + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: start, + click_count: 1, + ..Default::default() + }); + for (position, expected) in [ + (end, &text[1..text.len() - 1]), + (start, ""), + (selection_position(&key, 0), &text[..1]), + ] { + cx.simulate_event(gpui::MouseMoveEvent { + position, + pressed_button: Some(gpui::MouseButton::Left), + ..Default::default() + }); + assert_eq!( + super::super::selection::selected_text().unwrap_or_default(), + expected, + "drag inside {text}" + ); + } + cx.simulate_event(gpui::MouseUpEvent { + button: gpui::MouseButton::Left, + position: selection_position(&key, 0), + ..Default::default() + }); + super::super::selection::clear_if_owner(&key); + } + } + + #[gpui::test] + fn markdown_drag_clamps_in_cell_padding_and_outside_paragraphs(cx: &mut TestAppContext) { + let _selection = super::super::selection::test_state_lock(); + cx.update(|cx| cx.set_global(Theme::dark())); + let (_, cx) = cx.add_window_view(|_, _| MarkdownSelectionHarness); + cx.simulate_resize(size(px(560.0), px(800.0))); + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(); + }); + for text in [ + "linux", + "lab-linux", + "lab-macos", + "macOS jobs", + "A quoted paragraph.", + ] { + let key = selection_key_for_text(text); + let bounds = selection_test_bounds(&key); + // Padding belongs to the nearest cell, even if an earlier column + // has text at the same y. Each side clamps to that cell's endpoint. + for (position, expected) in [ + ( + point( + bounds.right() + px(4.0), + selection_position(&key, text.len()).y, + ), + &text[1..], + ), + ( + point(bounds.left() - px(4.0), selection_position(&key, 0).y), + &text[..1], + ), + ] { + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: selection_position(&key, 1), + click_count: 1, + ..Default::default() + }); + cx.simulate_event(gpui::MouseMoveEvent { + position, + pressed_button: Some(gpui::MouseButton::Left), + ..Default::default() + }); + let selected = super::super::selection::selected_text(); + super::super::selection::end_active_drag(); + super::super::selection::clear_if_owner(&key); + assert_eq!(selected.as_deref(), Some(expected), "padding beside {text}"); + } + } + // A far-right drag on a short heading must stay on that line instead + // of jumping to a wider paragraph below (Euclidean nearest is wrong). + let key = selection_key_for_text("Heading"); + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: selection_position(&key, 0), + click_count: 1, + ..Default::default() + }); + cx.simulate_event(gpui::MouseMoveEvent { + position: selection_position(&key, 0) + point(px(1000.0), px(0.0)), + pressed_button: Some(gpui::MouseButton::Left), + ..Default::default() + }); + let selected = super::super::selection::selected_text(); + super::super::selection::end_active_drag(); + super::super::selection::clear_if_owner(&key); + assert_eq!(selected.as_deref(), Some("Heading")); + } + + #[gpui::test] + fn markdown_drag_crosses_cells_and_formatted_blocks_in_document_order(cx: &mut TestAppContext) { + let _selection = super::super::selection::test_state_lock(); + cx.update(|cx| cx.set_global(Theme::dark())); + let (_, cx) = cx.add_window_view(|_, _| MarkdownSelectionHarness); + cx.simulate_resize(size(px(560.0), px(800.0))); + cx.update(|window, cx| { + window.refresh(); + window.draw(cx).clear(); + }); + let branch_key = selection_key_for_text("lab-linux"); + let target = "Linux jobs, ten minutes or less. This long target wraps across several visual lines in its cell."; + let target_key = selection_key_for_text(target); + let macos_key = selection_key_for_text("lab-macos"); + let before_key = selection_key_for_text("Before bold and italic with code."); + let after_key = selection_key_for_text("After the table."); + for (anchor_key, anchor_ix, head_key, head_ix, expected) in [ + ( + &branch_key, + 4, + &target_key, + 10, + "linux\nLinux jobs".to_string(), + ), + ( + &target_key, + 10, + &branch_key, + 4, + "linux\nLinux jobs".to_string(), + ), + ( + &branch_key, + 4, + &macos_key, + 3, + format!("linux\n{target}\nmacos\nlab"), + ), + ( + &before_key, + 0, + &after_key, + 16, + format!( + "Before bold and italic with code.\nAgent\nBranch\nTarget\nlinux\nlab-linux\n{target}\nmacos\nlab-macos\nmacOS jobs\nA quoted paragraph.\nA list with inline code.\nAfter the table." + ), + ), + ] { + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: selection_position(anchor_key, anchor_ix), + click_count: 1, + ..Default::default() + }); + cx.simulate_event(gpui::MouseMoveEvent { + position: selection_position(head_key, head_ix), + pressed_button: Some(gpui::MouseButton::Left), + ..Default::default() + }); + let selected = super::super::selection::selected_text(); + super::super::selection::end_active_drag(); + super::super::selection::clear_if_owner(anchor_key); + assert_eq!(selected.as_deref(), Some(expected.as_str())); + } + } + #[gpui::test] fn code_block_lines_participate_in_text_selection(cx: &mut TestAppContext) { let _selection = super::super::selection::test_state_lock(); From 0fab030fcacae566356d319679f4a5e13b823c1e Mon Sep 17 00:00:00 2001 From: Daniel Katulevskiy <84909978+katulevskiy@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:52:04 -0700 Subject: [PATCH 03/74] Question panel: paint an opaque background (#669) * Question panel: paint an opaque background so the transcript doesn't show through * Question panel: frost it like the composer pill instead of a new opaque fill The panel stands in for the composer pill but was the only input surface painting a translucent fill without the backdrop blur, so the transcript showed through unblurred. Wrap it in frost::frosted and use the pill's composer_surface_bg, matching the pill and queue tray on every material and wallpaper tint, and drop the separate input_solid_bg token. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Wing Co-authored-by: Claude Opus 5.5 --- crates/ui/src/composer.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index ee8eaba92e..fcb7c0c18b 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -8692,16 +8692,20 @@ impl Composer { }) }); - div() + // Stands in for the composer pill, so it is the same frosted surface: + // without the backdrop blur the translucent fill let the transcript + // show through unblurred. + let panel = div() .id("question-panel") .track_focus(&self.wizard_focus) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { this.on_wizard_key(event, window, cx) })) + .occlude() .rounded(px(COMPOSER_RADIUS)) .border_1() .border_color(theme.border) - .bg(theme.input_glass_bg()) + .bg(theme.composer_surface_bg()) .when(!theme.is_frost(), |el| el.shadow_lg()) .flex() .flex_col() @@ -8806,8 +8810,8 @@ impl Composer { .when(!can_advance, |el| el.opacity(0.4)) .on_click(cx.listener(|this, _, _, cx| this.wizard_advance(cx))), ), - ) - .into_any_element() + ); + crate::frost::frosted(COMPOSER_RADIUS, crate::frost::MENU_BLUR, panel).into_any_element() } fn render_send_button( From 651aa7a95d5a65ce45406b7ad29d3975f7b8df0b Mon Sep 17 00:00:00 2001 From: sen <102120097+senxd@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:54:27 +0100 Subject: [PATCH 04/74] Providers: update Homebrew-installed CLIs with brew (#661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provider CLI installed as a Homebrew cask or formula (Caskroom///… or Cellar///{bin,libexec}/…) gets an Update action that runs `/bin/brew upgrade --cask|--formula ` as argv. The brew binary is the one in the prefix that owns the CLI. The installable version comes from formulae.brew.sh and `brew info`. When an upstream release is newer than Homebrew's, the row names the brew command. Update still runs it, but idle auto-update waits for Homebrew. npm globals under Homebrew's Node keep their own updaters. Detection is macOS/Linux only. Co-Authored-By: grahamwinter Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/harness_updates.rs | 555 +++++++++++++++++++++++++-- crates/ui/src/settings/harnesses.rs | 16 +- 2 files changed, 538 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/harness_updates.rs b/crates/engine/src/harness_updates.rs index 24557fe2d2..92a90613c6 100644 --- a/crates/engine/src/harness_updates.rs +++ b/crates/engine/src/harness_updates.rs @@ -72,6 +72,33 @@ struct CodexStandaloneInstall { target: String, } +/// A Homebrew cask or formula that owns the resolved CLI. The token is taken +/// from the `Caskroom` or `Cellar` directory, never from installer output. +#[derive(Debug, Clone, PartialEq, Eq)] +struct HomebrewPackage { + brew: PathBuf, + cask: bool, + token: String, +} + +impl HomebrewPackage { + fn upgrade_command(&self) -> String { + if self.cask { + format!("brew upgrade --cask {}", self.token) + } else { + format!("brew upgrade --formula {}", self.token) + } + } + + fn upgrade_args(&self) -> Vec<&str> { + if self.cask { + vec!["upgrade", "--cask", self.token.as_str()] + } else { + vec!["upgrade", "--formula", self.token.as_str()] + } + } +} + enum UpdatePlan { Command { executable: PathBuf, @@ -79,6 +106,7 @@ enum UpdatePlan { }, AntigravityArchive, CodexStandalone(CodexStandaloneInstall), + Homebrew(HomebrewPackage), } struct ReleaseAsset { @@ -195,9 +223,8 @@ fn provider(id: HarnessId) -> ProviderSpec { repository: "openai/codex", tag_prefix: "rust-v", }, - // Codex has no self-update command. npm/Homebrew remain manual; - // `update_plan` separately recognizes the official standalone - // release layout, where an atomic in-place update is unambiguous. + // npm stays manual. Homebrew casks and formulae are upgraded with + // `brew upgrade`; the official standalone layout is updated in place. update_args: None, manual_command: "Update Codex with its original installer or package manager", }, @@ -255,6 +282,9 @@ fn provider(id: HarnessId) -> ProviderSpec { } fn update_plan(harness: HarnessId, executable: &Path) -> Result { + if let Some(package) = homebrew_package(executable) { + return Ok(UpdatePlan::Homebrew(package)); + } if harness == HarnessId::ClaudeCode && claude_package_manager_command(executable).is_some() { return Err("update Claude Code with its package manager".into()); } @@ -534,6 +564,13 @@ impl HarnessUpdateCoordinator { && status.policy == HarnessUpdatePolicy::AutoWhenIdle && status.phase == HarnessUpdatePhase::Available && status.can_apply + // The Update button can still run `brew upgrade` when Homebrew has + // not published the upstream release. Automatic installs wait until + // Homebrew itself reports a newer package. + && !status + .manual_command + .as_deref() + .is_some_and(unpublished_homebrew_upgrade) } fn schedule_automatic_update(&self, harness: HarnessId) { @@ -609,6 +646,11 @@ impl HarnessUpdateCoordinator { if self.settle_if_unmonitored(harness) { return Ok(()); } + if let Some(package) = homebrew_package(&executable) { + return self + .finish_homebrew_check(harness, &executable, installed, source, package) + .await; + } let latest = match spec.latest { LatestSource::AntigravityAcp => self .antigravity_acp_latest() @@ -750,6 +792,148 @@ impl HarnessUpdateCoordinator { Ok(()) } + /// Homebrew owns this binary, so the installable release is the cask or + /// formula version. An upstream release that Homebrew has not published yet + /// still gets an Update button — it runs `brew upgrade` — but is not + /// installed automatically. + async fn finish_homebrew_check( + &self, + harness: HarnessId, + executable: &Path, + installed: String, + source: HarnessInstallSource, + package: HomebrewPackage, + ) -> Result<(), String> { + let brew_version = match self.homebrew_latest(&package).await { + Ok(version) => version, + Err(error) => { + return self.fail_check_with_installed(harness, installed, source, error); + } + }; + let upstream = self + .upstream_version(harness, &installed) + .await + .ok() + .filter(|version| version_is_newer(version, &installed)); + let brew_newer = version_is_newer(&brew_version, &installed); + let (latest, note) = if brew_newer { + (brew_version, None) + } else if let Some(upstream) = + upstream.filter(|version| version_is_newer(version, &brew_version)) + { + (upstream, Some(package.upgrade_command())) + } else { + (brew_version, None) + }; + if self.settle_if_unmonitored(harness) { + return Ok(()); + } + let dismissed = lock(&self.inner.prefs) + .dismissed_versions + .get(&harness) + .is_some_and(|dismissed| dismissed == &latest); + let available = version_is_newer(&latest, &installed) && !dismissed; + let manual_command = if available { note } else { None }; + let channel = (harness == HarnessId::ClaudeCode) + .then(|| claude_cask_channel(executable)) + .flatten() + .map(str::to_owned); + let registry = self.inner.registry.clone(); + self.mutate(harness, |status| { + status.installed_version = Some(installed); + status.latest_version = Some(latest); + if channel.is_some() { + status.channel = channel.clone(); + } + status.source = source; + status.can_apply = true; + status.manual_command = manual_command; + status.phase = if status.policy == HarnessUpdatePolicy::Off + || !registry.enabled_set().contains(&harness) + { + HarnessUpdatePhase::Dormant + } else if available { + HarnessUpdatePhase::Available + } else { + HarnessUpdatePhase::Current + }; + status.checked_at = Some(now_ms()); + status.error = None; + }); + Ok(()) + } + + async fn homebrew_latest(&self, package: &HomebrewPackage) -> Result { + let api = self.homebrew_api_version(package).await; + let local = self.homebrew_local_version(package).await; + match (api, local) { + (Ok(api), Ok(local)) => Ok(if version_is_newer(&local, &api) { + local + } else { + api + }), + (Ok(version), Err(_)) | (Err(_), Ok(version)) => Ok(version), + (Err(api), Err(local)) => Err(format!("{api}; {local}")), + } + } + + async fn homebrew_api_version(&self, package: &HomebrewPackage) -> Result { + let kind = if package.cask { "cask" } else { "formula" }; + let url = format!( + "https://formulae.brew.sh/api/{kind}/{}.json", + homebrew_url_component(&package.token) + ); + let response = self + .inner + .client + .get(url) + .send() + .await + .map_err(|error| format!("Homebrew version check failed: {error}"))? + .error_for_status() + .map_err(|error| format!("Homebrew version check failed: {error}"))?; + let body = response + .text() + .await + .map_err(|error| format!("Homebrew version response was invalid: {error}"))?; + parse_homebrew_version(package.cask, &body) + } + + async fn homebrew_local_version(&self, package: &HomebrewPackage) -> Result { + let brew = resolve_brew(&package.brew)?; + let token = package.token.as_str(); + let args: &[&str] = if package.cask { + &["info", "--cask", "--json=v2", token] + } else { + &["info", "--formula", "--json=v2", token] + }; + // Don't refresh taps on a probe. `brew upgrade` does that when applying. + let output = run_command_output_env(&brew, args, COMMAND_TIMEOUT, BREW_INFO_ENV).await?; + parse_homebrew_version(package.cask, &output) + } + + /// Release feeds that do not spawn the CLI. A failure here only hides the + /// "published upstream, not yet in Homebrew" note. + async fn upstream_version( + &self, + harness: HarnessId, + installed: &str, + ) -> Result { + match provider(harness).latest { + LatestSource::Github { + repository, + tag_prefix, + } => self.github_latest(repository, tag_prefix).await, + LatestSource::Npm(package) => self.npm_latest(package).await, + LatestSource::Opencode => self.npm_latest(opencode_release_package(installed)).await, + LatestSource::AntigravityAcp => self.antigravity_acp_latest().await, + LatestSource::Claude + | LatestSource::Command(_) + | LatestSource::Hermes + | LatestSource::Manual => Err("no separate upstream feed".into()), + } + } + /// Apply one known release. Cancellation is honored while waiting for the /// exclusive lease and before mutation; once the vendor updater starts, /// the installation phase is intentionally non-interruptible. The owned @@ -876,6 +1060,20 @@ impl HarnessUpdateCoordinator { self.install_codex_standalone(harness, ¤t, install, &cancel) .await } + UpdatePlan::Homebrew(package) => match resolve_brew(&package.brew) { + Ok(brew) => { + let args = package.upgrade_args(); + match self.begin_install(harness, &cancel) { + Ok(()) => { + run_command_output_env(&brew, &args, UPDATE_TIMEOUT, BREW_UPGRADE_ENV) + .await + .map(drop) + } + Err(error) => Err(error), + } + } + Err(error) => Err(error), + }, }; if let Err(error) = applied { drop(lease); @@ -893,18 +1091,32 @@ impl HarnessUpdateCoordinator { }); let verified = match self.executable(harness) { Ok(executable) => { - run_version_command(harness, &executable, provider(harness).version_args).await + run_version_command(harness, &executable, provider(harness).version_args) + .await + .map(|version| (version, executable)) } Err(error) => Err(error), }; let result = match verified { - Ok(version) => { + Ok((version, executable)) => { let expected = current.latest_version.as_deref(); - if expected.is_some_and(|latest| version_is_newer(latest, &version)) { - Err(format!( - "verification returned {version}, older than expected {}", - expected.unwrap_or_default() - )) + if let Some(latest) = expected.filter(|latest| version_is_newer(latest, &version)) { + let lag = current + .manual_command + .as_deref() + .is_some_and(unpublished_homebrew_upgrade); + Err(match homebrew_package(&executable) { + Some(package) if lag => format!( + "Homebrew is still on {version}. {latest} is not available from `{}` yet", + package.upgrade_command() + ), + Some(_) => format!( + "Homebrew upgrade left the CLI on {version}, older than expected {latest}" + ), + None => { + format!("verification returned {version}, older than expected {latest}") + } + }) } else { lock(&self.inner.prefs).dismissed_versions.remove(&harness); self.persist_preferences(); @@ -1697,6 +1909,134 @@ fn activate_codex_release( Err("automatic Codex standalone updates are not supported on this platform".into()) } +fn unpublished_homebrew_upgrade(command: &str) -> bool { + command + .trim() + .trim_matches('`') + .starts_with("brew upgrade ") +} + +fn homebrew_token_ok(token: &str) -> bool { + let Some(first) = token.chars().next() else { + return false; + }; + first.is_ascii_alphanumeric() + && token.len() <= 128 + && token + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '_' | '.' | '@')) + // brew loads a `.rb` or `.json` argument as a local package file. + && !token.ends_with(".rb") + && !token.ends_with(".json") +} + +fn homebrew_url_component(token: &str) -> String { + let mut encoded = String::new(); + for byte in token.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char); + } + _ => encoded.push_str(&format!("%{byte:02X}")), + } + } + encoded +} + +/// `Caskroom///…` and `Cellar///{bin,libexec}/…` +/// are Homebrew's own layout. Anything else under a Homebrew prefix is not a +/// Homebrew-owned CLI: npm packages in `lib/node_modules`, including globals +/// that a keg-only runtime such as `node@20` keeps inside its own keg, belong +/// to that package manager, and upgrading the runtime would not update them. +fn homebrew_package(path: &Path) -> Option { + // Homebrew runs only on macOS and Linux. + if !cfg!(unix) { + return None; + } + let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let components: Vec<_> = canonical.components().collect(); + let (index, cask) = + components + .iter() + .enumerate() + .find_map(|(index, component)| match component.as_os_str().to_str()? { + "Caskroom" => Some((index, true)), + "Cellar" => Some((index, false)), + _ => None, + })?; + let token = components.get(index + 1)?.as_os_str().to_str()?; + if !homebrew_token_ok(token) { + return None; + } + let is_normal = |offset: usize| { + matches!( + components.get(index + offset), + Some(std::path::Component::Normal(_)) + ) + }; + // The executable must live inside a versioned install of the package. + if !is_normal(2) || !is_normal(3) { + return None; + } + if !cask + && !matches!( + components[index + 3].as_os_str().to_str(), + Some("bin" | "libexec") + ) + { + return None; + } + let mut prefix = PathBuf::new(); + for component in &components[..index] { + prefix.push(component); + } + if prefix.as_os_str().is_empty() { + return None; + } + Some(HomebrewPackage { + brew: prefix.join("bin").join("brew"), + cask, + token: token.to_string(), + }) +} + +fn resolve_brew(preferred: &Path) -> Result { + if preferred.is_file() { + Ok(preferred.to_path_buf()) + } else { + // A different prefix's brew would upgrade the wrong install. + Err(format!("Homebrew was not found at {}", preferred.display())) + } +} + +fn parse_homebrew_version(cask: bool, body: &str) -> Result { + let json: serde_json::Value = serde_json::from_str(body) + .map_err(|error| format!("Homebrew version response was invalid: {error}"))?; + let version = if cask { + json.get("version") + .and_then(serde_json::Value::as_str) + .or_else(|| { + json.pointer("/casks/0/version") + .and_then(serde_json::Value::as_str) + }) + } else { + json.pointer("/versions/stable") + .and_then(serde_json::Value::as_str) + .or_else(|| { + json.pointer("/formulae/0/versions/stable") + .and_then(serde_json::Value::as_str) + }) + } + .map(str::trim) + .filter(|version| !version.is_empty()) + .ok_or_else(|| "Homebrew version response contained no version".to_string())?; + let comparable = version.split(',').next().unwrap_or(version).trim(); + if version_numbers(comparable).is_none() { + return Err(format!("Homebrew version {version} is not comparable")); + } + Ok(comparable.to_string()) +} + fn classify_source(path: &Path) -> HarnessInstallSource { let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); let text = format!("{}\n{}", path.display(), canonical.display()).to_ascii_lowercase(); @@ -1724,17 +2064,14 @@ fn claude_package_manager_command_for(harness: HarnessId, path: &Path) -> Option } fn claude_package_manager_command(path: &Path) -> Option { + if let Some(package) = homebrew_package(path) { + return Some(package.upgrade_command()); + } let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); let text = format!("{}\n{}", path.display(), canonical.display()) .replace('\\', "/") .to_ascii_lowercase(); - if let Some(channel) = claude_cask_channel(path) { - Some(if channel == "latest" { - "brew upgrade claude-code@latest".into() - } else { - "brew upgrade claude-code".into() - }) - } else if text.contains("/winget/") || text.contains("/microsoft/winget/") { + if text.contains("/winget/") || text.contains("/microsoft/winget/") { Some("winget upgrade Anthropic.ClaudeCode".into()) } else if canonical.starts_with("/usr/bin") || canonical.starts_with("/nix/store") @@ -1806,6 +2143,21 @@ async fn run_command(executable: &Path, args: &[&str], timeout: Duration) -> Res .map(drop) } +const BREW_UPGRADE_ENV: &[(&str, &str)] = &[ + ("NONINTERACTIVE", "1"), + ("HOMEBREW_NO_ANALYTICS", "1"), + ("HOMEBREW_NO_ENV_HINTS", "1"), + ("HOMEBREW_NO_EMOJI", "1"), +]; + +const BREW_INFO_ENV: &[(&str, &str)] = &[ + ("NONINTERACTIVE", "1"), + ("HOMEBREW_NO_ANALYTICS", "1"), + ("HOMEBREW_NO_ENV_HINTS", "1"), + ("HOMEBREW_NO_EMOJI", "1"), + ("HOMEBREW_NO_AUTO_UPDATE", "1"), +]; + /// Unix updaters can delegate installation to npm, pip, or a shell. Own /// their process group as well as the leader, including on future cancellation. #[cfg(unix)] @@ -1823,6 +2175,15 @@ async fn run_command_output( executable: &Path, args: &[&str], timeout: Duration, +) -> Result { + run_command_output_env(executable, args, timeout, &[]).await +} + +async fn run_command_output_env( + executable: &Path, + args: &[&str], + timeout: Duration, + env: &[(&str, &str)], ) -> Result { let mut command = Command::new(executable); command @@ -1832,6 +2193,9 @@ async fn run_command_output( .stderr(Stdio::piped()) .kill_on_drop(true) .env("NO_COLOR", "1"); + for (key, value) in env { + command.env(*key, *value); + } zeron_harness::compose_child_path(&mut command, executable); #[cfg(unix)] command.process_group(0); @@ -3103,22 +3467,25 @@ esac for (path, command, channel) in [ ( "/opt/homebrew/Caskroom/claude-code/2.1.100/claude", - "brew upgrade claude-code", + "brew upgrade --cask claude-code", "stable", ), ( "/opt/homebrew/Caskroom/claude-code@latest/2.1.110/claude", - "brew upgrade claude-code@latest", + "brew upgrade --cask claude-code@latest", "latest", ), ] { let path = std::path::Path::new(path); - assert!(!super::can_apply_update(HarnessId::ClaudeCode, path)); - assert_eq!( - super::claude_package_manager_command(path).as_deref(), - Some(command) - ); assert_eq!(super::claude_cask_channel(path).unwrap(), channel); + // Homebrew runs only on macOS and Linux. + if cfg!(unix) { + assert!(super::can_apply_update(HarnessId::ClaudeCode, path)); + assert_eq!( + super::claude_package_manager_command(path).as_deref(), + Some(command) + ); + } } assert!(!super::can_apply_update( HarnessId::ClaudeCode, @@ -3179,4 +3546,144 @@ esac ); coordinator.shutdown().await; } + + #[cfg(unix)] + #[test] + fn homebrew_layout_selects_cask_or_formula_upgrade() { + let codex = super::homebrew_package(std::path::Path::new( + "/opt/homebrew/Caskroom/codex/0.159.0/bin/codex", + )) + .unwrap(); + assert!(codex.cask); + assert_eq!(codex.token, "codex"); + assert_eq!( + codex.brew, + std::path::PathBuf::from("/opt/homebrew/bin/brew") + ); + assert_eq!(codex.upgrade_command(), "brew upgrade --cask codex"); + assert_eq!(codex.upgrade_args(), ["upgrade", "--cask", "codex"]); + assert!(super::can_apply_update( + HarnessId::Codex, + std::path::Path::new("/opt/homebrew/Caskroom/codex/0.159.0/bin/codex") + )); + + let devin = super::homebrew_package(std::path::Path::new( + "/opt/homebrew/Caskroom/devin-cli/3000.10.21/bin/devin", + )) + .unwrap(); + assert_eq!(devin.upgrade_command(), "brew upgrade --cask devin-cli"); + + let formula = super::homebrew_package(std::path::Path::new( + "/home/linuxbrew/.linuxbrew/Cellar/pi-coding-agent/1.2.3/bin/pi", + )) + .unwrap(); + assert!(!formula.cask); + assert_eq!(formula.token, "pi-coding-agent"); + assert_eq!( + formula.upgrade_command(), + "brew upgrade --formula pi-coding-agent" + ); + assert_eq!( + formula.upgrade_args(), + ["upgrade", "--formula", "pi-coding-agent"] + ); + assert_eq!( + formula.brew, + std::path::PathBuf::from("/home/linuxbrew/.linuxbrew/bin/brew") + ); + + assert!( + super::homebrew_package(std::path::Path::new( + "/opt/homebrew/lib/node_modules/@openai/codex/bin/codex" + )) + .is_none() + ); + assert!( + super::homebrew_package(std::path::Path::new( + "/opt/homebrew/Caskroom/../not-a-real-codex" + )) + .is_none() + ); + // npm globals of a keg-only Node live inside the runtime's keg. Brew + // must never upgrade node@20 on behalf of the CLI installed with it. + assert!( + super::homebrew_package(std::path::Path::new( + "/opt/homebrew/Cellar/node@20/20.18.0/lib/node_modules/@openai/codex/bin/codex.js" + )) + .is_none() + ); + assert!( + super::homebrew_package(std::path::Path::new( + "/usr/local/Cellar/python@3.12/3.12.7/Frameworks/Python.framework/Versions/3.12/bin/hermes" + )) + .is_none() + ); + // Formulae that vendor a Node package keep it under libexec. + let gemini = super::homebrew_package(std::path::Path::new( + "/usr/local/Cellar/gemini-cli/0.9.0/libexec/lib/node_modules/@google/gemini-cli/dist/index.js", + )) + .unwrap(); + assert_eq!( + gemini.upgrade_args(), + ["upgrade", "--formula", "gemini-cli"] + ); + assert_eq!(gemini.brew, std::path::PathBuf::from("/usr/local/bin/brew")); + for unversioned in [ + "/opt/homebrew/Caskroom/codex", + "/opt/homebrew/Cellar/codex/bin", + ] { + assert!(super::homebrew_package(std::path::Path::new(unversioned)).is_none()); + } + // A leading dash would be read as a brew option. + assert!( + super::homebrew_package(std::path::Path::new( + "/opt/homebrew/Caskroom/--force/1.0.0/codex" + )) + .is_none() + ); + // brew would load these as local package files. + for token in ["codex.rb", "codex.json"] { + assert!( + super::homebrew_package( + &std::path::Path::new("/opt/homebrew/Cellar") + .join(token) + .join("1.0.0/bin/codex") + ) + .is_none() + ); + } + assert!( + !super::can_apply_update( + HarnessId::Codex, + std::path::Path::new("/tmp/zeron-not-installed/codex") + ), + "an unclassified codex binary stays manual" + ); + + assert_eq!( + super::parse_homebrew_version(true, r#"{"version":"0.159.1"}"#).unwrap(), + "0.159.1" + ); + assert_eq!( + super::parse_homebrew_version(true, r#"{"casks":[{"version":"0.159.1"}]}"#).unwrap(), + "0.159.1" + ); + assert_eq!( + super::parse_homebrew_version(false, r#"{"versions":{"stable":"1.2.3"}}"#).unwrap(), + "1.2.3" + ); + assert_eq!( + super::parse_homebrew_version( + false, + r#"{"formulae":[{"versions":{"stable":"1.2.3"}}]}"# + ) + .unwrap(), + "1.2.3" + ); + assert!(super::parse_homebrew_version(true, r#"{"version":"latest"}"#).is_err()); + assert_eq!( + super::homebrew_url_component("claude-code@latest"), + "claude-code%40latest" + ); + } } diff --git a/crates/ui/src/settings/harnesses.rs b/crates/ui/src/settings/harnesses.rs index 81c70d38d0..6367679d73 100644 --- a/crates/ui/src/settings/harnesses.rs +++ b/crates/ui/src/settings/harnesses.rs @@ -131,15 +131,13 @@ fn harness_update_label(status: &HarnessUpdateStatus, theme: &Theme) -> (SharedS Some(latest) => format!("{installed} · v{latest} available"), None => format!("{installed} · Update available"), }; - if status.can_apply { - available - } else { - status - .manual_command - .as_deref() - .map(|instruction| format!("{available} · {instruction}")) - .unwrap_or(available) - } + // Applicable Homebrew updates still name the brew command when the + // cask or formula has not published the upstream release yet. + status + .manual_command + .as_deref() + .map(|instruction| format!("{available} · {instruction}")) + .unwrap_or(available) } HarnessUpdatePhase::WaitingForIdle => format!("{installed} · Waiting for agent to be idle"), HarnessUpdatePhase::Preparing => format!("{installed} · Preparing update…"), From cc8b147a3ccce7f3bf57697cd5ef41dd06f489f5 Mon Sep 17 00:00:00 2001 From: Daniel Katulevskiy <84909978+katulevskiy@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:02:39 -0700 Subject: [PATCH 05/74] Fix subagent lifecycle identity and restart recovery (#676) * Fix subagent spawn identity across resumes and recover stale chips * Claude resume: scan native history off the runtime, skip sidechains Long sessions' transcripts reach 100+ MB; parsing every line as JSON on a tokio worker before each cold resume stalled the runtime. Read with std I/O under spawn_blocking, parse only lines that can name a spawn or its agent, stop on read errors, and ignore a subagent's own nested spawns. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Wing Co-authored-by: Claude Opus 5.5 --- crates/engine/src/doc_host.rs | 30 ++- crates/engine/src/sessions.rs | 21 +- crates/engine/tests/e2e.rs | 156 ++++++++++++++- crates/harness/src/claude/mod.rs | 13 +- crates/harness/src/claude/normalize.rs | 258 ++++++++++++++++++++++++- crates/harness/src/claude/wire.rs | 9 +- 6 files changed, 456 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/doc_host.rs b/crates/engine/src/doc_host.rs index e581310069..bffa3545f0 100644 --- a/crates/engine/src/doc_host.rs +++ b/crates/engine/src/doc_host.rs @@ -31,8 +31,8 @@ use tokio_util::task::TaskTracker; use zeron_doc::{ COMMAND_DEFAULT_TTL_MS, CommandBasedOn, CommandDisposition, DocError, EvaluationContext, MessagePart, MessageRole, MessageStatus, QueueDeliveryGate, QueuedMessage, SessionCommandEntry, - SessionCommandPayload, SessionCommandStatus, SessionDoc, SessionMessageEntry, evaluate_command, - join_continuation_entries, + SessionCommandPayload, SessionCommandStatus, SessionDoc, SessionMessageEntry, SubagentStatus, + evaluate_command, join_continuation_entries, }; use zeron_proto::{ConversationSourceContext, HarnessId, UserInputAnswer, UserInputQuestion}; use zeron_sync::DocsStore; @@ -786,17 +786,33 @@ impl ChatDocHandle { }) } - /// Recovery sweep: stamp this device's abandoned `streaming` entries `aborted`, appending + /// Recovery sweep: settle this device's running subagent chips (including + /// chips in completed parent turns), then stamp abandoned `streaming` + /// entries `aborted`, appending /// `note` as a visible error part so the transcript says WHY the turn /// ended (zeron folded "Run interrupted by backend restart" the same /// way). Returns the stamped entries' `(id, created_at)` — recovery uses /// them for the resume-freshness check. pub fn mark_abandoned_streams(&self, note: &str) -> Result, DocError> { let mut stamped = Vec::new(); + let mut chips_changed = false; for entry in self.doc.read_entries()? { - if entry.role == MessageRole::Assistant - && entry.status == Some(MessageStatus::Streaming) - && entry.device_id == self.device_id + if entry.role != MessageRole::Assistant || entry.device_id != self.device_id { + continue; + } + for part in &entry.parts { + if let MessagePart::Tool { + id, + subagent_status: Some(SubagentStatus::Running), + .. + } = part + { + chips_changed |= + self.doc + .update_subagent_chip(id, None, Some("failed"), None)?; + } + } + if entry.status == Some(MessageStatus::Streaming) && self .doc .set_message_status(&entry.id, MessageStatus::Aborted)? @@ -808,7 +824,7 @@ impl ChatDocHandle { stamped.push((entry.id.clone(), entry.created_at)); } } - if !stamped.is_empty() { + if chips_changed || !stamped.is_empty() { self.publish_messages(); } Ok(stamped) diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index fb342c8c3e..eaaf6dd8d4 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -2350,6 +2350,17 @@ async fn drive_run( let done = matches!(sub_event.as_ref(), AgentEvent::Done { .. }); if done { settled_subagents.insert(parent_tool_use_id.clone()); + if !chip_streaming { + // A resumed run can finish an older chip without ever + // opening a sink. The chip's lifecycle is independent of + // whether this run received transcript content. + let _ = doc_ref.update_subagent_chip( + parent_tool_use_id, + None, + subagent_chip_update(sub_event), + None, + ); + } } if let Some(sink) = subagents.get_mut(parent_tool_use_id) { if let AgentEvent::UserMessage { text } = sub_event.as_ref() { @@ -2370,16 +2381,6 @@ async fn drive_run( flush_at = tokio::time::Instant::now() + std::time::Duration::from_millis(STREAM_COMMIT_MS); } - if !chip_streaming && done { - // In-place chip refresh on lifecycle transitions only — - // content never rewrites the parent doc. - let _ = doc_ref.update_subagent_chip( - parent_tool_use_id, - None, - subagent_chip_update(sub_event), - None, - ); - } if done { let status = match sub_event.as_ref() { AgentEvent::Done { diff --git a/crates/engine/tests/e2e.rs b/crates/engine/tests/e2e.rs index 2c2ccca088..693f58fbc1 100644 --- a/crates/engine/tests/e2e.rs +++ b/crates/engine/tests/e2e.rs @@ -11,7 +11,7 @@ use futures::stream::BoxStream; use zeron_doc::{ MessagePart, MessageRole, MessageStatus, SegmentWriter, SessionCommandEntry, - SessionCommandPayload, SessionCommandStatus, SessionDoc, SessionMessageEntry, + SessionCommandPayload, SessionCommandStatus, SessionDoc, SessionMessageEntry, SubagentStatus, }; use zeron_engine::{EngineCore, HarnessRegistry, RunJournal}; use zeron_harness::mock::MockHarness; @@ -1281,6 +1281,160 @@ async fn recover_stale_journal_stamps_aborted_on_boot() { ); } +fn spawn_chip(id: &str, status: SubagentStatus) -> MessagePart { + MessagePart::Tool { + id: id.into(), + call: ToolCall::Unknown { + name: "Agent: scout".into(), + input: None, + }, + output: None, + diff: None, + is_error: false, + resolved: true, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: None, + subagent_ref: Some(format!("{CHAT}--sub--{id}")), + subagent_status: Some(status), + subagent_tail: None, + } +} + +#[tokio::test] +async fn recover_stale_journal_settles_chips_in_completed_local_entries() { + let dir = tempfile::tempdir().unwrap(); + let device_id = "dev-host-fixed"; + std::fs::write(dir.path().join("device-id"), device_id).unwrap(); + let journal = RunJournal::open(dir.path().join("orgs/dev-org/dev-user/journals")).unwrap(); + journal + .append( + CHAT, + &AgentEvent::Subagent { + parent_tool_use_id: "spawn-running".into(), + event: Box::new(AgentEvent::TextDelta { + text: "working".into(), + }), + }, + ) + .unwrap(); + let doc = SessionDoc::init(CHAT).unwrap(); + for (id, device) in [("local", device_id), ("remote", "other-device")] { + doc.push_message(&SessionMessageEntry { + id: id.into(), + role: MessageRole::Assistant, + parts: vec![ + spawn_chip(&format!("{id}-running"), SubagentStatus::Running), + spawn_chip(&format!("{id}-done"), SubagentStatus::Done), + ], + created_at: 1, + device_id: device.into(), + status: Some(MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + }) + .unwrap(); + } + let store = DocsStore::open(dir.path().join("orgs/dev-org/dev-user")).unwrap(); + store + .save_snapshot(CHAT, &doc.export_snapshot().unwrap()) + .unwrap(); + let core = assemble( + dir.path(), + Arc::new(MockHarness { + script: mock_script(), + }), + ); + let all = entries(&core); + let statuses: Vec<_> = all + .iter() + .flat_map(|e| &e.parts) + .filter_map(|p| match p { + MessagePart::Tool { + subagent_status, .. + } => *subagent_status, + _ => None, + }) + .collect(); + assert_eq!( + statuses, + [ + SubagentStatus::Failed, + SubagentStatus::Done, + SubagentStatus::Running, + SubagentStatus::Done + ] + ); + assert!( + all.iter() + .all(|e| e.status == Some(MessageStatus::Complete)) + ); + core.shutdown().await; +} + +#[tokio::test] +async fn subagent_done_without_a_live_sink_updates_a_persisted_chip() { + let dir = tempfile::tempdir().unwrap(); + let core = assemble( + dir.path(), + Arc::new(MockHarness { + script: vec![ + AgentEvent::Subagent { + parent_tool_use_id: "old-spawn".into(), + event: Box::new(done(DoneStatus::Completed)), + }, + done(DoneStatus::Completed), + ], + }), + ); + core.doc_host + .open(CHAT) + .unwrap() + .doc() + .push_message(&SessionMessageEntry { + id: "old-assistant".into(), + role: MessageRole::Assistant, + parts: vec![spawn_chip("old-spawn", SubagentStatus::Running)], + created_at: 1, + device_id: core.device_id.clone(), + status: Some(MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + }) + .unwrap(); + core.sessions + .dispatch(CHAT, HarnessId::Mock, run_request("resume"), None) + .await + .unwrap(); + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Idle) + }, + "resumed turn completes", + ) + .await; + let all = entries(&core); + let chip = all + .iter() + .flat_map(|e| &e.parts) + .find(|p| matches!(p, MessagePart::Tool { id, .. } if id == "old-spawn")) + .unwrap(); + assert!( + matches!( + chip, + MessagePart::Tool { + subagent_status: Some(SubagentStatus::Done), + .. + } + ), + "{chip:?}" + ); + core.shutdown().await; +} + #[tokio::test] async fn rpc_surface_over_in_memory_transport() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/harness/src/claude/mod.rs b/crates/harness/src/claude/mod.rs index 9e4d792f89..a5be1d7245 100644 --- a/crates/harness/src/claude/mod.rs +++ b/crates/harness/src/claude/mod.rs @@ -544,6 +544,15 @@ impl ClaudeHarness { // config with settings-sourced ones. cmd.args(["--mcp-config", &mcp_config_arg(mcp)]); } + let normalizer = if let Some(session_id) = &request.resume { + let config = crate::model_context::root( + "CLAUDE_CONFIG_DIR", + crate::executable::home_or_current_dir().join(".claude"), + ); + Normalizer::for_resume(&config, session_id).await + } else { + Normalizer::new() + }; let mut child = cmd.spawn().map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { HarnessError::NotInstalled(crate::executable::binary_hint(&exe)) @@ -590,6 +599,7 @@ impl ClaudeHarness { let (event_tx, event_rx) = mpsc::channel::>(256); tokio::spawn(run_session(Session { + normalizer, title_only, child, stdout_lines: BufReader::new(stdout).lines(), @@ -714,6 +724,7 @@ async fn stdin_writer(mut stdin: ChildStdin, mut rx: mpsc::UnboundedReceiver>, @@ -731,6 +742,7 @@ struct Session { /// mailbox, the interrupt token, and consumer liveness. async fn run_session(session: Session) { let Session { + normalizer: mut norm, title_only, mut child, mut stdout_lines, @@ -750,7 +762,6 @@ async fn run_session(session: Session) { } = controls; let request_input = Arc::new(request_input); - let mut norm = Normalizer::new(); let mut pending_steers = std::collections::VecDeque::new(); // Top-level tool calls in flight: a steer must not abort them (see // `wire::steer_message_line`). diff --git a/crates/harness/src/claude/normalize.rs b/crates/harness/src/claude/normalize.rs index d49aa07afc..2d20c6edd5 100644 --- a/crates/harness/src/claude/normalize.rs +++ b/crates/harness/src/claude/normalize.rs @@ -191,6 +191,8 @@ pub(crate) struct Normalizer { /// re-keys them onto the spawn chip's feed (the wire never echoes the /// steer on the child feed — live-verified 2.1.228). agent_tasks: std::collections::HashMap, + /// Later tool calls that wake the same task → its original spawn. + agent_tool_spawns: std::collections::HashMap, /// tool_use ids of Agent/Task spawn calls, recorded from their own /// assistant frames (plus `task_started`'s agent-task pairing). Gates /// `task_notification`: background SHELL tasks settle through the same @@ -212,12 +214,116 @@ impl Normalizer { saw_init: false, last_model: None, agent_tasks: std::collections::HashMap::new(), + agent_tool_spawns: std::collections::HashMap::new(), agent_spawn_tools: std::collections::HashSet::new(), assistant_message_id: new_message_id(), session_id: None, } } + /// Restore only spawn identity from Claude's native history. The CLI + /// does not replay task_started when --resume starts a new process. + /// Session ids are global; search project directories rather than + /// duplicating the CLI's cwd encoding (including long-path hashing). + /// Long sessions' histories run to hundreds of MB: the scan runs off the + /// runtime and only parses lines that can name a spawn or its agent. + pub async fn for_resume(config_root: &std::path::Path, session_id: &str) -> Self { + let config_root = config_root.to_owned(); + let session_id = session_id.to_owned(); + tokio::task::spawn_blocking(move || Self::restore_spawns(&config_root, &session_id)) + .await + .unwrap_or_else(|_| Self::new()) + } + + fn restore_spawns(config_root: &std::path::Path, session_id: &str) -> Self { + use std::io::BufRead as _; + let mut norm = Self::new(); + if session_id.is_empty() + || !session_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + { + return norm; + } + let Ok(projects) = std::fs::read_dir(config_root.join("projects")) else { + return norm; + }; + let Some(file) = projects.flatten().find_map(|project| { + std::fs::File::open(project.path().join(format!("{session_id}.jsonl"))).ok() + }) else { + return norm; + }; + let mut reader = std::io::BufReader::with_capacity(1 << 16, file); + let mut line = Vec::new(); + loop { + line.clear(); + match reader.read_until(b'\n', &mut line) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + // Torn lines and older/newer unknown shapes are harmless. + let Ok(text) = std::str::from_utf8(&line) else { + continue; + }; + if !(text.contains("\"Agent\"") + || text.contains("\"Task\"") + || text.contains("agentId")) + { + continue; + } + let Ok(record) = serde_json::from_str::(text) else { + continue; + }; + // A subagent's own nested spawns are not chips on this chat. + if record.get("isSidechain").and_then(Value::as_bool) == Some(true) + || !record.get("parent_tool_use_id").is_none_or(Value::is_null) + { + continue; + } + let Some(blocks) = record.pointer("/message/content").and_then(Value::as_array) else { + continue; + }; + match record.get("type").and_then(Value::as_str) { + Some("assistant") => { + for block in blocks { + if block.get("type").and_then(Value::as_str) == Some("tool_use") + && matches!( + block.get("name").and_then(Value::as_str), + Some("Agent" | "Task") + ) + && let Some(id) = block + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + { + norm.agent_spawn_tools.insert(id.to_owned()); + } + } + } + Some("user") => { + if let Some(agent) = record + .pointer("/toolUseResult/agentId") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + && let Some(spawn) = blocks + .iter() + .filter(|b| { + b.get("type").and_then(Value::as_str) == Some("tool_result") + }) + .filter_map(|b| b.get("tool_use_id").and_then(Value::as_str)) + .find(|id| norm.agent_spawn_tools.contains(*id)) + { + norm.agent_tasks + .entry(agent.to_owned()) + .or_insert_with(|| spawn.to_owned()); + } + } + _ => {} + } + } + norm + } + /// Rotate the assistant message id for a steer boundary; returns /// (previous, next) for the `Steered` event. pub fn rotate_for_steer(&mut self) -> (String, String) { @@ -237,16 +343,22 @@ impl Normalizer { // Surface it as the subagent's tagged Done so the chip flips // done/failed and the transcript freezes. if f.subtype == "task_notification" { - let Some(parent) = f.tool_use_id.as_deref().filter(|t| !t.is_empty()) else { + // A resumed task's notification may carry SendMessage's + // id, or omit the tool id entirely. Its task id is stable. + let parent = f + .task_id + .as_deref() + .and_then(|task| self.agent_tasks.get(task).map(String::as_str)) + .or_else(|| f.tool_use_id.as_deref().filter(|t| !t.is_empty())); + let Some(parent) = parent else { return Vec::new(); }; // Only a KNOWN spawn settles as a subagent. Background // SHELL tasks (`Bash` with `run_in_background`) settle // through this same subtype carrying the Bash call's id — // tagging that Done would bind a subagent ref onto an - // ordinary Run chip. A real spawn's tool_use frame always - // precedes its notification on the wire, so the set is - // populated by the time a genuine one arrives. + // ordinary Run chip. Spawn ids are learned from live + // calls/task starts or restored from the native history. if !self.agent_spawn_tools.contains(parent) { return Vec::new(); } @@ -279,8 +391,13 @@ impl Normalizer { f.tool_use_id.as_deref().filter(|t| !t.is_empty()), ) { - self.agent_tasks.insert(task.to_owned(), tool.to_owned()); - self.agent_spawn_tools.insert(tool.to_owned()); + let spawn = self + .agent_tasks + .entry(task.to_owned()) + .or_insert_with(|| tool.to_owned()); + self.agent_spawn_tools.insert(spawn.clone()); + self.agent_tool_spawns + .insert(tool.to_owned(), spawn.clone()); return Vec::new(); } if f.subtype != "init" || self.saw_init { @@ -311,6 +428,7 @@ impl Normalizer { return Vec::new(); } if let Some(parent) = &f.parent_tool_use_id { + let parent = self.agent_tool_spawns.get(parent).unwrap_or(parent); return match f.event.delta.kind.as_str() { "text_delta" => vec![tag( parent, @@ -350,6 +468,7 @@ impl Normalizer { Frame::Assistant(f) => { if let Some(parent) = &f.parent_tool_use_id { + let parent = self.agent_tool_spawns.get(parent).unwrap_or(parent); // Subagent content, attributed. The 2.1.x wire streams NO // tagged partial deltas (live-verified): a subagent's text // arrives only as full text blocks on its tagged @@ -386,7 +505,7 @@ impl Normalizer { return out; } // Record spawn tool ids up front: `task_notification` keys on - // them, and only foreground spawns ever get a `task_started`. + // them; a task may finish before a `task_started` arrives. for b in f.message.blocks() { if b.kind == "tool_use" && matches!(b.name.as_str(), "Agent" | "Task") { self.agent_spawn_tools.insert(b.id.clone()); @@ -485,6 +604,7 @@ impl Normalizer { Frame::User(f) => { if let Some(parent) = &f.parent_tool_use_id { + let parent = self.agent_tool_spawns.get(parent).unwrap_or(parent); // A subagent's tool results echo on the main channel too; // they belong to its transcript, attributed like its calls. let mut out: Vec = f @@ -1025,6 +1145,130 @@ mod tests { ); } + #[test] + fn resumed_task_notifications_settle_the_original_spawn() { + for notification in [ + r#"{"type":"system","subtype":"task_notification","task_id":"a1","tool_use_id":"toolu_send","status":"completed"}"#, + r#"{"type":"system","subtype":"task_notification","task_id":"a1","status":"stopped"}"#, + ] { + let mut norm = Normalizer::new(); + for raw in [ + r#"{"type":"system","subtype":"task_started","task_id":"a1","tool_use_id":"toolu_spawn","subagent_type":"general-purpose"}"#, + r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_send","name":"SendMessage","input":{"to":"a1","message":"Keep going."}}]}}"#, + r#"{"type":"system","subtype":"task_started","task_id":"a1","tool_use_id":"toolu_send","subagent_type":"general-purpose"}"#, + ] { + norm.normalize(crate::claude::wire::parse_frame(raw).unwrap(), false); + } + let events = norm.normalize( + crate::claude::wire::parse_frame(notification).unwrap(), + false, + ); + let expected = if notification.contains("completed") { + DoneStatus::Completed + } else { + DoneStatus::Interrupted + }; + assert_eq!( + events, + vec![tag( + "toolu_spawn", + AgentEvent::Done { + status: expected, + result: None, + error: None, + session_id: None, + } + )] + ); + // The next steer must still use the original transcript too. + let events = norm.normalize(crate::claude::wire::parse_frame( + r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_send2","name":"SendMessage","input":{"to":"a1","message":"One more check."}}]}}"#, + ).unwrap(), false); + assert!( + events.iter().any(|e| matches!(e, + AgentEvent::Subagent { parent_tool_use_id, event } + if parent_tool_use_id == "toolu_spawn" + && matches!(event.as_ref(), AgentEvent::UserMessage { .. }) + )), + "{events:?}" + ); + } + } + + #[test] + fn resumed_child_frames_use_the_original_transcript() { + let mut norm = Normalizer::new(); + for raw in [ + r#"{"type":"system","subtype":"task_started","task_id":"a1","tool_use_id":"toolu_spawn","subagent_type":"general-purpose"}"#, + r#"{"type":"system","subtype":"task_started","task_id":"a1","tool_use_id":"toolu_send","subagent_type":"general-purpose"}"#, + ] { + norm.normalize(crate::claude::wire::parse_frame(raw).unwrap(), false); + } + for raw in [ + r#"{"type":"stream_event","parent_tool_use_id":"toolu_send","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"working"}}}"#, + r#"{"type":"assistant","parent_tool_use_id":"toolu_send","message":{"content":[{"type":"text","text":"finished"}]}}"#, + r#"{"type":"user","parent_tool_use_id":"toolu_send","message":{"content":[{"type":"tool_result","tool_use_id":"child-tool"}]}}"#, + ] { + let events = norm.normalize(crate::claude::wire::parse_frame(raw).unwrap(), false); + assert!(!events.is_empty()); + assert!(events.iter().all(|e| matches!(e, + AgentEvent::Subagent { parent_tool_use_id, .. } if parent_tool_use_id == "toolu_spawn" + )), "{events:?}"); + } + } + + #[tokio::test] + async fn resumed_session_restores_task_ids_from_the_native_transcript() { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("projects/encoded-project"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("session-1.jsonl"), concat!( + "not json\n", + r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_spawn","name":"Agent","input":{"description":"scout"}}]}}"#, + "\n", + r#"{"type":"user","toolUseResult":{"agentId":"a1","status":"async_launched"},"message":{"content":[{"type":"tool_result","tool_use_id":"toolu_spawn"}]}}"#, + "\n", + r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_send","name":"SendMessage","input":{"to":"a1","message":"continue"}}]}}"#, + "\n", + r#"{"type":"user","toolUseResult":{"agentId":"a1"},"message":{"content":[{"type":"tool_result","tool_use_id":"toolu_send"}]}}"#, + "\n", + // A subagent's own nested spawn is not a chip on this chat. + r#"{"type":"assistant","isSidechain":true,"message":{"content":[{"type":"tool_use","id":"toolu_nested","name":"Agent","input":{"description":"deeper"}}]}}"#, + "\n", + r#"{"type":"user","isSidechain":true,"toolUseResult":{"agentId":"a2"},"message":{"content":[{"type":"tool_result","tool_use_id":"toolu_nested"}]}}"#, + "\n{torn tail" + )).unwrap(); + let mut norm = Normalizer::for_resume(root.path(), "session-1").await; + assert!(!norm.agent_spawn_tools.contains("toolu_nested")); + assert!(!norm.agent_tasks.contains_key("a2")); + // The revived process has no original spawn frame on its wire. + norm.normalize(crate::claude::wire::parse_frame( + r#"{"type":"system","subtype":"task_started","task_id":"a1","tool_use_id":"toolu_send","subagent_type":"general-purpose"}"#, + ).unwrap(), false); + let events = norm.normalize(crate::claude::wire::parse_frame( + r#"{"type":"system","subtype":"task_notification","task_id":"a1","tool_use_id":"toolu_send","status":"completed"}"#, + ).unwrap(), false); + assert_eq!( + events, + vec![tag( + "toolu_spawn", + AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: None, + } + )] + ); + assert!(!norm.saw_init, "history must not consume the live init"); + assert!( + Normalizer::for_resume(root.path(), "missing") + .await + .agent_tasks + .is_empty() + ); + } + #[test] fn shell_task_notification_never_settles_a_subagent() { // `Bash` with `run_in_background` settles through the SAME diff --git a/crates/harness/src/claude/wire.rs b/crates/harness/src/claude/wire.rs index 828c4f18d9..af75a6ab62 100644 --- a/crates/harness/src/claude/wire.rs +++ b/crates/harness/src/claude/wire.rs @@ -33,16 +33,15 @@ pub(crate) struct SystemFrame { pub cwd: String, #[serde(default)] pub session_id: String, - /// `task_notification` (a background subagent settling): the spawning - /// Agent tool's id — the only TAGGED terminal signal the 2.1.x wire has - /// for a background subagent (its frames otherwise just stop). + /// `task_notification`: the spawning Agent tool's id, or the latest + /// SendMessage id after a resume. Stop notifications may omit it. #[serde(default, alias = "toolUseId")] pub tool_use_id: Option, /// `task_notification` terminal status (`completed`/`failed`/`killed`…). #[serde(default)] pub status: Option, - /// `task_started`: the agent/task id (`SendMessage`'s `to:` address) — - /// with `tool_use_id`, the agentId→spawn mapping steers need. + /// `task_started` / `task_notification`: stable agent/task id + /// (`SendMessage`'s `to:` address), used to retain spawn identity. #[serde(default, alias = "taskId")] pub task_id: Option, /// `task_started`: present only for AGENT tasks (a subagent spawning), From 626bccc85cb59bb101cf2274eb172c3db6cb7f1f Mon Sep 17 00:00:00 2001 From: Mohammed <93622996+Mohaddz@users.noreply.github.com> Date: Thu, 1 Oct 2026 02:05:07 +0300 Subject: [PATCH 06/74] Appearance: add background positioning and zoom (#660) * Appearance: add background positioning and zoom * Wallpaper: keep the preload queue when a background's framing changes The preload key compared the whole background setting, so applying a crop or zoom flushed three decoded wallpapers and re-decoded them. Key on the managed path instead, and extend the warm-switch test to cover it. Also rustfmt the new background-mask code. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Wing Co-authored-by: Claude Opus 5.5 --- crates/ui/examples/new-project-fixture.rs | 6 +- .../ui/src/new_thread_background_effects.rs | 113 +- crates/ui/src/new_thread_background_mask.rs | 406 ++++++- crates/ui/src/settings.rs | 180 ++- crates/ui/src/settings/appearance.rs | 1000 ++++++++++++++++- crates/ui/src/settings/wallpaper.rs | 71 +- crates/ui/src/settings/wallpaper_colors.rs | 1 + crates/ui/src/shell.rs | 14 +- 8 files changed, 1747 insertions(+), 44 deletions(-) diff --git a/crates/ui/examples/new-project-fixture.rs b/crates/ui/examples/new-project-fixture.rs index 40ea092f89..a41f523027 100644 --- a/crates/ui/examples/new-project-fixture.rs +++ b/crates/ui/examples/new-project-fixture.rs @@ -13,7 +13,11 @@ fn main() -> anyhow::Result<()> { let mut settings = settings::UiSettings::default(); settings.sidebar_show_branch = true; if let Ok(path) = std::env::var("ZERON_FIXTURE_BACKGROUND") { - settings.new_thread_composer_background = Some(settings::NewThreadComposerBackground { path, name: "Uploaded background".into() }); + settings.new_thread_composer_background = Some(settings::NewThreadComposerBackground { + path, + name: "Uploaded background".into(), + adjustment: settings::NewThreadBackgroundAdjustment::default(), + }); } if let Some(folder) = std::env::var_os("ZERON_FIXTURE_WALLPAPER_FOLDER") { settings.wallpaper_folder = Some(folder.into()); diff --git a/crates/ui/src/new_thread_background_effects.rs b/crates/ui/src/new_thread_background_effects.rs index abd0eee26c..bedcd0af4c 100644 --- a/crates/ui/src/new_thread_background_effects.rs +++ b/crates/ui/src/new_thread_background_effects.rs @@ -1,5 +1,5 @@ //! Effects remain cached source-space images; a separate alpha mask follows layout. -use crate::settings::NewThreadBackgroundEffect; +use crate::settings::{NewThreadBackgroundAdjustment, NewThreadBackgroundEffect}; use crate::theme::Theme; use gpui::{Pixels, px}; use std::path::{Path, PathBuf}; @@ -12,12 +12,18 @@ use std::time::Instant; pub(crate) struct Readiness { current: Option>, previous: Option>, + current_adjustment: NewThreadBackgroundAdjustment, + previous_adjustment: NewThreadBackgroundAdjustment, + current_path: Option, + previous_path: Option, started: Option, } pub(crate) struct ArtworkFrame { pub current: Option>, pub previous: Option>, + pub current_adjustment: NewThreadBackgroundAdjustment, + pub previous_adjustment: NewThreadBackgroundAdjustment, pub mix: f32, pub active: bool, } @@ -32,6 +38,8 @@ impl Readiness { pub fn frame( &mut self, image: Option>, + path: Option<&Path>, + adjustment: NewThreadBackgroundAdjustment, enabled: bool, reduced: bool, now: Instant, @@ -39,17 +47,31 @@ impl Readiness { let progress = self.started.map_or(1.0, |start| crossfade_mix(start, now)); if reduced || progress >= 1.0 { self.previous = None; + self.previous_path = None; self.started = None; } + // Effects may still be loading, but a crop belongs to the source path, + // not its effect raster. Replacement images keep their own framing. + if path.is_some() && path == self.current_path.as_deref() { + self.current_adjustment = adjustment.normalized(); + } + if path.is_some() && path == self.previous_path.as_deref() { + self.previous_adjustment = adjustment.normalized(); + } // None while enabled means "still loading", not "remove artwork". if self.started.is_none() && (!enabled || image.is_some()) { let target = if enabled { image } else { None }; if self.current.as_ref().map(|image| image.id) != target.as_ref().map(|image| image.id) { self.previous = self.current.take(); + self.previous_adjustment = self.current_adjustment; + self.previous_path = self.current_path.take(); self.current = target; + self.current_adjustment = adjustment.normalized(); + self.current_path = path.map(Path::to_path_buf); if reduced { self.previous = None; + self.previous_path = None; } else { self.started = Some(now); } @@ -58,6 +80,8 @@ impl Readiness { ArtworkFrame { current: self.current.clone(), previous: self.previous.clone(), + current_adjustment: self.current_adjustment, + previous_adjustment: self.previous_adjustment, mix: self.started.map_or(1.0, |start| crossfade_mix(start, now)), active: self.started.is_some(), } @@ -414,11 +438,13 @@ mod tests { let latest = artwork(); let mut ready = Readiness::default(); let now = Instant::now(); - ready.frame(Some(first.clone()), true, true, now); - let loading = ready.frame(None, true, false, now); + let path = Some(Path::new("background.png")); + let adjustment = NewThreadBackgroundAdjustment::default(); + ready.frame(Some(first.clone()), path, adjustment, true, true, now); + let loading = ready.frame(None, path, adjustment, true, false, now); assert_eq!(loading.current.unwrap().id, first.id); assert!(!loading.active); - let start = ready.frame(Some(next.clone()), true, false, now); + let start = ready.frame(Some(next.clone()), path, adjustment, true, false, now); assert_eq!(start.mix, 0.0); assert_eq!(start.previous.unwrap().id, first.id); let halfway = now @@ -427,22 +453,93 @@ mod tests { * crate::motion::speed_scale() * 0.5, ); - let frame = ready.frame(Some(latest.clone()), true, false, halfway); + let frame = ready.frame(Some(latest.clone()), path, adjustment, true, false, halfway); assert!((frame.mix - 0.875).abs() < 0.001); assert_eq!(frame.current.unwrap().id, next.id); let done = now + std::time::Duration::from_secs(1); - let frame = ready.frame(Some(latest.clone()), true, false, done); + let frame = ready.frame(Some(latest.clone()), path, adjustment, true, false, done); assert_eq!(frame.previous.unwrap().id, next.id); assert_eq!(frame.current.unwrap().id, latest.id); assert_eq!(frame.mix, 0.0); - let frame = ready.frame(Some(latest), true, true, done); + let frame = ready.frame(Some(latest), path, adjustment, true, true, done); assert!(!frame.active); assert!(frame.previous.is_none()); assert_eq!(frame.mix, 1.0); - let removed = ready.frame(None, false, true, done); + let removed = ready.frame(None, None, adjustment, false, true, done); assert!(removed.current.is_none() && removed.previous.is_none()); } + #[test] + fn wallpaper_replacement_and_removal_keep_the_departing_crop() { + let first = artwork(); + let next = artwork(); + let first_path = Some(Path::new("first.png")); + let next_path = Some(Path::new("next.png")); + let cropped = NewThreadBackgroundAdjustment { + focal_x: 0.2, + focal_y: 0.8, + zoom: 2.0, + }; + let centered = NewThreadBackgroundAdjustment::default(); + let mut ready = Readiness::default(); + let now = Instant::now(); + ready.frame(Some(first.clone()), first_path, cropped, true, true, now); + let loading = ready.frame(None, next_path, centered, true, false, now); + assert_eq!(loading.current.as_ref().unwrap().id, first.id); + assert_eq!(loading.current_adjustment, cropped); + let start = ready.frame(Some(next), next_path, centered, true, false, now); + assert_eq!(start.previous_adjustment, cropped); + assert_eq!(start.current_adjustment, centered); + let latest = artwork(); + let latest_path = Some(Path::new("latest.png")); + let waiting = ready.frame(Some(latest.clone()), latest_path, cropped, true, false, now); + assert_eq!(waiting.previous_adjustment, cropped); + assert_eq!(waiting.current_adjustment, centered); + let done = now + std::time::Duration::from_secs(1); + let adopted = ready.frame(Some(latest), latest_path, cropped, true, false, done); + assert_eq!(adopted.previous_adjustment, centered); + assert_eq!(adopted.current_adjustment, cropped); + + let mut ready = Readiness::default(); + ready.frame(Some(first), first_path, cropped, true, true, now); + let removed = ready.frame(None, None, centered, false, false, now); + assert!(removed.current.is_none()); + assert!(removed.previous.is_some()); + assert_eq!(removed.previous_adjustment, cropped); + } + + #[test] + fn applying_crop_during_effect_loading_updates_the_same_source_without_a_fade() { + let first = artwork(); + let effect = artwork(); + let path = Some(Path::new("background.png")); + let centered = NewThreadBackgroundAdjustment::default(); + let cropped = NewThreadBackgroundAdjustment { + zoom: 2.0, + ..centered + }; + let latest = NewThreadBackgroundAdjustment { + zoom: 3.0, + ..centered + }; + let mut ready = Readiness::default(); + let now = Instant::now(); + ready.frame(Some(first), path, centered, true, true, now); + let loading = ready.frame(None, path, cropped, true, false, now); + assert_eq!(loading.current_adjustment, cropped); + assert!(!loading.active); + let start = ready.frame(Some(effect.clone()), path, cropped, true, false, now); + assert_eq!(start.previous_adjustment, cropped); + assert_eq!(start.current_adjustment, cropped); + let applied = ready.frame(Some(effect.clone()), path, latest, true, false, now); + assert_eq!(applied.previous_adjustment, latest); + assert_eq!(applied.current_adjustment, latest); + let done = now + std::time::Duration::from_secs(1); + let applied = ready.frame(Some(effect), path, cropped, true, false, done); + assert_eq!(applied.current_adjustment, cropped); + assert!(!applied.active); + } + #[gpui::test] fn prewarming_decodes_off_thread_and_reuses_artwork_without_hero_geometry( cx: &mut gpui::TestAppContext, diff --git a/crates/ui/src/new_thread_background_mask.rs b/crates/ui/src/new_thread_background_mask.rs index d36ba347b7..303fefde6d 100644 --- a/crates/ui/src/new_thread_background_mask.rs +++ b/crates/ui/src/new_thread_background_mask.rs @@ -1,6 +1,7 @@ //! Paint-time source-alpha feather. Resizing changes only GPU parameters, not //! the image identity, pixels, atlas entry, or an asynchronous raster job. -use gpui::{Bounds, ImageAlphaMask, Pixels, RenderImage, Window, point, px, size}; +use crate::settings::NewThreadBackgroundAdjustment; +use gpui::{Bounds, Corners, ImageAlphaMask, Pixels, Point, RenderImage, Window, point, px, size}; use std::{cell::Cell, rc::Rc, sync::Arc}; // Reveal half-strength artwork through the cutout's darkest area. The main @@ -9,6 +10,122 @@ pub(crate) const CUTOUT_REVEAL_OPACITY: f32 = 0.5; pub(crate) type SurfaceBounds = Rc>>>; +#[derive(Clone, Copy)] +struct FittedGeometry { + bounds: Bounds, + width: f32, + height: f32, + overflow_x: f32, + overflow_y: f32, +} + +fn fitted_geometry( + source_width: f32, + source_height: f32, + bounds: Bounds, + adjustment: NewThreadBackgroundAdjustment, +) -> Option { + let width = f32::from(bounds.size.width); + let height = f32::from(bounds.size.height); + if width <= 0.0 + || height <= 0.0 + || !width.is_finite() + || !height.is_finite() + || source_width <= 0.0 + || source_height <= 0.0 + || !source_width.is_finite() + || !source_height.is_finite() + { + return None; + } + + let adjustment = adjustment.normalized(); + let cover = (width / source_width).max(height / source_height); + let fitted_width = (source_width * cover * adjustment.zoom).max(width); + let fitted_height = (source_height * cover * adjustment.zoom).max(height); + let overflow_x = (fitted_width - width).max(0.0); + let overflow_y = (fitted_height - height).max(0.0); + let fitted = Bounds::new( + point( + bounds.left() - px(overflow_x * adjustment.focal_x), + bounds.top() - px(overflow_y * adjustment.focal_y), + ), + size(px(fitted_width), px(fitted_height)), + ); + Some(FittedGeometry { + bounds: fitted, + width: fitted_width, + height: fitted_height, + overflow_x, + overflow_y, + }) +} + +fn source_geometry( + source: &RenderImage, + bounds: Bounds, + adjustment: NewThreadBackgroundAdjustment, +) -> Option { + let source_size = source.size(0); + fitted_geometry( + source_size.width.0 as f32, + source_size.height.0 as f32, + bounds, + adjustment, + ) +} + +/// Translate a direct-manipulation drag into viewport-independent framing. +pub(crate) fn pan_adjustment( + source: &RenderImage, + bounds: Bounds, + adjustment: NewThreadBackgroundAdjustment, + delta: Point, +) -> NewThreadBackgroundAdjustment { + let adjustment = adjustment.normalized(); + let Some(geometry) = source_geometry(source, bounds, adjustment) else { + return adjustment; + }; + let mut next = adjustment; + if geometry.overflow_x > 0.0 { + next.focal_x -= f32::from(delta.x) / geometry.overflow_x; + } + if geometry.overflow_y > 0.0 { + next.focal_y -= f32::from(delta.y) / geometry.overflow_y; + } + next.normalized() +} + +/// Preserve the source pixel beneath `anchor` while changing magnification. +pub(crate) fn zoom_adjustment_around( + source: &RenderImage, + bounds: Bounds, + adjustment: NewThreadBackgroundAdjustment, + zoom: f32, + anchor: Point, +) -> NewThreadBackgroundAdjustment { + let adjustment = adjustment.normalized(); + let Some(previous) = source_geometry(source, bounds, adjustment) else { + return adjustment; + }; + let mut next = NewThreadBackgroundAdjustment { zoom, ..adjustment }.normalized(); + let Some(fitted) = source_geometry(source, bounds, next) else { + return adjustment; + }; + + let source_x = f32::from(anchor.x - previous.bounds.left()) / previous.width; + let source_y = f32::from(anchor.y - previous.bounds.top()) / previous.height; + if fitted.overflow_x > 0.0 { + let desired_left = f32::from(anchor.x) - source_x * fitted.width; + next.focal_x = (f32::from(bounds.left()) - desired_left) / fitted.overflow_x; + } + if fitted.overflow_y > 0.0 { + let desired_top = f32::from(anchor.y) - source_y * fitted.height; + next.focal_y = (f32::from(bounds.top()) - desired_top) / fitted.overflow_y; + } + next.normalized() +} + fn mask(bounds: Bounds, composer: Bounds, cutout: bool) -> ImageAlphaMask { let height = f32::from(bounds.size.height); // Keep the cleared area open through the hero's bottom. A taller image @@ -46,6 +163,44 @@ fn mask(bounds: Bounds, composer: Bounds, cutout: bool) -> Image } } +fn adjusted_image_paint( + source: &RenderImage, + bounds: Bounds, + adjustment: NewThreadBackgroundAdjustment, + corner_radii: Corners, +) -> Option<(Bounds, Bounds, Corners)> { + let fitted = source_geometry(source, bounds, adjustment)?; + Some((bounds, fitted.bounds, corner_radii)) +} + +fn paint_adjusted_with_mask( + source: Arc, + bounds: Bounds, + adjustment: NewThreadBackgroundAdjustment, + corner_radii: Corners, + mask: Option, + window: &mut Window, +) { + let Some((visible, fitted, corner_radii)) = + adjusted_image_paint(&source, bounds, adjustment, corner_radii) + else { + return; + }; + let _ = window.paint_image_fitted_masked(visible, fitted, corner_radii, source, 0, false, mask); +} + +/// Paint the exact responsive crop used by the new-thread hero, without its +/// composer mask. Appearance uses this for a faithful adjustment preview. +pub(crate) fn paint_adjusted( + source: Arc, + bounds: Bounds, + adjustment: NewThreadBackgroundAdjustment, + corner_radii: Corners, + window: &mut Window, +) { + paint_adjusted_with_mask(source, bounds, adjustment, corner_radii, None, window); +} + /// All elements have finished prepaint before this reads the measured surface, /// so the first visible frame uses the current composer, including on sidebar /// resize and right-panel handoffs. Object-fit cropping is independent. @@ -53,32 +208,17 @@ pub(crate) fn paint( source: Arc, bounds: Bounds, composer: Bounds, + adjustment: NewThreadBackgroundAdjustment, cutout: bool, window: &mut Window, ) { - let width = f32::from(bounds.size.width); - let height = f32::from(bounds.size.height); - let source_size = source.size(0); - if width <= 0.0 || height <= 0.0 || source_size.width.0 <= 0 || source_size.height.0 <= 0 { - return; - } - let scale = (width / source_size.width.0 as f32).max(height / source_size.height.0 as f32); - let fitted_size = size( - px(source_size.width.0 as f32 * scale), - px(source_size.height.0 as f32 * scale), - ); - let fitted = Bounds::new( - bounds.center() - point(fitted_size.width * 0.5, fitted_size.height * 0.5), - fitted_size, - ); - let _ = window.paint_image_fitted_masked( + paint_adjusted_with_mask( + source, bounds, - fitted, + adjustment, Default::default(), - source, - 0, - false, Some(mask(bounds, composer, cutout)), + window, ); } @@ -87,6 +227,221 @@ mod tests { use super::*; use gpui::{Context, Render, canvas, div, prelude::*}; + fn hero(width: f32, height: f32) -> Bounds { + Bounds::new(point(px(40.0), px(24.0)), size(px(width), px(height))) + } + + fn fitted( + source: (f32, f32), + viewport: Bounds, + adjustment: NewThreadBackgroundAdjustment, + ) -> FittedGeometry { + fitted_geometry(source.0, source.1, viewport, adjustment).unwrap() + } + + fn assert_near(actual: Pixels, expected: Pixels) { + assert!( + (f32::from(actual - expected)).abs() < 0.001, + "expected {expected:?}, got {actual:?}" + ); + } + + fn source(width: u32, height: u32) -> RenderImage { + RenderImage::new([image::Frame::new(image::RgbaImage::from_pixel( + width, + height, + image::Rgba([79, 151, 233, 180]), + ))]) + } + + #[test] + fn default_adjustment_is_the_existing_centered_cover_crop() { + let viewport = hero(1000.0, 500.0); + let crop = fitted( + (1600.0, 900.0), + viewport, + NewThreadBackgroundAdjustment::default(), + ); + assert_eq!(crop.bounds.size.width, px(1000.0)); + assert_eq!(crop.bounds.size.height, px(562.5)); + assert_eq!(crop.bounds.left(), viewport.left()); + assert_eq!(crop.bounds.top(), viewport.top() - px(31.25)); + assert_eq!(crop.bounds.center(), viewport.center()); + } + + #[test] + fn focal_extremes_align_the_overflowing_image_edges() { + let viewport = hero(1000.0, 500.0); + let start = fitted( + (1600.0, 900.0), + viewport, + NewThreadBackgroundAdjustment { + focal_x: 0.0, + focal_y: 0.0, + zoom: 2.0, + }, + ); + assert_eq!(start.bounds.left(), viewport.left()); + assert_eq!(start.bounds.top(), viewport.top()); + + let end = fitted( + (1600.0, 900.0), + viewport, + NewThreadBackgroundAdjustment { + focal_x: 1.0, + focal_y: 1.0, + zoom: 2.0, + }, + ); + assert_near(end.bounds.right(), viewport.right()); + assert_near(end.bounds.bottom(), viewport.bottom()); + } + + #[test] + fn zoom_multiplies_cover_before_positioning() { + let viewport = hero(1000.0, 500.0); + let base = fitted( + (1600.0, 900.0), + viewport, + NewThreadBackgroundAdjustment::default(), + ); + let zoomed = fitted( + (1600.0, 900.0), + viewport, + NewThreadBackgroundAdjustment { + zoom: 2.0, + ..Default::default() + }, + ); + assert_near(zoomed.bounds.size.width, base.bounds.size.width * 2.0); + assert_near(zoomed.bounds.size.height, base.bounds.size.height * 2.0); + assert_eq!(zoomed.bounds.center(), viewport.center()); + } + + #[test] + fn resizing_preserves_the_normalized_focal_alignment() { + let adjustment = NewThreadBackgroundAdjustment { + focal_x: 0.23, + focal_y: 0.71, + zoom: 2.2, + }; + for viewport in [hero(420.0, 300.0), hero(960.0, 420.0), hero(1200.0, 760.0)] { + let crop = fitted((1600.0, 900.0), viewport, adjustment); + let x = f32::from(viewport.left() - crop.bounds.left()) / crop.overflow_x; + let y = f32::from(viewport.top() - crop.bounds.top()) / crop.overflow_y; + assert!((x - adjustment.focal_x).abs() < 0.0001); + assert!((y - adjustment.focal_y).abs() < 0.0001); + } + } + + #[test] + fn every_supported_crop_covers_the_entire_viewport() { + for source in [(900.0, 1600.0), (1600.0, 900.0), (1000.0, 1000.0)] { + for viewport in [hero(360.0, 760.0), hero(1000.0, 500.0), hero(640.0, 640.0)] { + for focal_x in [0.0, 0.37, 1.0] { + for focal_y in [0.0, 0.61, 1.0] { + for zoom in [1.0, 1.8, NewThreadBackgroundAdjustment::MAX_ZOOM] { + let crop = fitted( + source, + viewport, + NewThreadBackgroundAdjustment { + focal_x, + focal_y, + zoom, + }, + ); + assert!(crop.bounds.left() <= viewport.left()); + assert!(crop.bounds.top() <= viewport.top()); + assert!(crop.bounds.right() >= viewport.right()); + assert!(crop.bounds.bottom() >= viewport.bottom()); + } + } + } + } + } + } + + #[test] + fn masked_passes_share_identical_adjusted_geometry() { + let viewport = hero(1000.0, 500.0); + let composer = Bounds::new(point(px(180.0), px(320.0)), size(px(720.0), px(124.0))); + let adjustment = NewThreadBackgroundAdjustment { + focal_x: 0.17, + focal_y: 0.82, + zoom: 1.7, + }; + let main = fitted((1600.0, 900.0), viewport, adjustment); + let reveal = fitted((1600.0, 900.0), viewport, adjustment); + assert_eq!(main.bounds, reveal.bounds); + let main_mask = mask(viewport, composer, true); + let reveal_mask = mask(viewport, composer, false); + assert_ne!(main_mask.bounds, reveal_mask.bounds); + assert_eq!(main_mask.bottom_fade, reveal_mask.bottom_fade); + } + + #[test] + fn pan_adjustment_tracks_direct_pointer_movement() { + let source = source(16, 9); + let viewport = hero(100.0, 50.0); + let adjustment = NewThreadBackgroundAdjustment { + zoom: 2.0, + ..Default::default() + }; + let before = source_geometry(&source, viewport, adjustment).unwrap(); + let next = pan_adjustment(&source, viewport, adjustment, point(px(10.0), px(-5.0))); + let after = source_geometry(&source, viewport, next).unwrap(); + assert_near(after.bounds.left(), before.bounds.left() + px(10.0)); + assert_near(after.bounds.top(), before.bounds.top() - px(5.0)); + } + + #[test] + fn pointer_anchored_zoom_keeps_the_same_source_pixel_underneath() { + let source = source(16, 9); + let viewport = hero(100.0, 50.0); + let adjustment = NewThreadBackgroundAdjustment { + zoom: 1.5, + ..Default::default() + }; + let anchor = point(viewport.left() + px(60.0), viewport.top() + px(30.0)); + let before = source_geometry(&source, viewport, adjustment).unwrap(); + let source_x = f32::from(anchor.x - before.bounds.left()) / before.width; + let source_y = f32::from(anchor.y - before.bounds.top()) / before.height; + + let next = zoom_adjustment_around(&source, viewport, adjustment, 2.4, anchor); + let after = source_geometry(&source, viewport, next).unwrap(); + let next_source_x = f32::from(anchor.x - after.bounds.left()) / after.width; + let next_source_y = f32::from(anchor.y - after.bounds.top()) / after.height; + assert!((next_source_x - source_x).abs() < 0.0001); + assert!((next_source_y - source_y).abs() < 0.0001); + } + + #[test] + fn preview_paint_keeps_rounded_viewport_corners_when_panning_and_zooming() { + let source = source(16, 9); + let viewport = hero(100.0, 50.0); + let rounded = Corners::all(px(11.0)); + let initial = NewThreadBackgroundAdjustment::default(); + let zoomed = zoom_adjustment_around(&source, viewport, initial, 2.0, viewport.center()); + let panned = pan_adjustment(&source, viewport, zoomed, point(px(10.0), px(-5.0))); + + for adjustment in [initial, zoomed, panned] { + let (visible, fitted, corners) = + adjusted_image_paint(&source, viewport, adjustment, rounded).unwrap(); + let (hero_visible, hero_fitted, hero_corners) = + adjusted_image_paint(&source, viewport, adjustment, Default::default()).unwrap(); + assert_eq!(visible, viewport); + assert_eq!(corners, rounded); + assert_eq!(hero_corners, Corners::all(px(0.0))); + assert_eq!((visible, fitted), (hero_visible, hero_fitted)); + assert_eq!( + fitted, + source_geometry(&source, viewport, adjustment) + .unwrap() + .bounds + ); + } + } + #[gpui::test] fn background_paint_sees_same_frame_composer_bounds_even_when_painted_first( cx: &mut gpui::TestAppContext, @@ -113,7 +468,14 @@ mod tests { move |bounds, _, window, _| { painted.set(surface.get()); if let Some(composer) = surface.get() { - paint(source, bounds, composer, true, window); + paint( + source, + bounds, + composer, + NewThreadBackgroundAdjustment::default(), + true, + window, + ); } }, ) diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index b3d9a63f2c..e142454e63 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -65,13 +65,50 @@ pub const FILES_AUTOSAVE_DELAY_MAX_MS: u64 = 10_000; const FILE_NAME: &str = "ui-settings.json"; const NEW_THREAD_BACKGROUND_DIR: &str = "new-thread-backgrounds"; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NewThreadComposerBackground { /// Managed copy inside Zeron's device-local data directory. pub path: String, /// Original file name shown in Appearance settings. pub name: String, + /// Viewport-relative framing, kept with the image it belongs to. + #[serde(default)] + pub adjustment: NewThreadBackgroundAdjustment, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct NewThreadBackgroundAdjustment { + /// Normalized horizontal alignment of the overflowing image: 0 left, 1 right. + pub focal_x: f32, + /// Normalized vertical alignment of the overflowing image: 0 top, 1 bottom. + pub focal_y: f32, + /// Multiplier applied after the image has been scaled to cover the viewport. + pub zoom: f32, +} + +impl NewThreadBackgroundAdjustment { + pub const MIN_ZOOM: f32 = 1.0; + pub const MAX_ZOOM: f32 = 4.0; + + pub fn normalized(mut self) -> Self { + let defaults = Self::default(); + self.focal_x = clamp_or(self.focal_x, 0.0, 1.0, defaults.focal_x); + self.focal_y = clamp_or(self.focal_y, 0.0, 1.0, defaults.focal_y); + self.zoom = clamp_or(self.zoom, Self::MIN_ZOOM, Self::MAX_ZOOM, defaults.zoom); + self + } +} + +impl Default for NewThreadBackgroundAdjustment { + fn default() -> Self { + Self { + focal_x: 0.5, + focal_y: 0.5, + zoom: 1.0, + } + } } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -390,6 +427,7 @@ fn prepare_background_file( let replacement = NewThreadComposerBackground { path: destination.to_string_lossy().into_owned(), name: staged.name, + adjustment: NewThreadBackgroundAdjustment::default(), }; Ok(PreparedBackgroundFile(Some(replacement))) } @@ -471,6 +509,19 @@ pub fn set_new_thread_background_effect(effect: NewThreadBackgroundEffect, cx: & } } +pub fn set_new_thread_background_adjustment( + adjustment: NewThreadBackgroundAdjustment, + cx: &mut App, +) { + if update(SavePolicy::Immediate, cx, |settings| { + if let Some(background) = settings.new_thread_composer_background.as_mut() { + background.adjustment = adjustment.normalized(); + } + }) { + cx.refresh_windows(); + } +} + fn remove_managed_new_thread_background( background: Option<&NewThreadComposerBackground>, backgrounds_dir: &Path, @@ -1564,6 +1615,9 @@ impl UiSettings { self.git_history_column_widths = self.git_history_column_widths.clamped(); self.git_history_column_order = self.git_history_column_order.normalized(); self.ui_font_size = self.ui_font_size.normalized(); + if let Some(background) = self.new_thread_composer_background.as_mut() { + background.adjustment = background.adjustment.normalized(); + } self.keymap.heal_jump_slots(); self.keymap.heal_reserved_composer_shortcuts(); self @@ -2177,6 +2231,7 @@ mod tests { Some(&NewThreadComposerBackground { path: unrelated.to_string_lossy().into_owned(), name: "keep.png".into(), + adjustment: NewThreadBackgroundAdjustment::default(), }), &backgrounds, ); @@ -2185,12 +2240,115 @@ mod tests { Some(&NewThreadComposerBackground { path: managed.to_string_lossy().into_owned(), name: "owned.png".into(), + adjustment: NewThreadBackgroundAdjustment::default(), }), &backgrounds, ); assert!(!managed.exists()); } + #[test] + fn legacy_background_defaults_to_centered_cover_adjustment() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + UiSettings::path(dir.path()), + r#"{"newThreadComposerBackground":{"path":"managed.png","name":"background.png"}}"#, + ) + .unwrap(); + + let loaded = UiSettings::load(dir.path()); + assert_eq!( + loaded.new_thread_composer_background.unwrap().adjustment, + NewThreadBackgroundAdjustment::default() + ); + } + + #[test] + fn background_adjustment_normalizes_invalid_and_out_of_range_values() { + let normalized = NewThreadBackgroundAdjustment { + focal_x: f32::NAN, + focal_y: 2.0, + zoom: f32::INFINITY, + } + .normalized(); + assert_eq!( + normalized, + NewThreadBackgroundAdjustment { + focal_x: 0.5, + focal_y: 1.0, + zoom: 1.0, + } + ); + + let clamped = UiSettings { + new_thread_composer_background: Some(NewThreadComposerBackground { + path: "managed.png".into(), + name: "background.png".into(), + adjustment: NewThreadBackgroundAdjustment { + focal_x: -1.0, + focal_y: 0.25, + zoom: 99.0, + }, + }), + ..Default::default() + } + .clamped(); + assert_eq!( + clamped.new_thread_composer_background.unwrap().adjustment, + NewThreadBackgroundAdjustment { + focal_x: 0.0, + focal_y: 0.25, + zoom: NewThreadBackgroundAdjustment::MAX_ZOOM, + } + ); + } + + #[gpui::test] + fn background_adjustment_setter_normalizes_and_persists_immediately( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + let settings = UiSettings { + new_thread_composer_background: Some(NewThreadComposerBackground { + path: "managed.png".into(), + name: "background.png".into(), + adjustment: NewThreadBackgroundAdjustment::default(), + }), + ..Default::default() + }; + init(settings, dir.path(), cx); + set_new_thread_background_adjustment( + NewThreadBackgroundAdjustment { + focal_x: -0.5, + focal_y: 0.7, + zoom: 2.25, + }, + cx, + ); + + let expected = NewThreadBackgroundAdjustment { + focal_x: 0.0, + focal_y: 0.7, + zoom: 2.25, + }; + assert_eq!( + current(cx) + .new_thread_composer_background + .unwrap() + .adjustment, + expected + ); + assert_eq!( + UiSettings::load(dir.path()) + .new_thread_composer_background + .unwrap() + .adjustment, + expected + ); + }); + } + #[gpui::test] fn invalid_background_replacement_preserves_previous_image_and_settings( cx: &mut gpui::TestAppContext, @@ -2245,6 +2403,14 @@ mod tests { }; init(initial, dir.path(), cx); install_new_thread_composer_background(&first, cx).unwrap(); + set_new_thread_background_adjustment( + NewThreadBackgroundAdjustment { + focal_x: 0.2, + focal_y: 0.8, + zoom: 2.0, + }, + cx, + ); let old_path = current(cx).new_thread_composer_background.unwrap().path; install_new_thread_composer_background(&second, cx).unwrap(); let settings = current(cx); @@ -2259,6 +2425,10 @@ mod tests { settings.new_thread_background_effect, NewThreadBackgroundEffect::Ascii ); + assert_eq!( + replacement.adjustment, + NewThreadBackgroundAdjustment::default() + ); assert!(!Path::new(&old_path).exists()); assert!(first.exists() && second.exists()); assert_eq!( @@ -2416,6 +2586,11 @@ mod tests { new_thread_composer_background: Some(NewThreadComposerBackground { path: "/tmp/zeron/new-thread-background.png".into(), name: "background.png".into(), + adjustment: NewThreadBackgroundAdjustment { + focal_x: 0.25, + focal_y: 0.75, + zoom: 1.8, + }, }), wallpaper_folder: Some("/tmp/wallpapers".into()), wallpaper_source: Some("/tmp/wallpapers/background.png".into()), @@ -2434,6 +2609,9 @@ mod tests { assert!(json.contains(r#""codeFencesFitContent": true"#)); assert!(json.contains(r#""openWebLinksInZeron": false"#)); assert!(json.contains(r#""newThreadBackgroundEffect": "ascii""#)); + assert!(json.contains(r#""focalX": 0.25"#)); + assert!(json.contains(r#""focalY": 0.75"#)); + assert!(json.contains(r#""zoom": 1.8"#)); assert!(json.contains(r#""reduceMotion": "on""#)); assert!(json.contains(r#""pauseAnimationsInBackground": true"#)); assert!(json.contains(r#""terminalFontFamily": "installed:Menlo""#)); diff --git a/crates/ui/src/settings/appearance.rs b/crates/ui/src/settings/appearance.rs index 9b25d004c6..3fc10efcce 100644 --- a/crates/ui/src/settings/appearance.rs +++ b/crates/ui/src/settings/appearance.rs @@ -5,11 +5,12 @@ use std::cell::Cell; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::rc::Rc; +use std::sync::Arc; use gpui::{ - AnyElement, Context, Entity, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement, - KeyDownEvent, ObjectFit, Render, SharedString, StyledImage as _, Subscription, Window, div, - img, prelude::*, px, + AnyElement, Bounds, Context, Entity, EventEmitter, FocusHandle, Focusable, Hsla, IntoElement, + KeyDownEvent, ObjectFit, Pixels, Point, Render, ScrollDelta, SharedString, StyledImage as _, + Subscription, Window, div, img, point, prelude::*, px, }; use zeron_theme::vscode::{ImportReport, SourceCompilation}; use zeron_theme::{ @@ -39,6 +40,91 @@ struct ImportDialog { error: Option, } +struct BackgroundAdjustmentDialog { + focus: FocusHandle, + zoom_focus: FocusHandle, + end_focus: FocusHandle, + return_focus: Option, + path: String, + source: Option>, + draft: crate::settings::NewThreadBackgroundAdjustment, + preview_bounds: Rc>>>, + zoom_bounds: Rc>>>, + drag: Option<( + Point, + crate::settings::NewThreadBackgroundAdjustment, + )>, + pinch: Option<(f32, f32)>, +} + +#[derive(Clone)] +struct BackgroundAdjustmentDrag; + +impl Render for BackgroundAdjustmentDrag { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +#[derive(Clone)] +struct BackgroundAdjustmentZoomDrag; + +impl Render for BackgroundAdjustmentZoomDrag { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +const BACKGROUND_ZOOM_STEP: f32 = 0.1; + +fn background_pinch_factor(factor: f32, delta: f32, relative_delta: bool) -> f32 { + if !delta.is_finite() { + return factor; + } + // Direct Manipulation reports a ratio to the last scale; the other + // native backends report an additive change to the gesture's scale. + let next = if relative_delta { + factor * (1.0 + delta) + } else { + factor + delta + }; + next.max(0.001) +} + +fn background_zoom_fraction(zoom: f32) -> f32 { + let minimum = crate::settings::NewThreadBackgroundAdjustment::MIN_ZOOM; + let maximum = crate::settings::NewThreadBackgroundAdjustment::MAX_ZOOM; + ((zoom - minimum) / (maximum - minimum)).clamp(0.0, 1.0) +} + +fn background_zoom_at_x(bounds: Bounds, x: Pixels) -> f32 { + let minimum = crate::settings::NewThreadBackgroundAdjustment::MIN_ZOOM; + let maximum = crate::settings::NewThreadBackgroundAdjustment::MAX_ZOOM; + let fraction = ((f32::from(x - bounds.left()) - 7.0) + / (f32::from(bounds.size.width) - 14.0).max(1.0)) + .clamp(0.0, 1.0); + minimum + fraction * (maximum - minimum) +} + +fn background_preview_size( + viewport: gpui::Size, + sidebar_width: f32, + sidebar_collapsed: bool, +) -> gpui::Size { + let hero_width = (f32::from(viewport.width) + - if sidebar_collapsed { + 0.0 + } else { + sidebar_width + }) + .max(1.0); + let hero_height = + crate::shell::new_thread_background_height(f32::from(viewport.height)).max(1.0); + let max_width = (f32::from(viewport.width) - 84.0).clamp(1.0, 556.0); + let scale = (max_width / hero_width).min(258.0 / hero_height); + gpui::size(px(hero_width * scale), px(hero_height * scale)) +} + /// The three independently configurable font slots. Interface and code/diff /// draw from the whole catalog — proportional faces are legal there. The /// terminal draws from the fixed-width subset only. @@ -227,6 +313,7 @@ pub struct AppearancePage { background_effect_select: widgets::SelectState, reduce_motion_select: widgets::SelectState, import_dialog: Option, + background_adjustment_dialog: Option, review_entry: Option, library_error: Option, background_error: Option, @@ -490,6 +577,7 @@ impl AppearancePage { background_effect_select: widgets::SelectState::default(), reduce_motion_select: widgets::SelectState::default(), import_dialog: None, + background_adjustment_dialog: None, review_entry: None, library_error: None, background_error: None, @@ -968,6 +1056,163 @@ impl AppearancePage { cx.notify(); } + fn open_background_adjustment(&mut self, window: &mut Window, cx: &mut Context) { + let Some(background) = crate::settings::current(cx).new_thread_composer_background else { + return; + }; + let focus = cx.focus_handle(); + let zoom_focus = cx.focus_handle(); + let return_focus = window.focused(cx); + window.focus(&focus, cx); + self.background_adjustment_dialog = Some(BackgroundAdjustmentDialog { + focus, + zoom_focus, + end_focus: cx.focus_handle(), + return_focus, + path: background.path, + source: None, + draft: background.adjustment.normalized(), + preview_bounds: Rc::default(), + zoom_bounds: Rc::default(), + drag: None, + pinch: None, + }); + cx.notify(); + } + + fn dismiss_background_adjustment(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(dialog) = self.background_adjustment_dialog.take() + && let Some(focus) = dialog.return_focus + { + window.focus(&focus, cx); + } + cx.notify(); + } + + fn apply_background_adjustment(&mut self, window: &mut Window, cx: &mut Context) { + let Some(dialog) = self.background_adjustment_dialog.as_ref() else { + return; + }; + // A shuffle or replacement must not receive a draft for another image. + if dialog.source.is_none() + || crate::settings::current(cx) + .new_thread_composer_background + .as_ref() + .is_none_or(|background| background.path != dialog.path) + { + return; + } + crate::settings::set_new_thread_background_adjustment(dialog.draft, cx); + self.dismiss_background_adjustment(window, cx); + } + + fn reset_background_adjustment(&mut self, cx: &mut Context) { + if let Some(dialog) = self.background_adjustment_dialog.as_mut() { + dialog.draft = Default::default(); + dialog.drag = None; + dialog.pinch = None; + } + cx.notify(); + } + + fn set_background_zoom(&mut self, zoom: f32, cx: &mut Context) { + self.zoom_background_around(zoom, None, cx); + } + + fn set_background_zoom_from_x(&mut self, x: Pixels, cx: &mut Context) { + let Some(bounds) = self + .background_adjustment_dialog + .as_ref() + .and_then(|dialog| dialog.zoom_bounds.get()) + else { + return; + }; + self.set_background_zoom(background_zoom_at_x(bounds, x), cx); + } + + fn zoom_background_around( + &mut self, + zoom: f32, + anchor: Option>, + cx: &mut Context, + ) { + let Some(dialog) = self.background_adjustment_dialog.as_mut() else { + return; + }; + if dialog.source.is_none() { + return; + } + let zoom = zoom.clamp( + crate::settings::NewThreadBackgroundAdjustment::MIN_ZOOM, + crate::settings::NewThreadBackgroundAdjustment::MAX_ZOOM, + ); + let next = match (dialog.preview_bounds.get(), dialog.source.as_ref()) { + (Some(bounds), Some(source)) => { + crate::new_thread_background_mask::zoom_adjustment_around( + source, + bounds, + dialog.draft, + zoom, + anchor.unwrap_or_else(|| bounds.center()), + ) + } + _ => crate::settings::NewThreadBackgroundAdjustment { + zoom, + ..dialog.draft + }, + }; + if dialog.draft != next { + dialog.draft = next; + dialog.drag = None; + cx.notify(); + } + } + + fn pan_background(&mut self, delta: Point, cx: &mut Context) { + let Some(dialog) = self.background_adjustment_dialog.as_mut() else { + return; + }; + let (Some(bounds), Some(source)) = (dialog.preview_bounds.get(), dialog.source.as_ref()) + else { + return; + }; + let before = dialog.draft; + dialog.draft = crate::new_thread_background_mask::pan_adjustment( + source, + bounds, + dialog.draft, + point(px(delta.x), px(delta.y)), + ); + if dialog.draft != before { + cx.notify(); + } + } + + fn drag_background(&mut self, position: Point, cx: &mut Context) { + let Some(dialog) = self.background_adjustment_dialog.as_ref() else { + return; + }; + let Some((start, initial)) = dialog.drag else { + return; + }; + let delta = point( + f32::from(position.x - start.x), + f32::from(position.y - start.y), + ); + if let Some(dialog) = self.background_adjustment_dialog.as_mut() { + dialog.draft = initial; + } + self.pan_background(delta, cx); + } + + fn finish_background_drag(&mut self, cx: &mut Context) { + if let Some(dialog) = self.background_adjustment_dialog.as_mut() + && dialog.drag.take().is_some() + { + cx.notify(); + } + } + fn finish_import(&mut self, cx: &mut Context) { let Some(dialog) = self.import_dialog.as_mut() else { return; @@ -2334,6 +2579,523 @@ impl AppearancePage { Some(popover::modal("theme-import-dialog", viewport, card)) } + fn render_background_adjustment_dialog( + &mut self, + viewport: gpui::Size, + theme: &Theme, + cx: &mut Context, + ) -> Option { + let dialog = self.background_adjustment_dialog.as_ref()?; + let focus = dialog.focus.clone(); + let zoom_focus = dialog.zoom_focus.clone(); + let end_focus = dialog.end_focus.clone(); + let tab_start = focus.clone(); + let tab_end = end_focus.clone(); + let path = dialog.path.clone(); + let draft = dialog.draft; + let preview_bounds = dialog.preview_bounds.clone(); + let zoom_bounds = dialog.zoom_bounds.clone(); + let dragging = dialog.drag.is_some(); + let settings = crate::settings::current(cx); + let same_image = settings + .new_thread_composer_background + .as_ref() + .is_some_and(|background| background.path == path); + let available = Path::new(&path).is_file(); + let source = (same_image && available) + .then(|| { + crate::new_thread_background_effects::prepare( + settings.new_thread_background_effect, + theme, + Path::new(&path), + cx, + ) + }) + .flatten(); + let ready = source.is_some(); + let preview_size = + background_preview_size(viewport, settings.sidebar_width, settings.sidebar_collapsed); + if let Some(dialog) = self.background_adjustment_dialog.as_mut() { + dialog.source = source.clone(); + } + + let paint_source = source.clone(); + let measured_bounds = preview_bounds.clone(); + let preview = div() + .id("new-thread-background-adjustment-preview") + .relative() + .w(preview_size.width + px(2.0)) + .h(preview_size.height + px(2.0)) + .overflow_hidden() + .rounded(px(12.0)) + .border_1() + .border_color(crate::theme::hairline(0.12)) + .bg(theme.bg) + .cursor(if !ready { + gpui::CursorStyle::Arrow + } else if dragging { + gpui::CursorStyle::ClosedHand + } else { + gpui::CursorStyle::OpenHand + }) + .on_scroll_wheel( + cx.listener(|this, event: &gpui::ScrollWheelEvent, window, cx| { + let delta = match event.delta { + ScrollDelta::Pixels(delta) => f32::from(delta.y), + ScrollDelta::Lines(delta) => delta.y * 40.0, + }; + let Some(dialog) = this.background_adjustment_dialog.as_ref() else { + return; + }; + let zoom = dialog.draft.zoom * (delta * 0.0025).clamp(-2.0, 2.0).exp(); + this.zoom_background_around(zoom, Some(event.position), cx); + cx.stop_propagation(); + window.prevent_default(); + }), + ) + .on_pinch(cx.listener(|this, event: &gpui::PinchEvent, window, cx| { + let Some(dialog) = this.background_adjustment_dialog.as_mut() else { + return; + }; + if event.phase == gpui::TouchPhase::Ended { + dialog.pinch = None; + return; + } + if event.phase == gpui::TouchPhase::Started { + dialog.pinch = Some((dialog.draft.zoom, 1.0)); + } + let current_zoom = dialog.draft.zoom; + let (start, factor) = dialog.pinch.get_or_insert((current_zoom, 1.0)); + *factor = + background_pinch_factor(*factor, event.delta, cfg!(target_os = "windows")); + let zoom = *start * *factor; + this.zoom_background_around(zoom, Some(event.position), cx); + cx.stop_propagation(); + window.prevent_default(); + })) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, event: &gpui::MouseDownEvent, window, cx| { + let Some(dialog) = this.background_adjustment_dialog.as_mut() else { + return; + }; + if dialog.source.is_none() { + return; + } + window.focus(&dialog.focus, cx); + dialog.drag = Some((event.position, dialog.draft)); + cx.notify(); + cx.stop_propagation(); + }), + ) + .on_drag(BackgroundAdjustmentDrag, |drag, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| drag.clone()) + }) + .child( + gpui::canvas( + move |bounds, _, _| measured_bounds.set(Some(bounds)), + move |bounds, _, window, _| { + if let Some(source) = paint_source.clone() { + crate::new_thread_background_mask::paint_adjusted( + source, + bounds, + draft, + // Canvas content masks are rectangular; round the image + // itself to the frame's 12px radius inside its 1px border. + gpui::Corners::all(px(11.0)), + window, + ); + } + }, + ) + .w(preview_size.width) + .h(preview_size.height), + ) + .when(source.is_none(), |preview| { + preview.child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .text_size(typography::ui_rems(11.5)) + .text_color(theme.text_muted) + .child(if !same_image { + "Background changed. Close and adjust the new image." + } else if !available { + "Image unavailable. Choose a replacement or remove it." + } else { + "Preparing preview…" + }), + ) + }) + .when(dragging, |preview| { + preview + .child( + div() + .absolute() + .left(gpui::relative(1.0 / 3.0)) + .top_0() + .bottom_0() + .w(px(1.0)) + .bg(gpui::white().opacity(0.22)), + ) + .child( + div() + .absolute() + .left(gpui::relative(2.0 / 3.0)) + .top_0() + .bottom_0() + .w(px(1.0)) + .bg(gpui::white().opacity(0.22)), + ) + .child( + div() + .absolute() + .top(gpui::relative(1.0 / 3.0)) + .left_0() + .right_0() + .h(px(1.0)) + .bg(gpui::white().opacity(0.22)), + ) + .child( + div() + .absolute() + .top(gpui::relative(2.0 / 3.0)) + .left_0() + .right_0() + .h(px(1.0)) + .bg(gpui::white().opacity(0.22)), + ) + }); + + let zoom_fraction = background_zoom_fraction(draft.zoom); + let measured_zoom_bounds = zoom_bounds.clone(); + let zoom_slider = div() + .id("new-thread-background-adjustment-zoom") + .track_focus(&zoom_focus) + .tab_index(0) + .role(gpui::Role::Slider) + .aria_label("Background zoom") + .aria_numeric_value(f64::from(draft.zoom * 100.0)) + .aria_min_numeric_value(f64::from( + crate::settings::NewThreadBackgroundAdjustment::MIN_ZOOM * 100.0, + )) + .aria_max_numeric_value(f64::from( + crate::settings::NewThreadBackgroundAdjustment::MAX_ZOOM * 100.0, + )) + .aria_numeric_value_step(f64::from(BACKGROUND_ZOOM_STEP * 100.0)) + .aria_value(format!("{:.0}%", draft.zoom * 100.0)) + .focus_visible(|s| s.border_2().border_color(theme.accent)) + .relative() + .w(px(168.0)) + .h(px(28.0)) + .cursor_pointer() + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, event: &gpui::MouseDownEvent, window, cx| { + if let Some(dialog) = this.background_adjustment_dialog.as_ref() { + window.focus(&dialog.zoom_focus, cx); + } + this.set_background_zoom_from_x(event.position.x, cx); + cx.stop_propagation(); + window.prevent_default(); + }), + ) + .on_drag(BackgroundAdjustmentZoomDrag, |drag, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| drag.clone()) + }) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + let Some(current) = this + .background_adjustment_dialog + .as_ref() + .map(|dialog| dialog.draft.zoom) + else { + return; + }; + let next = match event.keystroke.key.as_str() { + "left" | "down" => current - BACKGROUND_ZOOM_STEP, + "right" | "up" => current + BACKGROUND_ZOOM_STEP, + "home" => crate::settings::NewThreadBackgroundAdjustment::MIN_ZOOM, + "end" => crate::settings::NewThreadBackgroundAdjustment::MAX_ZOOM, + _ => return, + }; + this.set_background_zoom(next, cx); + cx.stop_propagation(); + window.prevent_default(); + })) + .child( + gpui::canvas( + move |bounds, _, _| measured_zoom_bounds.set(Some(bounds)), + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ) + .child( + div() + .absolute() + .left(px(7.0)) + .right(px(7.0)) + .top(px(12.0)) + .h(px(4.0)) + .rounded_full() + .bg(theme.border) + .child( + div() + .h_full() + .w(gpui::relative(zoom_fraction)) + .rounded_full() + .bg(theme.accent), + ) + .child( + div() + .absolute() + .left(gpui::relative(zoom_fraction)) + .ml(px(-7.0)) + .top(px(-5.0)) + .size(px(14.0)) + .rounded_full() + .bg(theme.accent), + ), + ); + let controls = div() + .mt(px(14.0)) + .flex() + .items_center() + .justify_between() + .gap(px(12.0)) + .child( + compact_action(theme, "Reset", "new-thread-background-adjustment-reset") + .tab_index(0) + .role(gpui::Role::Button) + .focus_visible(|s| s.border_2().border_color(theme.accent).opacity(1.0)) + .on_click(cx.listener(|this, _, _, cx| this.reset_background_adjustment(cx))), + ) + .child( + div() + .flex() + .items_center() + .gap(px(6.0)) + .child( + compact_action(theme, "−", "new-thread-background-adjustment-zoom-out") + .tab_index(0) + .role(gpui::Role::Button) + .aria_label("Zoom out") + .focus_visible(|s| s.border_2().border_color(theme.accent).opacity(1.0)) + .on_click(cx.listener(move |this, _, _, cx| { + let Some(dialog) = this.background_adjustment_dialog.as_ref() + else { + return; + }; + this.set_background_zoom( + dialog.draft.zoom - BACKGROUND_ZOOM_STEP, + cx, + ); + })), + ) + .child(zoom_slider) + .child( + div() + .w(px(52.0)) + .text_center() + .text_size(typography::ui_rems(11.5)) + .text_color(theme.text_muted) + .child(format!("{:.0}%", draft.zoom * 100.0)), + ) + .child( + compact_action(theme, "+", "new-thread-background-adjustment-zoom-in") + .tab_index(0) + .role(gpui::Role::Button) + .aria_label("Zoom in") + .focus_visible(|s| s.border_2().border_color(theme.accent).opacity(1.0)) + .on_click(cx.listener(move |this, _, _, cx| { + let Some(dialog) = this.background_adjustment_dialog.as_ref() + else { + return; + }; + this.set_background_zoom( + dialog.draft.zoom + BACKGROUND_ZOOM_STEP, + cx, + ); + })), + ), + ); + + let footer = div() + .mt(px(18.0)) + .pt(px(12.0)) + .border_t_1() + .border_color(theme.border) + .flex() + .items_center() + .justify_end() + .gap(px(8.0)) + .child( + compact_action(theme, "Cancel", "new-thread-background-adjustment-cancel") + .h(px(34.0)) + .px(px(13.0)) + .tab_index(0) + .role(gpui::Role::Button) + .focus_visible(|s| s.border_2().border_color(theme.accent).opacity(1.0)) + .on_click(cx.listener(|this, _, window, cx| { + this.dismiss_background_adjustment(window, cx) + })), + ) + .child( + widgets::text_action(theme, widgets::ActionTone::Solid, "Apply") + .id("new-thread-background-adjustment-apply") + .h(px(34.0)) + .px(px(14.0)) + .py(px(0.0)) + .flex() + .items_center() + .when(!ready, |button| button.opacity(0.45)) + .when(ready, |button| { + button + .tab_index(0) + .role(gpui::Role::Button) + .focus_visible(|s| s.border_2().border_color(theme.accent).opacity(1.0)) + .on_click(cx.listener(|this, _, window, cx| { + this.apply_background_adjustment(window, cx) + })) + }), + ); + + let header = div() + .flex() + .items_start() + .gap(px(16.0)) + .child( + div() + .flex_1() + .min_w_0() + .child(popover::dialog_title(theme, "Adjust background")) + .child( + popover::dialog_body(theme, "Drag to reposition. Scroll or pinch to zoom.") + .mt(px(4.0)), + ), + ) + .child( + div() + .id("new-thread-background-adjustment-close") + .size(px(28.0)) + .rounded(px(7.0)) + .border_1() + .border_color(theme.border) + .bg(theme.surface_raised.opacity(0.28)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|style| style.bg(theme.surface_raised_hover)) + .tab_index(0) + .role(gpui::Role::Button) + .aria_label("Close background adjustment") + .focus_visible(|s| s.border_2().border_color(theme.accent).opacity(1.0)) + .on_click(cx.listener(|this, _, window, cx| { + this.dismiss_background_adjustment(window, cx) + })) + .child( + icons::icon(icons::CLOSE) + .size(px(12.0)) + .text_color(theme.text_muted), + ), + ); + + let card = popover::dialog_card(theme) + .id("new-thread-background-adjustment-card") + .w(px((f32::from(viewport.width) - 40.0).clamp(1.0, 600.0))) + .p(px(20.0)) + .track_focus(&focus) + .tab_group() + .tab_index(0) + .tab_stop(false) + .on_key_down( + cx.listener(move |this, event: &gpui::KeyDownEvent, window, cx| { + let step = if event.keystroke.modifiers.shift { + 24.0 + } else { + 8.0 + }; + match popover::classify_key( + event.keystroke.key.as_str(), + event.keystroke.modifiers.platform, + event.keystroke.modifiers.control, + ) { + popover::MenuKey::Escape => this.dismiss_background_adjustment(window, cx), + popover::MenuKey::ModEnter => this.apply_background_adjustment(window, cx), + _ => match event.keystroke.key.as_str() { + "tab" => { + if event.keystroke.modifiers.shift { + window.focus_prev(cx); + } else { + window.focus_next(cx); + } + if !tab_start.contains_focused(window, cx) { + if event.keystroke.modifiers.shift { + window.focus(&tab_end, cx); + window.focus_prev(cx); + } else { + window.focus(&tab_start, cx); + window.focus_next(cx); + } + } + } + "left" => this.pan_background(point(step, 0.0), cx), + "right" => this.pan_background(point(-step, 0.0), cx), + "up" => this.pan_background(point(0.0, step), cx), + "down" => this.pan_background(point(0.0, -step), cx), + "+" | "=" => { + let zoom = this + .background_adjustment_dialog + .as_ref() + .map_or(1.0, |dialog| dialog.draft.zoom + BACKGROUND_ZOOM_STEP); + this.set_background_zoom(zoom, cx); + } + "-" => { + let zoom = this + .background_adjustment_dialog + .as_ref() + .map_or(1.0, |dialog| dialog.draft.zoom - BACKGROUND_ZOOM_STEP); + this.set_background_zoom(zoom, cx); + } + _ => return, + }, + } + cx.stop_propagation(); + window.prevent_default(); + }), + ) + .on_mouse_down_out( + cx.listener(|this, _, window, cx| this.dismiss_background_adjustment(window, cx)), + ) + .child(header) + .child(div().h(px(16.0))) + .child( + div() + .w_full() + .h(px(260.0)) + .flex() + .items_center() + .justify_center() + .child(preview), + ) + .child(controls) + .child(footer) + .child(div().track_focus(&end_focus).tab_index(0).tab_stop(false)) + .into_any_element(); + + Some(popover::modal( + "new-thread-background-adjustment-dialog", + viewport, + card, + )) + } + fn render_review_dialog( &mut self, viewport: gpui::Size, @@ -2847,6 +3609,25 @@ impl Render for AppearancePage { .gap(px(8.0)) .when(current_background.is_some(), |actions| { actions + .when(background_available, |actions| { + actions.child( + compact_action( + &theme, + "Adjust", + "new-thread-background-adjust", + ) + .tab_index(0) + .role(gpui::Role::Button) + .focus_visible(|s| { + s.border_2().border_color(theme.accent).opacity(1.0) + }) + .on_click( + cx.listener(|this, _, window, cx| { + this.open_background_adjustment(window, cx) + }), + ), + ) + }) .child( compact_action( &theme, @@ -3099,7 +3880,8 @@ impl Render for AppearancePage { .clone() .or_else(|| theme_library::load_warning(cx).map(SharedString::from)); let modal = self - .render_import_dialog(window.viewport_size(), &theme, window, cx) + .render_background_adjustment_dialog(window.viewport_size(), &theme, cx) + .or_else(|| self.render_import_dialog(window.viewport_size(), &theme, window, cx)) .or_else(|| self.render_review_dialog(window.viewport_size(), &theme, cx)); let ui_picker = self.render_font_picker( @@ -3197,6 +3979,16 @@ impl Render for AppearancePage { this.drag_width(event.event.position.x, window, cx); }, )) + .on_drag_move(cx.listener( + |this, event: &gpui::DragMoveEvent, _, cx| { + this.drag_background(event.event.position, cx); + }, + )) + .on_drag_move(cx.listener( + |this, event: &gpui::DragMoveEvent, _, cx| { + this.set_background_zoom_from_x(event.event.position.x, cx); + }, + )) .on_mouse_up( gpui::MouseButton::Left, cx.listener(|this, _: &gpui::MouseUpEvent, _, cx| { @@ -3205,6 +3997,7 @@ impl Render for AppearancePage { cx.notify(); } this.apply_pending_width(cx); + this.finish_background_drag(cx); }), ) .on_mouse_up_out( @@ -3215,6 +4008,7 @@ impl Render for AppearancePage { cx.notify(); } this.apply_pending_width(cx); + this.finish_background_drag(cx); }), ) .relative() @@ -3285,6 +4079,204 @@ impl Render for AppearancePage { mod tests { use super::*; + fn background_settings() -> crate::settings::UiSettings { + crate::settings::UiSettings { + new_thread_composer_background: Some(crate::settings::NewThreadComposerBackground { + path: "background.png".into(), + name: "Background".into(), + adjustment: crate::settings::NewThreadBackgroundAdjustment { + focal_x: 0.25, + focal_y: 0.75, + zoom: 2.0, + }, + }), + ..Default::default() + } + } + + fn ready_background_preview(page: &mut AppearancePage) { + page.background_adjustment_dialog.as_mut().unwrap().source = Some(Arc::new( + gpui::RenderImage::new([image::Frame::new(image::RgbaImage::new(4, 4))]), + )); + } + + #[gpui::test] + fn background_adjustment_cancel_and_reset_leave_saved_settings_untouched( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + let original = background_settings(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + crate::settings::init(original.clone(), dir.path(), cx); + }); + let window = cx.add_window(|_, cx| AppearancePage::new(cx)); + window + .update(cx, |page, window, cx| { + window.focus(&page.width_focus, cx); + page.open_background_adjustment(window, cx); + let dialog = page.background_adjustment_dialog.as_mut().unwrap(); + dialog.drag = Some((point(px(0.0), px(0.0)), dialog.draft)); + dialog.pinch = Some((2.0, 1.0)); + page.reset_background_adjustment(cx); + let dialog = page.background_adjustment_dialog.as_ref().unwrap(); + assert_eq!(dialog.draft, Default::default()); + assert!(dialog.drag.is_none()); + assert!(dialog.pinch.is_none()); + assert_eq!(crate::settings::current(cx), original); + page.dismiss_background_adjustment(window, cx); + assert!(page.background_adjustment_dialog.is_none()); + assert!(page.width_focus.is_focused(window)); + assert_eq!(crate::settings::current(cx), original); + assert!(!crate::settings::UiSettings::path(dir.path()).exists()); + }) + .unwrap(); + } + + #[gpui::test] + fn background_adjustment_apply_waits_for_preview_and_persists_only_the_draft( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + let original = background_settings(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + crate::settings::init(original.clone(), dir.path(), cx); + }); + let window = cx.add_window(|_, cx| AppearancePage::new(cx)); + window + .update(cx, |page, window, cx| { + window.focus(&page.width_focus, cx); + page.open_background_adjustment(window, cx); + page.apply_background_adjustment(window, cx); + assert!(page.background_adjustment_dialog.is_some()); + assert_eq!(crate::settings::current(cx), original); + ready_background_preview(page); + page.reset_background_adjustment(cx); + assert_eq!(crate::settings::current(cx), original); + page.apply_background_adjustment(window, cx); + assert!(page.background_adjustment_dialog.is_none()); + assert!(page.width_focus.is_focused(window)); + let mut expected = original.clone(); + expected + .new_thread_composer_background + .as_mut() + .unwrap() + .adjustment = Default::default(); + assert_eq!(crate::settings::current(cx), expected); + assert_eq!(crate::settings::UiSettings::load(dir.path()), expected); + }) + .unwrap(); + } + + #[gpui::test] + fn background_adjustment_does_not_apply_to_a_replaced_or_removed_image( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + crate::settings::init(background_settings(), dir.path(), cx); + }); + let window = cx.add_window(|_, cx| AppearancePage::new(cx)); + window + .update(cx, |page, window, cx| { + page.open_background_adjustment(window, cx); + ready_background_preview(page); + page.reset_background_adjustment(cx); + crate::settings::update(crate::settings::SavePolicy::Immediate, cx, |settings| { + settings + .new_thread_composer_background + .as_mut() + .unwrap() + .path = "replacement.png".into(); + }); + let replacement = crate::settings::current(cx); + page.apply_background_adjustment(window, cx); + assert_eq!(crate::settings::current(cx), replacement); + assert!(page.background_adjustment_dialog.is_some()); + crate::settings::update(crate::settings::SavePolicy::Immediate, cx, |settings| { + settings.new_thread_composer_background = None; + }); + page.apply_background_adjustment(window, cx); + assert!( + crate::settings::current(cx) + .new_thread_composer_background + .is_none() + ); + assert!(page.background_adjustment_dialog.is_some()); + }) + .unwrap(); + } + + #[test] + fn background_preview_matches_the_runtime_hero_aspect_ratio() { + for viewport in [ + gpui::size(px(1440.0), px(900.0)), + gpui::size(px(800.0), px(1200.0)), + ] { + for collapsed in [false, true] { + let preview = background_preview_size(viewport, 260.0, collapsed); + let hero_width = f32::from(viewport.width) - if collapsed { 0.0 } else { 260.0 }; + let hero_height = + crate::shell::new_thread_background_height(f32::from(viewport.height)); + let aspect = f32::from(preview.width) / f32::from(preview.height); + assert!((aspect - hero_width / hero_height).abs() < 0.00001); + assert!(f32::from(preview.width) <= 556.0); + assert!(f32::from(preview.height) <= 260.0); + } + } + } + + #[test] + fn background_pinch_preserves_native_scale_and_round_trips() { + for relative_delta in [false, true] { + let mut previous = 1.0; + let mut factor = 1.0; + for scale in [1.5, 2.0, 1.5, 1.0] { + let delta = if relative_delta { + scale / previous - 1.0 + } else { + scale - previous + }; + factor = background_pinch_factor(factor, delta, relative_delta); + assert!((factor - scale).abs() < 0.00001); + previous = scale; + } + assert!((factor - 1.0).abs() < 0.00001); + } + } + + #[test] + fn background_pinch_clamps_degenerate_scales_and_ignores_invalid_deltas() { + for relative_delta in [false, true] { + assert_eq!(background_pinch_factor(1.0, -1.0, relative_delta), 0.001); + assert_eq!(background_pinch_factor(1.0, -2.0, relative_delta), 0.001); + for delta in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(background_pinch_factor(1.5, delta, relative_delta), 1.5); + } + } + } + + #[test] + fn background_zoom_slider_matches_its_inset_track_and_clamps() { + let bounds = Bounds::new(point(px(100.0), px(20.0)), gpui::size(px(168.0), px(28.0))); + let minimum = crate::settings::NewThreadBackgroundAdjustment::MIN_ZOOM; + let maximum = crate::settings::NewThreadBackgroundAdjustment::MAX_ZOOM; + + assert_eq!(background_zoom_at_x(bounds, px(90.0)), minimum); + assert_eq!(background_zoom_at_x(bounds, px(107.0)), minimum); + assert_eq!(background_zoom_at_x(bounds, px(261.0)), maximum); + assert_eq!(background_zoom_at_x(bounds, px(280.0)), maximum); + assert_eq!(background_zoom_at_x(bounds, px(184.0)), 2.5); + assert_eq!(background_zoom_fraction(minimum), 0.0); + assert_eq!(background_zoom_fraction(2.5), 0.5); + assert_eq!(background_zoom_fraction(maximum), 1.0); + } + #[test] fn every_mode_gets_a_card() { assert_eq!(AppearanceMode::ALL.len(), 3); diff --git a/crates/ui/src/settings/wallpaper.rs b/crates/ui/src/settings/wallpaper.rs index 2bbff9664a..1fa61fdc93 100644 --- a/crates/ui/src/settings/wallpaper.rs +++ b/crates/ui/src/settings/wallpaper.rs @@ -72,7 +72,9 @@ const LOOKAHEAD: usize = 3; struct QueueKey { folder: Option, history: Vec, - background: Option, + /// Managed path only: adjusting the active image's framing must not + /// discard the decoded lookahead. + background: Option, effect: super::NewThreadBackgroundEffect, light: bool, data_dir: PathBuf, @@ -88,7 +90,9 @@ impl QueueKey { Self { folder: settings.wallpaper_folder, history, - background: settings.new_thread_composer_background, + background: settings + .new_thread_composer_background + .map(|background| background.path), effect: settings.new_thread_background_effect, light: cx .try_global::() @@ -152,11 +156,10 @@ fn remove_orphaned_preloads(key: &QueueKey) { }; // Compare names, not full paths, so a differently spelled data directory // can never make the active image look orphaned. - let active = key.background.as_ref().and_then(|background| { - Path::new(&background.path) - .file_name() - .map(|name| name.to_owned()) - }); + let active = key + .background + .as_deref() + .and_then(|path| Path::new(path).file_name().map(|name| name.to_owned())); for entry in entries.flatten() { let name = entry.file_name(); let managed = name @@ -358,6 +361,59 @@ mod tests { cx.run_until_parked(); } + #[gpui::test] + fn warm_switch_resets_previous_background_adjustment(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + let folder = wallpaper_folder(dir.path()); + let data = dir.path().join("data"); + cx.update(|cx| { + cx.set_global(crate::theme::Theme::default()); + super::super::init( + super::super::UiSettings { + wallpaper_folder: Some(folder.clone()), + ..Default::default() + }, + &data, + cx, + ); + super::super::install_new_thread_composer_background(&folder.join("0.png"), cx) + .unwrap(); + preload(cx); + }); + cx.run_until_parked(); + cx.update(|cx| { + // Framing belongs to the active image; it must not flush the + // decoded lookahead for the next shuffle. + let generation = cx.global::().generation; + super::super::set_new_thread_background_adjustment( + super::super::NewThreadBackgroundAdjustment { + focal_x: 0.2, + focal_y: 0.8, + zoom: 2.0, + }, + cx, + ); + preload(cx); + assert_eq!(cx.global::().generation, generation); + assert_eq!(cx.global::().ready.len(), LOOKAHEAD); + let expected = cx.global::().ready[0].source.clone(); + randomize(cx).detach(); + assert_eq!(cx.global::().ready.len(), LOOKAHEAD - 1); + let settings = super::super::current(cx); + assert_eq!(settings.wallpaper_source.as_ref(), Some(&expected)); + assert_eq!( + settings + .new_thread_composer_background + .as_ref() + .unwrap() + .adjustment, + super::super::NewThreadBackgroundAdjustment::default(), + ); + assert_eq!(super::super::UiSettings::load(&data), settings); + }); + cx.run_until_parked(); + } + #[gpui::test] fn first_preload_retires_copies_left_by_a_previous_session(cx: &mut gpui::TestAppContext) { let dir = tempfile::tempdir().unwrap(); @@ -377,6 +433,7 @@ mod tests { super::super::NewThreadComposerBackground { path: active.to_string_lossy().into_owned(), name: "active.png".into(), + adjustment: super::super::NewThreadBackgroundAdjustment::default(), }, ), ..Default::default() diff --git a/crates/ui/src/settings/wallpaper_colors.rs b/crates/ui/src/settings/wallpaper_colors.rs index 60ca76477b..6f5e092bf3 100644 --- a/crates/ui/src/settings/wallpaper_colors.rs +++ b/crates/ui/src/settings/wallpaper_colors.rs @@ -252,6 +252,7 @@ mod tests { super::super::NewThreadComposerBackground { path: image.to_string_lossy().into_owned(), name: "existing.png".into(), + adjustment: super::super::NewThreadBackgroundAdjustment::default(), }, ), ..Default::default() diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index c763fb3e77..4753a7b604 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -1271,13 +1271,14 @@ fn new_thread_background_opacity(is_frost: bool) -> f32 { } } -fn new_thread_background_height(viewport_height: f32) -> f32 { +pub(crate) fn new_thread_background_height(viewport_height: f32) -> f32 { (viewport_height.max(0.0) * NEW_THREAD_BACKGROUND_VIEWPORT_RATIO) .min(NEW_THREAD_BACKGROUND_MAX_HEIGHT) } fn new_thread_background( artwork: Option>, + adjustment: settings::NewThreadBackgroundAdjustment, viewport_height: f32, hero_width: f32, composer_bounds: crate::new_thread_background_mask::SurfaceBounds, @@ -1322,6 +1323,7 @@ fn new_thread_background( artwork.clone(), bounds, composer, + adjustment, cutout, window, ); @@ -9821,6 +9823,10 @@ impl Shell { let ui_settings = settings::current(cx); let new_thread_background_setting = ui_settings.new_thread_composer_background; let new_thread_background_effect = ui_settings.new_thread_background_effect; + let new_thread_background_adjustment = new_thread_background_setting + .as_ref() + .map(|background| background.adjustment) + .unwrap_or_default(); let frame_time = self.render_time.unwrap_or_else(std::time::Instant::now); // Prewarm even in an established thread. Decode/effect work is not // contingent on a hero measurement or a navigation gesture. @@ -9836,6 +9842,10 @@ impl Shell { }); let artwork_frame = self.new_thread_artwork_ready.frame( artwork, + new_thread_background_setting + .as_ref() + .map(|background| std::path::Path::new(&background.path)), + new_thread_background_adjustment, new_thread_background_setting.is_some(), self.reduced_motion, frame_time, @@ -9883,6 +9893,7 @@ impl Shell { .inset_0() .child(new_thread_background( artwork_frame.previous, + artwork_frame.previous_adjustment, self.viewport_height, width, bounds.clone(), @@ -9891,6 +9902,7 @@ impl Shell { )) .child(new_thread_background( artwork_frame.current, + artwork_frame.current_adjustment, self.viewport_height, width, bounds, From 710b06ff854e2bb0e54ec5795e3d665a78923bcf Mon Sep 17 00:00:00 2001 From: Daniel Katulevskiy Date: Wed, 30 Sep 2026 16:33:28 -0700 Subject: [PATCH 07/74] CI: seed the Rust cache from main and stop cancelling main runs PR caches are scoped to their merge ref, so no other PR could read them, and main never saved one because in-progress main runs were cancelled. Only main writes the Rust caches now (PRs restore them), main runs are no longer cancelled, and Cursor compatibility also runs on pushes to main so it seeds its cache. --- .github/workflows/cursor-compatibility.yml | 13 +++++++++++++ .github/workflows/linux-installer.yml | 2 +- .github/workflows/preview-tests.yml | 5 ++++- .github/workflows/ui-tests.yml | 14 +++++++++++++- .github/workflows/windows.yml | 3 ++- 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cursor-compatibility.yml b/.github/workflows/cursor-compatibility.yml index c521f439ee..329443b7c1 100644 --- a/.github/workflows/cursor-compatibility.yml +++ b/.github/workflows/cursor-compatibility.yml @@ -9,6 +9,16 @@ on: - 'Cargo.lock' - 'scripts/*cursor-sdk*' - '.github/workflows/cursor-*.yml' + push: + branches: [main] + paths: + - 'crates/harness/**' + - 'crates/doc/**' + - 'crates/engine/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'scripts/*cursor-sdk*' + - '.github/workflows/cursor-*.yml' workflow_dispatch: workflow_call: permissions: @@ -23,6 +33,9 @@ jobs: with: node-version: '22' - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true - name: Dependency updater validation run: python3 scripts/test-update-cursor-sdk.py - name: Install the exact SDK selected by this engine diff --git a/.github/workflows/linux-installer.yml b/.github/workflows/linux-installer.yml index 71e8d89fae..574f803f36 100644 --- a/.github/workflows/linux-installer.yml +++ b/.github/workflows/linux-installer.yml @@ -25,7 +25,7 @@ permissions: concurrency: group: linux-installer-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: desktop-entry: diff --git a/.github/workflows/preview-tests.yml b/.github/workflows/preview-tests.yml index 43487fb3eb..ff8b162392 100644 --- a/.github/workflows/preview-tests.yml +++ b/.github/workflows/preview-tests.yml @@ -9,7 +9,7 @@ permissions: contents: read concurrency: group: preview-tests-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: networking: strategy: @@ -21,6 +21,9 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true - run: cargo test --locked -p zeron-preview coordinator: runs-on: ubuntu-latest diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index efe5a97dfb..93a8226a23 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -31,7 +31,7 @@ permissions: concurrency: group: ui-tests-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: changes: @@ -75,6 +75,9 @@ jobs: - name: Rust toolchain run: rustup show active-toolchain || rustup toolchain install - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true - name: OpenCode protocol and permission regressions run: | cargo test --locked -p zeron-harness --lib opencode:: @@ -102,6 +105,9 @@ jobs: - name: Rust toolchain run: rustup show active-toolchain || rustup toolchain install - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true - name: UI and headless layout regressions run: cargo test --release --locked -p zeron-ui --lib -- --test-threads=1 @@ -122,6 +128,7 @@ jobs: git -C frame-source-tests checkout --detach FETCH_HEAD - uses: Swatinem/rust-cache@v2 with: + save-if: ${{ github.ref == 'refs/heads/main' }} cache-on-failure: true workspaces: | . @@ -199,6 +206,8 @@ jobs: rustup target add aarch64-apple-ios aarch64-apple-ios-sim - uses: Swatinem/rust-cache@v2 with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true key: ios-core - name: Rust mobile core tests run: cargo test --locked -p zeron-text -p zeron-markdown -p zeron-client -p zeron-mobile @@ -239,6 +248,9 @@ jobs: - name: Rust toolchain run: rustup show active-toolchain || rustup toolchain install - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true - name: Build browser fixture run: cargo build --release --locked -p zeron-ui --example browser-fixture --features browser-fixture - name: X11 and Wayland browser regressions diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 5b00048499..b9a96c6ddd 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -36,7 +36,7 @@ permissions: concurrency: group: windows-tests-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} defaults: run: @@ -58,6 +58,7 @@ jobs: "GPUI_FXC_PATH=$($fxc.FullName)" >> $env:GITHUB_ENV - uses: Swatinem/rust-cache@v2 with: + save-if: ${{ github.ref == 'refs/heads/main' }} cache-on-failure: true cache-workspace-crates: true - name: Native harness security and integration tests From f4abe69b8805294b96e47606642914319662caa2 Mon Sep 17 00:00:00 2001 From: Daniel Katulevskiy Date: Wed, 30 Sep 2026 16:35:50 -0700 Subject: [PATCH 08/74] CI: run Windows tests in three parallel jobs on a cheaper profile --- .github/actions/windows-ci-setup/action.yml | 54 +++++++++++++++++++++ .github/workflows/windows.yml | 52 +++++++++++--------- 2 files changed, 82 insertions(+), 24 deletions(-) create mode 100644 .github/actions/windows-ci-setup/action.yml diff --git a/.github/actions/windows-ci-setup/action.yml b/.github/actions/windows-ci-setup/action.yml new file mode 100644 index 0000000000..74ddc7e28b --- /dev/null +++ b/.github/actions/windows-ci-setup/action.yml @@ -0,0 +1,54 @@ +name: Windows CI setup +description: >- + Prepare a Windows runner for the Windows test jobs: toolchain, shader + compiler, a cheaper release profile for the CI builds, and the Rust cache. + +runs: + using: composite + steps: + - name: Disable Defender real-time scanning + shell: pwsh + # Hosted runners are ephemeral and only build this repository; scanning + # every file rustc writes slows builds down. Best effort: warn and carry + # on if the runner image does not allow it. + run: | + try { Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction Stop } + catch { Write-Warning "Could not disable Defender real-time scanning: $_" } + - name: Rust toolchain + shell: pwsh + run: rustup show active-toolchain || rustup toolchain install + - name: Locate Windows SDK shader compiler + shell: pwsh + run: | + $fxc = Get-ChildItem "${env:ProgramFiles(x86)}/Windows Kits/10/bin/*/x64/fxc.exe" | + Sort-Object FullName -Descending | Select-Object -First 1 + if (-not $fxc) { throw 'Windows SDK fxc.exe is required for GPUI release shaders' } + "GPUI_FXC_PATH=$($fxc.FullName)" >> $env:GITHUB_ENV + - name: Cheaper release profile for CI builds + shell: pwsh + # CI only: the binaries built and tested here are NOT the shipped ones + # (release.yml still builds those with the committed profile). Thin LTO is + # off and the workspace's own crates are built at opt-level 0; third-party + # dependencies (gpui, tokio, ...) keep the release opt-level. Written to + # $CARGO_HOME/config.toml so Cargo.toml is untouched and every cargo + # invocation in the job, including the ones inside + # scripts/package-windows.ps1, picks it up. + run: | + $members = (cargo metadata --no-deps --format-version 1 | ConvertFrom-Json).packages.name + $config = @('[profile.release]', 'lto = false') + foreach ($name in $members) { + $config += "[profile.release.package.`"$name`"]" + $config += 'opt-level = 0' + } + $cargoHome = if ($env:CARGO_HOME) { $env:CARGO_HOME } else { Join-Path $env:USERPROFILE '.cargo' } + Add-Content -LiteralPath (Join-Path $cargoHome 'config.toml') -Value $config + Write-Host "opt-level 0 for $($members.Count) workspace crates: $($members -join ', ')" + - uses: Swatinem/rust-cache@v2 + with: + # Only main writes the cache; PRs restore it. + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true + # Workspace crates are rebuilt on every change anyway and are cheap at + # opt-level 0, so keep them out of the cache. Each job gets its own + # cache (rust-cache keys on the job id by default). + cache-workspace-crates: false diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index b9a96c6ddd..33e5c69b45 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -43,26 +43,14 @@ defaults: shell: pwsh jobs: - tests: + # The original single serial job is split into three independent jobs so they + # run in parallel. Together they run exactly the commands the single job ran. + ui-tests: runs-on: windows-2022 - timeout-minutes: 90 + timeout-minutes: 45 steps: - uses: actions/checkout@v4 - - name: Rust toolchain - run: rustup show active-toolchain || rustup toolchain install - - name: Locate Windows SDK shader compiler - run: | - $fxc = Get-ChildItem "${env:ProgramFiles(x86)}/Windows Kits/10/bin/*/x64/fxc.exe" | - Sort-Object FullName -Descending | Select-Object -First 1 - if (-not $fxc) { throw 'Windows SDK fxc.exe is required for GPUI release shaders' } - "GPUI_FXC_PATH=$($fxc.FullName)" >> $env:GITHUB_ENV - - uses: Swatinem/rust-cache@v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - cache-on-failure: true - cache-workspace-crates: true - - name: Native harness security and integration tests - run: cargo test --release --locked -p zeron-harness --features native-fixture --lib --test codex_availability --test windows_native + - uses: ./.github/actions/windows-ci-setup - name: UI tests env: RUST_BACKTRACE: '1' @@ -74,12 +62,15 @@ jobs: cargo test --release --locked -p zeron-ui --lib -- --test-threads=1 --skip native_diff_font_geometry --nocapture $headlessResult = $LASTEXITCODE if ($nativeResult -ne 0 -or $headlessResult -ne 0) { exit 1 } - - name: Build application - run: cargo build --release --locked -p zeron - - name: Verify portable and installer packaging - run: ./scripts/package-windows.ps1 -ReleasesUrl "https://github.com/$env:GITHUB_REPOSITORY/releases/latest/download" - - name: Installer install/uninstall - run: ./scripts/test-windows-installer.ps1 + + crate-tests: + runs-on: windows-2022 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/windows-ci-setup + - name: Native harness security and integration tests + run: cargo test --release --locked -p zeron-harness --features native-fixture --lib --test codex_availability --test windows_native - name: Shader layout tests run: cargo test --release --locked -p gpui_windows --lib layout - name: Application and updater tests @@ -90,6 +81,19 @@ jobs: run: cargo test --release --locked -p zeron-engine --test codex_catalog --test codex_login_resolution - name: Auth integration tests run: cargo test --release --locked -p zeron-engine --test auth + + app: + runs-on: windows-2022 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/windows-ci-setup + - name: Build application + run: cargo build --release --locked -p zeron + - name: Verify portable and installer packaging + run: ./scripts/package-windows.ps1 -ReleasesUrl "https://github.com/$env:GITHUB_REPOSITORY/releases/latest/download" + - name: Installer install/uninstall + run: ./scripts/test-windows-installer.ps1 - name: GUI subsystem and redirected CLI output run: ./scripts/test-windows-startup.ps1 - name: CLI startup without HOME @@ -143,7 +147,7 @@ jobs: native-gui: if: ${{ github.event_name == 'workflow_dispatch' && inputs.native_gui }} - needs: tests + needs: app runs-on: windows-2022 timeout-minutes: 10 steps: From 52a3481dc5339fb628015857d25a1b4f403f421d Mon Sep 17 00:00:00 2001 From: Daniel Katulevskiy Date: Wed, 30 Sep 2026 16:36:54 -0700 Subject: [PATCH 09/74] CI: add a macOS workflow with one compile and parallel jobs New macos.yml holds all macOS CI work: updater/preview/native UI tests and the WebKit fixtures from one nextest compile on the dev profile, the gpui frame-source test, and the iOS checks (Rust mobile core tests and the Swift bindings check now on Linux, simulator tests on macOS behind the original path filter). scripts/ci/run-macos-fixture.sh wraps the native fixtures with bounded retries. --- .github/workflows/macos.yml | 283 ++++++++++++++++++++++++++++++++ scripts/ci/run-macos-fixture.sh | 62 +++++++ 2 files changed, 345 insertions(+) create mode 100644 .github/workflows/macos.yml create mode 100755 scripts/ci/run-macos-fixture.sh diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000000..16c74e478f --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,283 @@ +name: macOS tests + +on: + pull_request: + paths: + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - 'crates/**' + - 'edge/**' + - 'scripts/run-macos-browser-fixture.sh' + - 'scripts/ci/run-macos-fixture.sh' + - 'dist/macos/Info.plist' + - 'apps/ios/**' + - '.github/workflows/macos.yml' + push: + branches: [main] + paths: + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - 'crates/**' + - 'edge/**' + - 'scripts/run-macos-browser-fixture.sh' + - 'scripts/ci/run-macos-fixture.sh' + - 'dist/macos/Info.plist' + - 'apps/ios/**' + - '.github/workflows/macos.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: macos-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # Tests run on the dev profile (workspace crates at opt-level 0, dependencies at 2 as configured in + # Cargo.toml) instead of --release: a cold build is several times cheaper. No debug info is needed. + CARGO_PROFILE_DEV_DEBUG: 0 + +# Every macOS job starts with `rustup update stable`: runner images are not uniform (one image may ship +# rustc 1.98.0 and another 1.98.1) and rust-cache keys include the rustc version, so a job that lands +# on an older image would otherwise miss the cache that `main` seeded. +jobs: + changes: + runs-on: ubuntu-24.04 + permissions: + pull-requests: read + outputs: + ios: ${{ steps.filter.outputs.result }} + steps: + - uses: actions/github-script@v8 + id: filter + with: + script: | + // Preserve iOS coverage for main pushes and manual runs. + if (context.eventName !== 'pull_request') return true; + const files = await github.paginate(github.rest.pulls.listFiles, { + ...context.repo, + pull_number: context.payload.pull_request.number, + per_page: 100, + }); + // Run conservatively if the API did not return the full diff. + if (files.length < context.payload.pull_request.changed_files) return true; + // The app links the Rust mobile core and everything it depends on. + const mobileCore = ['crates/mobile/', 'crates/client/', 'crates/text/', 'crates/markdown/', + 'crates/doc/', 'crates/sync/', 'crates/proto/', 'crates/rpc/', 'crates/syntax/', + 'scripts/ios/']; + const affectsIOS = path => path?.startsWith('apps/ios/') + || mobileCore.some(prefix => path?.startsWith(prefix)) + || path === 'Cargo.lock' + || path === 'Cargo.toml' + || path === 'rust-toolchain.toml' + || path === '.github/workflows/macos.yml'; + return files.some(file => affectsIOS(file.filename) + || affectsIOS(file.previous_filename)); + + # Updater and preview tests, native UI regressions and the two native WebKit fixtures. Everything is + # compiled once (one cargo invocation, so dependencies are built once) and then run. + macos-native: + runs-on: macos-14 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Rust toolchain + run: | + rustup update stable --no-self-update + rustup show active-toolchain || rustup toolchain install + - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true + - name: Install cargo-nextest + run: | + # Pinned release, verified against a checksum. + version=0.9.146 + sha256=39785160b3c2f6ed9a765049cf4fa79f3b39aa02eb7598a5a0e2a1a0b9ffb9a8 + curl -fsSL -o "$RUNNER_TEMP/nextest.tar.gz" \ + "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-$version/cargo-nextest-$version-universal-apple-darwin.tar.gz" + echo "$sha256 $RUNNER_TEMP/nextest.tar.gz" | shasum -a 256 -c - + tar -xzf "$RUNNER_TEMP/nextest.tar.gz" -C "$HOME/.cargo/bin" + - name: Build tests and fixtures + run: | + cargo nextest run --no-run --locked \ + -p zeron-update -p zeron-preview -p zeron-ui \ + --features zeron-ui/browser-fixture --lib --tests + cargo build --locked -p zeron-update -p zeron-preview -p zeron-ui \ + --features zeron-ui/browser-fixture --example browser-fixture --example preview-fixture + - name: Check macOS application + env: + # Match the old `cargo check --release`: no debug assertions. + CARGO_PROFILE_DEV_DEBUG_ASSERTIONS: false + run: cargo check --locked -p zeron + - name: Updater, preview and native UI regressions + run: | + cargo nextest run --no-fail-fast --locked \ + -p zeron-update -p zeron-preview -p zeron-ui \ + --features zeron-ui/browser-fixture --lib --tests \ + -E 'package(zeron-update) | package(zeron-preview) | (binary_id(zeron-ui) & (test(native_diff_font_geometry) | test(/notify::tests::/)))' + - name: Native browser regression and screenshots + timeout-minutes: 7 + env: + RUST_BACKTRACE: 1 + FIXTURE_ATTEMPT_TIMEOUT: 110 + run: | + scripts/ci/run-macos-fixture.sh target/debug/examples/browser-fixture "$RUNNER_TEMP/browser-captures" + test -f "$RUNNER_TEMP/browser-captures/result.txt" + - name: Upload browser screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: browser-macos-captures + path: ${{ runner.temp }}/browser-captures* + if-no-files-found: ignore + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Install preview fixture server + run: npm ci --prefix edge + - name: Project discovery, stable URLs and HMR in native WebKit + timeout-minutes: 5 + env: + ZERON_PREVIEW_AUTO_OPEN: 1 + PREVIEW_HOSTS: 1 + run: | + VITE_BINARY="$GITHUB_WORKSPACE/edge/node_modules/vite/bin/vite.js" scripts/ci/run-macos-fixture.sh target/debug/examples/preview-fixture "$RUNNER_TEMP/preview-captures" + test -f "$RUNNER_TEMP/preview-captures/result.txt" + - name: Upload preview screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: preview-macos-captures + path: ${{ runner.temp }}/preview-captures* + if-no-files-found: ignore + + # gpui_macos lives in the pinned GPUI checkout, a separate workspace with its own cache. + macos-frame-source: + runs-on: macos-14 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Rust toolchain + run: | + rustup update stable --no-self-update + rustup show active-toolchain || rustup toolchain install + - name: Check out pinned GPUI for native frame-source tests + shell: bash + run: | + rev=$(python3 -c 'import tomllib; print(tomllib.load(open("Cargo.toml", "rb"))["workspace"]["dependencies"]["gpui"]["rev"])') + git init frame-source-tests + git -C frame-source-tests remote add origin https://github.com/zeronsh/zui + git -C frame-source-tests fetch --depth=1 origin "$rev" + git -C frame-source-tests checkout --detach FETCH_HEAD + - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true + workspaces: frame-source-tests + - name: Native frame-source recovery regression + working-directory: frame-source-tests + run: cargo test --locked -p gpui_macos --test frame_source_recovery + + # The iOS checks that do not need Apple tooling run on Linux: Rust mobile core tests, and "the + # committed Swift bindings are current" (the UniFFI metadata is identical for any target, so the + # bindings are generated from the host build of the library). + ios-core: + needs: changes + if: needs.changes.outputs.ios == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Rust toolchain + run: rustup show active-toolchain || rustup toolchain install + - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true + - name: Rust mobile core tests + run: cargo test --locked -p zeron-text -p zeron-markdown -p zeron-client -p zeron-mobile + - name: Generated Swift bindings are current + run: | + cargo build --locked -p zeron-mobile --lib + cargo build --locked -p zeron-mobile --bin uniffi-bindgen --features bindgen + target/debug/uniffi-bindgen generate --library target/debug/libzeron_mobile.so \ + --language swift --out-dir "$RUNNER_TEMP/gen" >/dev/null + diff -u apps/ios/Zeron/Core/Generated/zeron_core.swift "$RUNNER_TEMP/gen/zeron_core.swift" + # The simulator job links these headers instead of regenerating them. + mkdir -p "$RUNNER_TEMP/ios-include" + cp "$RUNNER_TEMP/gen/zeron_coreFFI.h" "$RUNNER_TEMP/ios-include/zeron_coreFFI.h" + cp "$RUNNER_TEMP/gen/zeron_coreFFI.modulemap" "$RUNNER_TEMP/ios-include/module.modulemap" + - uses: actions/upload-artifact@v4 + with: + name: ios-ffi-include + path: ${{ runner.temp }}/ios-include + retention-days: 1 + + # iOS unit tests in the simulator (only when iOS or the mobile core changed, see `changes`). + # Waiting for macos-frame-source keeps a run to two macOS runners at a time. + # Do not boot the simulator in the background: hundreds of launchd_sim daemons starve the 3-vCPU + # VM and slow every other step down by minutes. xcodebuild boots it when needed. + ios-tests: + needs: [changes, ios-core, macos-frame-source] + if: needs.changes.outputs.ios == 'true' + runs-on: macos-15 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Select an available iPhone simulator + run: | + # The app targets iOS 26: use the newest Xcode 26 and the newest iOS runtime installed. + xcode=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1 || true) + if [ -n "$xcode" ]; then echo "DEVELOPER_DIR=$xcode/Contents/Developer" >> "$GITHUB_ENV"; fi + python3 - <<'PYTHON' + import json, os, subprocess + devices = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "--json"]))["devices"] + def version(runtime): + return [int(part) for part in runtime.split("iOS-")[-1].split("-") if part.isdigit()] + iphones = [device for runtime, group in sorted(devices.items(), key=lambda item: version(item[0]), reverse=True) + if "iOS" in runtime for device in group if device["name"].startswith("iPhone")] + if not iphones: + raise SystemExit("No iPhone simulator is installed") + with open(os.environ["GITHUB_ENV"], "a") as env: + env.write("IOS_SIMULATOR_ID=" + iphones[0]["udid"] + "\n") + PYTHON + - name: Rust toolchain + iOS simulator target + run: | + rustup update stable --no-self-update + rustup show active-toolchain || rustup toolchain install + rustup target add aarch64-apple-ios-sim + - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} + cache-on-failure: true + - name: Build the Rust core for the simulator + run: | + # Same build as scripts/ios/build-core.sh iphonesimulator; the bindings came from ios-core. + IPHONEOS_DEPLOYMENT_TARGET=26.0 cargo build --locked -p zeron-mobile --lib --profile mobile --target aarch64-apple-ios-sim + mkdir -p target/ios-core/iphonesimulator + cp target/aarch64-apple-ios-sim/mobile/libzeron_mobile.a target/ios-core/iphonesimulator/ + - uses: actions/download-artifact@v4 + with: + name: ios-ffi-include + path: target/ios-core/iphonesimulator/include + - name: Run iOS unit tests + working-directory: apps/ios + env: + # Reuse the core built above instead of rebuilding it in the Xcode build phase. + ZERON_SKIP_CORE: 1 + run: | + xcodebuild -project Zeron.xcodeproj -scheme Zeron \ + -destination "platform=iOS Simulator,id=$IOS_SIMULATOR_ID" \ + -resultBundlePath "$RUNNER_TEMP/ios-tests.xcresult" \ + -only-testing:ZeronTests CODE_SIGNING_ALLOWED=NO test + - name: Upload iOS test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: ios-test-results + path: ${{ runner.temp }}/ios-tests.xcresult + if-no-files-found: ignore diff --git a/scripts/ci/run-macos-fixture.sh b/scripts/ci/run-macos-fixture.sh new file mode 100755 index 0000000000..52b1ed2863 --- /dev/null +++ b/scripts/ci/run-macos-fixture.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Runs a native macOS GUI fixture (browser-fixture / preview-fixture) with guards against a flaky, +# heavily loaded GitHub runner VM (3 vCPU). The fixture binary, its arguments and assertions are +# untouched; only the wrapper changes: +# * bounded retries: each attempt has its own timeout (instead of one long hang) and a failed attempt +# is reported with a ::warning:: so flakes stay visible. A real regression fails every attempt. +# * PREVIEW_HOSTS=1 (preview fixture only): WebKit sometimes fails to resolve *.localhost +# (NSURLErrorDomain -1003) although the preview proxy is listening; map the fixture hostnames to +# loopback in /etc/hosts so DNS can never be the reason. The fixture still goes through the same +# per-domain proxy path; this only removes the DNS race. +# usage: run-macos-fixture.sh +set -uo pipefail +ROOT="${ZERON_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}" +BINARY="$1" +OUT="$2" +ATTEMPTS="${FIXTURE_ATTEMPTS:-3}" +ATTEMPT_TIMEOUT="${FIXTURE_ATTEMPT_TIMEOUT:-70}" + +if [ -n "${PREVIEW_HOSTS:-}" ]; then + # Same slug() as crates/preview/src/catalog.rs: device label = slug(hostname). + label=$(hostname | python3 -c 'import re,sys; s=re.sub(r"[^a-z0-9]+","-",sys.stdin.read().strip().lower()).strip("-")[:48].strip("-"); print(s or "project")') + for svc in fieldnotes fieldnotes-vite fieldnotes-api fieldnotes-node fieldnotes-web fieldnotes-server; do + echo "127.0.0.1 $label.$svc.localhost" + echo "::1 $label.$svc.localhost" + done | sudo tee -a /etc/hosts >/dev/null + echo "hosts entries added for device label '$label'" +fi + +# macOS has no coreutils `timeout`; do it in bash. +run_with_timeout() { + local secs=$1; shift + "$@" & + local pid=$! + ( sleep "$secs"; kill -TERM "$pid" 2>/dev/null; sleep 3; kill -KILL "$pid" 2>/dev/null ) & + local watchdog=$! + wait "$pid"; local rc=$? + kill "$watchdog" 2>/dev/null; wait "$watchdog" 2>/dev/null + return $rc +} + +for attempt in $(seq 1 "$ATTEMPTS"); do + rm -rf "$OUT"; mkdir -p "$OUT" + echo "=== $(basename "$BINARY") attempt $attempt/$ATTEMPTS ===" + if run_with_timeout "$ATTEMPT_TIMEOUT" bash "$ROOT/scripts/run-macos-browser-fixture.sh" "$BINARY" "$OUT" && test -f "$OUT/result.txt"; then + echo "$(basename "$BINARY") passed on attempt $attempt" + [ "$attempt" -gt 1 ] && echo "::warning::$(basename "$BINARY") needed $attempt attempts (flaky on this runner)" + exit 0 + fi + echo "::warning::$(basename "$BINARY") attempt $attempt failed" + if [ -n "${PREVIEW_HOSTS:-}" ]; then + dscacheutil -q host -a name "$label.fieldnotes.localhost" || true + lsof -nP -iTCP:7331 -sTCP:LISTEN || true + fi + mkdir -p "$OUT.failed-$attempt" + cp -R "$OUT"/. "$OUT.failed-$attempt"/ 2>/dev/null || true + pkill -f 'vite/bin/vite.js' 2>/dev/null || true + pkill -f 'node api.js' 2>/dev/null || true + pkill -f 'Fixture.app/Contents/MacOS/fixture' 2>/dev/null || true + sleep 2 +done +echo "$(basename "$BINARY") failed after $ATTEMPTS attempts" +exit 1 From c286d118b48f592b4e1252802f7ff10c788caecd Mon Sep 17 00:00:00 2001 From: Daniel Katulevskiy Date: Wed, 30 Sep 2026 16:36:54 -0700 Subject: [PATCH 10/74] CI: remove the macOS jobs from ui-tests and preview-tests macos-frame-recovery, ios-tests and their path-filter job move to macos.yml; the preview networking tests on macOS run there as well. --- .github/workflows/preview-tests.yml | 2 +- .github/workflows/ui-tests.yml | 152 ---------------------------- 2 files changed, 1 insertion(+), 153 deletions(-) diff --git a/.github/workflows/preview-tests.yml b/.github/workflows/preview-tests.yml index ff8b162392..fee2ff30bb 100644 --- a/.github/workflows/preview-tests.yml +++ b/.github/workflows/preview-tests.yml @@ -14,7 +14,7 @@ jobs: networking: strategy: matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest] runs-on: ${{ matrix.os }} timeout-minutes: 30 steps: diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 93a8226a23..81dae1aa89 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -34,39 +34,6 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - changes: - runs-on: ubuntu-24.04 - permissions: - pull-requests: read - outputs: - ios: ${{ steps.filter.outputs.result }} - steps: - - uses: actions/github-script@v8 - id: filter - with: - script: | - // Preserve iOS coverage for main pushes and manual runs. - if (context.eventName !== 'pull_request') return true; - const files = await github.paginate(github.rest.pulls.listFiles, { - ...context.repo, - pull_number: context.payload.pull_request.number, - per_page: 100, - }); - // Run conservatively if the API did not return the full diff. - if (files.length < context.payload.pull_request.changed_files) return true; - // The app links the Rust mobile core and everything it depends on. - const mobileCore = ['crates/mobile/', 'crates/client/', 'crates/text/', 'crates/markdown/', - 'crates/doc/', 'crates/sync/', 'crates/proto/', 'crates/rpc/', 'crates/syntax/', - 'scripts/ios/']; - const affectsIOS = path => path?.startsWith('apps/ios/') - || mobileCore.some(prefix => path?.startsWith(prefix)) - || path === 'Cargo.lock' - || path === 'Cargo.toml' - || path === 'rust-toolchain.toml' - || path === '.github/workflows/ui-tests.yml'; - return files.some(file => affectsIOS(file.filename) - || affectsIOS(file.previous_filename)); - session-sync-regressions: runs-on: ubuntu-24.04 timeout-minutes: 20 @@ -111,125 +78,6 @@ jobs: - name: UI and headless layout regressions run: cargo test --release --locked -p zeron-ui --lib -- --test-threads=1 - macos-frame-recovery: - runs-on: macos-latest - timeout-minutes: 45 - steps: - - uses: actions/checkout@v4 - - name: Rust toolchain - run: rustup show active-toolchain || rustup toolchain install - - name: Check out pinned GPUI for native frame-source tests - shell: bash - run: | - rev=$(python3 -c 'import tomllib; print(tomllib.load(open("Cargo.toml", "rb"))["workspace"]["dependencies"]["gpui"]["rev"])') - git init frame-source-tests - git -C frame-source-tests remote add origin https://github.com/zeronsh/zui - git -C frame-source-tests fetch --depth=1 origin "$rev" - git -C frame-source-tests checkout --detach FETCH_HEAD - - uses: Swatinem/rust-cache@v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - cache-on-failure: true - workspaces: | - . - frame-source-tests - - name: Check macOS application - run: cargo check --release --locked -p zeron - - name: Updater tests (bundle staging, install detection) - run: cargo test --release --locked -p zeron-update - - name: Native diff font geometry regression - run: cargo test --release --locked -p zeron-ui --lib native_diff_font_geometry -- --test-threads=1 - - name: Native notification click regressions - run: | - cargo test --release --locked -p zeron-ui --lib notify::tests:: -- --test-threads=1 - - name: Build native browser fixture - run: cargo build --release --locked -p zeron-ui --example browser-fixture --example preview-fixture --features browser-fixture - - name: Native browser regression and screenshots - timeout-minutes: 3 - env: - RUST_BACKTRACE: 1 - run: | - bash scripts/run-macos-browser-fixture.sh target/release/examples/browser-fixture "$RUNNER_TEMP/browser-captures" - test -f "$RUNNER_TEMP/browser-captures/result.txt" - - name: Upload browser screenshots - if: always() - uses: actions/upload-artifact@v4 - with: - name: browser-macos-captures - path: ${{ runner.temp }}/browser-captures - if-no-files-found: ignore - - uses: actions/setup-node@v4 - with: - node-version: 24 - - name: Install preview fixture server - run: npm ci --prefix edge - - name: Project discovery, stable URLs and HMR in native WebKit - timeout-minutes: 3 - env: - ZERON_PREVIEW_AUTO_OPEN: 1 - run: | - VITE_BINARY="$GITHUB_WORKSPACE/edge/node_modules/vite/bin/vite.js" bash scripts/run-macos-browser-fixture.sh target/release/examples/preview-fixture "$RUNNER_TEMP/preview-captures" - test -f "$RUNNER_TEMP/preview-captures/result.txt" - - name: Upload preview screenshots - if: always() - uses: actions/upload-artifact@v4 - with: - name: preview-macos-captures - path: ${{ runner.temp }}/preview-captures - if-no-files-found: ignore - - name: Native frame-source recovery regression - working-directory: frame-source-tests - run: cargo test --release --locked -p gpui_macos --test frame_source_recovery - - ios-tests: - needs: changes - if: needs.changes.outputs.ios == 'true' - runs-on: macos-latest - timeout-minutes: 50 - steps: - - uses: actions/checkout@v4 - - name: Select an available iPhone simulator - run: | - python3 - <<'PYTHON' - import json, os, subprocess - devices = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "--json"]))["devices"] - iphones = [device for runtime, group in devices.items() if "iOS" in runtime - for device in group if device["name"].startswith("iPhone")] - if not iphones: - raise SystemExit("No iPhone simulator is installed") - with open(os.environ["GITHUB_ENV"], "a") as env: - env.write("IOS_SIMULATOR_ID=" + iphones[0]["udid"] + "\n") - PYTHON - - name: Rust toolchain + iOS targets - run: | - rustup show active-toolchain || rustup toolchain install - rustup target add aarch64-apple-ios aarch64-apple-ios-sim - - uses: Swatinem/rust-cache@v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - cache-on-failure: true - key: ios-core - - name: Rust mobile core tests - run: cargo test --locked -p zeron-text -p zeron-markdown -p zeron-client -p zeron-mobile - - name: Generated Swift bindings are current - run: | - scripts/ios/build-core.sh iphonesimulator - git diff --exit-code -- apps/ios/Zeron/Core/Generated - - name: Run iOS unit tests - working-directory: apps/ios - run: | - xcodebuild -project Zeron.xcodeproj -scheme Zeron \ - -destination "platform=iOS Simulator,id=$IOS_SIMULATOR_ID" \ - -resultBundlePath "$RUNNER_TEMP/ios-tests.xcresult" \ - -only-testing:ZeronTests CODE_SIGNING_ALLOWED=NO test - - name: Upload iOS test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: ios-test-results - path: ${{ runner.temp }}/ios-tests.xcresult - if-no-files-found: ignore - linux-browser: runs-on: ubuntu-24.04 timeout-minutes: 30 From 366b3c9e2a8210b0efb6067ec23bcf088bfcf8bd Mon Sep 17 00:00:00 2001 From: Daniel Katulevskiy <84909978+katulevskiy@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:43:58 -0700 Subject: [PATCH 11/74] Fix drag-select failing when several transcripts share a frame (#632) * Fix drag-select failing when several transcripts share a frame The markdown selection registry was one thread-local list that every transcript's frame reset cleared. With a side chat or subagent tab painted beside the main chat, only the last-painted transcript kept its text in the registry, so a drag anchored in any other one resolved to nothing. Triple-click still worked because its span is stored at mouse-down. Give each transcript a surface id (its entity id). Its reset clears only its own entries, entries are stamped with the surface they were painted in, and a drag resolves only against the surface it started in. Adds a two-pane regression test. * Keep forked panes' selections apart and prune closed panes' entries A side chat's forked history repeats its parent's entry ids, so both transcripts paint the same selection keys and a selection in one washed the other too. The selection now records the surface it began on; the wash and drag resolution use that instead of a separate thread-local. A surface that stops painting (closed side chat or subagent tab) never resets again, so its registry entries lived for the rest of the session and every frame's reset walked them. Drop a surface's entries once a full cycle of another surface's resets passes without it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Wing Co-authored-by: Claude Opus 5.5 --- crates/ui/src/markdown/render.rs | 235 ++++++++++++++++++++++++++-- crates/ui/src/markdown/selection.rs | 35 ++++- crates/ui/src/transcript.rs | 12 +- 3 files changed, 257 insertions(+), 25 deletions(-) diff --git a/crates/ui/src/markdown/render.rs b/crates/ui/src/markdown/render.rs index f92e30bc9a..7faa45f52e 100644 --- a/crates/ui/src/markdown/render.rs +++ b/crates/ui/src/markdown/render.rs @@ -8,7 +8,7 @@ //! chunk opacity veil over the text runs (see [`super::veil`]) — opacity only, //! zero translate, applied after layout-relevant properties are fixed. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::ops::Range; use std::path::PathBuf; @@ -1213,6 +1213,7 @@ pub(super) fn flat_text_presented_element( let underlay = canvas( |bounds, window, _| window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal), move |_, hitbox, window, _| { + let surface = PAINTING_SURFACE.with(Cell::get); for range in &code_ranges { for rect in range_rects(&layout, range, INLINE_CODE_PAD_X, INLINE_CODE_INSET_Y) { window.paint_quad(quad( @@ -1225,7 +1226,7 @@ pub(super) fn flat_text_presented_element( )); } } - if let Some(range) = super::selection::wash_range(&sel_key) { + if let Some(range) = super::selection::wash_range(surface, &sel_key) { let range = offsets .as_ref() .map_or_else(|| range.clone(), |map| map.displayed_range(range.clone())); @@ -1245,6 +1246,7 @@ pub(super) fn flat_text_presented_element( // mouse listeners. REGISTRY.with(|r| { r.borrow_mut().push(RegEntry { + surface, key: sel_key.clone(), text: flat_text.clone(), layout: layout.clone(), @@ -1255,6 +1257,7 @@ pub(super) fn flat_text_presented_element( window, hitbox, &sel_key, + surface, &flat_text, &layout, offsets.clone(), @@ -1354,7 +1357,8 @@ fn paint_text_selection_with_wash( layout: &gpui::TextLayout, wash: Hsla, ) { - if let Some(range) = super::selection::wash_range(key) { + let surface = PAINTING_SURFACE.with(Cell::get); + if let Some(range) = super::selection::wash_range(surface, key) { for rect in range_rects(layout, &range, 0.0, 0.0) { window.paint_quad(quad( rect, @@ -1368,13 +1372,14 @@ fn paint_text_selection_with_wash( } REGISTRY.with(|r| { r.borrow_mut().push(RegEntry { + surface, key: key.clone(), text: text.clone(), layout: layout.clone(), offsets: None, }) }); - register_selection_listeners(window, hitbox, key, text, layout, None); + register_selection_listeners(window, hitbox, key, surface, text, layout, None); } /// The wrapping div shared by every selectable text region: markdown @@ -1418,6 +1423,9 @@ fn code_line_selection_key(row_key: &str, code_ix: usize, line_ix: usize) -> std /// continuity model that lets a drag span paragraphs/list items (Zed gets /// this for free from its single-element markdown; our tree rebuilds it). struct RegEntry { + /// Which painted transcript this element belongs to (see + /// `selection_frame_reset_for`); 0 for surfaces that never share a frame. + surface: u64, key: std::sync::Arc, text: SharedString, layout: gpui::TextLayout, @@ -1426,6 +1434,11 @@ struct RegEntry { thread_local! { static REGISTRY: RefCell> = const { RefCell::new(Vec::new()) }; + /// The surface currently painting: set by its reset canvas, which paints + /// before any of its text, and stamped onto each entry it registers. + static PAINTING_SURFACE: Cell = const { Cell::new(0) }; + /// Surfaces whose reset painted since the latest one's previous reset. + static PAINTED_SURFACES: RefCell> = const { RefCell::new(Vec::new()) }; } #[cfg(test)] @@ -1466,13 +1479,38 @@ pub(super) fn selection_test_snapshot( /// the transcript root (before any markdown), so each frame's registry holds /// exactly that frame's visible text elements in paint order. pub fn selection_frame_reset() -> impl IntoElement { + selection_frame_reset_for(0) +} + +/// `selection_frame_reset` for one of several surfaces painted in the same +/// frame (the main chat beside a side chat or subagent tab). It clears only +/// its own surface's entries and stamps the text painted after it, so +/// another transcript's reset can't wipe this one's drag anchor. +/// +/// A surface that stops painting (closed side chat or subagent tab) never +/// resets again, so its entries are dropped once a full cycle of this +/// surface's resets passes without it. +pub fn selection_frame_reset_for(surface: u64) -> impl IntoElement { canvas( |_, _, _| (), - |_, _, _, _| { - REGISTRY.with(|r| { - r.borrow_mut() - .retain(|e| !selection_scope(&e.key).is_empty()) - }) + move |_, _, _, _| { + PAINTING_SURFACE.with(|s| s.set(surface)); + PAINTED_SURFACES.with(|painted| { + let mut painted = painted.borrow_mut(); + // Seeing this surface again closes a cycle: `painted` now + // holds every surface still on screen. + let cycle = painted.contains(&surface); + REGISTRY.with(|r| { + r.borrow_mut().retain(|e| { + !selection_scope(&e.key).is_empty() + || (e.surface != surface && (!cycle || painted.contains(&e.surface))) + }) + }); + if cycle { + painted.clear(); + } + painted.push(surface); + }); }, ) .absolute() @@ -1517,9 +1555,14 @@ fn registry_point(position: gpui::Point) -> Option<(usize, usize)> REGISTRY.with(|r| { let reg = r.borrow(); let anchor = super::selection::anchor_key().unwrap_or_default(); + // A drag only resolves against its own surface, so a second + // transcript on screen can't capture it. + let anchor_surface = super::selection::anchor_surface(); let mut best: Option<(usize, f32, f32)> = None; for (ei, entry) in reg.iter().enumerate() { - if selection_scope(&entry.key) != selection_scope(&anchor) { + if selection_scope(&entry.key) != selection_scope(&anchor) + || entry.surface != anchor_surface + { continue; } let b = entry.layout.bounds(); @@ -1568,7 +1611,7 @@ fn resolve_drag(head: (usize, usize)) -> bool { let filtered: Vec<_> = reg .iter() .enumerate() - .filter(|(_, e)| selection_scope(&e.key) == scope) + .filter(|(_, e)| selection_scope(&e.key) == scope && e.surface == entry.surface) .collect(); let Some(index) = filtered.iter().position(|(ix, _)| *ix == head.0) else { return false; @@ -1598,6 +1641,7 @@ fn register_selection_listeners( window: &mut Window, hitbox: gpui::Hitbox, key: &std::sync::Arc, + surface: u64, text: &SharedString, layout: &gpui::TextLayout, offsets: Option, @@ -1620,12 +1664,12 @@ fn register_selection_listeners( match e.click_count { 2 => { let range = super::selection::word_range(&text, ix); - super::selection::begin_with_span(&key, &text, range); + super::selection::begin_with_span_in(surface, &key, &text, range); } n if n >= 3 => { - super::selection::begin_with_span(&key, &text, 0..text.len()); + super::selection::begin_with_span_in(surface, &key, &text, 0..text.len()); } - _ => super::selection::begin(&key, ix), + _ => super::selection::begin_in(surface, &key, ix), } window.refresh(); } else if super::selection::clear_if_owner(&key) { @@ -2800,6 +2844,169 @@ mod tests { } } + /// Two transcripts painted side by side (main chat + side chat or subagent + /// tab), each opening with its own `selection_frame_reset()`. A side + /// chat's forked history repeats its parent's row keys: `prefixes` equal. + struct TwoPaneSelectionHarness { + prefixes: [&'static str; 2], + second_pane: bool, + } + + impl TwoPaneSelectionHarness { + fn new(prefixes: [&'static str; 2]) -> Self { + Self { + prefixes, + second_pane: true, + } + } + } + + impl Render for TwoPaneSelectionHarness { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::of(cx).clone(); + let pane = |prefix: &str, surface: u64| { + let opts = RenderOptions::settled(prefix.to_string().into()); + div() + .w(px(320.0)) + .h_full() + .child(selection_frame_reset_for(surface)) + .child(text_element( + &[InlineRun { + text: "alpha beta gamma delta".into(), + style: InlineStyle::default(), + }], + MD_TEXT_SIZE, + MD_LINE_HEIGHT, + false, + 0, + 0, + &opts, + &theme, + )) + }; + div() + .size_full() + .flex() + .child(pane(self.prefixes[0], 1)) + .when(self.second_pane, |el| el.child(pane(self.prefixes[1], 2))) + } + } + + #[gpui::test] + fn dragging_selects_text_in_every_pane_of_a_two_transcript_layout(cx: &mut TestAppContext) { + let _selection = super::super::selection::test_state_lock(); + cx.update(|cx| cx.set_global(Theme::dark())); + let (_, cx) = cx.add_window_view(|_, _| TwoPaneSelectionHarness::new(["pane-a", "pane-b"])); + cx.simulate_resize(size(px(640.0), px(240.0))); + cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + + // Each pane is 320px wide; drag across the start of its text. The + // last-painted pane is the control: it always worked. + for (name, left) in [("pane-b", 320.0), ("pane-a", 0.0)] { + let start = point(px(left + 1.0), px(9.0)); + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: start, + click_count: 1, + ..Default::default() + }); + cx.simulate_event(gpui::MouseMoveEvent { + position: start + point(px(60.0), px(0.0)), + pressed_button: Some(gpui::MouseButton::Left), + ..Default::default() + }); + let selected = super::super::selection::selected_text(); + super::super::selection::end_active_drag(); + super::super::selection::clear_if_owner(&format!("{name}:0")); + assert!( + selected.is_some_and(|text| !text.is_empty()), + "a drag in {name} must select text" + ); + } + } + + #[gpui::test] + fn a_forked_pane_sharing_keys_keeps_its_selection_to_itself(cx: &mut TestAppContext) { + use super::super::selection; + let _selection = selection::test_state_lock(); + cx.update(|cx| cx.set_global(Theme::dark())); + let (_, cx) = cx.add_window_view(|_, _| TwoPaneSelectionHarness::new(["fork", "fork"])); + cx.simulate_resize(size(px(640.0), px(240.0))); + cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + + // Both panes paint `fork:0`. Whichever pane the drag starts in, it + // must select there (the other pane's same-key listener must not + // clear it) and wash only there. + for (surface, other, left) in [(1, 2, 0.0), (2, 1, 320.0)] { + let start = point(px(left + 1.0), px(9.0)); + let end = start + point(px(60.0), px(0.0)); + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: start, + click_count: 1, + ..Default::default() + }); + cx.simulate_event(gpui::MouseMoveEvent { + position: end, + pressed_button: Some(gpui::MouseButton::Left), + ..Default::default() + }); + cx.simulate_event(gpui::MouseUpEvent { + button: gpui::MouseButton::Left, + position: end, + ..Default::default() + }); + let selected = selection::selected_text(); + let washed = selection::wash_range(surface, "fork:0"); + let leaked = selection::wash_range(other, "fork:0"); + selection::clear_if_owner("fork:0"); + let selected = selected.unwrap_or_default(); + assert!( + "alpha beta gamma delta".starts_with(&selected) && !selected.is_empty(), + "a drag in surface {surface} copies its own text, got {selected:?}" + ); + assert_eq!(washed, Some(0..selected.len()), "surface {surface} washes"); + assert_eq!(leaked, None, "surface {other} stays unwashed"); + } + } + + #[gpui::test] + fn a_closed_pane_leaves_no_registry_entries_behind(cx: &mut TestAppContext) { + let _selection = super::super::selection::test_state_lock(); + cx.update(|cx| cx.set_global(Theme::dark())); + let (view, cx) = + cx.add_window_view(|_, _| TwoPaneSelectionHarness::new(["pane-a", "pane-b"])); + cx.simulate_resize(size(px(640.0), px(240.0))); + let entries = |surface: u64| { + REGISTRY.with(|r| r.borrow().iter().filter(|e| e.surface == surface).count()) + }; + cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + assert!(entries(2) > 0); + + // The side chat closes: its reset never paints again. + view.update(cx, |harness, cx| { + harness.second_pane = false; + cx.notify(); + }); + for _ in 0..2 { + cx.update(|window, cx| { + window.refresh(); + let _ = window.draw(cx); + }); + } + assert_eq!(entries(2), 0, "the closed pane's text is dropped"); + assert!(entries(1) > 0, "the open pane keeps its text"); + } + #[gpui::test] fn code_block_lines_participate_in_text_selection(cx: &mut TestAppContext) { let _selection = super::super::selection::test_state_lock(); diff --git a/crates/ui/src/markdown/selection.rs b/crates/ui/src/markdown/selection.rs index 74639c573e..3bf2743d49 100644 --- a/crates/ui/src/markdown/selection.rs +++ b/crates/ui/src/markdown/selection.rs @@ -30,6 +30,9 @@ pub struct Span { #[derive(Clone, Default)] struct MdSelection { + /// Painted surface (transcript) the selection lives in. Keys are only + /// unique within one: a side chat's forked history repeats its parent's. + surface: u64, /// Element that owns the drag (where the mouse went down). anchor_key: String, /// Byte offset of the anchor within its element. @@ -87,7 +90,13 @@ pub fn resolve_spans(elements: &[(&str, &str)], a: (usize, usize), b: (usize, us /// Begin a drag anchored at `(key, ix)`; claims the global selection. pub fn begin(key: &str, ix: usize) { + begin_in(0, key, ix); +} + +/// `begin` on one of several surfaces painted in the same frame. +pub(crate) fn begin_in(surface: u64, key: &str, ix: usize) { *state().lock().unwrap() = Some(MdSelection { + surface, anchor_key: key.to_string(), anchor_ix: ix, dragging: true, @@ -98,7 +107,13 @@ pub fn begin(key: &str, ix: usize) { /// Begin with an immediate span (double/triple click inside one element). pub fn begin_with_span(key: &str, text: &str, range: Range) { + begin_with_span_in(0, key, text, range); +} + +/// `begin_with_span` on one of several surfaces painted in the same frame. +pub(crate) fn begin_with_span_in(surface: u64, key: &str, text: &str, range: Range) { *state().lock().unwrap() = Some(MdSelection { + surface, anchor_key: key.to_string(), anchor_ix: range.start, dragging: true, @@ -126,6 +141,11 @@ pub(crate) fn anchor_key() -> Option { .map(|s| s.anchor_key.clone()) } +/// The surface the selection lives in (0 when there is none). +pub(crate) fn anchor_surface() -> u64 { + state().lock().unwrap().as_ref().map_or(0, |s| s.surface) +} + /// Whether a markdown selection drag is currently in flight. pub fn is_dragging() -> bool { state() @@ -268,10 +288,11 @@ pub fn clear_if_owner(key: &str) -> bool { false } -/// The wash range for `key` this frame (empty ⇒ nothing to paint). -pub fn wash_range(key: &str) -> Option> { +/// The wash range for `key` painted on `surface` this frame (empty ⇒ nothing +/// to paint). Another surface showing the same key stays unwashed. +pub fn wash_range(surface: u64, key: &str) -> Option> { let guard = state().lock().unwrap(); - let sel = guard.as_ref()?; + let sel = guard.as_ref().filter(|sel| sel.surface == surface)?; sel.spans .iter() .find(|s| s.key == key && !s.range.is_empty()) @@ -381,9 +402,9 @@ mod tests { let spans = resolve_spans(&elems(), (0, 6), (1, 6)); assert!(update_spans(spans.clone())); assert!(!update_spans(spans)); // unchanged ⇒ no repaint - assert_eq!(wash_range("p1"), Some(6..15)); - assert_eq!(wash_range("p2"), Some(0..6)); - assert_eq!(wash_range("p3"), None); + assert_eq!(wash_range(0, "p1"), Some(6..15)); + assert_eq!(wash_range(0, "p2"), Some(0..6)); + assert_eq!(wash_range(0, "p3"), None); assert_eq!(end_drag("p1").as_deref(), Some("paragraph\nsecond")); assert_eq!(selected_text().as_deref(), Some("paragraph\nsecond")); // Settled: a down elsewhere clears via the owner's listener. @@ -434,7 +455,7 @@ mod tests { fn double_click_span() { let _state = test_state_lock(); begin_with_span("p1", "hello world", 6..11); - assert_eq!(wash_range("p1"), Some(6..11)); + assert_eq!(wash_range(0, "p1"), Some(6..11)); assert_eq!(end_drag("p1").as_deref(), Some("world")); } diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index ef584e9b8e..be8d082944 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -9025,10 +9025,14 @@ impl Render for Transcript { .on_mouse_move(cx.listener(Self::on_selection_mouse_move)) .on_mouse_up(MouseButton::Left, cx.listener(Self::on_selection_mouse_up)) .on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_selection_mouse_up)) - // FIRST child ⇒ paints first: clears the frame's markdown text- - // selection registry before any row's text elements re-register - // (document paint order = selection order; see markdown/render.rs). - .child(crate::markdown::render::selection_frame_reset()) + // FIRST child ⇒ paints first: clears this transcript's slice of the + // frame's markdown text-selection registry before any row's text + // elements re-register (document paint order = selection order; + // see markdown/render.rs). Keyed by entity so a side chat or + // subagent tab painted in the same frame can't wipe it. + .child(crate::markdown::render::selection_frame_reset_for( + cx.entity_id().as_u64(), + )) .child(content) .child(rail); // Full-size viewer for a clicked user-bubble thumbnail From a2a593a5d51110db2f1bd3302235451662ee8c1a Mon Sep 17 00:00:00 2001 From: Wing Date: Wed, 30 Sep 2026 16:46:25 -0700 Subject: [PATCH 12/74] Bump version to v0.2.100 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 34 +++++++++++++++++----------------- Cargo.toml | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d2ae3fd6d..ebe7663a14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12101,7 +12101,7 @@ dependencies = [ [[package]] name = "zeron" -version = "0.2.99" +version = "0.2.100" dependencies = [ "anyhow", "clap", @@ -12123,7 +12123,7 @@ dependencies = [ [[package]] name = "zeron-client" -version = "0.2.99" +version = "0.2.100" dependencies = [ "async-trait", "chrono", @@ -12147,7 +12147,7 @@ dependencies = [ [[package]] name = "zeron-doc" -version = "0.2.99" +version = "0.2.100" dependencies = [ "chrono", "loro", @@ -12161,7 +12161,7 @@ dependencies = [ [[package]] name = "zeron-engine" -version = "0.2.99" +version = "0.2.100" dependencies = [ "anyhow", "async-trait", @@ -12201,7 +12201,7 @@ dependencies = [ [[package]] name = "zeron-harness" -version = "0.2.99" +version = "0.2.100" dependencies = [ "anyhow", "async-trait", @@ -12228,14 +12228,14 @@ dependencies = [ [[package]] name = "zeron-markdown" -version = "0.2.99" +version = "0.2.100" dependencies = [ "pulldown-cmark", ] [[package]] name = "zeron-mcp" -version = "0.2.99" +version = "0.2.100" dependencies = [ "anyhow", "async-trait", @@ -12253,7 +12253,7 @@ dependencies = [ [[package]] name = "zeron-mobile" -version = "0.2.99" +version = "0.2.100" dependencies = [ "serde", "serde_json", @@ -12270,7 +12270,7 @@ dependencies = [ [[package]] name = "zeron-preview" -version = "0.2.99" +version = "0.2.100" dependencies = [ "anyhow", "async-trait", @@ -12298,7 +12298,7 @@ dependencies = [ [[package]] name = "zeron-proto" -version = "0.2.99" +version = "0.2.100" dependencies = [ "chrono", "pulldown-cmark", @@ -12309,7 +12309,7 @@ dependencies = [ [[package]] name = "zeron-rpc" -version = "0.2.99" +version = "0.2.100" dependencies = [ "anyhow", "async-trait", @@ -12328,7 +12328,7 @@ dependencies = [ [[package]] name = "zeron-sync" -version = "0.2.99" +version = "0.2.100" dependencies = [ "block2 0.6.2", "chrono", @@ -12351,7 +12351,7 @@ dependencies = [ [[package]] name = "zeron-syntax" -version = "0.2.99" +version = "0.2.100" dependencies = [ "thiserror 2.0.20", "tree-sitter", @@ -12385,7 +12385,7 @@ dependencies = [ [[package]] name = "zeron-text" -version = "0.2.99" +version = "0.2.100" dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", @@ -12402,7 +12402,7 @@ dependencies = [ [[package]] name = "zeron-theme" -version = "0.2.99" +version = "0.2.100" dependencies = [ "anyhow", "clap", @@ -12417,7 +12417,7 @@ dependencies = [ [[package]] name = "zeron-ui" -version = "0.2.99" +version = "0.2.100" dependencies = [ "alacritty_terminal", "anyhow", @@ -12484,7 +12484,7 @@ dependencies = [ [[package]] name = "zeron-update" -version = "0.2.99" +version = "0.2.100" dependencies = [ "anyhow", "futures", diff --git a/Cargo.toml b/Cargo.toml index f71c5fbc6a..9f66383825 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ members = [ ] [workspace.package] -version = "0.2.99" +version = "0.2.100" edition = "2024" license = "MIT" publish = false From c168ba5b4589352877ad5450168860a1ad308a26 Mon Sep 17 00:00:00 2001 From: Daniel Katulevskiy Date: Wed, 30 Sep 2026 17:12:22 -0700 Subject: [PATCH 13/74] windows: run the crate suites as two parallel jobs --- .github/workflows/windows.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 33e5c69b45..24fc44a313 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -63,7 +63,9 @@ jobs: $headlessResult = $LASTEXITCODE if ($nativeResult -ne 0 -or $headlessResult -ne 0) { exit 1 } - crate-tests: + # The crate suites compile different package sets, so the two jobs below each + # build what they need in parallel instead of one job building them in turn. + harness-app-tests: runs-on: windows-2022 timeout-minutes: 45 steps: @@ -75,6 +77,13 @@ jobs: run: cargo test --release --locked -p gpui_windows --lib layout - name: Application and updater tests run: cargo test --release --locked -p zeron -p zeron-update + + engine-tests: + runs-on: windows-2022 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/windows-ci-setup - name: Engine unit tests run: cargo test --release --locked -p zeron-engine --lib - name: Harness catalog and login tests From fd3b2c0662901dde913fdaf5031008f5afb9f77e Mon Sep 17 00:00:00 2001 From: Sina Khalili <20732540+SinaKhalili@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:35:51 -0700 Subject: [PATCH 14/74] footer --- apps/landing/public/index.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/landing/public/index.html b/apps/landing/public/index.html index e0243c68e7..8e5edeb7df 100644 --- a/apps/landing/public/index.html +++ b/apps/landing/public/index.html @@ -808,6 +808,7 @@ } .footer-inner .right { margin-left: auto; display: flex; align-items: center; gap: 20px; } .footer-inner .gh-icon { padding: 0; } + .footer-company { flex-basis: 100%; font-size: 11px; } footer a:hover { color: var(--dim); } /* ── mobile ──────────────────────────────────────────── */ @@ -1207,6 +1208,7 @@

Bring your agents together.

MIT © 2026 + 1499487 B.C. LTD. From 9cb1de50f580abe6a081c11c9e8921319aebd783 Mon Sep 17 00:00:00 2001 From: Daniel Katulevskiy Date: Wed, 30 Sep 2026 17:37:33 -0700 Subject: [PATCH 15/74] CI: add Linux test helpers (setup action, nextest, apt with retries) A composite action installs the toolchain, a checksum-pinned cargo-nextest and apt packages (bounded retries, cached .debs), and wires the main-seeded Rust cache. scripts/ci/test-core.sh runs the harness, engine, sync, update, doc and preview tests as one build and one nextest run. nextest.toml retries the two tests that are timing-sensitive under nextest. Not used by any workflow yet. --- .github/actions/linux-ci/action.yml | 78 +++++++++++++++++++++++++++++ scripts/ci/apt-install.sh | 41 +++++++++++++++ scripts/ci/install-nextest.sh | 17 +++++++ scripts/ci/nextest.toml | 24 +++++++++ scripts/ci/test-core.sh | 33 ++++++++++++ 5 files changed, 193 insertions(+) create mode 100644 .github/actions/linux-ci/action.yml create mode 100755 scripts/ci/apt-install.sh create mode 100755 scripts/ci/install-nextest.sh create mode 100644 scripts/ci/nextest.toml create mode 100755 scripts/ci/test-core.sh diff --git a/.github/actions/linux-ci/action.yml b/.github/actions/linux-ci/action.yml new file mode 100644 index 0000000000..59d0b9f7ed --- /dev/null +++ b/.github/actions/linux-ci/action.yml @@ -0,0 +1,78 @@ +name: Linux Rust CI setup +description: >- + Rust toolchain, cargo-nextest, optional apt packages (with a cached .deb + directory) and the main-seeded Rust cache for Linux test jobs. Run + actions/checkout first. + +inputs: + rust-cache: + description: >- + rust-cache shared key. Jobs with the same key share one cache, so they + must have the same dependency graph. + required: true + save-rust-cache: + description: >- + Whether this job may save the cache when it runs on main. Give it to + exactly one job per key. Pull requests never save. + default: 'false' + apt-packages: + description: apt packages to install (space separated). Nothing is installed when empty. + default: '' + apt-cache: + description: Name of the cached .deb directory for apt-packages (one per package list). + default: '' + apt-recommends: + description: Install recommended packages too ('true' or 'false'). + default: 'false' + +runs: + using: composite + steps: + - name: Rust toolchain + shell: bash + run: rustup show active-toolchain || rustup toolchain install + + # apt mirrors on hosted runners occasionally stall for minutes; apt-install.sh + # bounds and retries the download and uses a directory of .debs that main + # saves to the cache (pull requests only restore it). + - name: Restore apt packages + id: apt-cache + if: inputs.apt-packages != '' + uses: actions/cache/restore@v4 + with: + path: ~/apt-debs/*.deb + key: apt-debs-v1-${{ inputs.apt-cache }}-${{ hashFiles('scripts/ci/apt-install.sh') }} + - name: Install system packages + if: inputs.apt-packages != '' + shell: bash + env: + APT_RECOMMENDS: ${{ inputs.apt-recommends == 'true' && '1' || '0' }} + APT_PACKAGES: ${{ inputs.apt-packages }} + run: | + # shellcheck disable=SC2086 # the package list is word-split on purpose + bash scripts/ci/apt-install.sh $APT_PACKAGES + - name: Save apt packages + if: inputs.apt-packages != '' && github.ref == 'refs/heads/main' && steps.apt-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/apt-debs/*.deb + key: apt-debs-v1-${{ inputs.apt-cache }}-${{ hashFiles('scripts/ci/apt-install.sh') }} + + - name: Install cargo-nextest + shell: bash + run: bash scripts/ci/install-nextest.sh + + # Exported for the rest of the job. Tests and the browser fixture are built in + # the dev profile without debug info (smaller, faster to link). CARGO_* variables + # are part of the rust-cache key, so every job sharing a cache must set the same. + - name: Build settings + shell: bash + run: echo "CARGO_PROFILE_DEV_DEBUG=0" >> "$GITHUB_ENV" + + - uses: Swatinem/rust-cache@v2 + with: + prefix-key: ci-linux-v1 + shared-key: ${{ inputs.rust-cache }} + add-job-id-key: false + save-if: ${{ inputs.save-rust-cache == 'true' && github.ref == 'refs/heads/main' }} + cache-on-failure: true diff --git a/scripts/ci/apt-install.sh b/scripts/ci/apt-install.sh new file mode 100755 index 0000000000..bf7254127f --- /dev/null +++ b/scripts/ci/apt-install.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Install apt packages from a user-owned, cacheable .deb directory with +# stall-resistant retries and per-phase timestamps. +# usage: APT_DEB_DIR=~/apt-debs [APT_RECOMMENDS=1] apt-install.sh pkg... +# Hosted-runner apt mirrors occasionally stall for minutes (observed 7-14 min +# for a ~400 MB install); a bounded download retry + actions/cache of the .debs +# makes the common case ~20 s and the bad case bounded. +set -uo pipefail +dir=${APT_DEB_DIR:-$HOME/apt-debs} +mkdir -p "$dir/partial" +export DEBIAN_FRONTEND=noninteractive +ts() { while IFS= read -r l; do printf '[%(%T)T] %s\n' -1 "$l"; done; } +log() { printf '[%(%T)T] apt-install: %s\n' -1 "$*"; } + +# No docs/man/locales, no fsync: faster unpack. +printf '%s\n' force-unsafe-io 'path-exclude=/usr/share/doc/*' 'path-exclude=/usr/share/man/*' \ + 'path-exclude=/usr/share/locale/*' 'path-exclude=/usr/share/info/*' | sudo tee /etc/dpkg/dpkg.cfg.d/90ci >/dev/null +sudo rm -f /var/lib/man-db/auto-update + +APT=(-y -o Acquire::Retries=3 -o Acquire::http::Timeout=15 -o Acquire::Languages=none + -o "Dir::Cache::archives=$dir" -o APT::Sandbox::User=root -o DPkg::Lock::Timeout=120) +[ "${APT_RECOMMENDS:-0}" = 1 ] || APT+=(--no-install-recommends) + +log "cached debs: $(ls "$dir"/*.deb 2>/dev/null | wc -l)" +log "update" +# Only Ubuntu packages are needed; unrelated runner repositories can be out of sync. +sudo timeout 120 apt-get update -qq -o Acquire::Retries=3 -o Acquire::http::Timeout=15 \ + -o Dir::Etc::sourcelist="sources.list.d/ubuntu.sources" -o Dir::Etc::sourceparts="-" 2>&1 | ts + +ok=0 +for attempt in 1 2 3 4; do + log "download attempt $attempt" + sudo timeout 150 apt-get install "${APT[@]}" --download-only "$@" 2>&1 | ts + if [ "${PIPESTATUS[0]}" = 0 ]; then ok=1; break; fi +done +[ "$ok" = 1 ] || { log "download failed"; exit 1; } +log "install" +sudo apt-get install "${APT[@]}" -qq "$@" 2>&1 | ts +rc=${PIPESTATUS[0]} +log "done rc=$rc" +exit "$rc" diff --git a/scripts/ci/install-nextest.sh b/scripts/ci/install-nextest.sh new file mode 100755 index 0000000000..3c4d912058 --- /dev/null +++ b/scripts/ci/install-nextest.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Install a pinned cargo-nextest release (x86_64 Linux) after verifying its +# SHA-256, instead of trusting a third-party install action. To bump: change +# VERSION and SHA256 (from the release's .tar.gz asset). +set -euo pipefail +VERSION=0.9.146 +SHA256=682c21b777c333e96fd532e114d3a5a894e0729ab88d94c0a9f20f8419695428 + +[ "$(uname -m)" = x86_64 ] || { echo "install-nextest.sh: x86_64 only" >&2; exit 1; } +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +curl --proto '=https' --tlsv1.2 -fsSL --retry 5 -o "$tmp/nextest.tar.gz" \ + "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-$VERSION/cargo-nextest-$VERSION-x86_64-unknown-linux-gnu.tar.gz" +echo "$SHA256 $tmp/nextest.tar.gz" | sha256sum -c - +mkdir -p "$HOME/.cargo/bin" +tar -xzf "$tmp/nextest.tar.gz" -C "$HOME/.cargo/bin" cargo-nextest +cargo nextest --version diff --git a/scripts/ci/nextest.toml b/scripts/ci/nextest.toml new file mode 100644 index 0000000000..7666a4adad --- /dev/null +++ b/scripts/ci/nextest.toml @@ -0,0 +1,24 @@ +# cargo-nextest settings for CI (pass with --config-file). +# +# Two tests are timing-sensitive and fail now and then when nextest runs many tests +# at once (the old `cargo test` runs were serial per binary, and the UI run was +# `--test-threads=1` in the release profile). nextest reports a pass-on-retry as +# FLAKY, so these stay visible in the log. Remove an override once its test is fixed. + +# zeron-ui `state::tests::bootstrap_reports_local_assembly_failure_before_returning_a_handle` +# picks a "free" port (bind(:0) + drop), makes an engine bootstrap fail, then asserts +# nothing listens on that port ("failed bootstrap must release the IPC listener"). +# In the dev profile it fails on the first attempt in roughly one run in three and +# passes on the retry, even when it runs alone: a race in listener teardown or port +# reuse. +[[profile.default.overrides]] +filter = 'package(zeron-ui) & test(bootstrap_reports_local_assembly_failure_before_returning_a_handle)' +retries = 2 + +# zeron-harness `opencode::mcp_injection_tests::mcp_injection_reaches_isolated_server_processes` +# starts `node` fixture scripts and `binary_version` gives each `--version` probe 2 s; +# on a loaded 4-core runner the probe occasionally times out ("cannot determine +# opencode version for MCP configuration"). +[[profile.default.overrides]] +filter = 'package(zeron-harness) & test(mcp_injection_reaches_isolated_server_processes)' +retries = 2 diff --git a/scripts/ci/test-core.sh b/scripts/ci/test-core.sh new file mode 100755 index 0000000000..539b1a09ab --- /dev/null +++ b/scripts/ci/test-core.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# The Linux Rust tests that used to run as separate `cargo test` invocations in +# session-sync-regressions, cursor-compatibility and preview-tests, as ONE +# build and ONE nextest run (separate invocations recompiled the same +# dependency graph with different feature sets). +# +# Same coverage as the old invocations: +# zeron-harness every test binary (cursor-compatibility ran `-p zeron-harness` +# plus `--test pi_rpc` with native-fixture; native-fixture only +# adds the pi_rpc fixture binaries, so it is on for the whole build) +# zeron-preview every test binary (preview-tests) +# zeron-doc lib + attachments_roundtrip (cursor-compatibility) +# zeron-sync, zeron-update lib only (session-sync-regressions) +# zeron-engine lib + the integration tests named below. Engine has many other +# integration binaries (live agents etc.) that no workflow ran, so +# they are listed instead of globbed to avoid compiling them. +# nextest does not run doctests; these crates have none (their doc comments +# contain no Rust code blocks). +set -euo pipefail +cd "$(dirname "$0")/../.." + +tests=() +for f in crates/harness/tests/*.rs crates/preview/tests/*.rs; do + tests+=(--test "$(basename "$f" .rs)") +done +for t in session_publication restart_resume codex_subagents local_profiles message_queue pi_resume attachments_roundtrip; do + tests+=(--test "$t") +done + +exec cargo nextest run --config-file scripts/ci/nextest.toml --locked --no-fail-fast \ + -p zeron-harness -p zeron-engine -p zeron-sync -p zeron-update -p zeron-doc -p zeron-preview \ + --features zeron-harness/native-fixture \ + --lib --bins "${tests[@]}" From 878307a3d685a216e4100324be939a75c03eef65 Mon Sep 17 00:00:00 2001 From: Daniel Katulevskiy Date: Wed, 30 Sep 2026 17:37:33 -0700 Subject: [PATCH 16/74] CI: run Linux tests with nextest on a dev profile and one shared build - ui-tests: session-sync-regressions becomes core-tests (one build for the harness, engine, sync, update, doc and preview tests); ui-tests and linux-browser use nextest, the dev profile and a shared rust-cache - cursor-compatibility: the Rust half runs from core-tests on PRs and main; it only runs here for release, cursor-sdk-update dispatch and manual runs - preview-tests: the Linux leg moves into core-tests - linux-installer: install desktop-file-utils with bounded apt retries --- .github/workflows/cursor-compatibility.yml | 33 +++++---- .github/workflows/linux-installer.yml | 8 ++- .github/workflows/preview-tests.yml | 4 +- .github/workflows/ui-tests.yml | 84 ++++++++++------------ 4 files changed, 63 insertions(+), 66 deletions(-) diff --git a/.github/workflows/cursor-compatibility.yml b/.github/workflows/cursor-compatibility.yml index 329443b7c1..a42c7337ff 100644 --- a/.github/workflows/cursor-compatibility.yml +++ b/.github/workflows/cursor-compatibility.yml @@ -32,10 +32,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '22' - - uses: Swatinem/rust-cache@v2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - cache-on-failure: true - name: Dependency updater validation run: python3 scripts/test-update-cursor-sdk.py - name: Install the exact SDK selected by this engine @@ -44,14 +40,21 @@ jobs: npm install --prefix "$RUNNER_TEMP/cursor-sdk-contract" --ignore-scripts --no-audit --no-fund "$pin" - name: Actual SDK token expiry, stream invalidation, and transient auth recovery run: node scripts/test-cursor-sdk-auth.mjs "$RUNNER_TEMP/cursor-sdk-contract/node_modules/@cursor/sdk" - - name: Harness compatibility, steering, and session recovery - run: cargo test --locked -p zeron-harness - - name: Durable commands and engine message delivery - run: | - cargo test --locked -p zeron-doc - cargo test --locked -p zeron-engine --test message_queue - cargo test --locked -p zeron-engine --lib sessions::tests - - name: Native Pi RPC driver - run: | - cargo test --locked -p zeron-harness --features native-fixture --test pi_rpc - cargo test --locked -p zeron-engine --test pi_resume + + # The Rust half (harness compatibility, steering, session recovery, durable + # commands, message delivery, native Pi RPC) is part of the `core-tests` job + # of ui-tests.yml, which already runs on pull requests and on main with a + # superset of this workflow's paths. Run it here only for the callers that + # do not get that: release.yml (tag push), cursor-sdk-update.yml (manual + # dispatch on the update branch) and manual runs. + rust-tests: + if: ${{ github.event_name != 'pull_request' && !(github.event_name == 'push' && github.ref == 'refs/heads/main') }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/linux-ci + with: + rust-cache: core # saved by core-tests in ui-tests.yml + - name: Harness, engine, sync, update, doc and preview tests + run: scripts/ci/test-core.sh diff --git a/.github/workflows/linux-installer.yml b/.github/workflows/linux-installer.yml index 574f803f36..62c990069b 100644 --- a/.github/workflows/linux-installer.yml +++ b/.github/workflows/linux-installer.yml @@ -6,6 +6,7 @@ on: - 'edge/src/install.sh' - 'scripts/package-linux.sh' - 'scripts/test-linux-desktop-entry.sh' + - 'scripts/ci/apt-install.sh' - 'dist/zeron.desktop' - 'dist/zeron.png' - '.github/workflows/linux-installer.yml' @@ -15,6 +16,7 @@ on: - 'edge/src/install.sh' - 'scripts/package-linux.sh' - 'scripts/test-linux-desktop-entry.sh' + - 'scripts/ci/apt-install.sh' - 'dist/zeron.desktop' - 'dist/zeron.png' - '.github/workflows/linux-installer.yml' @@ -34,9 +36,9 @@ jobs: steps: - uses: actions/checkout@v4 - name: Desktop entry validator - run: | - sudo apt-get update -qq -o Dir::Etc::sourcelist="sources.list.d/ubuntu.sources" -o Dir::Etc::sourceparts="-" - sudo apt-get install -y -qq desktop-file-utils + # Skips apt when the runner image already has it; otherwise the install + # retries and is bounded instead of hanging on a stalled mirror. + run: command -v desktop-file-validate || bash scripts/ci/apt-install.sh desktop-file-utils # Both installers, offline, under a throwaway HOME (no network, no systemd). - name: Installers write a valid launcher entry run: scripts/test-linux-desktop-entry.sh diff --git a/.github/workflows/preview-tests.yml b/.github/workflows/preview-tests.yml index ff8b162392..d4115aef2a 100644 --- a/.github/workflows/preview-tests.yml +++ b/.github/workflows/preview-tests.yml @@ -11,10 +11,12 @@ concurrency: group: preview-tests-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # The Linux leg runs in the `core-tests` job of ui-tests.yml (same tests, one + # shared build with the rest of the Rust tests). networking: strategy: matrix: - os: [ubuntu-latest, macos-latest] + os: [macos-latest] runs-on: ${{ matrix.os }} timeout-minutes: 30 steps: diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 93a8226a23..29b9e30006 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -7,6 +7,8 @@ on: - 'Cargo.lock' - 'rust-toolchain.toml' - 'crates/**' + - '.github/actions/linux-ci/**' + - 'scripts/ci/**' - 'scripts/test-linux-browser.sh' - 'scripts/run-macos-browser-fixture.sh' - 'dist/macos/Info.plist' @@ -19,6 +21,8 @@ on: - 'Cargo.lock' - 'rust-toolchain.toml' - 'crates/**' + - '.github/actions/linux-ci/**' + - 'scripts/ci/**' - 'scripts/test-linux-browser.sh' - 'scripts/run-macos-browser-fixture.sh' - 'dist/macos/Info.plist' @@ -67,49 +71,38 @@ jobs: return files.some(file => affectsIOS(file.filename) || affectsIOS(file.previous_filename)); - session-sync-regressions: + # Every Linux Rust test that is not UI: one build, one nextest run (see + # scripts/ci/test-core.sh). Replaces session-sync-regressions and the Rust + # steps of cursor-compatibility and preview-tests. + core-tests: runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - - name: Rust toolchain - run: rustup show active-toolchain || rustup toolchain install - - uses: Swatinem/rust-cache@v2 + - uses: ./.github/actions/linux-ci with: - save-if: ${{ github.ref == 'refs/heads/main' }} - cache-on-failure: true - - name: OpenCode protocol and permission regressions - run: | - cargo test --locked -p zeron-harness --lib opencode:: - cargo test --locked -p zeron-harness --test opencode - - name: Durable session publication and recovery - run: | - cargo test --locked -p zeron-sync --lib - cargo test --locked -p zeron-update --lib - cargo test --locked -p zeron-engine --lib --test session_publication --test restart_resume --test codex_subagents --test local_profiles + rust-cache: core + save-rust-cache: 'true' + - name: Harness, engine, sync, update, doc and preview tests + run: scripts/ci/test-core.sh ui-tests: runs-on: ubuntu-24.04 timeout-minutes: 30 steps: - uses: actions/checkout@v4 - - name: GPUI system dependencies - run: | - # Only Ubuntu packages are needed; unrelated runner repositories can be out of sync. - sudo apt-get update -qq -o Dir::Etc::sourcelist="sources.list.d/ubuntu.sources" -o Dir::Etc::sourceparts="-" - sudo apt-get install -y -qq \ - libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \ - libx11-dev libxcb1-dev libx11-xcb-dev \ - libfontconfig1-dev libfreetype-dev libasound2-dev \ - libvulkan-dev pkg-config cmake libwebkit2gtk-4.1-dev libjson-glib-dev - - name: Rust toolchain - run: rustup show active-toolchain || rustup toolchain install - - uses: Swatinem/rust-cache@v2 + - uses: ./.github/actions/linux-ci with: - save-if: ${{ github.ref == 'refs/heads/main' }} - cache-on-failure: true + rust-cache: ui + save-rust-cache: 'true' + apt-cache: gpui + apt-packages: >- + libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev + libx11-dev libxcb1-dev libx11-xcb-dev + libfontconfig1-dev libfreetype-dev libasound2-dev + libvulkan-dev pkg-config cmake libwebkit2gtk-4.1-dev libjson-glib-dev - name: UI and headless layout regressions - run: cargo test --release --locked -p zeron-ui --lib -- --test-threads=1 + run: cargo nextest run --config-file scripts/ci/nextest.toml --locked --no-fail-fast -p zeron-ui --lib macos-frame-recovery: runs-on: macos-latest @@ -235,28 +228,25 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@v4 - - name: Native browser and GPUI dependencies - run: | - # Only Ubuntu packages are needed; unrelated runner repositories can be out of sync. - sudo apt-get update -qq -o Dir::Etc::sourcelist="sources.list.d/ubuntu.sources" -o Dir::Etc::sourceparts="-" - sudo apt-get install -y -qq \ - libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \ - libx11-dev libxcb1-dev libx11-xcb-dev libfontconfig1-dev \ - libfreetype-dev libasound2-dev libvulkan-dev pkg-config cmake \ - libwebkit2gtk-4.1-dev libjson-glib-dev xvfb xdotool openbox \ - weston ffmpeg imagemagick fonts-noto-cjk - - name: Rust toolchain - run: rustup show active-toolchain || rustup toolchain install - - uses: Swatinem/rust-cache@v2 + - uses: ./.github/actions/linux-ci with: - save-if: ${{ github.ref == 'refs/heads/main' }} - cache-on-failure: true + # Same dependency graph as ui-tests, which saves this cache on main. + rust-cache: ui + apt-cache: browser + apt-recommends: 'true' + apt-packages: >- + libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev + libx11-dev libxcb1-dev libx11-xcb-dev libfontconfig1-dev + libfreetype-dev libasound2-dev libvulkan-dev pkg-config cmake + libwebkit2gtk-4.1-dev libjson-glib-dev xvfb xdotool openbox + weston ffmpeg imagemagick fonts-noto-cjk - name: Build browser fixture - run: cargo build --release --locked -p zeron-ui --example browser-fixture --features browser-fixture + run: cargo build --locked -p zeron-ui --example browser-fixture --features browser-fixture - name: X11 and Wayland browser regressions timeout-minutes: 5 env: RUST_LOG: gpui_wgpu=info + BROWSER_FIXTURE_BINARY: target/debug/examples/browser-fixture run: | scripts/test-linux-browser.sh "$RUNNER_TEMP/linux-browser/default" VK_DRIVER_FILES=/dev/null scripts/test-linux-browser.sh "$RUNNER_TEMP/linux-browser/opengl" From c72c66d8adce8a594a7acd342432b33cfb348c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 1 Oct 2026 00:13:10 -0300 Subject: [PATCH 17/74] Add file tree context actions and drag-and-drop moves (#514) * feat(proto): define workspace entry mutation contracts * feat(engine): move workspace entries without replacement * feat(engine): delete workspace entries and publish mutations * feat(files): coordinate workspace mutations across open surfaces * feat(files): reuse context menus for copy path and add to chat * feat(files): rename entries inline and confirm deletion * feat(files): preserve workspace drag origin and enable Windows handles * feat(files): move tree entries with scoped drop targets * feat(files): polish tree drag feedback and navigation * test(files): cover workspace mutations and composer drag regressions * fix(files): style delete menu action as destructive * fix(files): reuse icons for all tree context menu actions * fix(files): show only entry name in delete confirmation * fix(files): give delete dialog more spacing and shorten action label * fix(files): refresh mutation revisions after completed saves * fix(files): refresh editor presentation and syntax after rename * fix(files): show rename caret and preserve text input focus * fix(files): copy full paths from the owning workspace * fix(engine): compare canonical workspace paths on Windows * Route chat drops through the shared dropzone with origin checks The branch replaced the main chat's chat_dropzone() call from #612 with an inline copy, so the main chat validated drag origins but side chats did not. Both now go through chat_dropzone(), which hands workspace payloads to attach_workspace_drag() for the target composer. The dropzone fixture now stamps tree/search payloads with a live explorer origin, and also checks that an origin-less (stale) payload attaches nowhere. Also correct the Windows drag note: tree rows drag on every platform; there is no dedicated drag handle. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Wing Co-authored-by: Claude Opus 5.5 --- crates/engine/src/rpc.rs | 22 + crates/engine/src/workspace_files.rs | 58 +- .../engine/src/workspace_files/mutations.rs | 735 ++++++++++++++++++ crates/engine/tests/device_routing.rs | 53 ++ crates/engine/tests/workspace_files.rs | 84 ++ crates/proto/src/entities.rs | 132 ++++ crates/rpc/src/lib.rs | 2 + crates/ui/src/files/client.rs | 55 +- crates/ui/src/files/context_menu.rs | 322 ++++++++ crates/ui/src/files/drag.rs | 490 ++++++++++++ crates/ui/src/files/mod.rs | 101 ++- crates/ui/src/files/model.rs | 120 +++ crates/ui/src/files/mutations.rs | 445 +++++++++++ crates/ui/src/files/preview.rs | 176 ++++- crates/ui/src/files/rename.rs | 544 +++++++++++++ crates/ui/src/files/search.rs | 6 +- crates/ui/src/files/test_support.rs | 70 ++ crates/ui/src/files/tree.rs | 112 ++- crates/ui/src/files/watch.rs | 159 +++- crates/ui/src/shell.rs | 88 ++- crates/ui/src/shell/chat_dropzone.rs | 22 +- crates/ui/src/shell/chat_dropzone_tests.rs | 71 +- crates/ui/src/shell/file_mutations.rs | 316 ++++++++ crates/ui/src/shell/files_panel.rs | 34 + docs/file-tree-interactions.md | 103 +++ docs/research/windows-click-drag-audit.md | 7 + 26 files changed, 4271 insertions(+), 56 deletions(-) create mode 100644 crates/engine/src/workspace_files/mutations.rs create mode 100644 crates/ui/src/files/context_menu.rs create mode 100644 crates/ui/src/files/drag.rs create mode 100644 crates/ui/src/files/mutations.rs create mode 100644 crates/ui/src/files/rename.rs create mode 100644 crates/ui/src/files/test_support.rs create mode 100644 crates/ui/src/shell/file_mutations.rs create mode 100644 docs/file-tree-interactions.md diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 2ca1fce168..be5d0666fe 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -1380,6 +1380,8 @@ fn forwardable(method: &str) -> bool { | methods::SEARCH_WORKSPACE_FILES | methods::READ_WORKSPACE_IMAGE | methods::READ_WORKSPACE_FILE + | methods::DELETE_WORKSPACE_ENTRY + | methods::MOVE_WORKSPACE_ENTRY | methods::WRITE_WORKSPACE_FILE | methods::WATCH_WORKSPACE_FILES | methods::CREATE_WORKTREE @@ -3007,6 +3009,24 @@ impl RpcService for EngineRpc { .map_err(RpcError::from)?; RpcReply::value(&file) } + methods::DELETE_WORKSPACE_ENTRY => { + let request: zeron_proto::DeleteWorkspaceEntryRequest = parse_params(params)?; + let outcome = self + .workspace_files + .delete_entry(request) + .await + .map_err(RpcError::from)?; + RpcReply::value(&outcome) + } + methods::MOVE_WORKSPACE_ENTRY => { + let request: zeron_proto::MoveWorkspaceEntryRequest = parse_params(params)?; + let outcome = self + .workspace_files + .move_entry(request) + .await + .map_err(RpcError::from)?; + RpcReply::value(&outcome) + } methods::WRITE_WORKSPACE_FILE => { let request: zeron_proto::WriteWorkspaceFileRequest = parse_params(params)?; let outcome = tokio::time::timeout( @@ -3807,6 +3827,8 @@ mod tests { assert!(forwardable(methods::SEARCH_WORKSPACE_FILES)); assert!(forwardable(methods::READ_WORKSPACE_FILE)); assert!(forwardable(methods::READ_WORKSPACE_IMAGE)); + assert!(forwardable(methods::DELETE_WORKSPACE_ENTRY)); + assert!(forwardable(methods::MOVE_WORKSPACE_ENTRY)); assert!(forwardable(methods::WRITE_WORKSPACE_FILE)); assert!(forwardable(methods::WATCH_WORKSPACE_FILES)); assert!(forwardable(methods::WATCH_WORKSPACE_GIT_STATUS)); diff --git a/crates/engine/src/workspace_files.rs b/crates/engine/src/workspace_files.rs index 0eebf92c15..fd89a7d8ac 100644 --- a/crates/engine/src/workspace_files.rs +++ b/crates/engine/src/workspace_files.rs @@ -26,6 +26,8 @@ use zeron_rpc::RpcError; use crate::{Repos, WorkspaceHost}; +mod mutations; + const MAX_RELATIVE_PATH_BYTES: usize = 4096; const MAX_RELATIVE_PATH_COMPONENTS: usize = 256; const MAX_ABSOLUTE_PATH_BYTES: usize = 16 * 1024; @@ -52,6 +54,7 @@ struct WorkspaceFilesInner { repos: Repos, workspace: WorkspaceHost, device_id: String, + mutation_gates: Mutex>>>, write_locks: Mutex>>>, watches: Mutex>>, cancel: CancellationToken, @@ -242,6 +245,7 @@ impl WorkspaceFiles { repos, workspace, device_id: device_id.into(), + mutation_gates: Mutex::new(HashMap::new()), write_locks: Mutex::new(HashMap::new()), watches: Mutex::new(HashMap::new()), cancel: CancellationToken::new(), @@ -392,7 +396,14 @@ impl WorkspaceFiles { .await .map_err(|error| WorkspaceFilesError::Io(format!("directory worker failed: {error}")))?; cancel_on_drop.disarm(); - result + result.map(|mut page| { + page.checkout_id = Some(workspace.checkout_id); + page.mutation_capabilities = Some(zeron_proto::WorkspaceMutationCapabilities { + move_entry: cfg!(any(target_os = "linux", target_os = "macos", windows)), + delete_entry: true, + }); + page + }) } pub async fn search( @@ -498,6 +509,15 @@ impl WorkspaceFiles { .into(), )); } + let mutation_guard = self + .mutation_gate(&workspace.checkout_id) + .read_owned() + .await; + if self.resolve_target(&request.target).await? != workspace { + return Err(WorkspaceFilesError::Authorization( + "Workspace changed while waiting to save".into(), + )); + } let relative = WorkspaceRelativePath::file(&request.path)?; let key = WorkspaceFileKey { checkout_id: workspace.checkout_id.clone(), @@ -519,6 +539,7 @@ impl WorkspaceFiles { let cancel_on_drop = CancelOnDrop::new(cancel.clone()); let expected_hash = request.expected_content_hash; let result = tokio::task::spawn_blocking(move || { + let _mutation_guard = mutation_guard; let _write_guard = write_guard; write_file_blocking(&workspace.root, &relative, &expected_hash, &bytes, &cancel) }) @@ -883,6 +904,7 @@ fn normalize_watch_events( changes.insert( path.clone(), WorkspaceFileChange { + operation_id: None, kind: WorkspaceFileChangeKind::Modified, path, old_path: None, @@ -893,6 +915,7 @@ fn normalize_watch_events( changes.insert( path.clone(), WorkspaceFileChange { + operation_id: None, kind: WorkspaceFileChangeKind::Renamed, path, old_path: Some(old_path), @@ -919,6 +942,7 @@ fn normalize_watch_events( continue; }; let incoming = WorkspaceFileChange { + operation_id: None, kind, path: path.clone(), old_path: None, @@ -1091,6 +1115,8 @@ fn list_directory_blocking( .as_ref() .is_some_and(|visible| !visible.contains(&path)); entries.push(WorkspaceEntry { + mutation_revision: (!mutations::is_link(&metadata)) + .then(|| mutations::revision(&metadata)), name: entry.file_name().to_string_lossy().into_owned(), path, kind, @@ -1144,6 +1170,8 @@ fn list_directory_blocking( }) }); Ok(WorkspaceDirectoryPage { + checkout_id: None, + mutation_capabilities: None, directory: directory.wire_path(), entries: page_entries, next_cursor, @@ -1527,7 +1555,7 @@ fn checked_file_metadata( WorkspaceFilesError::Io(error.to_string()) } })?; - if metadata.file_type().is_symlink() { + if mutations::is_link(&metadata) { let message = if index + 1 == components.len() { "path is a symlink" } else { @@ -1986,6 +2014,8 @@ fn checked_directory( root: &Path, directory: &WorkspaceRelativePath, ) -> Result { + let canonical_root = + std::fs::canonicalize(root).map_err(|error| WorkspaceFilesError::Io(error.to_string()))?; let mut current = root.to_path_buf(); for component in directory.as_path().components() { let Component::Normal(component) = component else { @@ -1994,7 +2024,7 @@ fn checked_directory( current.push(component); let metadata = std::fs::symlink_metadata(¤t) .map_err(|error| WorkspaceFilesError::Io(error.to_string()))?; - if metadata.file_type().is_symlink() { + if mutations::is_link(&metadata) { return Err(WorkspaceFilesError::Unsupported( "symlink directories cannot be traversed".into(), )); @@ -2007,7 +2037,7 @@ fn checked_directory( } let canonical = std::fs::canonicalize(¤t) .map_err(|error| WorkspaceFilesError::Io(error.to_string()))?; - if !canonical.starts_with(root) { + if !canonical.starts_with(&canonical_root) { return Err(WorkspaceFilesError::Authorization( "directory escaped workspace".into(), )); @@ -2145,6 +2175,26 @@ mod tests { AtomicBool::new(false) } + #[test] + fn checked_directory_accepts_the_workspace_root_and_its_child_after_canonicalization() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("child")).unwrap(); + let canonical_root = std::fs::canonicalize(root.path()).unwrap(); + + assert_eq!( + checked_directory(root.path(), &WorkspaceRelativePath::directory("").unwrap()).unwrap(), + canonical_root + ); + assert_eq!( + checked_directory( + root.path(), + &WorkspaceRelativePath::directory("child").unwrap() + ) + .unwrap(), + canonical_root.join("child") + ); + } + #[test] fn relative_paths_preserve_normal_and_unicode_components() { let path = WorkspaceRelativePath::file("src/日本語/emoji-🛰️.rs").unwrap(); diff --git a/crates/engine/src/workspace_files/mutations.rs b/crates/engine/src/workspace_files/mutations.rs new file mode 100644 index 0000000000..6b3174fc7d --- /dev/null +++ b/crates/engine/src/workspace_files/mutations.rs @@ -0,0 +1,735 @@ +//! Structural mutations execute on the workspace host, serialized against saves. +use super::*; +use zeron_proto::{ + MoveWorkspaceEntryRequest, WorkspaceMutationOutcome, WorkspaceMutationRejection as Reason, +}; + +type MutationResult = Result; + +impl WorkspaceFiles { + pub async fn delete_entry( + &self, + request: zeron_proto::DeleteWorkspaceEntryRequest, + ) -> Result { + let workspace = self.resolve_target(&request.target).await?; + let gate = self + .mutation_gate(&workspace.checkout_id) + .write_owned() + .await; + if request.expected_checkout_id.is_empty() + || self.resolve_target(&request.target).await? != workspace + || request.expected_checkout_id != workspace.checkout_id + { + return Ok(rejected( + request.operation_id, + Reason::WorkspaceChanged, + "Workspace changed; refresh before deleting", + )); + } + let cancel = Arc::new(AtomicBool::new(false)); + let cancel_on_drop = CancelOnDrop::new(cancel.clone()); + let owner = self.clone(); + let result = tokio::task::spawn_blocking(move || { + let _gate = gate; + let result = match delete_blocking(&workspace, &request, &cancel) { + Ok(()) => WorkspaceMutationOutcome::Applied { + operation_id: request.operation_id.clone(), + checkout_id: workspace.checkout_id.clone(), + change: WorkspaceFileChange { + operation_id: Some(request.operation_id), + kind: WorkspaceFileChangeKind::Removed, + path: request.path, + old_path: None, + }, + entry: None, + }, + Err((reason, message)) => rejected(request.operation_id, reason, &message), + }; + owner.publish_mutation(&workspace.checkout_id, &result); + result + }) + .await + .map_err(|e| WorkspaceFilesError::Io(format!("delete worker failed: {e}")))?; + cancel_on_drop.disarm(); + Ok(result) + } + + fn publish_mutation(&self, checkout: &str, result: &WorkspaceMutationOutcome) { + if let Some(watch) = lock(&self.inner.watches).get(checkout).cloned() { + match result { + WorkspaceMutationOutcome::Applied { change, .. } => { + watch.publish(false, vec![change.clone()]) + } + WorkspaceMutationOutcome::Rejected { + reason: Reason::PartialFailure, + .. + } => watch.publish(true, vec![]), + _ => {} + } + } + } + + pub(super) fn mutation_gate(&self, checkout: &str) -> Arc> { + let mut gates = lock(&self.inner.mutation_gates); + gates.retain(|_, gate| gate.strong_count() > 0); + if let Some(gate) = gates.get(checkout).and_then(Weak::upgrade) { + return gate; + } + let gate = Arc::new(tokio::sync::RwLock::new(())); + gates.insert(checkout.into(), Arc::downgrade(&gate)); + gate + } + + pub async fn move_entry( + &self, + request: MoveWorkspaceEntryRequest, + ) -> Result { + let workspace = self.resolve_target(&request.target).await?; + let gate = self + .mutation_gate(&workspace.checkout_id) + .write_owned() + .await; + let current = self.resolve_target(&request.target).await?; + if request.expected_checkout_id.is_empty() + || current != workspace + || request.expected_checkout_id != workspace.checkout_id + { + return Ok(rejected( + request.operation_id, + Reason::WorkspaceChanged, + "Workspace changed; refresh before moving", + )); + } + let cancel = Arc::new(AtomicBool::new(false)); + let cancel_on_drop = CancelOnDrop::new(cancel.clone()); + let owner = self.clone(); + let result = tokio::task::spawn_blocking(move || { + let _gate = gate; + let result = match move_blocking(&workspace, &request, &cancel) { + Ok(entry) => WorkspaceMutationOutcome::Applied { + operation_id: request.operation_id.clone(), + checkout_id: workspace.checkout_id.clone(), + change: WorkspaceFileChange { + operation_id: Some(request.operation_id), + kind: WorkspaceFileChangeKind::Renamed, + path: entry.path.clone(), + old_path: Some(request.source_path), + }, + entry: Some(entry), + }, + Err((reason, message)) => rejected(request.operation_id, reason, &message), + }; + owner.publish_mutation(&workspace.checkout_id, &result); + result + }) + .await + .map_err(|e| WorkspaceFilesError::Io(format!("move worker failed: {e}")))?; + cancel_on_drop.disarm(); + Ok(result) + } +} + +fn delete_blocking( + workspace: &ResolvedWorkspace, + request: &zeron_proto::DeleteWorkspaceEntryRequest, + cancel: &AtomicBool, +) -> MutationResult<()> { + if request.operation_id.is_empty() || request.operation_id.len() > 128 { + return Err((Reason::InvalidPath, "Invalid operation identity".into())); + } + let (relative, metadata) = source( + &workspace.root, + &request.path, + &request.expected_source_revision, + request.expected_kind, + )?; + if metadata.is_dir() && !request.recursive { + return Err(( + Reason::InvalidDestination, + "Deleting a folder requires recursive confirmation".into(), + )); + } + if cancel.load(Ordering::Acquire) { + return Err((Reason::Busy, "Delete cancelled before execution".into())); + } + let target = workspace.root.join(relative.as_path()); + if metadata.is_dir() { + // std::fs::remove_dir_all does not follow symbolic links and uses platform + // handle-relative traversal. A failed recursive delete may have removed children. + std::fs::remove_dir_all(target).map_err(|e| { + ( + Reason::PartialFailure, + format!("Folder deletion did not complete; refresh to see remaining files: {e}"), + ) + }) + } else { + std::fs::remove_file(target).map_err(io_error) + } +} + +fn rejected(operation_id: String, reason: Reason, message: &str) -> WorkspaceMutationOutcome { + WorkspaceMutationOutcome::Rejected { + operation_id, + reason, + message: message.into(), + } +} + +fn domain_error(error: WorkspaceFilesError) -> (Reason, String) { + let reason = match &error { + WorkspaceFilesError::BadParams(_) => Reason::InvalidPath, + WorkspaceFilesError::Authorization(_) => Reason::WorkspaceChanged, + WorkspaceFilesError::NotFound(_) => Reason::SourceMissing, + WorkspaceFilesError::Unsupported(_) => Reason::Unsupported, + WorkspaceFilesError::Io(_) => Reason::InvalidDestination, + }; + (reason, error.to_string()) +} + +fn io_error(error: std::io::Error) -> (Reason, String) { + use std::io::ErrorKind; + ( + match error.kind() { + ErrorKind::AlreadyExists => Reason::DestinationExists, + ErrorKind::NotFound => Reason::SourceMissing, + ErrorKind::PermissionDenied => Reason::PermissionDenied, + ErrorKind::Unsupported | ErrorKind::CrossesDevices => Reason::Unsupported, + _ => Reason::InvalidDestination, + }, + error.to_string(), + ) +} + +/// Metadata revision is deliberately cheap; directory revisions are not recursive snapshots. +pub(super) fn revision(metadata: &std::fs::Metadata) -> String { + let mut hash = Sha256::new(); + hash.update(format!( + "{:?}:{:?}:{:?}:{}:{}", + metadata.file_type(), + metadata.modified().ok(), + metadata.created().ok(), + metadata.len(), + metadata.permissions().readonly() + )); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + hash.update(format!( + ":{}:{}:{}:{}", + metadata.dev(), + metadata.ino(), + metadata.ctime(), + metadata.ctime_nsec() + )); + } + hex(&hash.finalize()) +} + +pub(super) fn is_link(metadata: &std::fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + return metadata.file_attributes() & 0x400 != 0; // FILE_ATTRIBUTE_REPARSE_POINT, includes junctions. + } + #[cfg(not(windows))] + { + metadata.file_type().is_symlink() + } +} + +fn source( + root: &Path, + path: &str, + expected: &str, + kind: WorkspaceEntryKind, +) -> MutationResult<(WorkspaceRelativePath, std::fs::Metadata)> { + let relative = WorkspaceRelativePath::file(path).map_err(domain_error)?; + // Require canonical wire spelling so identity/deduplication is unambiguous. + if relative.wire_path() != path { + return Err(( + Reason::InvalidPath, + "Path must use canonical workspace-relative spelling".into(), + )); + } + let parent = WorkspaceRelativePath(relative.as_path().parent().unwrap_or(Path::new("")).into()); + checked_directory(root, &parent).map_err(domain_error)?; + let metadata = std::fs::symlink_metadata(root.join(relative.as_path())).map_err(io_error)?; + if is_link(&metadata) || !(metadata.is_file() || metadata.is_dir()) { + return Err(( + Reason::Unsupported, + "Links and special files cannot be mutated".into(), + )); + } + let actual_kind = if metadata.is_dir() { + WorkspaceEntryKind::Directory + } else { + WorkspaceEntryKind::File + }; + if actual_kind != kind || expected.is_empty() || revision(&metadata) != expected { + return Err(( + Reason::SourceChanged, + "Entry changed; refresh before trying again".into(), + )); + } + Ok((relative, metadata)) +} + +fn move_blocking( + workspace: &ResolvedWorkspace, + request: &MoveWorkspaceEntryRequest, + cancel: &AtomicBool, +) -> MutationResult { + if request.operation_id.is_empty() || request.operation_id.len() > 128 { + return Err((Reason::InvalidPath, "Invalid operation identity".into())); + } + let (source, metadata) = source( + &workspace.root, + &request.source_path, + &request.expected_source_revision, + request.expected_kind, + )?; + let destination = + WorkspaceRelativePath::file(&request.destination_path).map_err(domain_error)?; + if destination.wire_path() != request.destination_path { + return Err((Reason::InvalidPath, "Invalid destination spelling".into())); + } + if destination == source + || (metadata.is_dir() && destination.as_path().starts_with(source.as_path())) + { + return Err(( + Reason::InvalidDestination, + "Cannot move an entry into itself".into(), + )); + } + let parent = WorkspaceRelativePath( + destination + .as_path() + .parent() + .unwrap_or(Path::new("")) + .into(), + ); + checked_directory(&workspace.root, &parent).map_err(domain_error)?; + if cancel.load(Ordering::Acquire) { + return Err((Reason::Busy, "Move cancelled before execution".into())); + } + move_entry_no_replace(&workspace.root, source.as_path(), destination.as_path())?; + let target = workspace.root.join(destination.as_path()); + let updated = std::fs::symlink_metadata(&target).unwrap_or(metadata); + Ok(WorkspaceEntry { + path: destination.wire_path(), + name: target.file_name().unwrap().to_string_lossy().into_owned(), + kind: request.expected_kind, + size: updated.is_file().then_some(updated.len()), + modified_at: updated.modified().ok().map(chrono::DateTime::from), + ignored: false, + read_only: !updated.is_file(), + mutation_revision: Some(revision(&updated)), + }) +} + +/// Case-insensitive filesystems may reject even a spelling-only rename under +/// NOREPLACE. Stage that single directory entry, never a second hard link. +fn move_entry_no_replace(root: &Path, source: &Path, destination: &Path) -> MutationResult<()> { + match move_no_replace(root, source, destination) { + Ok(()) => Ok(()), + Err(error) => { + #[cfg(unix)] + if error.kind() == std::io::ErrorKind::AlreadyExists + && is_case_alias(root, source, destination) + { + let temporary = source + .parent() + .unwrap_or(Path::new("")) + .join(format!(".zeron-save-{}.tmp", uuid::Uuid::new_v4())); + move_no_replace(root, source, &temporary).map_err(io_error)?; + if let Err(error) = move_no_replace(root, &temporary, destination) { + if let Err(rollback) = move_no_replace(root, &temporary, source) { + return Err(( + Reason::PartialFailure, + format!( + "Rename could not complete ({error}) or restore its original name ({rollback}); recover the entry at {}", + temporary.display() + ), + )); + } + return Err(io_error(error)); + } + return Ok(()); + } + Err(io_error(error)) + } + } +} + +#[cfg(unix)] +fn is_case_alias(root: &Path, source: &Path, destination: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + if source.parent() != destination.parent() { + return false; + } + let (Some(from), Some(to)) = (source.file_name(), destination.file_name()) else { + return false; + }; + if from == to || from.to_string_lossy().to_lowercase() != to.to_string_lossy().to_lowercase() { + return false; + } + let (Ok(a), Ok(b)) = ( + std::fs::symlink_metadata(root.join(source)), + std::fs::symlink_metadata(root.join(destination)), + ) else { + return false; + }; + if a.dev() != b.dev() || a.ino() != b.ino() || is_link(&a) || is_link(&b) { + return false; + } + // Distinct hard-link names are a real collision, not an alias in a + // case-insensitive directory. A failed enumeration never authorizes a move. + let Ok(entries) = std::fs::read_dir(root.join(source.parent().unwrap_or(Path::new("")))) else { + return false; + }; + for entry in entries { + match entry { + Ok(entry) if entry.file_name() == to => return false, + Err(_) => return false, + _ => {} + } + } + true +} + +/// Open every ancestor without following links, and anchor the native rename to those handles. +#[cfg(unix)] +fn open_parent(root: &Path, relative: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + use std::os::{ + fd::{AsRawFd, FromRawFd}, + unix::ffi::OsStrExt, + }; + let mut directory = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW) + .open(root)?; + for component in relative.parent().unwrap_or(Path::new("")).components() { + let Component::Normal(name) = component else { + return Err(std::io::ErrorKind::InvalidInput.into()); + }; + let name = std::ffi::CString::new(name.as_bytes())?; + // SAFETY: a valid directory descriptor and NUL-terminated single path component. + let fd = unsafe { + libc::openat( + directory.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: openat returned a newly owned descriptor. + directory = unsafe { std::fs::File::from_raw_fd(fd) }; + } + Ok(directory) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn move_no_replace(root: &Path, source: &Path, destination: &Path) -> std::io::Result<()> { + use std::os::{fd::AsRawFd, unix::ffi::OsStrExt}; + let from = open_parent(root, source)?; + let to = open_parent(root, destination)?; + let source = std::ffi::CString::new( + source + .file_name() + .ok_or(std::io::ErrorKind::InvalidInput)? + .as_bytes(), + )?; + let destination = std::ffi::CString::new( + destination + .file_name() + .ok_or(std::io::ErrorKind::InvalidInput)? + .as_bytes(), + )?; + // SAFETY: live parent handles and NUL-terminated basename buffers; replacement is disabled. + #[cfg(target_os = "linux")] + let result = unsafe { + libc::renameat2( + from.as_raw_fd(), + source.as_ptr(), + to.as_raw_fd(), + destination.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + #[cfg(target_os = "macos")] + let result = unsafe { + libc::renameatx_np( + from.as_raw_fd(), + source.as_ptr(), + to.as_raw_fd(), + destination.as_ptr(), + libc::RENAME_EXCL, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(windows)] +fn move_no_replace(root: &Path, source: &Path, destination: &Path) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_WRITE_THROUGH, MoveFileExW}; + let from: Vec = root + .join(source) + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect(); + let to: Vec = root + .join(destination) + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect(); + // SAFETY: NUL-terminated paths; neither REPLACE_EXISTING nor COPY_ALLOWED is enabled. + if unsafe { MoveFileExW(from.as_ptr(), to.as_ptr(), MOVEFILE_WRITE_THROUGH) } == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] +fn move_no_replace(_: &Path, _: &Path, _: &Path) -> std::io::Result<()> { + Err(std::io::ErrorKind::Unsupported.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn request(root: &Path, path: &str, to: &str) -> MoveWorkspaceEntryRequest { + let metadata = std::fs::symlink_metadata(root.join(path)).unwrap(); + MoveWorkspaceEntryRequest { + target: WorkspaceTarget { + chat_id: Some("chat".into()), + space_id: None, + checkout_path: None, + }, + operation_id: "op".into(), + expected_checkout_id: "checkout".into(), + source_path: path.into(), + destination_path: to.into(), + expected_source_revision: revision(&metadata), + expected_kind: if metadata.is_dir() { + WorkspaceEntryKind::Directory + } else { + WorkspaceEntryKind::File + }, + } + } + #[test] + fn simultaneous_moves_never_replace_the_winner() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("one"), "one").unwrap(); + std::fs::write(root.join("two"), "two").unwrap(); + let barrier = Arc::new(std::sync::Barrier::new(2)); + let results = std::thread::scope(|scope| { + let a = barrier.clone(); + let first = scope.spawn(move || { + a.wait(); + move_entry_no_replace(root, Path::new("one"), Path::new("winner")) + }); + let b = barrier.clone(); + let second = scope.spawn(move || { + b.wait(); + move_entry_no_replace(root, Path::new("two"), Path::new("winner")) + }); + (first.join().unwrap(), second.join().unwrap()) + }); + assert_ne!(results.0.is_ok(), results.1.is_ok()); + let winner = std::fs::read_to_string(root.join("winner")).unwrap(); + let loser = if winner == "one" { "two" } else { "one" }; + assert_eq!(std::fs::read_to_string(root.join(loser)).unwrap(), loser); + } + #[test] + fn case_only_rename_preserves_contents() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("File.txt"), "keep").unwrap(); + move_entry_no_replace(dir.path(), Path::new("File.txt"), Path::new("file.txt")).unwrap(); + assert_eq!( + std::fs::read_to_string(dir.path().join("file.txt")).unwrap(), + "keep" + ); + let names = std::fs::read_dir(dir.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert_eq!(names, vec![std::ffi::OsString::from("file.txt")]); + } + #[cfg(unix)] + #[test] + fn case_similar_hard_links_are_still_collisions() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("A"), "keep").unwrap(); + // Only filesystems supporting distinct case names can create this fixture. + if std::fs::hard_link(dir.path().join("A"), dir.path().join("a")).is_err() { + return; + } + assert_eq!( + move_entry_no_replace(dir.path(), Path::new("A"), Path::new("a")) + .unwrap_err() + .0, + Reason::DestinationExists + ); + } + #[test] + fn delete_requires_current_revision_and_explicit_recursion() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir(root.join("folder")).unwrap(); + std::fs::write(root.join("folder/file"), "keep").unwrap(); + let ws = ResolvedWorkspace { + checkout_id: "checkout".into(), + root: root.into(), + }; + let mut req = zeron_proto::DeleteWorkspaceEntryRequest { + target: request(root, "folder", "unused").target, + operation_id: "delete".into(), + expected_checkout_id: "checkout".into(), + path: "folder".into(), + expected_source_revision: revision(&std::fs::metadata(root.join("folder")).unwrap()), + expected_kind: WorkspaceEntryKind::Directory, + recursive: false, + }; + assert!(delete_blocking(&ws, &req, &AtomicBool::new(false)).is_err()); + assert!(root.join("folder/file").exists()); + req.recursive = true; + delete_blocking(&ws, &req, &AtomicBool::new(false)).unwrap(); + assert!(!root.join("folder").exists()); + assert_eq!( + delete_blocking(&ws, &req, &AtomicBool::new(false)) + .unwrap_err() + .0, + Reason::SourceMissing + ); + } + #[cfg(unix)] + #[test] + fn deleting_directory_does_not_follow_links_to_external_contents() { + let dir = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("folder")).unwrap(); + std::fs::write(outside.path().join("keep"), "keep").unwrap(); + std::os::unix::fs::symlink(outside.path(), dir.path().join("folder/link")).unwrap(); + let ws = ResolvedWorkspace { + checkout_id: "checkout".into(), + root: dir.path().into(), + }; + let req = zeron_proto::DeleteWorkspaceEntryRequest { + target: request(dir.path(), "folder", "unused").target, + operation_id: "delete".into(), + expected_checkout_id: "checkout".into(), + path: "folder".into(), + expected_source_revision: revision( + &std::fs::metadata(dir.path().join("folder")).unwrap(), + ), + expected_kind: WorkspaceEntryKind::Directory, + recursive: true, + }; + delete_blocking(&ws, &req, &AtomicBool::new(false)).unwrap(); + assert_eq!( + std::fs::read_to_string(outside.path().join("keep")).unwrap(), + "keep" + ); + } + + #[test] + fn moves_subtrees_without_replacement_or_prefix_confusion() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("a/child")).unwrap(); + std::fs::create_dir(root.join("ab")).unwrap(); + std::fs::write(root.join("a/child/á.txt"), "keep").unwrap(); + let workspace = ResolvedWorkspace { + checkout_id: "checkout".into(), + root: root.into(), + }; + let cancel = AtomicBool::new(false); + assert_eq!( + move_blocking(&workspace, &request(root, "a", "a/child/new"), &cancel) + .unwrap_err() + .0, + Reason::InvalidDestination + ); + assert_eq!( + move_blocking(&workspace, &request(root, "a", "ab"), &cancel) + .unwrap_err() + .0, + Reason::DestinationExists + ); + move_blocking(&workspace, &request(root, "a", "ab/a"), &cancel).unwrap(); + assert_eq!( + std::fs::read_to_string(root.join("ab/a/child/á.txt")).unwrap(), + "keep" + ); + } + #[test] + fn stale_sources_invalid_paths_and_cancel_do_not_mutate() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("a"), "old").unwrap(); + let workspace = ResolvedWorkspace { + checkout_id: "checkout".into(), + root: root.into(), + }; + let req = request(root, "a", "b"); + std::fs::write(root.join("a"), "changed").unwrap(); + assert_eq!( + move_blocking(&workspace, &req, &AtomicBool::new(false)) + .unwrap_err() + .0, + Reason::SourceChanged + ); + for to in ["../b", ".git/file", "", "/tmp/outside"] { + assert!( + move_blocking(&workspace, &request(root, "a", to), &AtomicBool::new(false)) + .is_err() + ); + } + assert_eq!( + move_blocking(&workspace, &request(root, "a", "b"), &AtomicBool::new(true)) + .unwrap_err() + .0, + Reason::Busy + ); + assert!(root.join("a").exists()); + assert!(!root.join("b").exists()); + } + #[cfg(unix)] + #[test] + fn links_cannot_be_overwritten_or_traversed() { + let dir = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("a"), "keep").unwrap(); + std::os::unix::fs::symlink(outside.path().join("missing"), root.join("b")).unwrap(); + std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap(); + let ws = ResolvedWorkspace { + checkout_id: "checkout".into(), + root: root.into(), + }; + assert_eq!( + move_blocking(&ws, &request(root, "a", "b"), &AtomicBool::new(false)) + .unwrap_err() + .0, + Reason::DestinationExists + ); + assert!( + move_blocking(&ws, &request(root, "a", "link/a"), &AtomicBool::new(false)).is_err() + ); + assert!(root.join("a").exists()); + assert!(!outside.path().join("a").exists()); + } +} diff --git a/crates/engine/tests/device_routing.rs b/crates/engine/tests/device_routing.rs index 40d8457908..eb232df2c2 100644 --- a/crates/engine/tests/device_routing.rs +++ b/crates/engine/tests/device_routing.rs @@ -1843,3 +1843,56 @@ async fn queue_watch_and_single_consumption_route_to_the_remote_chat_host() { core_a.shutdown().await; core_b.shutdown().await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_entry_mutations_are_forwarded_to_the_owning_plain_folder() { + let (relay_url, _relay) = fake_device_room().await; + let dirs = tempfile::tempdir().unwrap(); + let host = assemble(&dirs.path().join("host"), "mutation-host"); + let _host_relay = host.start_host_relay(&relay_url); + let viewer = assemble(&dirs.path().join("viewer"), "mutation-viewer"); + let mut config = LinkCacheConfig::new(relay_url, Arc::new(StaticToken("test-user".into()))); + config.probe_timeout = Duration::from_secs(5); + viewer.set_links(LinkCache::new(config)); + let folder = dirs.path().join("plain-folder"); + std::fs::create_dir(&folder).unwrap(); + std::fs::write(folder.join("original.txt"), "host only").unwrap(); + host.workspace + .create_space( + "mutation-space", + "mutation-host", + &folder.to_string_lossy(), + None, + false, + ) + .unwrap(); + let client = zeron_rpc::memory_client(viewer.rpc_service()); + let page = client + .call( + methods::LIST_WORKSPACE_DIRECTORY, + serde_json::json!({"spaceId":"mutation-space","targetDeviceId":"mutation-host"}), + ) + .await + .unwrap(); + let entry = &page["entries"][0]; + assert!(page["checkoutId"].as_str().unwrap().starts_with("folder-")); + let moved=client.call(methods::MOVE_WORKSPACE_ENTRY,serde_json::json!({ + "spaceId":"mutation-space","targetDeviceId":"mutation-host","operationId":"remote-move", + "expectedCheckoutId":page["checkoutId"],"sourcePath":"original.txt","destinationPath":"renamed.txt", + "expectedSourceRevision":entry["mutationRevision"],"expectedKind":"file" + })).await.unwrap(); + assert_eq!(moved["status"], "applied"); + assert_eq!( + std::fs::read_to_string(folder.join("renamed.txt")).unwrap(), + "host only" + ); + let deleted=client.call(methods::DELETE_WORKSPACE_ENTRY,serde_json::json!({ + "spaceId":"mutation-space","targetDeviceId":"mutation-host","operationId":"remote-delete", + "expectedCheckoutId":page["checkoutId"],"path":"renamed.txt", + "expectedSourceRevision":moved["entry"]["mutationRevision"],"expectedKind":"file","recursive":false + })).await.unwrap(); + assert_eq!(deleted["status"], "applied"); + assert!(!folder.join("renamed.txt").exists()); + viewer.shutdown().await; + host.shutdown().await; +} diff --git a/crates/engine/tests/workspace_files.rs b/crates/engine/tests/workspace_files.rs index cc6ad8c9a1..9b5bb42c33 100644 --- a/crates/engine/tests/workspace_files.rs +++ b/crates/engine/tests/workspace_files.rs @@ -657,6 +657,90 @@ async fn write_rejects_changed_checkout_even_when_contents_match() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_mutations_validate_revision_and_publish_semantic_events() { + let temp = tempfile::tempdir().unwrap(); + let repo = temp.path().join("repo"); + init_repo(&repo).await; + let core = assemble(&temp.path().join("data"), "device-mutations"); + core.workspace + .create_space( + "space", + &core.device_id, + &repo.to_string_lossy(), + None, + true, + ) + .unwrap(); + core.workspace + .create_chat("chat", Some("space"), None, None, None) + .unwrap(); + let client = zeron_rpc::memory_client(core.rpc_service()); + let page: WorkspaceDirectoryPage = serde_json::from_value( + client + .call( + methods::LIST_WORKSPACE_DIRECTORY, + serde_json::json!({"chatId":"chat"}), + ) + .await + .unwrap(), + ) + .unwrap(); + assert!(page.mutation_capabilities.unwrap().delete_entry); + let revision = page + .entries + .iter() + .find(|e| e.path == "README.md") + .unwrap() + .mutation_revision + .clone() + .unwrap(); + let checkout = page.checkout_id.unwrap(); + let mut watch = client + .subscribe( + methods::WATCH_WORKSPACE_FILES, + serde_json::json!({"chatId":"chat"}), + ) + .await + .unwrap(); + watch.recv().await.unwrap(); + let mut request = serde_json::json!({"chatId":"chat", "operationId":"rename", "expectedCheckoutId":checkout, "sourcePath":"README.md", "destinationPath":"src/read me.md", "expectedSourceRevision":revision, "expectedKind":"file"}); + request["expectedCheckoutId"] = "wrong".into(); + let rejected = client + .call(methods::MOVE_WORKSPACE_ENTRY, request.clone()) + .await + .unwrap(); + assert_eq!(rejected["reason"], "workspaceChanged"); + request["expectedCheckoutId"] = checkout.clone().into(); + let moved = client + .call(methods::MOVE_WORKSPACE_ENTRY, request) + .await + .unwrap(); + assert_eq!(moved["status"], "applied"); + assert!(!repo.join("README.md").exists()); + assert_eq!( + std::fs::read_to_string(repo.join("src/read me.md")).unwrap(), + "hello\n" + ); + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let frame: WorkspaceFileChanges = + serde_json::from_value(watch.recv().await.unwrap()).unwrap(); + if frame.changes.iter().any(|c| { + c.operation_id.as_deref() == Some("rename") + && c.old_path.as_deref() == Some("README.md") + }) { + break; + } + } + }) + .await + .unwrap(); + let deleted = client.call(methods::DELETE_WORKSPACE_ENTRY, serde_json::json!({"chatId":"chat", "operationId":"delete", "expectedCheckoutId":checkout, "path":"src/read me.md", "expectedSourceRevision":moved["entry"]["mutationRevision"], "expectedKind":"file", "recursive":false})).await.unwrap(); + assert_eq!(deleted["status"], "applied"); + assert!(!repo.join("src/read me.md").exists()); +} + // Absolute reads take POSIX paths — the only shape a UI sends. #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/crates/proto/src/entities.rs b/crates/proto/src/entities.rs index b2b3b7cfe3..9946cb8501 100644 --- a/crates/proto/src/entities.rs +++ b/crates/proto/src/entities.rs @@ -480,6 +480,10 @@ pub struct ListWorkspaceDirectoryRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceDirectoryPage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checkout_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mutation_capabilities: Option, pub directory: String, pub entries: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -490,6 +494,8 @@ pub struct WorkspaceDirectoryPage { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceEntry { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mutation_revision: Option, pub path: String, pub name: String, pub kind: WorkspaceEntryKind, @@ -700,6 +706,8 @@ pub struct WorkspaceFileChanges { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceFileChange { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operation_id: Option, pub kind: WorkspaceFileChangeKind, pub path: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1399,6 +1407,7 @@ mod tests { sequence: 4, resync_required: false, changes: vec![WorkspaceFileChange { + operation_id: None, kind: WorkspaceFileChangeKind::Renamed, path: "src/new.rs".into(), old_path: Some("src/old.rs".into()), @@ -1414,3 +1423,126 @@ mod tests { ); } } + +/// Host capabilities; absent fields keep older peers read-only for mutations. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceMutationCapabilities { + #[serde(default)] + pub move_entry: bool, + #[serde(default)] + pub delete_entry: bool, +} + +/// Rename and move share the same no-replacement operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MoveWorkspaceEntryRequest { + #[serde(flatten)] + pub target: WorkspaceTarget, + pub operation_id: String, + pub expected_checkout_id: String, + pub source_path: String, + pub destination_path: String, + pub expected_source_revision: String, + pub expected_kind: WorkspaceEntryKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteWorkspaceEntryRequest { + #[serde(flatten)] + pub target: WorkspaceTarget, + pub operation_id: String, + pub expected_checkout_id: String, + pub path: String, + pub expected_source_revision: String, + pub expected_kind: WorkspaceEntryKind, + /// Explicit consent to delete the directory's current contents. + pub recursive: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum WorkspaceMutationOutcome { + #[serde(rename_all = "camelCase")] + Applied { + operation_id: String, + checkout_id: String, + change: WorkspaceFileChange, + #[serde(default, skip_serializing_if = "Option::is_none")] + entry: Option, + }, + #[serde(rename_all = "camelCase")] + Rejected { + operation_id: String, + reason: WorkspaceMutationRejection, + message: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WorkspaceMutationRejection { + InvalidPath, + WorkspaceChanged, + SourceMissing, + SourceChanged, + DestinationExists, + InvalidDestination, + PermissionDenied, + Unsupported, + Busy, + PartialFailure, +} + +#[cfg(test)] +mod mutation_contract_tests { + use super::*; + #[test] + fn old_peers_remain_readable_without_mutation_support() { + let page: WorkspaceDirectoryPage = serde_json::from_value(serde_json::json!({ + "directory":"", "entries":[{"path":"a", "name":"a", "kind":"file", "ignored":false, "readOnly":false}], + "truncated":false + })).unwrap(); + assert_eq!(page.checkout_id, None); + assert_eq!(page.mutation_capabilities, None); + assert_eq!(page.entries[0].mutation_revision, None); + let change: WorkspaceFileChange = + serde_json::from_value(serde_json::json!({"kind":"removed", "path":"a"})).unwrap(); + assert_eq!(change.operation_id, None); + } + #[test] + fn mutation_requests_and_outcomes_round_trip() { + let request = serde_json::json!({"chatId":"chat", "operationId":"op", "expectedCheckoutId":"checkout", "sourcePath":"a", "destinationPath":"b", "expectedSourceRevision":"rev", "expectedKind":"file"}); + let decoded: MoveWorkspaceEntryRequest = serde_json::from_value(request.clone()).unwrap(); + let encoded = serde_json::to_value(decoded).unwrap(); + for (key, value) in request.as_object().unwrap() { + assert_eq!(&encoded[key], value); + } + let delete: DeleteWorkspaceEntryRequest = serde_json::from_value(serde_json::json!({"chatId":"chat", "operationId":"op", "expectedCheckoutId":"checkout", "path":"a", "expectedSourceRevision":"rev", "expectedKind":"directory", "recursive":true})).unwrap(); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&delete).unwrap() + ) + .unwrap(), + delete + ); + let outcome = WorkspaceMutationOutcome::Rejected { + operation_id: "op".into(), + reason: WorkspaceMutationRejection::DestinationExists, + message: "Destination exists".into(), + }; + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&outcome).unwrap() + ) + .unwrap(), + outcome + ); + assert!( + serde_json::from_value::(serde_json::json!("overwrite")) + .is_err() + ); + } +} diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index c32758ab0f..c5d6d0e327 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -163,6 +163,8 @@ pub mod methods { pub const SEARCH_WORKSPACE_FILES: &str = "SearchWorkspaceFiles"; pub const READ_WORKSPACE_IMAGE: &str = "ReadWorkspaceImage"; pub const READ_WORKSPACE_FILE: &str = "ReadWorkspaceFile"; + pub const MOVE_WORKSPACE_ENTRY: &str = "MoveWorkspaceEntry"; + pub const DELETE_WORKSPACE_ENTRY: &str = "DeleteWorkspaceEntry"; pub const WRITE_WORKSPACE_FILE: &str = "WriteWorkspaceFile"; pub const WATCH_WORKSPACE_FILES: &str = "WatchWorkspaceFiles"; pub const CREATE_WORKTREE: &str = "CreateWorktree"; diff --git a/crates/ui/src/files/client.rs b/crates/ui/src/files/client.rs index a7669da2e6..8b100ccba2 100644 --- a/crates/ui/src/files/client.rs +++ b/crates/ui/src/files/client.rs @@ -132,7 +132,7 @@ impl WorkspaceFilesClient { /// Refresh through the cached children before publishing the new listing. /// Usually this reads only the pages already visited. If a cached child is /// missing, reach the end before treating it as deleted. - pub(super) async fn list_directory_snapshot( + pub(crate) async fn list_directory_snapshot( &self, mut request: ListWorkspaceDirectoryRequest, cached_paths: &[String], @@ -156,6 +156,11 @@ impl WorkspaceFilesClient { } request.cursor = Some(cursor); let next = self.list_directory(request.clone()).await?; + if next.checkout_id != page.checkout_id { + return Err(FilesClientError::Decode( + "Workspace changed between directory pages".into(), + )); + } for entry in &next.entries { remaining.remove(entry.path.as_str()); } @@ -256,6 +261,20 @@ impl WorkspaceFilesClient { self.call(methods::WRITE_WORKSPACE_FILE, &request).await } + pub async fn move_entry( + &self, + request: zeron_proto::MoveWorkspaceEntryRequest, + ) -> Result { + self.call(methods::MOVE_WORKSPACE_ENTRY, &request).await + } + + pub async fn delete_entry( + &self, + request: zeron_proto::DeleteWorkspaceEntryRequest, + ) -> Result { + self.call(methods::DELETE_WORKSPACE_ENTRY, &request).await + } + pub async fn watch(&self) -> Result, FilesClientError> { let request = WatchWorkspaceFilesRequest { target: self.context.target.clone(), @@ -356,6 +375,40 @@ mod tests { }) } + #[tokio::test] + async fn structural_mutations_preserve_remote_addressing_and_never_retry_transport_errors() { + let transport = Arc::new(DeterministicTransport { + scripted_responses: Mutex::new([Err(RpcError::Transport("reply lost".into()))].into()), + ..Default::default() + }); + let client = WorkspaceFilesClient::with_transport( + transport.clone(), + FilesRequestContext { + target: target(), + target_device_id: Some("host".into()), + cwd: "/remote".into(), + checkout_id: Some("checkout".into()), + }, + ); + let result = client + .move_entry(zeron_proto::MoveWorkspaceEntryRequest { + target: target(), + operation_id: "op".into(), + expected_checkout_id: "checkout".into(), + source_path: "a".into(), + destination_path: "folder/a".into(), + expected_source_revision: "rev".into(), + expected_kind: zeron_proto::WorkspaceEntryKind::File, + }) + .await; + assert!(result.is_err()); + let calls = transport.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, methods::MOVE_WORKSPACE_ENTRY); + assert_eq!(calls[0].1["targetDeviceId"], "host"); + assert_eq!(calls[0].1["expectedCheckoutId"], "checkout"); + assert_eq!(calls[0].1["sourcePath"], "a"); + } #[tokio::test] async fn cached_directory_refresh_collects_pages_but_initial_load_stays_lazy() { for refresh in [false, true] { diff --git a/crates/ui/src/files/context_menu.rs b/crates/ui/src/files/context_menu.rs new file mode 100644 index 0000000000..c6cefee1bd --- /dev/null +++ b/crates/ui/src/files/context_menu.rs @@ -0,0 +1,322 @@ +//! Tree menus share the editor's popup surface, positioning and dismissal. +use super::*; +use crate::{icons, popover, theme::Theme}; +use gpui::{ClipboardItem, KeyDownEvent, MouseButton}; +use mutations::WorkspaceInteractionOrigin; + +pub(super) struct TreeContextMenu { + pub path: String, + pub origin: WorkspaceInteractionOrigin, + pub position: Point, + pub active: usize, +} + +fn absolute_workspace_path(root: &str, relative: &str) -> String { + // Interpret the owning host's path, not the desktop's OS. PathBuf::join + // would mix separators when viewing a Windows workspace from Linux/macOS. + let bytes = root.as_bytes(); + let windows = root.starts_with("\\\\") + || (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\')); + if windows { + format!( + "{}\\{}", + root.trim_end_matches(['/', '\\']).replace('/', "\\"), + relative.replace('/', "\\") + ) + } else { + format!("{}/{}", root.trim_end_matches('/'), relative) + } +} + +impl FilesSurface { + pub(super) fn open_tree_context_menu( + &mut self, + path: String, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + let Some(origin) = self.interaction_origin(cx) else { + return; + }; + if self.tree.node(&path).is_none() { + return; + } + self.tree.select(path.clone()); + self.tree_focus.focus(window, cx); + self.close_editor_context_menu(cx); + self.tree_context_menu.open(TreeContextMenu { + path, + origin, + position, + active: 0, + }); + cx.notify(); + } + + pub(super) fn close_tree_context_menu(&mut self, cx: &mut Context) { + if self.tree_context_menu.begin_close() { + popover::reap_popup(cx, |files: &mut Self| &mut files.tree_context_menu); + cx.notify(); + } + } + + pub(super) fn tree_menu_key( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if !self.tree_context_menu.is_open() { + return false; + } + match popover::classify_key( + &event.keystroke.key, + event.keystroke.modifiers.platform, + event.keystroke.modifiers.control, + ) { + popover::MenuKey::Escape => self.close_tree_context_menu(cx), + key @ (popover::MenuKey::Up | popover::MenuKey::Down) => { + if let Some(menu) = self.tree_context_menu.as_open() { + let mut next = menu.active; + for _ in 0..4 { + next = popover::menu_step( + Some(next), + 4, + if matches!(key, popover::MenuKey::Up) { + -1 + } else { + 1 + }, + ) + .unwrap_or(0); + if next < 2 || self.can_mutate_tree_entry(&menu.path, next == 3, cx) { + break; + } + } + self.tree_context_menu.open_mut().unwrap().active = next; + cx.notify(); + } + } + popover::MenuKey::Enter => { + if let Some(menu) = self.tree_context_menu.as_open() { + self.dispatch_tree_menu(menu.active, window, cx); + } + } + _ => {} + } + window.prevent_default(); + cx.stop_propagation(); + true + } + + fn dispatch_tree_menu(&mut self, action: usize, window: &mut Window, cx: &mut Context) { + let Some(menu) = self.tree_context_menu.as_open() else { + return; + }; + let path = menu.path.clone(); + let origin = menu.origin.clone(); + if !self.accepts_origin(&origin, cx) { + self.close_tree_context_menu(cx); + return; + } + let Some(node) = self.tree.node(&path) else { + self.close_tree_context_menu(cx); + return; + }; + let is_directory = node.entry.kind == zeron_proto::WorkspaceEntryKind::Directory; + self.close_tree_context_menu(cx); + match action { + 0 => cx.emit(FilesEvent::AddToChat { + path, + is_directory, + origin, + }), + 1 => { + cx.write_to_clipboard(ClipboardItem::new_string(absolute_workspace_path( + &origin.context.cwd, + &path, + ))); + self.tree_focus.focus(window, cx); + } + 2 => self.begin_tree_rename(path, window, cx), + 3 => self.begin_tree_delete(path, window, cx), + _ => {} + } + } + + pub(super) fn render_tree_context_menu( + &self, + theme: &Theme, + cx: &mut Context, + ) -> Option { + let menu = self.tree_context_menu.get()?; + let theme = theme.for_popup(); + let mut card = popover::popover_card(&theme) + .w(px(190.0)) + .flex() + .flex_col() + .on_mouse_down_out(cx.listener(|this, _, _, cx| this.close_tree_context_menu(cx))) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()); + for (index, (label, icon_path)) in [ + ("Add to chat", icons::CHAT_ROUND_LINE), + ("Copy path", icons::COPY), + ("Rename…", icons::PEN), + ("Delete…", icons::TRASH_BIN_MINIMALISTIC), + ] + .into_iter() + .enumerate() + { + if index == 2 { + card = card.child(popover::menu_separator()); + } + let enabled = index < 2 || self.can_mutate_tree_entry(&menu.path, index == 3, cx); + card = card.child( + popover::menu_row(&theme, menu.active == index, format!("tree-menu-{index}")) + .id(gpui::SharedString::from(format!("tree-menu-{index}"))) + .role(gpui::Role::MenuItem) + .aria_label(label) + .when(index == 3, |row| row.text_color(theme.danger)) + .when(index == 1, |row| { + row.tooltip(|_, cx| { + cx.new(|_| preview::FileEditorTooltip { + text: "Copy full path".into(), + }) + .into() + }) + }) + .when(!enabled, |el| el.opacity(0.38).cursor_default()) + .when(enabled, |el| { + el.on_click(cx.listener(move |this, _, window, cx| { + this.dispatch_tree_menu(index, window, cx) + })) + }) + .child( + icons::icon(icon_path) + .size(px(16.0)) + .text_color(if index == 3 { + theme.danger + } else { + theme.text_muted + }), + ) + .child(label), + ); + } + Some(popover::menu_at( + "tree-context-menu", + menu.position, + card.into_any_element(), + self.tree_context_menu.closing_since(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext, TestAppContext}; + + #[test] + fn full_paths_follow_the_owning_hosts_path_format() { + for (root, expected) in [ + ("/workspace", "/workspace/src/a.txt"), + ("/workspace/", "/workspace/src/a.txt"), + ("/", "/src/a.txt"), + (r"C:\work", r"C:\work\src\a.txt"), + (r"C:\", r"C:\src\a.txt"), + ("C:/work/", r"C:\work\src\a.txt"), + (r"\\server\share\work\", r"\\server\share\work\src\a.txt"), + (r"\\?\C:\work", r"\\?\C:\work\src\a.txt"), + ] { + assert_eq!(absolute_workspace_path(root, "src/a.txt"), expected); + } + } + + #[gpui::test] + fn copy_uses_the_remote_workspace_root(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + }); + for (root, expected) in [ + ("/remote/project", "/remote/project/a.txt"), + (r"D:\remote\project", r"D:\remote\project\a.txt"), + ] { + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| { + let mut state = super::super::test_support::state(); + state.chats[0].device_id = "remote-host".into(); + state.chats[0].cwd = Some(root.into()); + state + }); + super::super::test_support::explorer(state, cx) + }); + window + .update(cx, |files, window, cx| { + assert_eq!( + files + .request_context + .as_ref() + .unwrap() + .target_device_id + .as_deref(), + Some("remote-host") + ); + files.open_tree_context_menu( + "a.txt".into(), + gpui::point(px(50.), px(50.)), + window, + cx, + ); + files.dispatch_tree_menu(1, window, cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some(expected) + ); + }) + .unwrap(); + } + } + + #[gpui::test] + fn menu_selects_without_opening_and_copies_full_path(cx: &mut TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let recorded = events.clone(); + let _subscription = cx.update(|_, cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + files.update_in(cx, |files, window, cx| { + files.open_tree_context_menu("a.txt".into(), gpui::point(px(50.), px(50.)), window, cx); + assert_eq!(files.tree.selected(), Some("a.txt")); + assert!(events.borrow().is_empty()); + files.dispatch_tree_menu(1, window, cx); + assert_eq!( + cx.read_from_clipboard().unwrap().text().as_deref(), + Some("/workspace/a.txt") + ); + files.open_tree_context_menu( + "folder".into(), + gpui::point(px(50.), px(50.)), + window, + cx, + ); + assert!(!files.tree.is_expanded("folder")); + files.dispatch_tree_menu(0, window, cx); + }); + cx.run_until_parked(); + assert!(events.borrow().iter().any(|event|matches!(event,FilesEvent::AddToChat{path,is_directory:true,..} if path=="folder"))); + assert!( + !events + .borrow() + .iter() + .any(|event| matches!(event, FilesEvent::OpenFile(_))) + ); + } +} diff --git a/crates/ui/src/files/drag.rs b/crates/ui/src/files/drag.rs new file mode 100644 index 0000000000..ccccd82aff --- /dev/null +++ b/crates/ui/src/files/drag.rs @@ -0,0 +1,490 @@ +//! Workspace drag targets are scoped to the tree viewport; the chat keeps its own receiver. +use super::*; +use gpui::{AnyElement, Bounds, CursorStyle, DragMoveEvent}; +use std::{cell::RefCell, rc::Rc}; +use zeron_proto::WorkspaceEntryKind; + +#[derive(Default)] +pub(super) struct TreeDrag { + rows: Rc, Option)>>>, + pub payload: Option, + pub destination: Option, + pub pointer: Point, + pub bounds: Bounds, + hover_since: Option, + last_tick: Option, + tick_task: Option>, +} + +/// The server is authoritative for existence/permissions/collisions. This +/// resolver handles gesture semantics without touching the filesystem. +pub(super) fn destination_path( + source: &str, + directory: &str, + is_directory: bool, +) -> Option { + if source.is_empty() || model::parent_path(source).as_deref() == Some(directory) { + return None; + } + if is_directory && mutations::contains_path(source, directory) { + return None; + } + let name = source.rsplit('/').next()?; + Some(if directory.is_empty() { + name.into() + } else { + format!("{directory}/{name}") + }) +} + +impl FilesSurface { + pub(super) fn track_tree_drop_row( + &self, + key: String, + target: Option, + height: f32, + content: AnyElement, + ) -> AnyElement { + let rows = self.tree_drag.rows.clone(); + div() + .relative() + .w_full() + .h(px(height)) + .flex_none() + .child(content) + .child( + gpui::canvas( + move |bounds, _, _| { + rows.borrow_mut() + .insert(key.clone(), (bounds, target.clone())); + }, + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ) + .into_any_element() + } + + pub(super) fn render_tree_root_target(&self, cx: &mut Context) -> AnyElement { + let theme = crate::theme::Theme::of(cx); + let active = self.tree_drag.destination.as_deref() == Some(""); + let label = if self.tree_drag.payload.is_some() { + "Move to workspace root" + } else { + "Workspace root" + }; + self.track_tree_drop_row( + "".into(), + Some("".into()), + 24., + div() + .id("tree-workspace-root") + .debug_selector(|| "tree-workspace-root".into()) + .h(px(24.)) + .w_full() + .px(px(8.)) + .flex() + .items_center() + .text_size(px(10.5)) + .text_color(theme.text_muted) + .when(active, |el| el.bg(crate::theme::wash(0.16))) + .aria_label(label) + .child(label) + .into_any_element(), + ) + } + + pub(super) fn reset_tree_drop_rows(&self) { + self.tree_drag.rows.borrow_mut().clear(); + } + + pub(super) fn tree_drag_compatible(&self, payload: &WorkspacePathDrag, cx: &gpui::App) -> bool { + payload.source == WorkspacePathSource::Tree + && payload.origin.as_ref().is_some_and(|origin| { + self.accepts_origin(origin, cx) && origin.checkout_id == self.effective_checkout_id + }) + && !self.mutation_busy() + && self.tree_rename.is_none() + && self.tree_delete.is_none() + && self + .mutation_capabilities + .is_some_and(|caps| caps.move_entry) + && self.tree.node(&payload.path).is_some_and(|node| { + node.entry.kind != WorkspaceEntryKind::Symlink + && node.entry.mutation_revision.is_some() + && node.entry.mutation_revision == payload.revision + }) + } + + fn drop_directory_at(&self, point: Point) -> Option { + if !self.tree_drag.bounds.contains(&point) { + return None; + } + // The rail remains a scroll control, not a root drop target. + if point.x > self.tree_drag.bounds.right() - px(12.) { + return None; + } + let rows = self.tree_drag.rows.borrow(); + for (bounds, target) in rows.values() { + if bounds.contains(&point) { + let path = target.as_ref()?; + if path.is_empty() { + return Some(String::new()); + } + let node = self.tree.node(path)?; + return match node.entry.kind { + WorkspaceEntryKind::Directory => Some(path.clone()), + WorkspaceEntryKind::File => model::parent_path(path), + WorkspaceEntryKind::Symlink => None, + }; + } + } + let viewport = self.tree_list.viewport_bounds(); + let last = rows.values().map(|(bounds, _)| bounds.bottom()).max(); + if viewport.contains(&point) && last.is_none_or(|bottom| point.y >= bottom) { + Some(String::new()) + } else { + None + } + } + + pub(super) fn on_tree_drag_move( + &mut self, + event: &DragMoveEvent, + window: &mut Window, + cx: &mut Context, + ) { + let payload = event.drag(cx).clone(); + self.tree_drag.bounds = event.bounds; + if !event.bounds.contains(&event.event.position) || !self.tree_drag_compatible(&payload, cx) + { + self.clear_tree_drag(window, cx); + return; + } + self.close_tree_context_menu(cx); + self.tree_drag.pointer = event.event.position; + self.tree_drag.payload = Some(payload.clone()); + let destination = self + .drop_directory_at(event.event.position) + .filter(|directory| { + destination_path(&payload.path, directory, payload.is_directory).is_some() + }); + if self.tree_drag.destination != destination { + self.tree_drag.hover_since = Some(cx.background_executor().now()); + self.tree_drag.destination = destination; + cx.notify(); + } + if self.tree_drag.tick_task.is_none() { + self.tree_drag.last_tick = Some(cx.background_executor().now()); + self.tree_drag.tick_task = Some(cx.spawn_in(window, async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(16)) + .await; + if !this + .update_in(cx, |files, window, cx| files.tree_drag_tick(window, cx)) + .unwrap_or(false) + { + break; + } + } + })); + } + cx.set_active_drag_cursor_style( + if self.tree_drag.destination.is_some() { + CursorStyle::ClosedHand + } else { + CursorStyle::OperationNotAllowed + }, + window, + ); + } + + pub(super) fn on_tree_drop( + &mut self, + payload: &WorkspacePathDrag, + window: &mut Window, + cx: &mut Context, + ) { + let destination = if self.tree_drag_compatible(payload, cx) { + self.drop_directory_at(window.mouse_position()) + .and_then(|directory| { + destination_path(&payload.path, &directory, payload.is_directory) + }) + } else { + None + }; + self.clear_tree_drag(window, cx); + if let Some(destination) = destination { + self.request_mutation(&payload.path, Some(destination), cx); + } + } + + pub(super) fn tree_drag_tick(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some(payload) = self.tree_drag.payload.clone() else { + return false; + }; + let pointer = window.mouse_position(); + if !cx.has_active_drag() + || !window.is_window_active() + || !self.tree_drag_compatible(&payload, cx) + || !self.tree_drag.bounds.contains(&pointer) + { + self.clear_tree_drag(window, cx); + return false; + } + let now = cx.background_executor().now(); + let elapsed = self + .tree_drag + .last_tick + .replace(now) + .map(|last| now.saturating_duration_since(last).as_secs_f32()) + .unwrap_or(0.) + .min(0.05); + let viewport = self.tree_list.viewport_bounds(); + let speed = if viewport.contains(&pointer) { + edge_scroll_speed( + f32::from(pointer.y), + f32::from(viewport.top()), + f32::from(viewport.bottom()), + ) + } else { + 0. + }; + if speed != 0. { + let offset = self.tree_list.scroll_px_offset_for_scrollbar(); + self.tree_list + .set_offset_from_scrollbar(gpui::point(offset.x, offset.y - px(speed * elapsed))); + cx.notify(); + } + let destination = self.drop_directory_at(pointer).filter(|directory| { + destination_path(&payload.path, directory, payload.is_directory).is_some() + }); + if destination != self.tree_drag.destination { + self.tree_drag.destination = destination; + self.tree_drag.hover_since = Some(now); + cx.notify(); + } + if self + .tree_drag + .hover_since + .is_some_and(|since| now.saturating_duration_since(since) >= Duration::from_millis(650)) + { + self.tree_drag.hover_since = None; + if let Some(directory) = self.tree_drag.destination.clone().filter(|p| !p.is_empty()) { + if self.tree.expand(&directory) { + self.sync_tree_list(); + let needs_load = self + .tree + .node(&directory) + .is_some_and(|node| node.stale || !node.has_loaded); + if needs_load { + self.load_directory(directory, None, cx); + } + cx.notify(); + } + } + } + true + } + + pub(crate) fn suspend_tree_interactions(&mut self, cx: &mut Context) { + if self.tree_drag.payload.is_some() + || self.tree_context_menu.is_open() + || self + .tree_rename + .as_ref() + .is_some_and(|rename| !rename.submitted) + || self.tree_delete.is_some() + { + self.interaction_generation = self.interaction_generation.wrapping_add(1); + self.tree_drag = TreeDrag::default(); + self.close_tree_context_menu(cx); + if self + .tree_rename + .as_ref() + .is_some_and(|rename| !rename.submitted) + { + self.tree_rename = None; + } + if let Some(dialog) = self.tree_delete.take() { + cx.emit(FilesEvent::HoldMutation { + origin: dialog.origin, + path: None, + }); + } + cx.notify(); + } + } + + pub(super) fn clear_tree_drag(&mut self, window: &mut Window, cx: &mut Context) { + self.tree_drag.tick_task = None; + self.tree_drag.hover_since = None; + self.tree_drag.last_tick = None; + if self.tree_drag.payload.take().is_some() || self.tree_drag.destination.take().is_some() { + self.tree_drag.destination = None; + cx.set_active_drag_cursor_style(CursorStyle::Arrow, window); + cx.notify(); + } + } +} + +fn edge_scroll_speed(y: f32, top: f32, bottom: f32) -> f32 { + if y < top || y > bottom || bottom <= top { + return 0.; + } + let band = 28.0_f32.min((bottom - top) / 2.0); + if y < top + band { + -540.0 * (1.0 - (y - top) / band) + } else if y > bottom - band { + 540.0 * (1.0 - (bottom - y) / band) + } else { + 0.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[gpui::test] + fn real_tree_drop_emits_one_move_and_no_chat_reference(cx: &mut gpui::TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let recorded = events.clone(); + let _sub = cx.update(|_, cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + let start = cx.debug_bounds("tree-entry:a.txt").unwrap().center(); + let end = cx.debug_bounds("tree-entry:folder").unwrap().center(); + cx.simulate_mouse_down(start, gpui::MouseButton::Left, gpui::Modifiers::default()); + cx.simulate_mouse_move( + start + gpui::point(px(9.), px(0.)), + Some(gpui::MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.simulate_mouse_move( + end, + Some(gpui::MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up(end, gpui::MouseButton::Left, gpui::Modifiers::default()); + cx.run_until_parked(); + let events = events.borrow(); + let moves = events + .iter() + .filter_map(|event| { + if let FilesEvent::Mutate(intent) = event { + Some(intent) + } else { + None + } + }) + .collect::>(); + assert_eq!(moves.len(), 1); + assert_eq!(moves[0].destination.as_deref(), Some("folder/a.txt")); + assert!( + !events + .iter() + .any(|event| matches!(event, FilesEvent::AddToChat { .. })) + ); + } + #[gpui::test] + fn hover_expands_only_after_delay_and_escape_stops_tasks(cx: &mut gpui::TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + let start = cx.debug_bounds("tree-entry:a.txt").unwrap().center(); + let end = cx.debug_bounds("tree-entry:folder").unwrap().center(); + cx.simulate_mouse_down(start, gpui::MouseButton::Left, gpui::Modifiers::default()); + cx.simulate_mouse_move( + start + gpui::point(px(9.), px(0.)), + Some(gpui::MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.simulate_mouse_move( + end, + Some(gpui::MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.executor().advance_clock(Duration::from_millis(649)); + files.update_in(cx, |files, window, cx| { + files.tree_drag_tick(window, cx); + assert!(!files.tree.is_expanded("folder")); + }); + cx.executor().advance_clock(Duration::from_millis(1)); + files.update_in(cx, |files, window, cx| { + files.tree_drag_tick(window, cx); + assert!(files.tree.is_expanded("folder")); + }); + cx.simulate_keystrokes("escape"); + files.read_with(cx, |files, cx| { + assert!(files.tree_drag.tick_task.is_none()); + assert!(files.tree_drag.destination.is_none()); + assert!(!cx.has_active_drag()); + }); + } + #[cfg(target_os = "windows")] + #[gpui::test] + fn windows_row_jitter_preserves_click_and_row_starts_drag(cx: &mut gpui::TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let recorded = events.clone(); + let _sub = cx.update(|_, cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + let row = cx.debug_bounds("tree-entry:a.txt").unwrap().center(); + cx.simulate_mouse_down(row, gpui::MouseButton::Left, gpui::Modifiers::default()); + cx.simulate_mouse_move( + row + gpui::point(px(3.), px(0.)), + Some(gpui::MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.update(|_, cx| assert!(!cx.has_active_drag())); + cx.simulate_mouse_up( + row + gpui::point(px(3.), px(0.)), + gpui::MouseButton::Left, + gpui::Modifiers::default(), + ); + cx.run_until_parked(); + assert!( + events + .borrow() + .iter() + .any(|event| matches!(event,FilesEvent::OpenFile(path) if path=="a.txt")) + ); + cx.simulate_mouse_down(row, gpui::MouseButton::Left, gpui::Modifiers::default()); + cx.simulate_mouse_move( + row + gpui::point(px(9.), px(0.)), + Some(gpui::MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.update(|_, cx| assert!(cx.has_active_drag())); + cx.simulate_keystrokes("escape"); + } + #[test] + fn autoscroll_is_bounded_and_stops_outside_the_viewport() { + assert_eq!(edge_scroll_speed(100., 100., 500.), -540.); + assert_eq!(edge_scroll_speed(500., 100., 500.), 540.); + assert_eq!(edge_scroll_speed(300., 100., 500.), 0.); + assert_eq!(edge_scroll_speed(99., 100., 500.), 0.); + assert!(edge_scroll_speed(486., 100., 500.) > 0.); + } + #[test] + fn tree_drop_resolves_parent_root_and_descendants() { + assert_eq!( + destination_path("a/file", "b", false).as_deref(), + Some("b/file") + ); + assert_eq!( + destination_path("a/file", "", false).as_deref(), + Some("file") + ); + assert_eq!(destination_path("a/file", "a", false), None); + assert_eq!(destination_path("a", "a/sub", true), None); + assert_eq!(destination_path("a", "a", true), None); + assert_eq!(destination_path("a", "ab", true).as_deref(), Some("ab/a")); + } +} diff --git a/crates/ui/src/files/mod.rs b/crates/ui/src/files/mod.rs index 08d73af527..e672ec44ad 100644 --- a/crates/ui/src/files/mod.rs +++ b/crates/ui/src/files/mod.rs @@ -18,7 +18,9 @@ use crate::{ }; pub mod client; +mod context_menu; pub mod document; +mod drag; pub mod editor; pub mod editor_adapter; mod git_status; @@ -26,9 +28,13 @@ mod image_preview; pub(crate) mod markdown_media; mod markdown_preview; pub mod model; +pub mod mutations; pub mod preview; +mod rename; pub mod search; mod sections; +#[cfg(test)] +pub(crate) mod test_support; pub mod tree; pub mod watch; @@ -80,13 +86,41 @@ pub(super) fn toolbar_button(id: &'static str, label: &'static str) -> gpui::Sta /// that workspace instead of leaking a path from the UI machine. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct WorkspacePathDrag { + pub origin: Option, + pub source: WorkspacePathSource, + pub revision: Option, pub path: String, pub is_directory: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkspacePathSource { + Tree, + Search, + FileTab, +} + impl WorkspacePathDrag { pub(crate) fn new(path: String, is_directory: bool) -> Self { - Self { path, is_directory } + Self { + path, + is_directory, + origin: None, + source: WorkspacePathSource::Search, + revision: None, + } + } + + pub(crate) fn with_origin( + mut self, + origin: Option, + source: WorkspacePathSource, + revision: Option, + ) -> Self { + self.origin = origin; + self.source = source; + self.revision = revision; + self } fn title(&self) -> SharedString { @@ -147,6 +181,16 @@ pub(crate) fn workspace_path_drag_ghost( #[derive(Debug, Clone, PartialEq)] pub enum FilesEvent { + AddToChat { + path: String, + is_directory: bool, + origin: mutations::WorkspaceInteractionOrigin, + }, + HoldMutation { + origin: mutations::WorkspaceInteractionOrigin, + path: Option, + }, + Mutate(mutations::MutationIntent), OpenFile(String), RevealFile(String), OpenWebLink(crate::markdown::render::LinkActivation), @@ -205,6 +249,18 @@ struct EditorContextMenu { } pub struct FilesSurface { + surface_id: gpui::EntityId, + interaction_generation: u64, + effective_checkout_id: Option, + mutation_capabilities: Option, + pending_mutation: Option, + deferred_file_changes: Vec, + applied_mutations: std::collections::VecDeque, + mutation_error: Option, + mutation_hold: Option, + tree_drag: drag::TreeDrag, + tree_rename: Option, + tree_delete: Option, state: Entity, chat_id: String, review_comment_flush_source: u64, @@ -231,6 +287,7 @@ pub struct FilesSurface { watch_sequence: Option, watch_error: Option, preview: FilePreviewState, + tree_context_menu: crate::popover::Popup, pending_line_navigation: Option<(u32, Option)>, line_navigation_generation: u64, editor_context_menu: crate::popover::Popup, @@ -245,6 +302,9 @@ pub struct FilesSurface { impl Render for FilesSurface { fn render(&mut self, window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { + if !cx.has_active_drag() || !self.is_current_target(cx) { + self.clear_tree_drag(window, cx); + } if std::mem::take(&mut self.search_restore_tree_focus) { self.tree_focus.focus(window, cx); } @@ -281,8 +341,15 @@ impl Render for FilesSurface { .flex_col() .children(header) .child(div().flex_1().min_h_0().w_full().child(body)) + .children( + self.mutation_error + .as_ref() + .map(|message| crate::popover::error_row(&theme, message).into_any_element()), + ) .children(sections) .children(editor_context_menu) + .children(self.render_tree_context_menu(&theme, cx)) + .children(self.render_tree_delete(&theme, window, cx)) } } @@ -562,6 +629,18 @@ impl FilesSurface { .ok(); }); let mut surface = Self { + surface_id: cx.entity_id(), + interaction_generation: 0, + effective_checkout_id: None, + mutation_capabilities: None, + pending_mutation: None, + deferred_file_changes: Vec::new(), + applied_mutations: std::collections::VecDeque::new(), + mutation_error: None, + mutation_hold: None, + tree_drag: drag::TreeDrag::default(), + tree_rename: None, + tree_delete: None, state, chat_id, review_comment_flush_source: NEXT_REVIEW_COMMENT_FLUSH_SOURCE @@ -593,6 +672,7 @@ impl FilesSurface { word_wrap, editor_font_size, ), + tree_context_menu: crate::popover::Popup::default(), pending_line_navigation: None, line_navigation_generation: 0, editor_context_menu: crate::popover::Popup::default(), @@ -957,6 +1037,8 @@ impl FilesSurface { let reload = surface.tree.node(&directory).is_some_and(|node| node.stale); match result { Ok(page) => { + surface.effective_checkout_id = page.checkout_id.clone(); + surface.mutation_capabilities = page.mutation_capabilities; surface.error = None; surface.tree.apply_page(page, generation); if directory.is_empty() { @@ -1027,6 +1109,22 @@ impl FilesSurface { } fn apply_target(&mut self, next: Option, cx: &mut Context) { + if let Some(dialog) = self.tree_delete.take() { + cx.emit(FilesEvent::HoldMutation { + origin: dialog.origin, + path: None, + }); + } + self.tree_rename = None; + self.tree_drag = drag::TreeDrag::default(); + self.mutation_hold = None; + self.interaction_generation = self.interaction_generation.wrapping_add(1); + self.effective_checkout_id = None; + self.mutation_capabilities = None; + self.pending_mutation = None; + self.deferred_file_changes.clear(); + self.applied_mutations.clear(); + self.mutation_error = None; self.release_git_status(); self.suspend_images(cx); self.cancel_review_comment_flush(cx); @@ -1034,6 +1132,7 @@ impl FilesSurface { self.watch_task = None; self.watch_sequence = None; self.watch_error = None; + self.tree_context_menu = crate::popover::Popup::default(); self.editor_context_menu = crate::popover::Popup::default(); self.preview.reset(); self.pending_line_navigation = None; diff --git a/crates/ui/src/files/model.rs b/crates/ui/src/files/model.rs index ca30729f54..609fb09224 100644 --- a/crates/ui/src/files/model.rs +++ b/crates/ui/src/files/model.rs @@ -383,6 +383,92 @@ impl FileTreeModel { } } + /// Re-key a confirmed move without throwing away expansion or selection. + pub fn relocate_subtree( + &mut self, + old: &str, + new: &str, + entry: Option, + ) -> bool { + if old.is_empty() || old == new || is_descendant(new, old) { + return false; + } + let remap = |path: &str| -> String { + if path == old { + new.to_string() + } else if is_descendant(path, old) { + format!("{new}{}", &path[old.len()..]) + } else { + path.to_string() + } + }; + if !self.nodes.contains_key(old) { + return false; + } + let old_parent = parent_path(old).unwrap(); + let new_parent = parent_path(new).unwrap(); + if let Some(parent) = self.nodes.get_mut(&old_parent) { + parent.children.retain(|p| p != old); + } + let nodes = std::mem::take(&mut self.nodes); + for (path, mut node) in nodes { + let next = remap(&path); + node.entry.path = next.clone(); + if path == old { + node.entry.name = new.rsplit('/').next().unwrap_or(new).into(); + } + node.children = node.children.iter().map(|p| remap(p)).collect(); + if is_descendant(&path, old) || path == old { + node.stale = true; + if node.entry.kind == WorkspaceEntryKind::Directory { + node.load = DirectoryLoadState::Unloaded; + } + } + self.nodes.insert(next, node); + } + if let Some(entry) = entry { + if let Some(node) = self.nodes.get_mut(new) { + node.entry = entry; + } + } + self.expanded = self.expanded.iter().map(|p| remap(p)).collect(); + self.selected = self.selected.as_deref().map(remap); + let mut ancestor = Some(new_parent.clone()); + while let Some(path) = ancestor { + self.expanded.insert(path.clone()); + ancestor = parent_path(&path); + } + self.listing_children.clear(); + if let Some(parent) = self.nodes.get_mut(&new_parent) { + if !parent.children.iter().any(|p| p == new) { + parent.children.push(new.into()); + } + } + for parent in [&old_parent, &new_parent] { + if let Some(node) = self.nodes.get_mut(parent) { + node.stale = true; + node.load = DirectoryLoadState::Unloaded; + let mut children = std::mem::take(&mut node.children); + children.sort_by(|a, b| compare_paths(&self.nodes, a, b)); + self.nodes.get_mut(parent).unwrap().children = children; + } + } + self.rebuild_visible_rows(); + true + } + + /// Invalidate asynchronous listings without resetting the visible tree. + pub fn invalidate_loads(&mut self) { + self.generation = self.generation.wrapping_add(1); + self.listing_children.clear(); + for node in self.nodes.values_mut() { + if matches!(node.load, DirectoryLoadState::Loading { .. }) { + node.load = DirectoryLoadState::Unloaded; + node.stale = true; + } + } + } + pub fn remove(&mut self, path: &str) -> bool { if path.is_empty() || !self.nodes.contains_key(path) { return false; @@ -556,6 +642,7 @@ impl FileTreeModel { fn root_entry() -> WorkspaceEntry { WorkspaceEntry { + mutation_revision: None, path: String::new(), name: String::new(), kind: WorkspaceEntryKind::Directory, @@ -614,6 +701,7 @@ mod tests { fn entry(path: &str, kind: WorkspaceEntryKind) -> WorkspaceEntry { WorkspaceEntry { + mutation_revision: None, path: path.into(), name: path.rsplit('/').next().unwrap_or(path).into(), kind, @@ -630,6 +718,8 @@ mod tests { next_cursor: Option<&str>, ) -> WorkspaceDirectoryPage { WorkspaceDirectoryPage { + checkout_id: None, + mutation_capabilities: None, directory: directory.into(), entries, next_cursor: next_cursor.map(str::to_string), @@ -637,6 +727,36 @@ mod tests { } } + #[test] + fn relocation_keeps_descendants_selection_and_expansion() { + let mut tree = FileTreeModel::new(); + tree.begin_load("", None, tree.generation()); + tree.apply_page( + page( + "", + vec![ + entry("a", WorkspaceEntryKind::Directory), + entry("b", WorkspaceEntryKind::Directory), + ], + None, + ), + tree.generation(), + ); + tree.expand("a"); + tree.begin_load("a", None, tree.generation()); + tree.apply_page( + page("a", vec![entry("a/child", WorkspaceEntryKind::File)], None), + tree.generation(), + ); + tree.select("a/child"); + assert!(tree.relocate_subtree("a", "b/a", None)); + assert!(tree.is_expanded("b/a")); + assert_eq!(tree.selected(), Some("b/a/child")); + assert!(tree.node("a").is_none()); + assert!(tree.node("b/a/child").is_some()); + assert!(!tree.relocate_subtree("a", "b/a", None)); + } + #[test] fn background_refresh_keeps_loaded_rows_even_when_empty_or_failed() { let mut tree = FileTreeModel::new(); diff --git a/crates/ui/src/files/mutations.rs b/crates/ui/src/files/mutations.rs new file mode 100644 index 0000000000..04d16575d3 --- /dev/null +++ b/crates/ui/src/files/mutations.rs @@ -0,0 +1,445 @@ +//! Shared identity and reconciliation for tree mutations and workspace references. +use super::*; +use zeron_proto::{WorkspaceEntry, WorkspaceEntryKind, WorkspaceMutationOutcome}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceInteractionOrigin { + pub context: FilesRequestContext, + pub surface_id: gpui::EntityId, + pub generation: u64, + pub checkout_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MutationIntent { + pub operation_id: String, + pub origin: WorkspaceInteractionOrigin, + pub entry: WorkspaceEntry, + /// None deletes; Some moves without replacement (including rename). + pub destination: Option, +} + +pub(crate) fn contains_path(ancestor: &str, path: &str) -> bool { + path == ancestor + || path + .strip_prefix(ancestor) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +impl MutationIntent { + pub(crate) fn affects(&self, path: &str) -> bool { + contains_path(&self.entry.path, path) + || self + .destination + .as_deref() + .is_some_and(|to| contains_path(to, path)) + } +} + +impl FilesSurface { + pub(crate) fn interaction_origin(&self, cx: &gpui::App) -> Option { + self.is_current_target(cx) + .then(|| WorkspaceInteractionOrigin { + context: self.request_context.clone().unwrap(), + surface_id: self.surface_id, + generation: self.interaction_generation, + checkout_id: self.effective_checkout_id.clone().or_else(|| { + self.request_context + .as_ref() + .and_then(|c| c.checkout_id.clone()) + }), + }) + } + + pub(crate) fn accepts_origin( + &self, + origin: &WorkspaceInteractionOrigin, + cx: &gpui::App, + ) -> bool { + self.is_current_target(cx) + && self.surface_id == origin.surface_id + && self.interaction_generation == origin.generation + && self.request_context.as_ref() == Some(&origin.context) + } + + pub(crate) fn shares_workspace(&self, origin: &WorkspaceInteractionOrigin) -> bool { + let Some(context) = &self.request_context else { + return false; + }; + if context.target_device_id != origin.context.target_device_id { + return false; + } + let checkout = self + .effective_checkout_id + .as_ref() + .or(context.checkout_id.as_ref()); + match (checkout, origin.checkout_id.as_ref()) { + (Some(a), Some(b)) => a == b, + _ => context.cwd == origin.context.cwd, + } + } + + pub(crate) fn mutation_busy(&self) -> bool { + self.pending_mutation.is_some() + } + + pub(crate) fn prepare_mutation(&mut self, intent: MutationIntent, cx: &mut Context) { + self.pending_mutation = Some(intent); + for (path, document) in &mut self.preview.documents { + if self.pending_mutation.as_ref().unwrap().affects(path) { + document.autosave_task = None; + } + } + self.mutation_error = None; + cx.notify(); + } + + pub(crate) fn mutation_has_save(&self) -> bool { + self.pending_mutation.as_ref().is_some_and(|intent| { + self.preview + .documents + .iter() + .any(|(path, doc)| intent.affects(path) && doc.pending_save.is_some()) + }) + } + + pub(crate) fn hold_mutation(&mut self, path: Option, cx: &mut Context) { + self.mutation_hold = path; + let paths = self.preview.documents.keys().cloned().collect::>(); + for path in paths { + if self.mutation_blocks_path(&path) { + self.preview.documents.get_mut(&path).unwrap().autosave_task = None; + } else { + self.schedule_autosave(path, cx); + } + } + } + pub(super) fn mutation_blocks_path(&self, path: &str) -> bool { + if self + .mutation_hold + .as_deref() + .is_some_and(|hold| contains_path(hold, path)) + { + return true; + } + self.pending_mutation + .as_ref() + .is_some_and(|intent| intent.affects(path)) + } + + pub(crate) fn report_mutation_error(&mut self, message: String, cx: &mut Context) { + self.mutation_error = Some(message.into()); + if let Some(rename) = self.tree_rename.as_mut() { + rename.submitted = false; + } + cx.notify(); + } + + pub(super) fn request_mutation( + &mut self, + path: &str, + destination: Option, + cx: &mut Context, + ) { + if self.pending_mutation.is_some() { + return; + } + let Some(origin) = self.interaction_origin(cx) else { + return; + }; + let Some(entry) = self.tree.node(path).map(|n| n.entry.clone()) else { + return; + }; + let available = self.mutation_capabilities.is_some_and(|c| { + if destination.is_some() { + c.move_entry + } else { + c.delete_entry + } + }); + if !available + || origin.checkout_id.is_none() + || entry.mutation_revision.is_none() + || entry.kind == WorkspaceEntryKind::Symlink + { + return; + } + cx.emit(FilesEvent::Mutate(MutationIntent { + operation_id: uuid::Uuid::new_v4().to_string(), + origin, + entry, + destination, + })); + } + + pub(crate) fn finish_mutation( + &mut self, + intent: &MutationIntent, + result: &Result, + cx: &mut Context, + ) { + if self.pending_mutation.as_ref().map(|p| &p.operation_id) != Some(&intent.operation_id) { + return; + } + // A semantic watch event is authoritative even if the RPC reply was lost. + let observed = self + .deferred_file_changes + .iter() + .flat_map(|frame| &frame.changes) + .find(|change| change.operation_id.as_deref() == Some(&intent.operation_id)) + .cloned(); + let observed_applied = observed.is_some(); + if let Some(change) = observed { + self.apply_semantic_mutation(&change, None, true, cx); + } + match result { + Ok(WorkspaceMutationOutcome::Applied { + checkout_id, + change, + entry, + .. + }) if intent.origin.checkout_id.as_ref() == Some(checkout_id) => { + self.apply_semantic_mutation(change, entry.clone(), true, cx); + } + Ok(WorkspaceMutationOutcome::Rejected { message, .. }) | Err(message) => { + if !observed_applied { + self.mutation_error = Some(message.clone().into()); + } + } + _ => self.mutation_error = Some("Workspace changed before operation completed".into()), + } + if observed_applied || matches!(result, Ok(WorkspaceMutationOutcome::Applied { .. })) { + self.tree_rename = None; + } else if let Some(rename) = &mut self.tree_rename { + rename.submitted = false; + } + self.pending_mutation = None; + let frames = std::mem::take(&mut self.deferred_file_changes); + // Queued events may contain duplicate native remove/create pairs. After + // applying the semantic transition, reconcile rather than destroying state. + drop(frames); + self.loads.clear(); + self.tree.invalidate_loads(); + self.tree_list_generation = self.tree.generation(); + self.refresh(cx); + self.reconcile_open_documents(cx); + let paths = self.preview.documents.keys().cloned().collect::>(); + for path in paths { + self.schedule_autosave(path, cx); + } + if !self.search_state.query.is_empty() { + self.search_state.query.clear(); + self.on_search_edited(cx); + } + cx.notify(); + } + + pub(super) fn apply_semantic_mutation( + &mut self, + change: &zeron_proto::WorkspaceFileChange, + entry: Option, + local: bool, + cx: &mut Context, + ) { + if let Some(id) = &change.operation_id { + if self.applied_mutations.contains(id) { + return; + } + self.applied_mutations.push_back(id.clone()); + if self.applied_mutations.len() > 128 { + self.applied_mutations.pop_front(); + } + } + self.loads.clear(); + self.tree.invalidate_loads(); + match change.kind { + zeron_proto::WorkspaceFileChangeKind::Renamed => { + if let Some(old) = &change.old_path { + self.tree.relocate_subtree(old, &change.path, entry); + let phases = if local { + self.preview + .documents + .iter() + .filter(|(p, _)| contains_path(old, p)) + .map(|(p, d)| { + ( + format!("{}{}", change.path, &p[old.len()..]), + d.phase.clone(), + ) + }) + .collect::>() + } else { + Vec::new() + }; + for (old_path, new_path) in self.rename_documents(old, change.path.clone(), cx) + { + cx.emit(FilesEvent::FileRenamed { old_path, new_path }); + } + for (path, phase) in phases { + if let Some(doc) = self.preview.documents.get_mut(&path) { + doc.phase = phase; + } + } + if !self.presentation.is_editor() { + self.tree.select(change.path.clone()); + } + } + } + zeron_proto::WorkspaceFileChangeKind::Removed => { + let rows = self.tree.visible_rows().to_vec(); + let index = rows.iter().position(|r| r.path == change.path); + let next = index + .and_then(|i| { + rows[i + 1..] + .iter() + .chain(rows[..i].iter().rev()) + .find(|r| { + matches!(r.kind, model::VisibleRowKind::Entry) + && !contains_path(&change.path, &r.path) + }) + }) + .map(|r| r.path.clone()); + self.tree.remove(&change.path); + self.mark_document_deleted(&change.path, cx); + if let Some(path) = next { + self.tree.select(path); + } + } + _ => {} + } + self.invalidate_markdown_images(None, cx); + self.tree_list_generation = self.tree.generation(); + self.sync_tree_list(); + self.reveal_tree_selection(); + cx.notify(); + } +} + +#[cfg(test)] +mod tests { + use super::super::document::{DocumentKey, DocumentPhase, FileDocument}; + use super::*; + use gpui::{AppContext, TestAppContext}; + + #[gpui::test] + fn watch_before_lost_reply_still_remaps_the_dirty_buffer(cx: &mut TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + files.update_in(cx, |files, _, cx| { + let origin = files.interaction_origin(cx).unwrap(); + let intent = MutationIntent { + operation_id: "observed".into(), + origin, + entry: files.tree.node("a.txt").unwrap().entry.clone(), + destination: Some("b.txt".into()), + }; + let mut document = FileDocument::loading(DocumentKey { + chat_id: "chat".into(), + checkout_id: Some("checkout".into()), + path: "a.txt".into(), + }); + document.phase = DocumentPhase::Ready; + document.revision = 4; + document.saved_revision = 1; + files.preview.documents.insert("a.txt".into(), document); + files.prepare_mutation(intent.clone(), cx); + let change = zeron_proto::WorkspaceFileChange { + operation_id: Some("observed".into()), + kind: zeron_proto::WorkspaceFileChangeKind::Renamed, + path: "b.txt".into(), + old_path: Some("a.txt".into()), + }; + files.apply_workspace_changes( + zeron_proto::WorkspaceFileChanges { + sequence: 2, + resync_required: false, + changes: vec![change], + }, + cx, + ); + assert!(files.preview.documents.contains_key("a.txt")); + files.apply_workspace_changes( + zeron_proto::WorkspaceFileChanges { + sequence: 3, + resync_required: false, + changes: vec![], + }, + cx, + ); + files.finish_mutation(&intent, &Err("reply lost".into()), cx); + assert_eq!(files.watch_sequence, Some(3)); + assert!(files.preview.documents["b.txt"].is_dirty()); + assert_eq!(files.preview.documents["b.txt"].phase, DocumentPhase::Ready); + assert!(files.mutation_error.is_none()); + assert!(!files.mutation_busy()); + }); + } + #[test] + fn path_membership_uses_component_boundaries() { + assert!(contains_path("a", "a/child")); + assert!(contains_path("a", "a")); + assert!(!contains_path("a", "ab/child")); + } + + #[gpui::test] + fn confirmed_move_preserves_dirty_editor_and_duplicate_events_are_idempotent( + cx: &mut TestAppContext, + ) { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(crate::theme::Theme::dark()); + }); + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + FilesSurface::new_explorer(state, "chat".into(), false, cx) + }); + window + .update(cx, |files, window, cx| { + let theme = crate::theme::Theme::of(cx).clone(); + let editor = super::super::editor::new_file_editor( + "unsaved text", + "folder/a.txt", + false, + &theme, + window, + cx, + ); + let mut doc = FileDocument::loading(DocumentKey { + chat_id: "chat".into(), + checkout_id: Some("checkout".into()), + path: "folder/a.txt".into(), + }); + doc.editor = Some(editor.clone()); + doc.phase = DocumentPhase::Ready; + doc.revision = 3; + doc.saved_revision = 1; + files.preview.documents.insert("folder/a.txt".into(), doc); + let change = zeron_proto::WorkspaceFileChange { + operation_id: Some("op".into()), + kind: zeron_proto::WorkspaceFileChangeKind::Renamed, + path: "other".into(), + old_path: Some("folder".into()), + }; + files.apply_semantic_mutation(&change, None, true, cx); + files.apply_semantic_mutation(&change, None, true, cx); + let doc = &files.preview.documents["other/a.txt"]; + assert_eq!(doc.editor.as_ref(), Some(&editor)); + assert_eq!(editor.read(cx).value().as_ref(), "unsaved text"); + assert!(doc.is_dirty()); + assert_eq!(doc.phase, DocumentPhase::Ready); + assert!(!files.preview.documents.contains_key("folder/a.txt")); + let removed = zeron_proto::WorkspaceFileChange { + operation_id: Some("delete".into()), + kind: zeron_proto::WorkspaceFileChangeKind::Removed, + path: "other".into(), + old_path: None, + }; + files.apply_semantic_mutation(&removed, None, true, cx); + assert_eq!( + files.preview.documents["other/a.txt"].phase, + DocumentPhase::DeletedOnDisk + ); + assert_eq!(editor.read(cx).value().as_ref(), "unsaved text"); + }) + .unwrap(); + } +} diff --git a/crates/ui/src/files/preview.rs b/crates/ui/src/files/preview.rs index 1538988207..a151ee1c01 100644 --- a/crates/ui/src/files/preview.rs +++ b/crates/ui/src/files/preview.rs @@ -97,7 +97,7 @@ enum ReloadDecision { pub(super) struct FilePreviewState { images_visible: bool, - documents: HashMap, + pub(super) documents: HashMap, document_recency: VecDeque, active: Option, highlights: HashMap, @@ -1483,7 +1483,10 @@ impl FilesSurface { } pub(super) fn schedule_autosave(&mut self, path: String, cx: &mut Context) { - if !self.preview.autosave_enabled || self.preview.autosave_paused_for_reload(&path) { + if self.mutation_blocks_path(&path) + || !self.preview.autosave_enabled + || self.preview.autosave_paused_for_reload(&path) + { return; } let delay = Duration::from_millis(self.preview.autosave_delay_ms); @@ -1522,7 +1525,7 @@ impl FilesSurface { } pub(super) fn save_document(&mut self, path: String, cx: &mut Context) { - if self.target_change_pending { + if self.mutation_blocks_path(&path) || self.target_change_pending { return; } let Some(context) = self.request_context.clone() else { @@ -1911,10 +1914,24 @@ impl FilesSurface { document.reconcile_task = None; document.pending_save = None; document.key.path = new_document_path.clone(); + let was_markdown = super::markdown_preview::is_markdown(old_document_path); + let is_markdown = super::markdown_preview::is_markdown(new_document_path); + if was_markdown != is_markdown { + document.show_markdown = is_markdown; + if let Some(view) = document.markdown.take() { + view.update(cx, |view, cx| view.suspend(cx)); + } + } if let Some(file) = document.file.as_mut() { file.path = new_document_path.clone(); } if let Some(editor) = document.editor.clone() { + // Retarget the existing editor so selection and undo survive. + // Its previous adapter captures parsed spans for the old language. + editor.update(cx, |editor, cx| { + editor.set_highlighter(new_document_path.clone(), cx); + editor.set_highlighter_factory(Rc::new(|_| None), cx); + }); let event_path = new_document_path.clone(); document.editor_events = Some(super::editor::subscribe_to_changes(&editor, event_path, cx)); @@ -1928,11 +1945,7 @@ impl FilesSurface { if self.editor_path.as_deref() == Some(old_document_path) { self.editor_path = Some(new_document_path.clone()); } - if let Some(highlight) = self.preview.highlights.remove(old_document_path) { - self.preview - .highlights - .insert(new_document_path.clone(), highlight); - } + self.preview.highlights.remove(old_document_path); if let Some(anchors) = self.preview.comment_anchors.remove(old_document_path) { self.preview .comment_anchors @@ -1967,6 +1980,17 @@ impl FilesSurface { .is_some_and(FileDocument::is_dirty) { self.read_file(path.clone(), cx); + } else if let Some(document) = self.preview.documents.get(path) { + if let Some(editor) = &document.editor { + let source = editor.read(cx).value().to_string(); + self.request_editor_highlight(path.clone(), source, document.revision, cx); + } else if let Some((text, hash)) = document + .file + .as_ref() + .and_then(|file| file.text.clone().zip(file.content_hash.clone())) + { + self.request_file_highlight(path.clone(), text, hash, cx); + } } } if !renames.is_empty() { @@ -3336,6 +3360,142 @@ fn read_only_message(reason: Option) -> SharedString { #[cfg(test)] mod tests { use super::*; + use gpui::TestAppContext; + + #[gpui::test] + fn renaming_markdown_to_text_keeps_dirty_editor_and_undo(cx: &mut TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + let editor = files.update_in(cx, |files, window, cx| { + let theme = Theme::of(cx).clone(); + let editor = super::super::editor::new_file_editor( + "# Original", + "note.md", + false, + &theme, + window, + cx, + ); + let mut document = cached_document("note.md", "# Original"); + document.set_loaded(zeron_proto::WorkspaceFileText { + checkout_id: "checkout".into(), + path: "note.md".into(), + text: Some("# Original".into()), + content_hash: Some("saved-hash".into()), + size: 10, + modified_at: None, + encoding: zeron_proto::WorkspaceTextEncoding::Utf8, + line_ending: Some(zeron_proto::WorkspaceLineEnding::Lf), + read_only_reason: None, + truncated: false, + }); + editor.update(cx, |editor, cx| { + editor.set_selected_range(10..10, cx); + editor.replace(" unsaved", window, cx); + editor.set_selected_range(2..10, cx); + }); + document.editor = Some(editor.clone()); + document.mark_user_edit(); + files.preview.documents.insert("note.md".into(), document); + files.preview.active = Some("note.md".into()); + files.editor_path = Some("note.md".into()); + files.presentation = super::super::FilesPresentation::Editor; + assert!( + files + .prepare_markdown_preview("note.md", Some(&editor), cx) + .is_some() + ); + files.apply_semantic_mutation( + &zeron_proto::WorkspaceFileChange { + operation_id: Some("rename-md".into()), + kind: zeron_proto::WorkspaceFileChangeKind::Renamed, + path: "note.txt".into(), + old_path: Some("note.md".into()), + }, + None, + true, + cx, + ); + let document = &files.preview.documents["note.txt"]; + assert!(!document.show_markdown); + assert!(document.markdown.is_none()); + assert!(document.is_dirty()); + assert_eq!(document.phase, DocumentPhase::Ready); + assert_eq!(document.editor.as_ref(), Some(&editor)); + assert_eq!(editor.read(cx).value().as_ref(), "# Original unsaved"); + assert_eq!(editor.read(cx).selected_range(), 2..10); + assert_eq!(document.saved_hash.as_deref(), Some("saved-hash")); + assert!( + files + .prepare_markdown_preview("note.txt", Some(&editor), cx) + .is_none() + ); + editor.update(cx, |editor, cx| editor.focus(window, cx)); + editor + }); + cx.run_until_parked(); + cx.update(|window, cx| { + window.draw(cx).clear(); + window.dispatch_action(Box::new(gpui_base::input::Undo), cx); + }); + cx.run_until_parked(); + assert_eq!( + editor.read_with(cx, |editor, _| editor.value().to_string()), + "# Original" + ); + } + + #[gpui::test] + fn rename_refreshes_language_and_preserves_markdown_view_choice(cx: &mut TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + files.update_in(cx, |files, window, cx| { + let theme = Theme::of(cx).clone(); + let editor = super::super::editor::new_file_editor( + "let value = 1;", + "note.txt", + false, + &theme, + window, + cx, + ); + let mut document = cached_document("note.txt", "let value = 1;"); + document.editor = Some(editor); + files.preview.documents.insert("note.txt".into(), document); + files.rename_documents("note.txt", "note.rs".into(), cx); + }); + cx.run_until_parked(); + cx.executor().advance_clock(Duration::from_millis(120)); + cx.run_until_parked(); + files.update_in(cx, |files, _, cx| { + let key = DocumentHighlightKey::new( + zeron_syntax::language_for_path("note.rs").unwrap(), + "let value = 1;", + ); + assert!( + files.preview.syntax_cache.get(&key).is_some(), + "the open editor must be parsed in the new language without an edit" + ); + files.rename_documents("note.rs", "note.md".into(), cx); + assert!(files.preview.documents["note.md"].show_markdown); + files + .preview + .documents + .get_mut("note.md") + .unwrap() + .show_markdown = false; + files.rename_documents("note.md", "renamed.md".into(), cx); + assert!( + !files.preview.documents["renamed.md"].show_markdown, + "renaming within Markdown must preserve the user's code view choice" + ); + files.rename_documents("renamed.md", "renamed.txt".into(), cx); + assert!( + files.preview.documents["renamed.txt"] + .highlight_task + .is_none() + ); + assert!(!files.preview.highlights.contains_key("renamed.txt")); + }); + } /// A missing file gets its own wording — the transport's RPC framing /// never reaches the reader — while every other failure keeps its own. diff --git a/crates/ui/src/files/rename.rs b/crates/ui/src/files/rename.rs new file mode 100644 index 0000000000..df7c7b4daa --- /dev/null +++ b/crates/ui/src/files/rename.rs @@ -0,0 +1,544 @@ +//! Inline single-line rename and permanent-delete confirmation. +use super::*; +use crate::{popover, theme::Theme}; +use gpui::{AnyElement, Focusable, KeyDownEvent, MouseButton}; +use gpui_base::input::{Input, InputEvent, InputState}; + +pub(super) struct TreeRename { + pub path: String, + pub origin: mutations::WorkspaceInteractionOrigin, + pub input: Entity, + pub submitted: bool, + revision: Option, + _events: Subscription, +} +pub(super) struct TreeDelete { + pub path: String, + pub origin: mutations::WorkspaceInteractionOrigin, + pub focus: FocusHandle, + pub confirm_focused: bool, + revision: Option, +} + +pub(super) fn name_selection(name: &str, directory: bool) -> std::ops::Range { + let end = if directory { + name.len() + } else { + name.rfind('.').filter(|i| *i > 0).unwrap_or(name.len()) + }; + 0..end +} +pub(super) fn renamed_path(path: &str, name: &str) -> Result { + if name.is_empty() + || name == "." + || name == ".." + || name.eq_ignore_ascii_case(".git") + || name.contains(['/', '\\', '\0', ':']) + { + return Err("Enter a single file name without path separators"); + } + let parent = model::parent_path(path).unwrap_or_default(); + Ok(if parent.is_empty() { + name.into() + } else { + format!("{parent}/{name}") + }) +} +impl FilesSurface { + pub(super) fn can_mutate_tree_entry(&self, path: &str, deleting: bool, cx: &gpui::App) -> bool { + !self.mutation_busy() + && self.is_current_target(cx) + && self.effective_checkout_id.is_some() + && self.mutation_capabilities.is_some_and(|c| { + if deleting { + c.delete_entry + } else { + c.move_entry + } + }) + && self.tree.node(path).is_some_and(|n| { + n.entry.mutation_revision.is_some() + && n.entry.kind != zeron_proto::WorkspaceEntryKind::Symlink + }) + } + pub(super) fn begin_tree_rename( + &mut self, + path: String, + window: &mut Window, + cx: &mut Context, + ) { + if !self.can_mutate_tree_entry(&path, false, cx) { + return; + } + let Some(origin) = self.interaction_origin(cx) else { + return; + }; + let entry = self.tree.node(&path).unwrap().entry.clone(); + let input = cx.new(|cx| InputState::new(window, cx).default_value(entry.name.clone())); + input.update(cx, |state, cx| { + state.set_selected_range( + name_selection( + &entry.name, + entry.kind == zeron_proto::WorkspaceEntryKind::Directory, + ), + cx, + ) + }); + let events = cx.subscribe_in(&input, window, |this, _, event, window, cx| match event { + InputEvent::PressEnter { .. } => this.submit_tree_rename(window, cx), + InputEvent::Blur + if this + .tree_rename + .as_ref() + .is_some_and(|rename| !rename.submitted) => + { + this.tree_rename = None; + cx.notify(); + } + _ => {} + }); + let focus = input.focus_handle(cx); + self.tree.select(path.clone()); + self.reveal_tree_selection(); + self.mutation_error = None; + self.tree_rename = Some(TreeRename { + path, + origin, + input, + submitted: false, + revision: entry.mutation_revision, + _events: events, + }); + window.focus(&focus, cx); + cx.notify(); + } + fn submit_tree_rename(&mut self, window: &mut Window, cx: &mut Context) { + let Some(rename) = &self.tree_rename else { + return; + }; + if rename.submitted { + return; + } + if !self.accepts_origin(&rename.origin, cx) { + self.tree_rename = None; + cx.notify(); + return; + } + if self + .tree + .node(&rename.path) + .and_then(|node| node.entry.mutation_revision.as_ref()) + != rename.revision.as_ref() + { + self.report_mutation_error("Entry changed; cancel and reopen Rename".into(), cx); + return; + } + let path = rename.path.clone(); + let name = rename.input.read(cx).value().to_string(); + let destination = match renamed_path(&path, &name) { + Ok(path) => path, + Err(message) => { + self.report_mutation_error(message.into(), cx); + return; + } + }; + if path == destination { + self.tree_rename = None; + self.tree_focus.focus(window, cx); + cx.notify(); + return; + } + self.tree_rename.as_mut().unwrap().submitted = true; + self.request_mutation(&path, Some(destination), cx); + } + pub(super) fn render_tree_rename( + &self, + path: &str, + cx: &mut Context, + ) -> Option { + let rename = self + .tree_rename + .as_ref() + .filter(|rename| rename.path == path)?; + let theme = Theme::of(cx).clone(); + // gpui-base's Input is unstyled: explicitly supply the same visible + // caret and selection colors as the file editor, including theme changes. + rename.input.update(cx, |input, _| { + input.set_editor_style(super::editor_adapter::editor_style(&theme)); + }); + Some( + div() + .id("tree-rename-input") + .debug_selector(|| "tree-rename-input".into()) + .flex_1() + .min_w_0() + .h(px(23.)) + .px(px(4.)) + .flex() + .items_center() + .rounded(px(4.)) + .border_1() + .border_color(theme.accent) + .bg(theme.input_glass_bg()) + .font_family(theme.font_sans.clone()) + .text_size(px(11.5)) + .text_color(theme.text) + .cursor_text() + .occlude() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(|_, _, cx| cx.stop_propagation()) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + if event.keystroke.key == "escape" { + if this.tree_rename.as_ref().is_some_and(|r| !r.submitted) { + this.tree_rename = None; + this.tree_focus.focus(window, cx); + cx.notify(); + } + window.prevent_default(); + cx.stop_propagation(); + } + })) + .child(Input::new(&rename.input)) + .into_any_element(), + ) + } + pub(super) fn begin_tree_delete( + &mut self, + path: String, + window: &mut Window, + cx: &mut Context, + ) { + if !self.can_mutate_tree_entry(&path, true, cx) { + return; + } + let Some(origin) = self.interaction_origin(cx) else { + return; + }; + let focus = cx.focus_handle(); + focus.focus(window, cx); + cx.emit(FilesEvent::HoldMutation { + origin: origin.clone(), + path: Some(path.clone()), + }); + let revision = self + .tree + .node(&path) + .and_then(|node| node.entry.mutation_revision.clone()); + self.tree_delete = Some(TreeDelete { + path, + origin, + focus, + confirm_focused: false, + revision, + }); + cx.notify(); + } + pub(super) fn dismiss_tree_delete( + &mut self, + confirm: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(dialog) = self.tree_delete.take() else { + return; + }; + cx.emit(FilesEvent::HoldMutation { + origin: dialog.origin.clone(), + path: None, + }); + if confirm && self.accepts_origin(&dialog.origin, cx) { + if self + .tree + .node(&dialog.path) + .and_then(|node| node.entry.mutation_revision.as_ref()) + != dialog.revision.as_ref() + { + self.report_mutation_error( + "Entry changed; reopen Delete to confirm its current contents".into(), + cx, + ); + } else { + self.request_mutation(&dialog.path, None, cx); + } + } + self.tree_focus.focus(window, cx); + cx.notify(); + } + pub(super) fn render_tree_delete( + &self, + theme: &Theme, + window: &Window, + cx: &mut Context, + ) -> Option { + let dialog = self.tree_delete.as_ref()?; + let theme = theme.for_popup(); + let directory = self + .tree + .node(&dialog.path) + .is_some_and(|n| n.entry.kind == zeron_proto::WorkspaceEntryKind::Directory); + let name = dialog.path.rsplit('/').next().unwrap_or(&dialog.path); + let copy = format!( + "Permanently delete {}? {}Open editor buffers will be kept for recovery.", + name, + if directory { + "All current folder contents will be deleted. " + } else { + "This cannot be undone. " + } + ); + let card = popover::dialog_card(&theme) + .w(px(380.)) + .gap(px(12.)) + .track_focus(&dialog.focus) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + match event.keystroke.key.as_str() { + "escape" => this.dismiss_tree_delete(false, window, cx), + "tab" | "left" | "right" => { + if let Some(dialog) = this.tree_delete.as_mut() { + dialog.confirm_focused = !dialog.confirm_focused; + cx.notify(); + } + } + "enter" => { + let confirm = this.tree_delete.as_ref().is_some_and(|d| d.confirm_focused); + this.dismiss_tree_delete(confirm, window, cx); + } + _ => {} + } + window.prevent_default(); + cx.stop_propagation(); + })) + .child(popover::dialog_title(&theme, "Delete permanently?")) + .child(popover::dialog_body(&theme, copy)) + .child( + div() + .flex() + .justify_end() + .gap(px(8.)) + .child( + popover::btn_ghost(&theme, "Cancel", "tree-delete-cancel") + .id("tree-delete-cancel") + .role(gpui::Role::Button) + .aria_label("Cancel") + .when(!dialog.confirm_focused, |el| { + el.bg(crate::theme::wash(0.12)) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.dismiss_tree_delete(false, window, cx) + })), + ) + .child( + popover::btn_danger(&theme, "Delete") + .id("tree-delete-confirm") + .role(gpui::Role::Button) + .aria_label("Delete") + .when(dialog.confirm_focused, |el| { + el.border_1().border_color(theme.text) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.dismiss_tree_delete(true, window, cx) + })), + ), + ); + Some(popover::modal( + "tree-delete-dialog", + window.viewport_size(), + card.into_any_element(), + )) + } +} +#[cfg(test)] +mod tests { + use super::*; + #[gpui::test] + fn rename_input_keeps_focus_and_supports_caret_editing(cx: &mut gpui::TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let recorded = events.clone(); + let _sub = cx.update(|_, cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + files.update_in(cx, |files, window, cx| { + files.tree.select("a.txt"); + files.tree_focus.focus(window, cx); + }); + cx.simulate_keystrokes("f2"); + let input = files.read_with(cx, |files, _| { + files.tree_rename.as_ref().unwrap().input.clone() + }); + assert_eq!(input.read_with(cx, |input, _| input.selected_range()), 0..1); + cx.simulate_keystrokes("right"); + assert_eq!(input.read_with(cx, |input, _| input.selected_range()), 1..1); + cx.simulate_input("b"); + assert_eq!( + input.read_with(cx, |input, _| input.value().to_string()), + "ab.txt" + ); + + let bounds = cx.debug_bounds("tree-rename-input").unwrap(); + cx.simulate_click(bounds.center(), gpui::Modifiers::default()); + files.update_in(cx, |files, window, cx| { + assert!( + files.tree_rename.is_some(), + "clicking the field must not activate its tree row" + ); + assert!(input.focus_handle(cx).is_focused(window)); + assert!(!files.tree_focus.is_focused(window)); + }); + for theme in [Theme::dark(), Theme::light()] { + cx.update(|window, cx| { + cx.set_global(theme); + window.refresh(); + window.draw(cx).clear(); + }); + input.read_with(cx, |input, _| { + let (caret, _) = input.cursor_layout().expect("rename must lay out a caret"); + assert!(caret.size.width > px(0.)); + assert!(caret.size.height > px(0.)); + assert!(caret.left() >= bounds.left()); + assert!(caret.right() <= bounds.right()); + assert!(caret.top() >= bounds.top()); + assert!(caret.bottom() <= bounds.bottom()); + }); + } + cx.simulate_keystrokes("escape"); + files.update_in(cx, |files, window, _| { + assert!(files.tree_rename.is_none()); + assert!(files.tree_focus.is_focused(window)); + }); + cx.run_until_parked(); + assert!( + !events + .borrow() + .iter() + .any(|event| matches!(event, FilesEvent::OpenFile(_) | FilesEvent::Mutate(_))) + ); + + cx.simulate_keystrokes("f2"); + cx.simulate_input("résumé"); + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + let events = events.borrow(); + let intents = events + .iter() + .filter_map(|event| match event { + FilesEvent::Mutate(intent) => Some(intent), + _ => None, + }) + .collect::>(); + assert_eq!(intents.len(), 1); + assert_eq!(intents[0].destination.as_deref(), Some("résumé.txt")); + } + + #[gpui::test] + fn refreshed_revision_does_not_authorize_an_already_open_confirmation( + cx: &mut gpui::TestAppContext, + ) { + let (files, cx) = super::super::test_support::setup(cx); + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let recorded = events.clone(); + let _sub = cx.update(|_, cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + files.update_in(cx, |files, window, cx| { + files.begin_tree_delete("a.txt".into(), window, cx); + files.begin_tree_rename("a.txt".into(), window, cx); + let input = files.tree_rename.as_ref().unwrap().input.clone(); + input.update(cx, |input, cx| input.set_value("renamed.txt", window, cx)); + let mut entry = + super::super::test_support::entry("a.txt", zeron_proto::WorkspaceEntryKind::File); + entry.mutation_revision = Some("external-edit".into()); + files.tree.apply_page( + zeron_proto::WorkspaceDirectoryPage { + directory: "".into(), + checkout_id: Some("checkout".into()), + mutation_capabilities: files.mutation_capabilities, + entries: vec![entry], + next_cursor: None, + truncated: false, + }, + files.tree.generation(), + ); + files.submit_tree_rename(window, cx); + assert!(!files.tree_rename.as_ref().unwrap().submitted); + assert!( + files + .mutation_error + .as_ref() + .unwrap() + .contains("Entry changed") + ); + files.dismiss_tree_delete(true, window, cx); + assert!( + files + .mutation_error + .as_ref() + .unwrap() + .contains("Entry changed") + ); + }); + cx.run_until_parked(); + assert!( + !events + .borrow() + .iter() + .any(|event| matches!(event, FilesEvent::Mutate(_))) + ); + } + + #[gpui::test] + fn inline_rename_submits_once_and_cancel_delete_never_mutates(cx: &mut gpui::TestAppContext) { + let (files, cx) = super::super::test_support::setup(cx); + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let recorded = events.clone(); + let _sub = cx.update(|_, cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + files.update_in(cx, |files, window, cx| { + files.begin_tree_rename("a.txt".into(), window, cx); + let input = files.tree_rename.as_ref().unwrap().input.clone(); + input.update(cx, |input, cx| input.set_value("new.txt", window, cx)); + files.submit_tree_rename(window, cx); + files.submit_tree_rename(window, cx); + }); + cx.run_until_parked(); + assert_eq!( + events + .borrow() + .iter() + .filter(|event| matches!(event, FilesEvent::Mutate(_))) + .count(), + 1 + ); + events.borrow_mut().clear(); + files.update_in(cx, |files, window, cx| { + files.tree_rename = None; + files.begin_tree_delete("folder".into(), window, cx); + assert!(!files.tree_delete.as_ref().unwrap().confirm_focused); + files.dismiss_tree_delete(false, window, cx); + }); + cx.run_until_parked(); + assert!( + !events + .borrow() + .iter() + .any(|event| matches!(event, FilesEvent::Mutate(_))) + ); + } + #[test] + fn rename_preserves_parent_and_selects_only_basename() { + assert_eq!(renamed_path("src/a.txt", "é.txt").unwrap(), "src/é.txt"); + for invalid in ["", ".", "..", "../a", "a/b", ".git", "a\\b"] { + assert!(renamed_path("src/a", invalid).is_err()); + } + assert_eq!(name_selection("foo.test.ts", false), 0..8); + assert_eq!(name_selection(".gitignore", false), 0..10); + assert_eq!(name_selection("foo.ts", true), 0..6); + } +} diff --git a/crates/ui/src/files/search.rs b/crates/ui/src/files/search.rs index c17905d723..20a0ffbfe9 100644 --- a/crates/ui/src/files/search.rs +++ b/crates/ui/src/files/search.rs @@ -610,7 +610,11 @@ impl FilesSurface { let is_directory = row.kind == WorkspaceEntryKind::Directory; let decoration = self.git_decoration(&row.path, is_directory, cx); let padding = 8.0 + row.depth as f32 * super::tree::TREE_INDENT; - let drag_payload = WorkspacePathDrag::new(row.path.clone(), is_directory); + let drag_payload = WorkspacePathDrag::new(row.path.clone(), is_directory).with_origin( + self.interaction_origin(cx), + super::WorkspacePathSource::Search, + None, + ); let content = div() .id(("files-search-result", index)) .role(gpui::Role::TreeItem) diff --git a/crates/ui/src/files/test_support.rs b/crates/ui/src/files/test_support.rs new file mode 100644 index 0000000000..b4c60ac2f4 --- /dev/null +++ b/crates/ui/src/files/test_support.rs @@ -0,0 +1,70 @@ +use super::*; +use gpui::{AppContext, TestAppContext, VisualTestContext}; +use zeron_proto::{WorkspaceDirectoryPage, WorkspaceEntry, WorkspaceEntryKind}; + +pub(crate) fn state() -> AppState { + let mut state = AppState::new(); + state.local_device_id = Some("device".into()); + state.chats.push( + serde_json::from_value(serde_json::json!({ + "id":"chat", "deviceId":"device", "archived":false, "cwd":"/workspace", + "checkoutId":"checkout", "createdAt":"2026-01-01T00:00:00Z" + })) + .unwrap(), + ); + state +} + +pub(crate) fn explorer(state: Entity, cx: &mut Context) -> FilesSurface { + let mut files = FilesSurface::new_explorer(state, "chat".into(), false, cx); + files.effective_checkout_id = Some("checkout".into()); + files.mutation_capabilities = Some(zeron_proto::WorkspaceMutationCapabilities { + move_entry: true, + delete_entry: true, + }); + files.tree.begin_load("", None, files.tree.generation()); + files.tree.apply_page( + WorkspaceDirectoryPage { + directory: "".into(), + checkout_id: Some("checkout".into()), + mutation_capabilities: files.mutation_capabilities, + entries: vec![ + entry("a.txt", WorkspaceEntryKind::File), + entry("folder", WorkspaceEntryKind::Directory), + ], + next_cursor: None, + truncated: false, + }, + files.tree.generation(), + ); + files.sync_tree_list(); + files +} + +pub(super) fn setup(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(crate::theme::Theme::dark()); + }); + let (files, cx) = cx.add_window_view(|_, cx| { + let state = cx.new(|_| state()); + explorer(state, cx) + }); + cx.update(|window, cx| { + window.activate_window(); + window.draw(cx).clear(); + }); + (files, cx) +} +pub(super) fn entry(path: &str, kind: WorkspaceEntryKind) -> WorkspaceEntry { + WorkspaceEntry { + path: path.into(), + name: path.rsplit('/').next().unwrap().into(), + kind, + size: Some(1), + modified_at: None, + ignored: false, + read_only: false, + mutation_revision: Some("rev".into()), + } +} diff --git a/crates/ui/src/files/tree.rs b/crates/ui/src/files/tree.rs index a6c92749a9..8c87d7a7ad 100644 --- a/crates/ui/src/files/tree.rs +++ b/crates/ui/src/files/tree.rs @@ -173,6 +173,7 @@ impl FilesSurface { } pub(super) fn on_tree_scrolled(&mut self, cx: &mut Context) { + self.close_tree_context_menu(cx); // The list repaints itself; the floating rail needs a view pass. cx.notify(); } @@ -186,8 +187,13 @@ impl FilesSurface { pub(super) fn render_tree(&mut self, cx: &mut Context) -> AnyElement { let theme = Theme::of(cx).clone(); let scrollbar = popover::rail(self, "files-tree-scrollbar", &theme, cx); + self.reset_tree_drop_rows(); + let root_target = self.render_tree_root_target(cx); div() .id("files-tree") + .debug_selector(|| "files-tree".into()) + .on_drag_move::(cx.listener(Self::on_tree_drag_move)) + .on_drop::(cx.listener(Self::on_tree_drop)) .role(gpui::Role::Tree) .aria_label("Workspace file tree") .relative() @@ -204,6 +210,7 @@ impl FilesSurface { .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { this.on_tree_key_down(event, window, cx) })) + .child(root_target) .child({ // The sidebar's overflow treatment: rows fade under the top // and bottom edges while there is more to scroll to, read @@ -235,17 +242,34 @@ impl FilesSurface { }; let theme = Theme::of(cx).clone(); let padding = 8.0 + row.depth as f32 * TREE_INDENT; + let drop_key = row.path.clone(); + let drop_target = match &row.kind { + VisibleRowKind::Entry => Some(row.path.clone()), + VisibleRowKind::Empty { directory } => Some(directory.clone()), + _ => None, + }; let content = match row.kind { VisibleRowKind::Entry => { let Some(node) = self.tree.node(&row.path).cloned() else { return gpui::Empty.into_any_element(); }; let path = row.path.clone(); + let menu_path = path.clone(); let selected = self.tree.selected() == Some(path.as_str()); let focused = self.tree_focus.is_focused(window); let is_directory = node.entry.kind == WorkspaceEntryKind::Directory; let decoration = self.git_decoration(&row.path, is_directory, cx); - let drag_payload = WorkspacePathDrag::new(path.clone(), is_directory); + let drag_payload = WorkspacePathDrag::new(path.clone(), is_directory).with_origin( + self.interaction_origin(cx), + super::WorkspacePathSource::Tree, + node.entry.mutation_revision.clone(), + ); + let drag_owner = cx.weak_entity(); + let renaming = self + .tree_rename + .as_ref() + .is_some_and(|rename| rename.path == path); + let rename_element = self.render_tree_rename(&path, cx); let expanded = is_directory && self.tree.is_expanded(&path); let text_color = if let Some(decoration) = decoration { decoration.color(&theme) @@ -268,6 +292,14 @@ impl FilesSurface { ))) .role(gpui::Role::TreeItem) .aria_label(node.entry.name.clone()) + .debug_selector({ + let path = row.path.clone(); + move || format!("tree-entry:{path}") + }) + .when( + is_directory && self.tree_drag.destination.as_deref() == Some(&path), + |el| el.bg(crate::theme::wash(0.18)), + ) .aria_selected(selected) .when(is_directory, |element| element.aria_expanded(expanded)) .h(px(TREE_ROW_HEIGHT)) @@ -292,9 +324,26 @@ impl FilesSurface { this.tree_focus.focus(window, cx); this.activate_tree_path(path.clone(), cx); })) - .on_drag(drag_payload, |payload, _, _, cx| { - cx.stop_propagation(); - workspace_path_drag_ghost(payload, cx) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &gpui::MouseDownEvent, window, cx| { + cx.stop_propagation(); + window.prevent_default(); + this.open_tree_context_menu( + menu_path.clone(), + event.position, + window, + cx, + ); + }), + ) + .when(!renaming, |element| { + element.on_drag(drag_payload, move |payload, _, _, cx| { + let _ = drag_owner + .update(cx, |files, cx| files.close_tree_context_menu(cx)); + cx.stop_propagation(); + workspace_path_drag_ghost(payload, cx) + }) }) .child( div() @@ -320,15 +369,16 @@ impl FilesSurface { .size(px(14.0)) .flex_none(), ) - .child( + .child(rename_element.unwrap_or_else(|| { div() .min_w_0() .truncate() .font_family(theme.font_sans.clone()) .text_size(px(11.5)) .text_color(text_color) - .child(node.entry.name), - ) + .child(node.entry.name) + .into_any_element() + })) .into_any_element() } VisibleRowKind::Loading { .. } => status_row( @@ -398,7 +448,12 @@ impl FilesSurface { ) .into_any_element(), }; - with_indent_guides(content, row.depth, TREE_ROW_HEIGHT, &theme) + self.track_tree_drop_row( + drop_key, + drop_target, + TREE_ROW_HEIGHT, + with_indent_guides(content, row.depth, TREE_ROW_HEIGHT, &theme), + ) } pub(super) fn activate_tree_path(&mut self, path: String, cx: &mut Context) { @@ -439,6 +494,47 @@ impl FilesSurface { window: &mut Window, cx: &mut Context, ) { + // Leave text input and editing shortcuts to the inline field. Stopping + // printable keys here would also prevent the platform's text insertion. + if self.tree_rename.is_some() { + return; + } + if event.keystroke.key == "escape" && cx.has_active_drag() { + cx.stop_active_drag(window); + self.clear_tree_drag(window, cx); + cx.stop_propagation(); + return; + } + if self.tree_menu_key(event, window, cx) { + return; + } + if event.keystroke.key == "menu" + || (event.keystroke.key == "f10" && event.keystroke.modifiers.shift) + { + if let Some(path) = self.tree.selected().map(str::to_string) { + self.open_tree_context_menu( + path, + self.tree_list.viewport_bounds().origin, + window, + cx, + ); + } + window.prevent_default(); + cx.stop_propagation(); + return; + } + if matches!(event.keystroke.key.as_str(), "f2" | "delete") { + if let Some(path) = self.tree.selected().map(str::to_string) { + if event.keystroke.key == "f2" { + self.begin_tree_rename(path, window, cx); + } else { + self.begin_tree_delete(path, window, cx); + } + } + window.prevent_default(); + cx.stop_propagation(); + return; + } let handled = match event.keystroke.key.as_str() { "up" => { self.tree.select_previous(); diff --git a/crates/ui/src/files/watch.rs b/crates/ui/src/files/watch.rs index acbdedce71..06ea0dd3f4 100644 --- a/crates/ui/src/files/watch.rs +++ b/crates/ui/src/files/watch.rs @@ -71,8 +71,36 @@ impl FilesSurface { })); } - fn apply_workspace_changes(&mut self, frame: WorkspaceFileChanges, cx: &mut Context) { - let gap = sequence_needs_resync(self.watch_sequence, frame.sequence); + pub(super) fn apply_workspace_changes( + &mut self, + mut frame: WorkspaceFileChanges, + cx: &mut Context, + ) { + if self.pending_mutation.is_some() { + // Defer affected events; unrelated changes still flow through. Keep + // sequence accounting on the live stream, not the deferred fragments. + let intent = self.pending_mutation.as_ref().unwrap(); + let (affected, independent): (Vec<_>, Vec<_>) = + frame.changes.into_iter().partition(|change| { + intent.affects(&change.path) + || change + .old_path + .as_deref() + .is_some_and(|old| intent.affects(old)) + }); + if !affected.is_empty() || frame.resync_required { + self.deferred_file_changes.push(WorkspaceFileChanges { + sequence: frame.sequence, + resync_required: frame.resync_required, + changes: affected, + }); + } + frame.changes = independent; + frame.resync_required = false; + } + + let gap = self.pending_mutation.is_none() + && sequence_needs_resync(self.watch_sequence, frame.sequence); tracing::trace!( sequence = frame.sequence, previous_sequence = ?self.watch_sequence, @@ -91,6 +119,16 @@ impl FilesSurface { let mut parents = HashSet::new(); for change in frame.changes { + if change.operation_id.is_some() { + self.apply_semantic_mutation(&change, None, false, cx); + if let Some(parent) = parent_path(&change.path) { + parents.insert(parent); + } + if let Some(old) = change.old_path.as_deref().and_then(parent_path) { + parents.insert(old); + } + continue; + } self.invalidate_markdown_images(Some(&change.path), cx); if let Some(old_path) = &change.old_path { self.invalidate_markdown_images(Some(old_path), cx); @@ -103,6 +141,11 @@ impl FilesSurface { } } WorkspaceFileChangeKind::Modified => { + // Saves (including atomic replacement) change the metadata + // revision used by move/delete, even when the name is unchanged. + if let Some(parent) = parent_path(&change.path) { + parents.insert(parent); + } self.reconcile_document(change.path, cx); } WorkspaceFileChangeKind::Removed => { @@ -114,7 +157,7 @@ impl FilesSurface { } WorkspaceFileChangeKind::Renamed => { if let Some(old_path) = change.old_path { - self.tree.remove(&old_path); + self.tree.relocate_subtree(&old_path, &change.path, None); for (old_path, new_path) in self.rename_documents(&old_path, change.path.clone(), cx) { @@ -160,6 +203,116 @@ pub(super) fn sequence_needs_resync(previous: Option, next: u64) -> bool { #[cfg(test)] mod tests { use super::*; + use gpui::AppContext; + + #[gpui::test] + fn completed_save_refreshes_revision_before_the_next_mutation(cx: &mut gpui::TestAppContext) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let _guard = runtime.enter(); + let (out, mut requests) = tokio::sync::mpsc::channel(16); + let (replies, inbound) = tokio::sync::mpsc::channel(16); + let engine = + crate::state::EngineHandle::from_test_client(zeron_rpc::RpcClient::new(out, inbound)); + let state = cx.new(|_| { + let mut state = super::super::test_support::state(); + state.set_test_engine(engine); + state + }); + let files = cx.new(|cx| super::super::test_support::explorer(state, cx)); + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let recorded = events.clone(); + let _sub = cx.update(|cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + files.update(cx, |files, cx| { + files.apply_workspace_changes( + WorkspaceFileChanges { + sequence: 1, + resync_required: false, + changes: vec![zeron_proto::WorkspaceFileChange { + operation_id: None, + kind: WorkspaceFileChangeKind::Modified, + path: "a.txt".into(), + old_path: None, + }], + }, + cx, + ); + }); + cx.run_until_parked(); + let request: serde_json::Value = serde_json::from_str( + &requests + .try_recv() + .expect("Modified must reload the containing directory"), + ) + .unwrap(); + assert_eq!( + request["method"], + zeron_rpc::methods::LIST_WORKSPACE_DIRECTORY + ); + assert_eq!(request["params"]["directory"], ""); + let mut entry = + super::super::test_support::entry("a.txt", zeron_proto::WorkspaceEntryKind::File); + entry.mutation_revision = Some("after-save".into()); + runtime.block_on(async { + replies + .send( + serde_json::json!({ + "id": request["id"], "ok": { + "directory": "", "checkoutId": "checkout", + "mutationCapabilities": {"moveEntry": true, "deleteEntry": true}, + "entries": [entry], "nextCursor": null, "truncated": false + } + }) + .to_string(), + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while replies.capacity() < replies.max_capacity() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + }); + cx.run_until_parked(); + files.update(cx, |files, cx| { + assert_eq!( + files + .tree + .node("a.txt") + .unwrap() + .entry + .mutation_revision + .as_deref(), + Some("after-save") + ); + files.request_mutation("a.txt", Some("renamed.txt".into()), cx); + files.request_mutation("a.txt", Some("folder/a.txt".into()), cx); + files.request_mutation("a.txt", None, cx); + }); + cx.run_until_parked(); + let events = events.borrow(); + let intents = events + .iter() + .filter_map(|event| match event { + FilesEvent::Mutate(intent) => Some(intent), + _ => None, + }) + .collect::>(); + assert_eq!(intents.len(), 3); + assert!( + intents + .iter() + .all(|intent| intent.entry.mutation_revision.as_deref() == Some("after-save")) + ); + } #[test] fn sequence_gaps_require_resync() { diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 4753a7b604..46b26e3024 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -63,6 +63,7 @@ mod chat_dropzone; #[cfg(test)] mod chat_dropzone_tests; mod command_palette; +mod file_mutations; mod files_panel; mod harness_updates; mod navigation_focus; @@ -3100,10 +3101,43 @@ impl Shell { .collect() } + /// Attach a workspace path only while the surface it came from still + /// shows this chat's workspace; a drag outliving a session switch is dropped. + fn attach_workspace_drag( + &mut self, + payload: &WorkspacePathDrag, + composer: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + let Some(origin) = &payload.origin else { + return; + }; + let current = crate::files::client::FilesRequestContext::for_chat( + self.state.read(cx), + &self.panel_key(cx), + ); + if current.as_ref() != Some(&origin.context) || !matches!(self.route, Route::Chat) { + return; + } + let valid = self + .files + .values() + .chain(self.file_surfaces.values()) + .any(|files| { + files.entity_id() == origin.surface_id && files.read(cx).accepts_origin(origin, cx) + }); + if valid { + composer.update(cx, |composer, cx| { + composer.add_workspace_path(&payload.path, payload.is_directory, window, cx) + }); + } + } + fn workspace_path_for_surface( &self, surface: RightSurface, - _cx: &App, + cx: &App, ) -> Option { let path = match surface { RightSurface::File(id) => self.file_surface_paths.get(&id)?.clone(), @@ -3116,7 +3150,15 @@ impl Shell { return None; } }; - Some(WorkspacePathDrag::new(path, false)) + let RightSurface::File(id) = surface else { + return None; + }; + let origin = self.file_surfaces.get(&id)?.read(cx).interaction_origin(cx); + Some(WorkspacePathDrag::new(path, false).with_origin( + origin, + crate::files::WorkspacePathSource::FileTab, + None, + )) } /// Drag-reorder a surface tab within this chat's strip. @@ -3537,6 +3579,35 @@ impl Shell { return; } match event { + FilesEvent::HoldMutation { origin, path } => { + let surfaces = this + .files + .values() + .chain(this.file_surfaces.values()) + .filter(|s| s.read(cx).shares_workspace(origin)) + .cloned() + .collect::>(); + for surface in surfaces { + surface.update(cx, |files, cx| files.hold_mutation(path.clone(), cx)); + } + } + FilesEvent::AddToChat { + path, + is_directory, + origin, + } => { + let payload = WorkspacePathDrag::new(path.clone(), *is_directory) + .with_origin( + Some(origin.clone()), + crate::files::WorkspacePathSource::Tree, + None, + ); + let composer = this.composer.clone(); + this.attach_workspace_drag(&payload, &composer, window, cx); + } + FilesEvent::Mutate(intent) => { + this.start_file_mutation(source.clone(), intent.clone(), cx) + } // Navigation from an editor stays in its own chat. FilesEvent::OpenFile(path) => { let owner = (source.read(cx).chat_id().to_owned(), owner_state.clone()); @@ -10013,6 +10084,7 @@ impl Shell { }; let status = self.render_status_strip(composer_width, cx); self.chat_dropzone("chat-dropzone", self.composer.clone(), cx) + .debug_selector(|| "chat-dropzone".into()) .track_focus(&self.navigation_focus.main) .capture_any_mouse_down(cx.listener(|this, _, window, cx| { this.capture_navigation_focus(false, false, window, cx); @@ -12071,6 +12143,18 @@ fn header_icon_button( impl Render for Shell { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let active_files_key = self.panel_key(cx); + let hidden_explorers = self + .files + .iter() + .filter(|(key, _)| { + key.as_str() != active_files_key || !matches!(self.route, Route::Chat) + }) + .map(|(_, files)| files.clone()) + .collect::>(); + for files in hidden_explorers { + files.update(cx, |files, cx| files.suspend_tree_interactions(cx)); + } settings::wallpaper::preload(cx); self.navigation_focus .remember(&self.shortcut_focus, window, cx); diff --git a/crates/ui/src/shell/chat_dropzone.rs b/crates/ui/src/shell/chat_dropzone.rs index 33216b4647..65c924156e 100644 --- a/crates/ui/src/shell/chat_dropzone.rs +++ b/crates/ui/src/shell/chat_dropzone.rs @@ -21,21 +21,19 @@ impl Shell { cx.notify(); })) .on_drop::(cx.listener( - move |_, payload: &WorkspacePathDrag, window, cx| { - workspace.update(cx, |composer, cx| { - composer.add_workspace_path(&payload.path, payload.is_directory, window, cx) - }); + move |this, payload: &WorkspacePathDrag, window, cx| { + this.attach_workspace_drag(payload, &workspace, window, cx); + cx.notify(); + }, + )) + .on_drop::(cx.listener( + move |this, payload: &RightTabDrag, window, cx| { + if let Some(path) = &payload.workspace_path { + this.attach_workspace_drag(path, &composer, window, cx); + } cx.notify(); }, )) - .on_drop::(cx.listener(move |_, payload: &RightTabDrag, window, cx| { - if let Some(path) = &payload.workspace_path { - composer.update(cx, |composer, cx| { - composer.add_workspace_path(&path.path, path.is_directory, window, cx) - }); - } - cx.notify(); - })) } pub(super) fn attachment_drop_overlay(theme: &Theme) -> gpui::Stateful { diff --git a/crates/ui/src/shell/chat_dropzone_tests.rs b/crates/ui/src/shell/chat_dropzone_tests.rs index 5b77466253..4240bc7977 100644 --- a/crates/ui/src/shell/chat_dropzone_tests.rs +++ b/crates/ui/src/shell/chat_dropzone_tests.rs @@ -18,6 +18,12 @@ impl Render for DropHost { self.shell.update(cx, |shell, cx| { let main = shell.render_main(window, 400., 400., cx); let right = shell.render_right_pane(window, cx); + // Tree and search rows stamp the explorer's current origin; the + // stale source models a drag that outlived its workspace. + let origin = shell + .files + .get(&shell.panel_key(cx)) + .and_then(|files| files.read(cx).interaction_origin(cx)); div() .size_full() .flex() @@ -27,22 +33,30 @@ impl Render for DropHost { // to enumerate a checkout. Targets and file tabs are real UI. .child( div().h(px(24.)).flex().children( - [("file", "src/tree.rs", false), ("directory", "src", true)] - .into_iter() - .map(|(id, path, directory)| { - div() - .id(id) - .debug_selector(move || id.into()) - .w(px(100.)) - .h_full() - .on_drag( - WorkspacePathDrag::new(path.into(), directory), - |payload, _, _, cx| { - crate::files::workspace_path_drag_ghost(payload, cx) - }, - ) - .child(id) - }), + [ + ("file", "src/tree.rs", false, origin.clone()), + ("directory", "src", true, origin), + ("stale", "src/stale.rs", false, None), + ] + .into_iter() + .map(|(id, path, directory, origin)| { + div() + .id(id) + .debug_selector(move || id.into()) + .w(px(100.)) + .h_full() + .on_drag( + WorkspacePathDrag::new(path.into(), directory).with_origin( + origin, + crate::files::WorkspacePathSource::Tree, + None, + ), + |payload, _, _, cx| { + crate::files::workspace_path_drag_ghost(payload, cx) + }, + ) + .child(id) + }), ), ) .child( @@ -129,6 +143,15 @@ fn setup_with_shell( cx.update(|window, cx| { shell.update(cx, |shell, cx| { shell.add_file_surface("src/tab.rs".into(), window, cx); + let explorer = cx.new(|cx| { + crate::files::FilesSurface::new_explorer( + shell.state.clone(), + shell.active_chat.clone(), + false, + cx, + ) + }); + shell.files.insert(shell.panel_key(cx), explorer); let mut side = shell.state.read(cx).chats[0].clone(); side.id = "saved-side".into(); side.parent_chat_id = Some("parent".into()); @@ -243,6 +266,22 @@ fn workspace_drops_target_saved_and_unsaved_side_chat_composers(cx: &mut TestApp } }); } + // A payload without a live origin attaches to neither side nor main chats. + let side = cx.update(|_, cx| { + shell.read(cx).side_chats[&1] + .composer + .read(cx) + .surface_bounds() + .get() + .unwrap() + .center() + }); + drag(cx, "stale", side); + drag(cx, "stale", gpui::point(px(200.), px(150.))); + shell.read_with(cx, |shell, cx| { + assert!(!text(&shell.side_chats[&1].composer, cx).contains("stale.rs")); + assert!(text(&shell.composer, cx).is_empty()); + }); } #[gpui::test] diff --git a/crates/ui/src/shell/file_mutations.rs b/crates/ui/src/shell/file_mutations.rs new file mode 100644 index 0000000000..9330db0640 --- /dev/null +++ b/crates/ui/src/shell/file_mutations.rs @@ -0,0 +1,316 @@ +//! Coordinate structural changes across every editor of the affected workspace. +use super::*; +use crate::files::{client::WorkspaceFilesClient, mutations::MutationIntent}; +use zeron_proto::{ + DeleteWorkspaceEntryRequest, MoveWorkspaceEntryRequest, WorkspaceMutationOutcome, +}; + +impl Shell { + pub(super) fn start_file_mutation( + &mut self, + source: Entity, + mut intent: MutationIntent, + cx: &mut Context, + ) { + if !source.read(cx).accepts_origin(&intent.origin, cx) { + return; + } + let Some(engine) = self.state.read(cx).engine().cloned() else { + source.update(cx, |files, cx| { + files.report_mutation_error("Workspace service is unavailable".into(), cx) + }); + return; + }; + let surfaces = self + .files + .values() + .chain(self.file_surfaces.values()) + .filter(|surface| surface.read(cx).shares_workspace(&intent.origin)) + .cloned() + .collect::>(); + if surfaces + .iter() + .any(|surface| surface.read(cx).mutation_busy()) + { + source.update(cx, |files, cx| { + files.report_mutation_error("Another file operation is still running".into(), cx) + }); + return; + } + for surface in &surfaces { + surface.update(cx, |files, cx| files.prepare_mutation(intent.clone(), cx)); + } + let waited_for_save = surfaces + .iter() + .any(|surface| surface.read(cx).mutation_has_save()); + let client = WorkspaceFilesClient::new(engine, intent.origin.context.clone()); + cx.spawn(async move |shell, cx| { + let result: Result = async { + let mut ready = false; + for _ in 0..400 { + if !surfaces + .iter() + .any(|surface| surface.read_with(cx, |files, _| files.mutation_has_save())) + { + ready = true; + break; + } + cx.background_executor() + .timer(Duration::from_millis(25)) + .await; + } + if !ready { + return Err("Wait for the pending save to finish and try again".into()); + } + if !source.read_with(cx, |files, cx| files.accepts_origin(&intent.origin, cx)) { + return Err("Workspace changed before the operation started".into()); + } + // Only refresh the source revision when our own save was awaited. + // Otherwise an external edit must cause SourceChanged, not consent. + if waited_for_save { + let page = client + .list_directory_snapshot( + zeron_proto::ListWorkspaceDirectoryRequest { + target: intent.origin.context.target.clone(), + directory: crate::files::model::parent_path(&intent.entry.path) + .unwrap_or_default(), + include_ignored: true, + cursor: None, + }, + &[intent.entry.path.clone()], + ) + .await + .map_err(|e| e.to_string())?; + if page.checkout_id != intent.origin.checkout_id { + return Err("Workspace changed while saving".into()); + } + let entry = page + .entries + .into_iter() + .find(|entry| entry.path == intent.entry.path) + .ok_or("Source no longer exists")?; + intent.entry.mutation_revision = entry.mutation_revision; + } + let owner_active = shell + .update(cx, |shell, cx| { + matches!(shell.route, Route::Chat) + && intent.origin.context.target.chat_id.as_deref() + == Some(shell.panel_key(cx).as_str()) + }) + .unwrap_or(false); + if !owner_active + || !source.read_with(cx, |files, cx| files.accepts_origin(&intent.origin, cx)) + { + return Err("File operation cancelled after switching sessions".into()); + } + let checkout = intent + .origin + .checkout_id + .clone() + .ok_or("Workspace identity unavailable")?; + let revision = intent + .entry + .mutation_revision + .clone() + .ok_or("Refresh the tree before trying again")?; + if let Some(destination) = &intent.destination { + client + .move_entry(MoveWorkspaceEntryRequest { + target: intent.origin.context.target.clone(), + operation_id: intent.operation_id.clone(), + expected_checkout_id: checkout, + source_path: intent.entry.path.clone(), + destination_path: destination.clone(), + expected_source_revision: revision, + expected_kind: intent.entry.kind, + }) + .await + .map_err(|e| e.to_string()) + } else { + client + .delete_entry(DeleteWorkspaceEntryRequest { + target: intent.origin.context.target.clone(), + operation_id: intent.operation_id.clone(), + expected_checkout_id: checkout, + path: intent.entry.path.clone(), + expected_source_revision: revision, + expected_kind: intent.entry.kind, + recursive: intent.entry.kind + == zeron_proto::WorkspaceEntryKind::Directory, + }) + .await + .map_err(|e| e.to_string()) + } + } + .await; + for surface in &surfaces { + let _ = surface.update(cx, |files, cx| files.finish_mutation(&intent, &result, cx)); + } + }) + .detach(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext, TestAppContext, VisualTestContext}; + use std::{cell::RefCell, rc::Rc}; + + struct DragHost { + shell: Entity, + files: Entity, + _directory: tempfile::TempDir, + } + impl Render for DragHost { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let main = self + .shell + .update(cx, |shell, cx| shell.render_main(window, 600., 600., cx)); + div() + .flex() + .w(px(900.)) + .h(px(600.)) + .child( + div() + .w(px(280.)) + .h_full() + .flex_none() + .child(self.files.clone()), + ) + .child(main) + } + } + fn setup( + cx: &mut TestAppContext, + ) -> (Entity, Entity, &mut VisualTestContext) { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + crate::app_menus::init(cx); + }); + let (host, cx) = cx.add_window_view(|_, cx| { + let directory = tempfile::tempdir().unwrap(); + let state = cx.new(|_| { + let mut state = crate::files::test_support::state(); + state.selected_chat = Some("chat".into()); + state.no_project = true; + state + }); + let files = cx.new(|cx| crate::files::test_support::explorer(state.clone(), cx)); + let shell = cx.new(|cx| { + let mut shell = Shell::new( + state, + EngineBootConfig { + data_dir: directory.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }, + cx, + ); + shell.active_chat = "chat".into(); + shell.route = Route::Chat; + shell.files.insert("chat".into(), files.clone()); + shell + }); + DragHost { + shell, + files, + _directory: directory, + } + }); + let (shell, files) = host.read_with(cx, |host, _| (host.shell.clone(), host.files.clone())); + cx.update(|window, cx| { + window.activate_window(); + window.draw(cx).clear(); + }); + (shell, files, cx) + } + + #[gpui::test] + fn tree_and_production_chat_dropzones_are_exclusive(cx: &mut TestAppContext) { + let (shell, files, cx) = setup(cx); + let events = Rc::new(RefCell::new(Vec::new())); + let recorded = events.clone(); + let _sub = cx.update(|_, cx| { + cx.subscribe(&files, move |_, event, _| { + recorded.borrow_mut().push(event.clone()) + }) + }); + let input = shell.read_with(cx, |shell, cx| shell.composer.read(cx).input.clone()); + input.update(cx, |input, cx| input.set_text("keep this ", cx)); + cx.update(|window, cx| window.draw(cx).clear()); + let start = cx.debug_bounds("tree-entry:a.txt").unwrap().center(); + let folder = cx.debug_bounds("tree-entry:folder").unwrap().center(); + let chat = cx.debug_bounds("chat-dropzone").unwrap().center(); + // Visit the tree target, then finish over the actual conversation. + cx.simulate_mouse_down(start, MouseButton::Left, gpui::Modifiers::default()); + cx.simulate_mouse_move( + start + gpui::point(px(9.), px(0.)), + Some(MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.simulate_mouse_move(folder, Some(MouseButton::Left), gpui::Modifiers::default()); + cx.simulate_mouse_move(chat, Some(MouseButton::Left), gpui::Modifiers::default()); + cx.simulate_mouse_up(chat, MouseButton::Left, gpui::Modifiers::default()); + cx.run_until_parked(); + let text = input.read_with(cx, |input, _| input.text().to_string()); + assert!(text.contains("keep this")); + assert!(text.contains("a.txt")); + assert!( + !events + .borrow() + .iter() + .any(|event| matches!(event, FilesEvent::Mutate(_))) + ); + // Visit the chat, then finish over the tree target. + cx.simulate_mouse_down(start, MouseButton::Left, gpui::Modifiers::default()); + cx.simulate_mouse_move( + start + gpui::point(px(9.), px(0.)), + Some(MouseButton::Left), + gpui::Modifiers::default(), + ); + cx.simulate_mouse_move(chat, Some(MouseButton::Left), gpui::Modifiers::default()); + cx.simulate_mouse_move(folder, Some(MouseButton::Left), gpui::Modifiers::default()); + cx.simulate_mouse_up(folder, MouseButton::Left, gpui::Modifiers::default()); + cx.run_until_parked(); + assert_eq!( + input.read_with(cx, |input, _| input.text().to_string()), + text + ); + assert_eq!( + events + .borrow() + .iter() + .filter(|event| matches!(event, FilesEvent::Mutate(_))) + .count(), + 1 + ); + cx.update(|_, cx| assert!(!cx.has_active_drag())); + } + + #[gpui::test] + fn stale_workspace_reference_cannot_attach_to_a_new_chat(cx: &mut TestAppContext) { + let (shell, files, cx) = setup(cx); + let origin = files + .read_with(cx, |files, cx| files.interaction_origin(cx)) + .unwrap(); + let payload = WorkspacePathDrag::new("a.txt".into(), false).with_origin( + Some(origin), + crate::files::WorkspacePathSource::Tree, + Some("rev".into()), + ); + shell.update_in(cx, |shell, window, cx| { + shell.active_chat = "other".into(); + let input = shell.composer.read(cx).input.clone(); + let before = input.read(cx).text().to_string(); + let composer = shell.composer.clone(); + shell.attach_workspace_drag(&payload, &composer, window, cx); + assert_eq!(input.read(cx).text(), before); + }); + } +} diff --git a/crates/ui/src/shell/files_panel.rs b/crates/ui/src/shell/files_panel.rs index 8cd01cb49f..6a5495a39d 100644 --- a/crates/ui/src/shell/files_panel.rs +++ b/crates/ui/src/shell/files_panel.rs @@ -172,6 +172,37 @@ impl Shell { &files, window, move |this: &mut Self, source, event, window, cx| match event { + FilesEvent::HoldMutation { origin, path } => { + let surfaces = this + .files + .values() + .chain(this.file_surfaces.values()) + .filter(|s| s.read(cx).shares_workspace(origin)) + .cloned() + .collect::>(); + for surface in surfaces { + surface.update(cx, |files, cx| files.hold_mutation(path.clone(), cx)); + } + } + FilesEvent::AddToChat { + path, + is_directory, + origin, + } => { + let payload = WorkspacePathDrag::new(path.clone(), *is_directory) + .with_origin( + Some(origin.clone()), + crate::files::WorkspacePathSource::Tree, + None, + ); + let composer = this.composer.clone(); + this.attach_workspace_drag(&payload, &composer, window, cx); + } + FilesEvent::Mutate(intent) + if this.accepts_file_navigation(&owner, &source, cx) => + { + this.start_file_mutation(source.clone(), intent.clone(), cx); + } FilesEvent::OpenFile(path) if this.accepts_file_navigation(&owner, &source, cx) => { @@ -242,6 +273,9 @@ impl Shell { if !self.files_panel_open(cx) { return; } + if let Some(files) = self.files.get(&self.panel_key(cx)).cloned() { + files.update(cx, |files, cx| files.suspend_tree_interactions(cx)); + } let from = self.files_visible_width(cx); self.panels .update(&self.panel_key(cx), |p| p.files_open = false); diff --git a/docs/file-tree-interactions.md b/docs/file-tree-interactions.md new file mode 100644 index 0000000000..7810271376 --- /dev/null +++ b/docs/file-tree-interactions.md @@ -0,0 +1,103 @@ +# File tree actions and workspace moves + +Right-click a file or folder in the explorer to open the shared context menu. +The same menu opens with the Menu key or Shift+F10 on the selected row. + +- **Add to chat** inserts a workspace reference into the current draft and focuses + the composer. It keeps existing text and does not send a message. +- **Copy path** copies the full path using the owning workspace's root and path + format, including when the workspace belongs to another device. +- **Rename…** edits the name in the row. Enter submits; Escape or losing focus + before submission cancels. F2 also opens the inline editor. A rename never + overwrites another entry. +- **Delete…** asks for permanent deletion. Cancel is initially selected; use + Tab/arrow keys to choose the destructive action. Deleting a folder includes + its current contents. Open buffers remain available for recovery; autosave + cannot recreate deleted files. The Delete key opens the same confirmation. + +Drag an entry onto a folder to move it. Dropping on a file targets that file's +parent directory. The root row and the empty space below the list target the +workspace root. A drop in the current parent is a no-op; a folder cannot move +into itself or a descendant. Existing destinations are never overwritten or +merged. The source remains visible until the host confirms the operation. + +Drag from the row on macOS, Linux, and Windows. The Windows drag threshold +protects ordinary clicks from small pointer jitter. Search results and file +tabs continue to support references into the conversation; only tree-originated +drags can move entries. + +Holding a compatible drag over a closed folder opens it after 650 ms. Dragging +near the top or bottom of the list scrolls it. Escape, leaving the tree, +switching sessions, and closing the explorer clear the feedback and timers. +Folders opened by hover stay open. The chat column retains its own drop zone, +so a gesture ending there adds a reference instead of moving the entry. OS file +attachments continue through the existing composer attachment pipeline. + +## Workspace and editor consistency + +Operations run through the owning engine, locally or remotely. Directory pages +announce the host's mutation capabilities and checkout identity; older hosts +remain usable for navigation, Copy path and Add to chat. Mutation requests +carry the expected checkout and an opaque metadata revision. A stale source +must be refreshed before another explicit attempt. Symlinks cannot be renamed, +moved or deleted through these actions, and traversal through symlink parents +is rejected. The workspace root and `.git` paths are protected. + +Move and rename use one RPC. Writes acquire a shared checkout gate before their +existing per-file lock; structural operations acquire the exclusive gate. +The UI pauses affected autosaves and waits for in-flight saves. It remaps open +editors, tabs and comment paths while retaining unsaved text. Response and +semantic watcher events share an operation ID so receiving both is harmless. +A semantic event can reconcile a successful operation whose RPC reply was lost. +Transport failures are never retried automatically. + +A recursive delete can fail after deleting some children. The response reports +that condition and the explorer refreshes the surviving contents. Cancellation +of a request after a native filesystem call starts is not an undo operation. +Metadata revisions are not recursive directory snapshots, and operations do +not provide transactions against arbitrary external processes. Cross-volume +moves return an error instead of falling back to copying and deleting. + +Native moves use Linux `RENAME_NOREPLACE`, macOS `RENAME_EXCL`, and Windows +`MoveFileExW` without replacement or cross-volume copy flags. See the +[Linux rename manual](https://man7.org/linux/man-pages/man2/rename.2.html), +[Apple's exclusive rename capability](https://developer.apple.com/documentation/foundation/urlresourcevalues/volumesupportsexclusiverenaming), +and [Microsoft's MoveFileExW contract](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw). +Case-only aliases on case-insensitive Unix volumes use a unique temporary entry +with no-replacement rollback. Distinct case-similar hard links are collisions. + +## Verification + +Automated coverage includes protocol compatibility, filesystem mutation and +collision tests, real RPC/watch integration, two engines forwarding through a +relay into a plain folder, tree model relocation, dirty-buffer reconciliation, +menus, inline rename, cancellation, and timed hover expansion. A GPUI fixture +renders the production tree beside `Shell::render_main` to test both directions +between the tree and the real conversation drop zone. + +Commands from the repository root: + +```sh +cargo test --release --locked -p zeron-proto --lib +cargo test --release --locked -p zeron-engine --lib workspace_files:: +cargo test --release --locked -p zeron-engine --lib rpc:: +cargo test --release --locked -p zeron-engine --test workspace_files +cargo test --release --locked -p zeron-engine --test device_routing workspace_entry_mutations_are_forwarded +cargo test --release --locked -p zeron-ui --lib -- --test-threads=1 +cargo check --release --locked -p zeron +``` + +This implementation was exercised on Linux with GPUI's test backend. Native +macOS/Windows interaction, Windows junctions, and the case-insensitive-volume +fallback require verification on those hosts. A Windows-specific regression is +included for row-click jitter and intentional row dragging. A desktop visual +review of themes, narrow windows and pointer feel is still required; headless +tests do not establish native rendering quality. + +Validated on 2026-09-23: 1,274 UI tests, 45 protocol tests, 37 workspace engine +tests, 18 RPC tests (one ignored), four workspace RPC integration tests, and the +two-engine forwarding test passed. The application release check also passed. +An initial full UI run hit a sidebar scheduler failure; its isolated rerun and +the subsequent complete suite passed. Formatting checks passed for every Rust +file changed here; the workspace-wide formatting check still reports existing +differences in unrelated files. diff --git a/docs/research/windows-click-drag-audit.md b/docs/research/windows-click-drag-audit.md index 9c6a93fb65..1e84700f2e 100644 --- a/docs/research/windows-click-drag-audit.md +++ b/docs/research/windows-click-drag-audit.md @@ -72,3 +72,10 @@ For each P0/P1 control, add a Windows regression covering mouse-down, approximat Comet now centralizes this distinction in `click_activation_drag_enabled()`. On Windows, the four P0/P1 control families above no longer register drag initiation on their click-activation hitboxes. Their primary click actions therefore survive pointer jitter. Drag-first controls remain unchanged, and macOS/Linux retain tab reordering and workspace-path dragging. The production right-tab visual regression reproduces an eight-pixel moving click. It failed before the change because GPUI entered an active drag, then passed after the Windows policy was applied. The complete `zeron-ui` library suite passed with 789 tests. + +## File tree actions (2026-09-23) + +Workspace tree rows keep their row-wide drag on every platform, including +Windows; GPUI's platform drag threshold protects ordinary clicks from jitter. +Tree, search and file-tab payloads carry their originating surface and workspace +context so a delayed drop cannot attach a path to a different session. From b3d7f48b5a1c33fc345f802302a266bf46d88abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 1 Oct 2026 00:52:30 -0300 Subject: [PATCH 18/74] Fix command and skill labels in the message queue (#682) * Fix command and skill labels in the message queue * Test queue row labels for commands, skills and files Extract the row label into queue_row_text so the projection is covered by a unit test alongside the attachment-trailer handling. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Wing Co-authored-by: Claude Opus 5.5 --- crates/ui/src/queue.rs | 44 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/queue.rs b/crates/ui/src/queue.rs index adbf4b0494..b727c7bf54 100644 --- a/crates/ui/src/queue.rs +++ b/crates/ui/src/queue.rs @@ -218,6 +218,17 @@ fn queue_visible_text(text: &str, attachments: &[String]) -> String { } } +/// The row's one-line label. Commands, skills and file mentions show the same +/// labels as the transcript; editing and delivery still read the stored text, +/// which keeps their canonical links. +fn queue_row_text(text: &str, attachments: &[String]) -> SharedString { + let visible = queue_visible_text(text, attachments); + let display = crate::composer::sent_mention_display(&visible) + .map(|(display, _)| display) + .unwrap_or(visible); + one_line(&display) +} + /// Presentation-only metadata. Never expose the observed accessibility payload. /// Rows show thumbnails only; these names surface as tooltips and labels. fn queue_attachment_labels(text: &str, paths: &[String]) -> Vec { @@ -433,7 +444,7 @@ impl Composer { Some(QueueDeliveryGate::ReviewRequired { .. }) if !being_edited => { SharedString::from("Needs review") } - _ => one_line(&queue_visible_text(&item.text, &item.attachments)), + _ => queue_row_text(&item.text, &item.attachments), }; let edit_id = item.id.clone(); @@ -2042,6 +2053,37 @@ mod tests { assert_eq!(super::queue_hidden_attachments_label(&[], 2), None); } + /// Rows label references the way the transcript does, never as raw + /// `zeron-invoke:`/`zeron-file:` links, and still hide attachment trailers. + #[test] + fn queue_rows_label_commands_skills_and_files() { + use zeron_proto::invocation::Invocation; + let command = Invocation::Command { + name: "compact".into(), + } + .link(); + let skill = Invocation::Skill { + name: "review-pr".into(), + path: "/skills/review-pr/SKILL.md".into(), + command: None, + } + .link(); + let file = zeron_proto::file_mentions::local_file_link("src/queue.rs", false); + let text = format!("{command} then {skill}\non {file}"); + assert_eq!( + super::queue_row_text(&text, &[]).as_ref(), + "/compact then $review-pr on @queue.rs" + ); + + let paths = vec!["/tmp/image.png".to_string()]; + let legacy = crate::attachments::with_attachments(&command, &paths); + assert_eq!(super::queue_row_text(&legacy, &paths).as_ref(), "/compact"); + assert_eq!( + super::queue_row_text("plain text", &[]).as_ref(), + "plain text" + ); + } + #[test] fn legacy_attachment_trailers_are_hidden_from_queue_text() { let paths = vec!["/tmp/image.png".to_string()]; From 42926c802a837097e6a05de89d48ca7ade326658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gurruchaga?= Date: Thu, 1 Oct 2026 01:50:50 -0300 Subject: [PATCH 19/74] Right panel tabs: close button on the right (#587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Right panel tabs: close button on the right The ✕ replaced the surface icon on hover in the leading slot. The icon now stays put on the left, the title takes the free space, and the ✕ fades in on hover in a trailing slot at the tab's right edge (same size, color and hover wash). The unsaved dot moves into that slot and swaps with the ✕ on hover. Chip padding is now symmetric (4px). * Right panel tabs: label the close button and pin its placement Give the trailing close slot a "Close tab" tooltip, a button role and an accessible label, matching the terminal drawer's "Close terminal" tooltip. Add a regression test that the close sits after the title at the chip's right edge. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Wing Co-authored-by: Claude Opus 5.5 --- crates/ui/src/shell.rs | 151 +++++++++++++++++++++++------------------ 1 file changed, 85 insertions(+), 66 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 46b26e3024..45f1e046ce 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -11033,8 +11033,7 @@ impl Shell { .h(px(24.0)) .w(px(CHIP_W)) .flex_none() - .pl(px(4.0)) - .pr(px(8.0)) + .px(px(4.0)) .rounded(px(6.0)) .flex() .flex_row() @@ -11136,8 +11135,62 @@ impl Shell { }, )) .child( - // Leading slot: icon normally, ✕ on tab hover — two - // stacked layers opacity-swapped by the group hover. + // Leading slot: the surface's icon. + div() + .flex_none() + .size(px(18.0)) + .flex() + .items_center() + .justify_center() + .child(if subagent_running { + loaders::mini_glyph_spinner( + format!("subagent-tab-{ix}"), + 2.0, + theme.glyph, + cx.entity_id(), + cx, + ) + .into_any_element() + } else if let Some(favicon) = browser_favicon { + gpui::img(favicon).size(px(12.0)).into_any_element() + } else if matches!(surface, RightSurface::File(_)) { + crate::file_icons::icon( + crate::file_icons::FileIconIdentity::file( + file_identity_path.as_ref(), + ), + theme.appearance, + ) + .size(px(14.0)) + .when(!is_active, |icon| icon.opacity(0.78)) + .into_any_element() + } else { + icon(icon_path) + .size(px(12.0)) + .text_color(if is_active { + theme.text_muted + } else { + theme.text_muted.opacity(0.7) + }) + .into_any_element() + }), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(crate::typography::ui_rems(11.5)) + .text_color(if is_active { + theme.text + } else { + theme.text_muted + }) + .child(title), + ) + .child( + // Trailing slot: the unsaved dot normally, ✕ on tab + // hover — two stacked layers opacity-swapped by the + // group hover. div() .id(("right-surface-close", ix)) .debug_selector(|| format!("right-surface-close-{ix}")) @@ -11145,6 +11198,9 @@ impl Shell { .size(px(18.0)) .rounded(px(4.0)) .relative() + .role(gpui::Role::Button) + .aria_label("Close tab") + .tooltip(crate::settings::widgets::text_tooltip("Close tab")) .hover(|s| s.bg(crate::theme::wash(0.12))) // The tab owns a drag payload. Claim the close press // before it reaches that parent or GPUI starts a tab @@ -11157,46 +11213,18 @@ impl Shell { cx.stop_propagation(); this.close_right_surface(surface, window, cx); })) - .child( - div() - .absolute() - .inset_0() - .flex() - .items_center() - .justify_center() - .group_hover(group.clone(), |s| s.opacity(0.0)) - .child(if subagent_running { - loaders::mini_glyph_spinner( - format!("subagent-tab-{ix}"), - 2.0, - theme.glyph, - cx.entity_id(), - cx, - ) - .into_any_element() - } else if let Some(favicon) = browser_favicon { - gpui::img(favicon).size(px(12.0)).into_any_element() - } else if matches!(surface, RightSurface::File(_)) { - crate::file_icons::icon( - crate::file_icons::FileIconIdentity::file( - file_identity_path.as_ref(), - ), - theme.appearance, - ) - .size(px(14.0)) - .when(!is_active, |icon| icon.opacity(0.78)) - .into_any_element() - } else { - icon(icon_path) - .size(px(12.0)) - .text_color(if is_active { - theme.text_muted - } else { - theme.text_muted.opacity(0.7) - }) - .into_any_element() - }), - ) + .when(dirty, |slot| { + slot.child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .group_hover(group.clone(), |s| s.opacity(0.0)) + .child(div().size(px(6.0)).rounded_full().bg(theme.text_muted)), + ) + }) .child( div() .absolute() @@ -11212,28 +11240,7 @@ impl Shell { .text_color(theme.text_muted), ), ), - ) - .child( - div() - .min_w_0() - .truncate() - .text_size(crate::typography::ui_rems(11.5)) - .text_color(if is_active { - theme.text - } else { - theme.text_muted - }) - .child(title), - ) - .when(dirty, |chip| { - chip.child( - div() - .flex_none() - .size(px(6.0)) - .rounded_full() - .bg(theme.text_muted), - ) - }); + ); // Sliding transform while a sibling drags over (the terminal // drawer's exact recipe): animate 150ms between committed // offsets; the dragged tab leaves an invisible spacer — the @@ -15833,6 +15840,18 @@ mod right_tab_mouse_regressions { }); } + #[gpui::test] + fn right_tab_close_sits_at_the_trailing_edge(cx: &mut TestAppContext) { + let (_shell, cx) = setup(cx); + let tab = cx.debug_bounds("right-surface-tab-0").unwrap(); + let close = cx.debug_bounds("right-surface-close-0").unwrap(); + assert!( + close.left() > tab.center().x, + "close is not after the title" + ); + assert_eq!(close.right(), tab.right() - px(4.)); + } + #[gpui::test] fn tab_strip_scrolls_over_chips_inside_titlebar(cx: &mut TestAppContext) { let (shell, cx) = setup(cx); From a73fa8fe4002c1f1fe393d6d4c5c05a858fd3763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Cado?= <130941423+gaelcado@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:06:08 +0200 Subject: [PATCH 20/74] Add optional compact model picker and effort controls (#471) Compact model picker (on by default, General settings): effort slider with fast-mode toggle, provider button + provider page (Starred first), per-model effort memory, Cursor effort options on the slider, page-scoped keyboard shortcuts, per-edge/per-pixel edge fades (zui 667d0aa). Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 46 +- Cargo.toml | 8 +- crates/ui/Cargo.toml | 7 +- crates/ui/assets/icons/fast-tier-bold.svg | 1 + crates/ui/examples/compact-picker-fixture.rs | 322 ++++ crates/ui/src/composer.rs | 221 +-- crates/ui/src/edge_fade.rs | 26 +- crates/ui/src/glass.rs | 161 ++ crates/ui/src/haptics.rs | 29 + crates/ui/src/icons.rs | 4 +- crates/ui/src/lib.rs | 2 + crates/ui/src/motion.rs | 39 + crates/ui/src/pickers.rs | 1209 ++++++++++++- crates/ui/src/pickers/compact.rs | 1569 +++++++++++++++++ crates/ui/src/popover.rs | 31 +- crates/ui/src/settings.rs | 23 + crates/ui/src/settings/composer.rs | 27 + crates/ui/src/settings/shortcuts.rs | 40 + crates/ui/src/shell.rs | 91 +- crates/ui/src/shell/tabs.rs | 15 +- crates/ui/src/state.rs | 90 +- docs/screenshots/compact-picker/README.md | 16 + .../compact-picker/compact-fast-keyboard.png | Bin 0 -> 134697 bytes .../compact-model-list-light-narrow.png | Bin 0 -> 115039 bytes .../compact-panel-dark-wide.png | Bin 0 -> 115534 bytes .../compact-panel-light-wide.png | Bin 0 -> 108940 bytes .../compact-picker/switch-states.png | Bin 0 -> 165934 bytes 27 files changed, 3714 insertions(+), 263 deletions(-) create mode 100644 crates/ui/assets/icons/fast-tier-bold.svg create mode 100644 crates/ui/examples/compact-picker-fixture.rs create mode 100644 crates/ui/src/glass.rs create mode 100644 crates/ui/src/haptics.rs create mode 100644 crates/ui/src/pickers/compact.rs create mode 100644 docs/screenshots/compact-picker/README.md create mode 100644 docs/screenshots/compact-picker/compact-fast-keyboard.png create mode 100644 docs/screenshots/compact-picker/compact-model-list-light-narrow.png create mode 100644 docs/screenshots/compact-picker/compact-panel-dark-wide.png create mode 100644 docs/screenshots/compact-picker/compact-panel-light-wide.png create mode 100644 docs/screenshots/compact-picker/switch-states.png diff --git a/Cargo.lock b/Cargo.lock index ebe7663a14..e67ec7e690 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -914,7 +914,7 @@ dependencies = [ "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.10.5", "log", "prettyplease", "proc-macro2", @@ -1561,7 +1561,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "gpui_util", "indexmap", @@ -2208,7 +2208,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "proc-macro2", "quote", @@ -3391,7 +3391,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "accesskit", "anyhow", @@ -3477,7 +3477,7 @@ dependencies = [ [[package]] name = "gpui-base" version = "0.5.2" -source = "git+https://github.com/zeronsh/gpui-component?rev=103f13da91ff2c7c52941bdc2b1910f29716dc19#103f13da91ff2c7c52941bdc2b1910f29716dc19" +source = "git+https://github.com/zeronsh/gpui-component?rev=2f73e5c2bc03d6768cb5fcc92442c4cf4b963b70#2f73e5c2bc03d6768cb5fcc92442c4cf4b963b70" dependencies = [ "aho-corasick", "anyhow", @@ -3507,7 +3507,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "accesskit", "accesskit_unix", @@ -3559,7 +3559,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "accesskit", "accesskit_macos", @@ -3608,7 +3608,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3619,7 +3619,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "console_error_panic_hook", "gpui", @@ -3632,7 +3632,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "schemars", "serde", @@ -3642,7 +3642,7 @@ dependencies = [ [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "anyhow", "gpui", @@ -3653,7 +3653,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "anyhow", "log", @@ -3663,7 +3663,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3687,7 +3687,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "anyhow", "bytemuck", @@ -3716,7 +3716,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "accesskit", "accesskit_windows", @@ -4048,7 +4048,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "anyhow", "async-compression", @@ -5221,7 +5221,7 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "anyhow", "bindgen", @@ -5701,7 +5701,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.119", @@ -6364,7 +6364,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "collections", "serde", @@ -7443,7 +7443,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "derive_refineable", ] @@ -8129,7 +8129,7 @@ checksum = "c62751faa8bc286982334a082fe125184a29fc89d17775766e4f891b7d726980" [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "async-task", "backtrace", @@ -8914,7 +8914,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "heapless 0.9.3", "log", @@ -10331,7 +10331,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=18a89af04cfe079d55275b111ac349d5640d9cda#18a89af04cfe079d55275b111ac349d5640d9cda" +source = "git+https://github.com/zeronsh/zui?rev=667d0aaf9531d2d1b2d0674a5e55f977df1b09f6#667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" dependencies = [ "perf", "quote", diff --git a/Cargo.toml b/Cargo.toml index 9f66383825..ad21a07a80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,15 +76,15 @@ loro-protocol = "0.3" # Linux — the Metal path's snapshot/blur/composite, ported). # Paint-time rounded image masks (#9), bounded Windows backdrop blur and # translucent compositing, plus blur-aware scene batching (#10). -gpui = { git = "https://github.com/zeronsh/zui", rev = "18a89af04cfe079d55275b111ac349d5640d9cda" } -gpui_platform = { git = "https://github.com/zeronsh/zui", rev = "18a89af04cfe079d55275b111ac349d5640d9cda", features = [ +gpui = { git = "https://github.com/zeronsh/zui", rev = "667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" } +gpui_platform = { git = "https://github.com/zeronsh/zui", rev = "667d0aaf9531d2d1b2d0674a5e55f977df1b09f6", features = [ "wayland", "x11", "font-kit", "runtime_shaders", ] } -gpui_tokio = { git = "https://github.com/zeronsh/zui", rev = "18a89af04cfe079d55275b111ac349d5640d9cda" } -gpui-base = { git = "https://github.com/zeronsh/gpui-component", rev = "103f13da91ff2c7c52941bdc2b1910f29716dc19" } +gpui_tokio = { git = "https://github.com/zeronsh/zui", rev = "667d0aaf9531d2d1b2d0674a5e55f977df1b09f6" } +gpui-base = { git = "https://github.com/zeronsh/gpui-component", rev = "2f73e5c2bc03d6768cb5fcc92442c4cf4b963b70" } # diffs similar = "2" diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 60bd67d758..3d66f80f3d 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [features] project-palette-fixture = [] +compact-picker-fixture = ["gpui_platform/test-support"] browser-fixture = [] windows-render-fixture = [] @@ -41,6 +42,10 @@ required-features = ["project-palette-fixture"] name = "sidebar-fixture" required-features = ["project-palette-fixture"] +[[example]] +name = "compact-picker-fixture" +required-features = ["compact-picker-fixture"] + [dependencies] url = "2" zeron-proto.workspace = true @@ -89,7 +94,7 @@ wry = { version = "0.56", default-features = false, features = ["os-webview"] } raw-window-handle = "0.6" objc2 = "0.6" block2 = "0.6" -objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSBitmapImageRep", "NSImage", "NSEvent", "NSResponder", "NSView", "NSWindow"] } +objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSHapticFeedback", "NSBitmapImageRep", "NSImage", "NSEvent", "NSResponder", "NSView", "NSWindow"] } objc2-foundation = "0.3.2" objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKNavigationDelegate", "WKNavigationAction", "WKSnapshotConfiguration"] } reqwest.workspace = true diff --git a/crates/ui/assets/icons/fast-tier-bold.svg b/crates/ui/assets/icons/fast-tier-bold.svg new file mode 100644 index 0000000000..cf4b3a0f4c --- /dev/null +++ b/crates/ui/assets/icons/fast-tier-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/ui/examples/compact-picker-fixture.rs b/crates/ui/examples/compact-picker-fixture.rs new file mode 100644 index 0000000000..6c5ab4b27d --- /dev/null +++ b/crates/ui/examples/compact-picker-fixture.rs @@ -0,0 +1,322 @@ +//! Isolated visual and keyboard evidence for the compact model picker. +use gpui::{ + AppContext, AsyncApp, Bounds, Context, Entity, Render, Window, WindowBounds, WindowOptions, + div, prelude::*, px, size, +}; +use std::{path::Path, path::PathBuf, time::Duration}; +use zeron_ui::*; + +struct Fixture { + pickers: Entity, + title: &'static str, +} + +impl Render for Fixture { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = theme::Theme::of(cx).clone(); + div() + .id("compact-picker-fixture") + .size_full() + .bg(theme.surface) + .text_color(theme.text) + .font_family(theme.font_sans.clone()) + .p(px(24.0)) + .child( + div() + .size_full() + .flex() + .flex_col() + .justify_end() + .gap(px(16.0)) + .child(settings::widgets::page_header(&theme, self.title, None)) + .child( + div() + .relative() + .h(px(48.0)) + .flex() + .items_center() + .child(self.pickers.clone()), + ), + ) + } +} + +async fn pause(cx: &mut AsyncApp) { + cx.background_executor() + .timer(Duration::from_millis(260)) + .await; +} + +fn capture( + window: gpui::AnyWindowHandle, + cx: &mut AsyncApp, + output: &Path, + name: &str, +) -> anyhow::Result<()> { + window.update(cx, |_, window, cx| { + window.draw(cx).clear(); + window.render_to_image()?.save(output.join(name))?; + Ok(()) + })? +} + +fn press(window: gpui::AnyWindowHandle, cx: &mut AsyncApp, key: &str) -> anyhow::Result<()> { + window.update(cx, |_, window, cx| { + assert!( + window.dispatch_keystroke(gpui::Keystroke::parse(key).unwrap(), cx), + "compact picker did not handle {key}" + ); + })?; + Ok(()) +} + +async fn capture_matrix_scene( + window: gpui::WindowHandle, + cx: &mut AsyncApp, + output: &Path, + appearance: appearance::AppearanceMode, + width: f32, + label: &str, +) -> anyhow::Result<()> { + window.update(cx, |view, window, cx| { + appearance::set_mode(appearance, cx); + window.resize(size(px(width), px(560.0))); + view.title = if appearance == appearance::AppearanceMode::Light { + "Compact picker · light" + } else { + "Compact picker · dark" + }; + cx.notify(); + })?; + pause(cx).await; + capture( + window.into(), + cx, + output, + &format!("compact-panel-{label}.png"), + )?; + press(window.into(), cx, "enter")?; + pause(cx).await; + window.update(cx, |view, _, cx| { + assert!(view.pickers.read(cx).fixture_compact_state(cx).model_list); + })?; + capture( + window.into(), + cx, + output, + &format!("compact-model-list-{label}.png"), + )?; + press(window.into(), cx, "escape")?; + pause(cx).await; + Ok(()) +} + +fn main() -> anyhow::Result<()> { + let runtime = tokio::runtime::Runtime::new()?; + let _guard = runtime.enter(); + let output = PathBuf::from(std::env::args().nth(1).expect("output directory")); + std::fs::create_dir_all(&output)?; + let temp = tempfile::tempdir()?; + let data = temp.path().to_path_buf(); + gpui_platform::application() + .with_assets(icons::Assets) + .run(move |cx| { + gpui_tokio::init(cx); + gpui_base::init(cx); + let mut prefs = settings::UiSettings::default(); + prefs.compact_model_picker = true; + settings::init(prefs.clone(), data.clone(), cx); + let fonts = typography::register_fonts(cx); + typography::init( + prefs.ui_font_family.clone(), + prefs.ui_font_size, + prefs.terminal_font_family.clone(), + prefs.terminal_font_size, + prefs.code_font_family.clone(), + prefs.code_font_size, + fonts, + cx, + ); + theme_library::init(data, cx); + appearance::init( + appearance::AppearanceMode::Dark, + prefs.theme_selection, + prefs.accent, + prefs.surface, + cx, + ); + composer::init(cx, prefs.composer_send_behavior); + + let state = cx.new(|_| state::AppState::new()); + let pickers = cx.new(|cx| { + let mut pickers = pickers::Pickers::new(state, cx); + pickers.fixture_compact_catalog(cx); + pickers + }); + let window = cx + .open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::new( + gpui::point(px(40.0), px(40.0)), + size(px(840.0), px(560.0)), + ))), + ..Default::default() + }, + |window, cx| { + pickers.update(cx, |pickers, cx| { + pickers.open_model_menu(window, cx) + }); + cx.new(|_| Fixture { + pickers, + title: "Compact picker · dark", + }) + }, + ) + .unwrap(); + cx.activate(true); + + cx.spawn(async move |cx| { + let result: anyhow::Result<()> = async { + pause(cx).await; + for (appearance, width, label) in [ + (appearance::AppearanceMode::Dark, 840.0, "dark-wide"), + (appearance::AppearanceMode::Dark, 360.0, "dark-narrow"), + (appearance::AppearanceMode::Light, 840.0, "light-wide"), + (appearance::AppearanceMode::Light, 360.0, "light-narrow"), + ] { + capture_matrix_scene(window, cx, &output, appearance, width, label).await?; + } + + window.update(cx, |view, window, cx| { + appearance::set_mode(appearance::AppearanceMode::Dark, cx); + window.resize(size(px(840.0), px(560.0))); + view.title = "Compact picker · keyboard controls"; + cx.notify(); + })?; + pause(cx).await; + + // Model → Fast → Effort, then choose the ladder's maximum + // using the real keyboard path. + for key in ["down", "down", "end"] { + press(window.into(), cx, key)?; + } + pause(cx).await; + window.update(cx, |view, _, cx| { + let state = view.pickers.read(cx).fixture_compact_state(cx); + assert_eq!(state.reasoning, Some(zeron_proto::ReasoningLevel::XHigh)); + })?; + capture( + window.into(), + cx, + &output, + "compact-effort-keyboard-xhigh.png", + )?; + + // Effort → Fast toggles the exact service-tier option. + for key in ["up", "enter"] { + press(window.into(), cx, key)?; + } + window.update(cx, |view, _, cx| { + assert!(view.pickers.read(cx).fixture_compact_state(cx).fast); + })?; + pause(cx).await; + capture( + window.into(), + cx, + &output, + "compact-fast-keyboard.png", + )?; + + // Return to Model, enter the list, move by keyboard, and + // star the focused model without changing the selection. + for key in ["up", "enter", "down", "cmd-shift-f"] { + press(window.into(), cx, key)?; + } + pause(cx).await; + window.update(cx, |view, _, cx| { + let state = view.pickers.read(cx).fixture_compact_state(cx); + assert!(state.model_list); + assert_eq!(state.focused_model.as_deref(), Some("gpt-5.3-codex")); + assert!(state.focused_model_favorite); + })?; + capture( + window.into(), + cx, + &output, + "compact-favorite-keyboard.png", + )?; + + press(window.into(), cx, "cmd-shift-f")?; + pause(cx).await; + window.update(cx, |view, _, cx| { + let state = view.pickers.read(cx).fixture_compact_state(cx); + assert_eq!(state.focused_model.as_deref(), Some("gpt-5.3-codex")); + assert!(!state.focused_model_favorite); + })?; + capture( + window.into(), + cx, + &output, + "compact-favorite-removed-keyboard.png", + )?; + + // Repeat the keyboard paths at the opposite visual and + // geometry extreme so the dense controls are exercised, + // rather than only photographed, in the narrow layout. + press(window.into(), cx, "escape")?; + window.update(cx, |view, window, cx| { + appearance::set_mode(appearance::AppearanceMode::Light, cx); + window.resize(size(px(360.0), px(560.0))); + view.title = "Compact picker · light narrow keyboard"; + cx.notify(); + })?; + // Fast turns back off, then Effort moves to its lowest level. + for key in ["down", "enter", "down", "home"] { + press(window.into(), cx, key)?; + } + pause(cx).await; + window.update(cx, |view, _, cx| { + let state = view.pickers.read(cx).fixture_compact_state(cx); + assert!(!state.fast); + assert_eq!(state.reasoning, Some(zeron_proto::ReasoningLevel::Low)); + })?; + capture( + window.into(), + cx, + &output, + "compact-effort-keyboard-light-narrow.png", + )?; + + for key in ["up", "up", "enter", "down", "cmd-shift-f"] { + press(window.into(), cx, key)?; + } + pause(cx).await; + window.update(cx, |view, _, cx| { + let state = view.pickers.read(cx).fixture_compact_state(cx); + assert!(state.model_list); + assert_eq!(state.focused_model.as_deref(), Some("gpt-5.3-codex")); + assert!(state.focused_model_favorite); + })?; + capture( + window.into(), + cx, + &output, + "compact-favorite-keyboard-light-narrow.png", + )?; + + std::fs::write( + output.join("result.txt"), + "PASS: compact picker panel/list layout at dark/light and narrow/wide sizes; keyboard effort, fast tier, and favorite identity behavior.\n", + )?; + Ok(()) + } + .await; + if let Err(error) = result { + eprintln!("compact picker fixture failed: {error:#}"); + } + cx.update(|cx| cx.quit()); + }) + .detach(); + }); + Ok(()) +} diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index fcb7c0c18b..658db0e7df 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -392,7 +392,7 @@ pub const CLUSTER_Y_DELTA: f32 = ACTIONS_BOTTOM_PAD + 16.0 + PILL_BORDER_V / 2.0 - COMPACT_TOTAL_HEIGHT / 2.0; /// Attachment and Send share an outer inset: compact 8px, expanded 12px. -/// Glide both edges together while the model picker changes groups. +/// Keep both action groups aligned with the changing surface inset. pub const CLUSTER_X_DELTA: f32 = 4.0; /// Optical join between the picker group and the paperclip. This is tighter /// than the structural spacing ladder because the narrow paperclip glyph @@ -401,17 +401,6 @@ pub const ACTION_UTILITY_GAP: f32 = 2.0; /// Structural separation between utility actions and the primary Send action. pub const ACTION_PRIMARY_GAP: f32 = Theme::SPACE_SM; -/// Fade out at the old endpoint, relocate while invisible, then fade in at -/// the new endpoint. Only a six-pixel nudge is visible; a long label never -/// sweeps across the prompt. Compact amount is reversible with the shared clock. -fn model_handoff(compact: f32) -> (f32, f32, f32) { - let compact = compact.clamp(0.0, 1.0); - let side = if compact < 0.5 { 0.0 } else { 1.0 }; - let opacity = ((compact - 0.5).abs() - 0.06).max(0.0) / 0.44; - let drift = (1.0 - opacity) * if side == 0.0 { 6.0 } else { -6.0 }; - (side, opacity, drift) -} - /// The shared outer inset for the in-flight morph: eases from the OLD mode's resting /// inset to the committed mode's (compact 8 ↔ expanded 12). pub fn morph_cluster_inset(expanded: bool, progress: f32) -> f32 { @@ -5376,9 +5365,7 @@ pub struct Composer { /// Pill height actually rendered last frame — a committed flip morphs /// from here, so mid-flight reversals hand off without a jump. last_rendered_height: f32, - model_handoff_position: f32, - model_handoff_from: f32, - model_handoff_morph: Option, + #[cfg(test)] model_bounds: Rc>>>, dock_frame: Option, dock_reflow: crate::composer_dock::DockReflow, @@ -5597,9 +5584,7 @@ impl Composer { settle_task: None, flip_morph: None, last_rendered_height: 0.0, - model_handoff_position: 1.0, - model_handoff_from: 1.0, - model_handoff_morph: None, + #[cfg(test)] model_bounds: Default::default(), dock_frame: None, dock_reflow: Default::default(), @@ -9412,31 +9397,9 @@ impl Render for Composer { // The pill's bottom edge is stationary on screen (the composer sits at // the bottom of the shell column; growth moves the TOP edge), so the // controls pin to the bottom and only the text glides with the reveal - // (round-9 follow-up: the send/attach/chips must not ride the height, - // while the model picker fades between its two horizontal anchors). + // while the model selector stays in the right action group. let cluster_dy = morph_cluster_dy(layout_morph_t); let action_inset = morph_cluster_inset(expanded, layout_morph_t); - // Share the height/route timeline instead of starting an independent - // animation. Reversals continue from the current handoff phase. - if self.model_handoff_morph != self.flip_morph { - self.model_handoff_from = self.model_handoff_position; - self.model_handoff_morph = self.flip_morph; - } - let compact_target = if expanded { 0.0 } else { 1.0 }; - self.model_handoff_position = if dock_owns_layout { - dock_compact_amount - } else { - self.flip_morph.map_or(compact_target, |morph| { - motion::lerp( - self.model_handoff_from, - compact_target, - motion::EASE_IN_OUT.eval(morph.raw(now_ms)), - ) - }) - }; - // The shell supplies this frame's animated width before rendering us. - // Measured bounds still belong to the previous frame here; using them - // would add the per-frame width delta to the model selector's glide. let surface_width = self.last_available_width.map_or_else( || { self.surface_bounds @@ -9447,37 +9410,23 @@ impl Render for Composer { }, |width| (width - 2.0 * Theme::SPACE_LG).max(0.0), ); - let model_travel = (surface_width - - PILL_BORDER_V - - action_inset - - 28.0 - - ACTION_UTILITY_GAP - - self - .model_bounds - .get() - .map_or(0.0, |bounds| f32::from(bounds.size.width)) - - ACTION_PRIMARY_GAP - - 28.0 - - action_inset) - .max(0.0); - let (model_side, model_opacity, model_drift) = model_handoff(self.model_handoff_position); - let model_offset = (model_side - compact_target) * model_travel + model_drift; - let measured_model_bounds = self.model_bounds.clone(); let model_picker = div() .min_w_0() .max_w(px(surface_width * 0.45)) .relative() - .left(px(model_offset)) - .opacity(model_opacity) - .child(self.pickers.clone()) - .child( + .child(self.pickers.clone()); + #[cfg(test)] + let model_picker = { + let measured_model_bounds = self.model_bounds.clone(); + model_picker.child( gpui::canvas( move |bounds, _, _| measured_model_bounds.set(Some(bounds)), |_, _, _, _| {}, ) .absolute() .inset_0(), - ); + ) + }; let body = if expanded { // Expanded: textarea on top (`px-4 pb-1 pt-4`), actions row // (8px bottom + 2px top, 32px chips → 42px) ABSOLUTE at the pill's @@ -9486,7 +9435,7 @@ impl Render for Composer { // text viewport follows the animated height so it cannot paint // over the controls. Its width stays fixed (no tween rewraps); // top padding eases 12→16. Attachment and Send stay on the bottom - // anchor while the model chip fades between its horizontal slots. + // anchor with the model selector always beside Send. pill.h(px(pill_height)) .overflow_hidden() .relative() @@ -9515,9 +9464,7 @@ impl Render for Composer { .flex() .flex_row() .items_center() - // Shared group geometry (see CLUSTER_X_DELTA): the - // attachment belongs to the utility pickers, while - // Send has a larger structural separation. + // Attachment stays left; model and Send stay right. .gap(px(ACTION_PRIMARY_GAP)) .px(px(action_inset)) .pt(px(2.0)) @@ -9530,9 +9477,9 @@ impl Render for Composer { .flex_row() .items_center() .gap(px(ACTION_UTILITY_GAP)) - .child(attach) - .child(model_picker), + .child(attach), ) + .child(model_picker) .child(send_button), ) } else { @@ -9542,7 +9489,7 @@ impl Render for Composer { // top sweeps down over a stationary row, the text walks down from // its expanded resting place via a decaying relative offset, and // attachment/Send hold their spots (the centering delta gliding - // in), with the model handoff sharing that same timeline. + // in). The model selector remains beside Send. let text_glide = if dock_owns_layout { collapse_text_glide(dock_layout.hero_height, dock_compact_amount) } else { @@ -9838,83 +9785,72 @@ mod tests { let input = handle .read_with(cx, |composer, _| composer.input.clone()) .unwrap(); - for thread_width in [436.0, 592.0, 768.0, 1232.0] { - for docked in [true, false] { - let amounts = if docked { - [0.0, 0.2, 0.6, 0.98, 1.0] - } else { - [1.0, 0.98, 0.6, 0.2, 0.0] - }; - for amount in amounts { - let outer_width = motion::lerp(COMPOSER_MAX_WIDTH, thread_width, amount); - cx.update(|cx| { - handle - .update(cx, |composer, window, cx| { - window.resize(size(px(outer_width), px(800.0))); - composer.set_available_width(outer_width, cx); - composer.state.update(cx, |state, _| { - state.selected_chat = docked.then(|| "chat".into()); - }); - composer.on_state_changed(cx); - composer - .input - .update(cx, |input, cx| input.set_text("Hi", cx)); - composer.expanded_mode = false; - let mut frame = crate::composer_dock::DockFrame::settled(docked); - frame.amount = amount; - frame.active = amount != if docked { 1.0 } else { 0.0 }; - composer.set_dock_frame(frame, cx); + // The trigger stays beside Send whichever picker the chip opens. + for compact_picker in [false, true] { + cx.update(|cx| { + crate::settings::update(crate::settings::SavePolicy::Debounced, cx, |settings| { + settings.compact_model_picker = compact_picker; + }); + }); + for thread_width in [436.0, 592.0, 768.0, 1232.0] { + for docked in [true, false] { + let amounts = if docked { + [0.0, 0.2, 0.6, 0.98, 1.0] + } else { + [1.0, 0.98, 0.6, 0.2, 0.0] + }; + for amount in amounts { + let outer_width = motion::lerp(COMPOSER_MAX_WIDTH, thread_width, amount); + cx.update(|cx| { + handle + .update(cx, |composer, window, cx| { + window.resize(size(px(outer_width), px(800.0))); + composer.set_available_width(outer_width, cx); + composer.state.update(cx, |state, _| { + state.selected_chat = docked.then(|| "chat".into()); + }); + composer.on_state_changed(cx); + composer + .input + .update(cx, |input, cx| input.set_text("Hi", cx)); + composer.expanded_mode = false; + let mut frame = crate::composer_dock::DockFrame::settled(docked); + frame.amount = amount; + frame.active = amount != if docked { 1.0 } else { 0.0 }; + composer.set_dock_frame(frame, cx); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| { + window.refresh(); + window.draw(cx).clear(); }) .unwrap(); - cx.update_window(handle.into(), |_, window, cx| { - window.refresh(); - window.draw(cx).clear(); - }) - .unwrap(); - // Inspect the first painted frame before TestAppContext - // flushes effects and automatically draws dirty views. - handle.read_with(cx, |composer, cx| { - assert_eq!(composer.input, input); - let surface = composer.surface_bounds.get().unwrap(); - assert!((f32::from(surface.size.width) - (outer_width - 2.0 * Theme::SPACE_LG)).abs() <= 1.0, - "surface width clipped: docked={docked}, amount={amount}, outer={outer_width}, surface={surface:?}"); - let origin = input.read(cx).last_bounds.unwrap().origin; - assert!((f32::from(origin.y - surface.top()) - (17.0 - 4.0 * amount)).abs() <= 1.0, - "editor jumped: docked={docked}, amount={amount}, origin={origin:?}, surface={surface:?}"); - let model = composer.model_bounds.get().unwrap(); - let inset = motion::lerp(12.0, 8.0, amount); - let left = surface.left() + px(1.0 + inset + 28.0 + ACTION_UTILITY_GAP); - let travel = surface.size.width - px(2.0 + inset + 28.0 + ACTION_UTILITY_GAP - + ACTION_PRIMARY_GAP + 28.0 + inset) - model.size.width; - let (side, _, drift) = model_handoff(amount); - let expected_x = left + travel * side + px(drift); - assert!((f32::from(model.left() - expected_x)).abs() <= 1.0, - "model jumped: docked={docked}, amount={amount}, actual={model:?}, expected={expected_x:?}"); - let expected = if docked { COMPACT_TOTAL_HEIGHT } else { COMPOSER_MIN_HEIGHT }; - assert!((composer.last_rendered_height + composer.dock_clearance_correction - expected).abs() < 0.1); - assert!((composer.last_rendered_height - motion::lerp(COMPOSER_MIN_HEIGHT, COMPACT_TOTAL_HEIGHT, amount)).abs() < 0.1); - }).unwrap(); - }); + // Inspect the first painted frame before TestAppContext + // flushes effects and automatically draws dirty views. + handle.read_with(cx, |composer, cx| { + assert_eq!(composer.input, input); + let surface = composer.surface_bounds.get().unwrap(); + assert!((f32::from(surface.size.width) - (outer_width - 2.0 * Theme::SPACE_LG)).abs() <= 1.0, + "surface width clipped: docked={docked}, amount={amount}, outer={outer_width}, surface={surface:?}"); + let origin = input.read(cx).last_bounds.unwrap().origin; + assert!((f32::from(origin.y - surface.top()) - (17.0 - 4.0 * amount)).abs() <= 1.0, + "editor jumped: docked={docked}, amount={amount}, origin={origin:?}, surface={surface:?}"); + let model = composer.model_bounds.get().unwrap(); + let inset = motion::lerp(12.0, 8.0, amount); + let expected_right = surface.right() - px(1.0 + inset + 28.0 + ACTION_PRIMARY_GAP); + assert!((f32::from(model.right() - expected_right)).abs() <= 1.0, + "model must stay right aligned: compact_picker={compact_picker}, docked={docked}, amount={amount}, actual={model:?}, expected={expected_right:?}"); + let expected = if docked { COMPACT_TOTAL_HEIGHT } else { COMPOSER_MIN_HEIGHT }; + assert!((composer.last_rendered_height + composer.dock_clearance_correction - expected).abs() < 0.1); + assert!((composer.last_rendered_height - motion::lerp(COMPOSER_MIN_HEIGHT, COMPACT_TOTAL_HEIGHT, amount)).abs() < 0.1); + }).unwrap(); + }); + } } } } } - #[test] - fn model_handoff_hides_relocation_and_keeps_visible_motion_local() { - assert_eq!(model_handoff(0.0), (0.0, 1.0, 0.0)); - assert_eq!(model_handoff(1.0), (1.0, 1.0, -0.0)); - for amount in [0.44, 0.49, 0.50, 0.51, 0.56] { - assert!(model_handoff(amount).1 < 0.0001); - } - for step in 0..=100 { - let (side, opacity, drift) = model_handoff(step as f32 / 100.0); - assert!((0.0..=1.0).contains(&opacity)); - assert!(drift.abs() <= 6.0); - assert!(side == 0.0 || side == 1.0); - } - } - #[gpui::test] fn dock_reflow_paints_attachment_wrap_without_a_height_jump(cx: &mut gpui::TestAppContext) { let (_dir, handle) = composer_focus_window(cx); @@ -10042,7 +9978,6 @@ mod tests { "text growth jumped from {before} to {}", composer.last_rendered_height ); - assert!((composer.model_handoff_position - 0.7).abs() < 0.001); assert!( (f32::from(composer.model_bounds.get().unwrap().left() - model_before)) .abs() @@ -10136,10 +10071,10 @@ mod tests { .read_with(cx, |composer, _| { let surface = composer.surface_bounds.get().unwrap(); let model = composer.model_bounds.get().unwrap(); - let left = surface.left() + px(1.0 + 12.0 + 28.0 + ACTION_UTILITY_GAP); + let right = surface.right() - px(1.0 + 12.0 + 28.0 + ACTION_PRIMARY_GAP); assert!( - (f32::from(model.left() - left)).abs() <= 1.0, - "expanded controls must finish on the left" + (f32::from(model.right() - right)).abs() <= 1.0, + "expanded controls must remain on the right" ); }) .unwrap(); diff --git a/crates/ui/src/edge_fade.rs b/crates/ui/src/edge_fade.rs index c78b8999e7..d59b0e6f9e 100644 --- a/crates/ui/src/edge_fade.rs +++ b/crates/ui/src/edge_fade.rs @@ -63,6 +63,7 @@ pub fn edge_faded(band: f32, top: bool, bottom: bool, child: impl IntoElement) - overflow_y: None, scroll_x: None, smooth_overflow_x: false, + grow_x: false, child: child.into_any_element(), } } @@ -81,6 +82,7 @@ pub struct EdgeFaded { overflow_y: Option (bool, bool)>>, scroll_x: Option, smooth_overflow_x: bool, + grow_x: bool, child: AnyElement, } @@ -139,6 +141,16 @@ impl EdgeFaded { self } + /// [`Self::fade_overflow_x`] for a scrolling strip: each side's ramp is + /// as wide as the content hidden past it, up to the band. The ramp still + /// reaches zero exactly at the clip edge, but a strip scrolled by 2 px + /// dims 2 px instead of a whole band — no snap as the fade switches on. + pub fn fade_scroll_x(mut self, handle: &ScrollHandle) -> Self { + self.scroll_x = Some(handle.clone()); + self.grow_x = true; + self + } + /// Ease a right-edge label fade into view as overflow grows from zero to /// one band. Unlike scroll chrome, a label should not suddenly dim its /// final characters as soon as it becomes a fraction too wide. @@ -221,7 +233,17 @@ impl Element for EdgeFaded { } let (mut left, mut right) = (self.left, self.right); let mut outset_right = 0.0; - if let Some(scroll) = &self.scroll_x { + let (mut band_left, mut band_right) = (None, None); + if let Some(scroll) = &self.scroll_x + && self.grow_x + { + let hidden_left = (-f32::from(scroll.offset().x)).max(0.0); + let hidden_right = (f32::from(scroll.max_offset().x) - hidden_left).max(0.0); + left &= hidden_left > 0.5; + right &= hidden_right > 0.5; + band_left = Some(px(hidden_left.min(self.band))); + band_right = Some(px(hidden_right.min(self.band))); + } else if let Some(scroll) = &self.scroll_x { let scrolled = -f32::from(scroll.offset().x); let max_scroll = f32::from(scroll.max_offset().x); left &= scrolled > 1.0; @@ -246,6 +268,8 @@ impl Element for EdgeFaded { band: px(self.band), band_top: self.band_top.map(px), band_bottom: self.band_bottom.map(px), + band_left, + band_right, top, bottom, left, diff --git a/crates/ui/src/glass.rs b/crates/ui/src/glass.rs new file mode 100644 index 0000000000..68df45fee9 --- /dev/null +++ b/crates/ui/src/glass.rs @@ -0,0 +1,161 @@ +//! Glass surfaces shared by controls: each is a vertical gradient (lit from +//! above), a hairline rim, an inner top highlight, and a soft drop. Dark +//! appearances keep the structure with translucent white instead of opaque +//! fills. `light` is the neutral plate (a track), `accent` the active plate +//! (Stop, an enabled switch, a slider's fill) and `thumb` the opaque handle +//! that rides on either. +use gpui::{ + Background, Bounds, BoxShadow, Corners, Hsla, Pixels, Styled, Window, hsla, linear_color_stop, + linear_gradient, point, px, +}; + +use crate::theme::Theme; + +fn shadow(color: Hsla, y: f32, blur: f32, spread: f32, inset: bool) -> BoxShadow { + BoxShadow { + color, + offset: point(px(0.0), px(y)), + blur_radius: px(blur), + spread_radius: px(spread), + inset, + } +} + +fn white(alpha: f32) -> Hsla { + hsla(0.0, 0.0, 1.0, alpha) +} + +fn black(alpha: f32) -> Hsla { + hsla(0.0, 0.0, 0.0, alpha) +} + +fn vertical(top: Hsla, bottom: Hsla) -> Background { + linear_gradient( + 180.0, + linear_color_stop(top, 0.0), + linear_color_stop(bottom, 1.0), + ) +} + +/// A lighter tint of `color` for the lit top of a gradient. +fn lift(color: Hsla, amount: f32) -> Hsla { + crate::motion::mix(color, white(color.a), amount) +} + +/// One glass surface, applicable to an element or paintable in a canvas. +pub(crate) struct Plate { + background: Background, + rim: Hsla, + shadows: Vec, +} + +impl Plate { + pub(crate) fn apply(self, el: E) -> E { + el.bg(self.background) + .border_1() + .border_color(self.rim) + .shadow(self.shadows) + } + + /// Paints the plate the way a styled element would: drops beneath the + /// fill, the rim on it, and inset light above it. + pub(crate) fn paint(&self, window: &mut Window, bounds: Bounds, radius: f32) { + let corners = Corners::all(px(radius)); + let (inset, drop): (Vec<_>, Vec<_>) = self.shadows.iter().cloned().partition(|s| s.inset); + window.paint_drop_shadows(bounds, corners, &drop); + window.paint_quad(gpui::quad( + bounds, + corners, + self.background, + px(1.0), + self.rim, + gpui::BorderStyle::default(), + )); + window.paint_inset_shadows(bounds, corners, &inset); + } +} + +/// Neutral glass. `t` fades the whole treatment in. Dark appearances lift +/// translucent white off the surface; light ones sink a translucent tint +/// into it (shaded under the top edge, lit inside the bottom edge), since a +/// near-white plate on a near-white surface washes out. +pub(crate) fn light_plate(theme: &Theme, t: f32) -> Plate { + if theme.appearance.is_dark() { + return Plate { + background: vertical(white(0.08 * t), white(0.05 * t)), + rim: white(0.09 * t), + shadows: vec![ + shadow(white(0.07 * t), 1.0, 0.0, 0.0, true), + shadow(black(0.16 * t), 1.0, 2.0, 0.0, false), + ], + }; + } + Plate { + background: vertical(black(0.075 * t), black(0.04 * t)), + rim: black(0.08 * t), + // Inset only: GPUI paints drop shadows under the whole box, so an + // outer lip would show through the translucent fill and whiten it. + shadows: vec![ + shadow(black(0.08 * t), 1.0, 2.0, 0.0, true), + shadow(white(0.55 * t), -1.0, 0.0, 0.0, true), + ], + } +} + +/// Accent glass. `t` fades it in over the resting control; `glow` (0–1) +/// spreads its coloured shadow. +pub(crate) fn accent_plate(theme: &Theme, t: f32, glow: f32) -> Plate { + let dark = theme.appearance.is_dark(); + // The theme's fill token, as every other accent fill uses. Dark plates + // keep their lighting faint so the token reads as itself, not a pastel. + let base = theme.accent_strong; + let top = lift(base, if dark { 0.06 } else { 0.22 }); + // Light: a defined edge a shade deeper than the fill, with a paler ring + // just inside it. Dark: a soft lifted rim. + let rim = if dark { + lift(base, 0.35).opacity(0.35) + } else { + crate::motion::mix(base, black(1.0), 0.14) + }; + let highlight = white(if dark { 0.12 } else { 0.32 }); + let ring = white(if dark { 0.0 } else { 0.22 }); + // Dark plates sit flat at rest; light ones keep a faint coloured glow. + let halo = base.opacity((if dark { 0.0 } else { 0.14 } + 0.3 * glow) * t); + Plate { + background: vertical(top.opacity(t), base.opacity(t)), + rim: rim.opacity(t), + shadows: vec![ + shadow(highlight.opacity(t), 1.0, 0.0, 0.0, true), + shadow(ring.opacity(t), 0.0, 0.0, 1.0, true), + shadow(halo, 2.0 + 2.0 * glow, 6.0 + 14.0 * glow, 0.0, false), + ], + } +} + +/// The opaque handle: the light-appearance neutral plate in every +/// appearance, so it stays solid over translucent dark glass, with the +/// appearance's own drop beneath it. +pub(crate) fn thumb_plate(theme: &Theme) -> Plate { + let dark = theme.appearance.is_dark(); + let mut shadows = vec![ + shadow(white(0.8), 1.0, 0.0, 0.0, true), + shadow(black(if dark { 0.22 } else { 0.14 }), 1.0, 2.0, 0.0, false), + ]; + if !dark { + // A wide, faint ambient drop separates it from pale tracks. + shadows.push(shadow(black(0.06), 2.0, 6.0, 0.0, false)); + } + Plate { + background: vertical(hsla(0.0, 0.0, 1.0, 1.0), hsla(0.0, 0.0, 0.965, 1.0)), + rim: black(if dark { 0.12 } else { 0.11 }), + shadows, + } +} + +pub(crate) fn light(el: E, theme: &Theme, t: f32) -> E { + light_plate(theme, t).apply(el) +} + +pub(crate) fn thumb(el: E, theme: &Theme) -> E { + thumb_plate(theme).apply(el) +} diff --git a/crates/ui/src/haptics.rs b/crates/ui/src/haptics.rs new file mode 100644 index 0000000000..ae2ae19b03 --- /dev/null +++ b/crates/ui/src/haptics.rs @@ -0,0 +1,29 @@ +//! Discrete native feedback for user-driven control detents. + +pub(crate) fn selection_step() { + #[cfg(all(target_os = "macos", not(test)))] + { + use objc2_app_kit::{ + NSHapticFeedbackManager, NSHapticFeedbackPattern, NSHapticFeedbackPerformanceTime, + NSHapticFeedbackPerformer, + }; + // UI input handlers run on the main thread. AppKit gracefully does + // nothing on devices without a haptic-capable trackpad. + if objc2::MainThreadMarker::new().is_some() { + NSHapticFeedbackManager::defaultPerformer().performFeedbackPattern_performanceTime( + NSHapticFeedbackPattern::LevelChange, + NSHapticFeedbackPerformanceTime::Now, + ); + } + } + #[cfg(test)] + STEPS.with(|steps| steps.set(steps.get() + 1)); +} + +#[cfg(test)] +thread_local! { static STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; } + +#[cfg(test)] +pub(crate) fn step_count() -> usize { + STEPS.with(|steps| steps.get()) +} diff --git a/crates/ui/src/icons.rs b/crates/ui/src/icons.rs index f747fec383..b211fc05e1 100644 --- a/crates/ui/src/icons.rs +++ b/crates/ui/src/icons.rs @@ -50,8 +50,10 @@ macro_rules! icon_assets { icon_assets![ (PROJECT_DEFAULT, "project-default"), (REMOTE_SERVER, "remote-server"), - // Service-tier bolt, drawn in the toolbar family's linear weight. + // Service-tier bolt, drawn in the toolbar family's linear weight; the + // filled twin marks fast mode on. (FAST_TIER, "fast-tier"), + (FAST_TIER_BOLD, "fast-tier-bold"), // Solar Icons (Linear), CC BY 4.0 — 480 Design. (MONITOR, "monitor"), (SUN, "sun"), diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 79a33d9886..e41a30df09 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -32,6 +32,8 @@ pub mod edge_fade; pub mod file_icons; pub mod files; pub mod frost; +mod glass; +mod haptics; pub mod history; pub mod icons; pub(crate) mod image_media; diff --git a/crates/ui/src/motion.rs b/crates/ui/src/motion.rs index 3363911098..3726af509c 100644 --- a/crates/ui/src/motion.rs +++ b/crates/ui/src/motion.rs @@ -547,6 +547,35 @@ where }) } +/// [`menu_in`] travelling from the trigger's side: `from` is the signed +/// starting offset (negative above the resting place for dropdowns, +/// positive below it for menus that open upward). +pub fn menu_in_from(id: impl Into, from: f32, element: E) -> AnimationElement +where + E: Styled + IntoElement + 'static, +{ + element.with_animation(id, MENU_IN.animation(), move |el, t| { + el.relative() + .opacity(0.3 + 0.7 * t) + .top(px(from * (1.0 - t))) + }) +} + +/// [`menu_out`] retreating toward the trigger by half the entrance travel. +pub fn menu_out_toward( + id: impl Into, + toward: f32, + t: f32, + element: E, +) -> AnimationElement +where + E: Styled + IntoElement + 'static, +{ + element.with_animation(id, MENU_OUT.animation(), move |el, _| { + el.relative().opacity(1.0 - t).top(px(toward * 0.5 * t)) + }) +} + /// Popover exit: the reverse of [`menu_in`] — fade to 0 + translateY 0→−2 over /// [`MENU_OUT`]. Unlike the entrances, the eased progress `t` comes from the /// caller (computed off [`crate::popover::Popup`]'s closing instant at render @@ -1419,3 +1448,13 @@ mod tests { #[cfg(windows)] #[path = "motion/windows_pulse.rs"] mod windows_pulse; + +/// A bounded activation sheen for Fast service tier; GPUI handles reduced motion. +pub fn fast_tier( + id: impl Into, + element: impl IntoElement + gpui::Styled + 'static, +) -> impl IntoElement { + element.with_animation(id, Animation::new(Duration::from_millis(700)), |el, t| { + el.opacity(1.0 - 0.35 * (1.0 - t) * (std::f32::consts::PI * t).sin()) + }) +} diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index ff7e800f6a..1bd2389404 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -11,6 +11,8 @@ //! in free functions with unit tests; RPC results land in [`Loadable`] slots //! rendered as skeletons / inline errors with Retry. +mod compact; + use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; @@ -364,6 +366,7 @@ const NO_ACTIVE_ROW: usize = usize::MAX; /// state; clicking a brand icon picks that harness. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum ModelRail { + All, Favorites, #[default] Harness, @@ -526,8 +529,20 @@ pub struct Pickers { menu_geometry: HashMap, model_trigger_bounds: Option>, // Keep the open menu anchored while its selected label and options change. - open_model_width: Option, + /// The model chip glides to its content's width as the model, effort + /// or fast tier changes; `chip_resizing` clips instead of truncating + /// while it moves. + chip_width: Option, + chip_resizing: bool, open_model_height: f32, + compact_model_list: bool, + /// The compact picker's provider page, entered from the panel's + /// provider button; exclusive with [`Self::compact_model_list`]. + compact_providers: bool, + effort_dragging: bool, + compact_motion: compact::CompactMotion, + compact_keyboard: bool, + effort_bounds: Option>, open: popover::Popup, /// The harness/model picker's rail selection (favorites vs the effective /// harness's list). Re-primed on every open. @@ -753,8 +768,15 @@ impl Pickers { target_generation: 0, menu_geometry: HashMap::new(), model_trigger_bounds: None, - open_model_width: None, + chip_width: None, + chip_resizing: false, open_model_height: model_menu_height(0), + compact_model_list: false, + compact_providers: false, + effort_dragging: false, + compact_motion: compact::CompactMotion::default(), + compact_keyboard: false, + effort_bounds: None, config: DraftConfig::default(), title, defaults, @@ -935,8 +957,12 @@ impl Pickers { let explicit = self.config.reasoning.or_else(|| { match self.state.read(cx).selected_chat_row() { Some(chat) => chat.config.as_ref().and_then(|c| c.reasoning), - // New chat: the remembered last-used level. - None => self.defaults.reasoning, + // New chat: the level last used with this model, else the + // last-used level overall. + None => self + .effective_harness(cx) + .and_then(|h| self.defaults.reasoning_for(h, self.effective_model_id(cx))) + .or(self.defaults.reasoning), } }); if self.selected_model(cx).is_none() { @@ -1080,6 +1106,7 @@ impl Pickers { /// Outside clicks and navigation keep focus at the clicked destination. fn dismiss(&mut self, cx: &mut Context) { self.focus_on_mount = false; + self.effort_dragging = false; self.cancel_setting_hover(); self.setting_menu = None; self.setting_bounds = None; @@ -1135,8 +1162,12 @@ impl Pickers { return; } if kind == PickerKind::HarnessModel { - self.open_model_width = self.model_trigger_bounds.map(|bounds| bounds.size.width); self.open_model_height = model_menu_height(self.setting_groups(cx).len()); + self.compact_model_list = false; + self.compact_providers = false; + self.effort_dragging = false; + self.compact_keyboard = false; + self.compact_motion = compact::CompactMotion::default(); } self.open.open(kind); self.focus_on_mount = true; @@ -1171,7 +1202,9 @@ impl Pickers { self.config.harness = None; self.model_rail = ModelRail::Harness; } else if kind == PickerKind::HarnessModel { - self.model_rail = if !self.harness_locked(cx) && !self.defaults.favorites.is_empty() { + self.model_rail = if self.compact_model_picker(cx) { + ModelRail::All + } else if !self.harness_locked(cx) && !self.defaults.favorites.is_empty() { ModelRail::Favorites } else { ModelRail::Harness @@ -1635,7 +1668,7 @@ impl Pickers { // provider settings so both the picker and first createChat use the // new harness, even when its model catalog has not loaded yet. let model = self.defaults.model_for(harness).map(|m| m.id.clone()); - let reasoning = self.defaults.reasoning; + let reasoning = self.defaults.reasoning_for(harness, model.as_deref()); let options = model .as_deref() .and_then(|model| self.defaults.model_options_for(harness, model)) @@ -1688,7 +1721,9 @@ impl Pickers { self.update_chat_config(cx, move |config| config.model = Some(model_id)); } else { // New chat: draft pick + sticky last-used memory for this harness. + // Effort follows the model: its own remembered level, if any. self.config.model = Some(model_id.clone()); + self.config.reasoning = None; if let Some(harness) = self.effective_harness(cx) { let label = self .models @@ -1710,7 +1745,13 @@ impl Pickers { self.update_chat_config(cx, move |config| config.reasoning = Some(level)); } else { self.config.reasoning = Some(level); - self.defaults.reasoning = Some(level); + if let Some(harness) = self.effective_harness(cx) { + let model = self.effective_model_id(cx).map(str::to_owned); + self.defaults + .remember_reasoning(harness, model.as_deref(), level); + } else { + self.defaults.reasoning = Some(level); + } self.save_defaults(); } cx.notify(); @@ -1996,6 +2037,10 @@ impl Pickers { fn activate_model_row(&mut self, cx: &mut Context) { if self.setting_menu.is_some() { self.activate_setting_choice(cx); + } else if self.compact_model_picker(cx) && self.compact_providers { + self.activate_compact_provider(cx); + } else if self.compact_model_picker(cx) && self.compact_model_list { + self.activate_model_index(self.active, cx); } else if let Some(index) = self.active.checked_sub(self.model_rows_len(cx)) { if let Some(group) = self.setting_groups(cx).get(index) { self.open_setting(SettingScope::Tray, group.id.clone(), cx); @@ -2022,20 +2067,35 @@ impl Pickers { self.pick_harness(row.harness, cx); } self.pick_model(row.model.id, cx); + if self.compact_model_picker(cx) { + self.compact_model_list = false; + self.focus_on_mount = true; + } } /// Star/unstar a model and persist it with the sticky defaults. fn toggle_model_favorite(&mut self, harness: HarnessId, model: &str, cx: &mut Context) { self.reload_defaults(); + let keyboard_row = (self.compact_model_picker(cx) && self.compact_keyboard) + .then(|| self.model_rows(cx).get(self.active).cloned()) + .flatten(); self.defaults.toggle_favorite(harness, model); self.persist_defaults(); self.catalog_rev += 1; - // Starring REORDERS the list (stars float to the top / leave the - // favorites view) — re-home the keyboard highlight onto the SELECTED - // row so exactly one row reads highlighted afterwards. Following the - // starred row instead left its cursor wash next to the selected - // row's ring: "two highlighted rows" (user report, twice). + // Pointer interactions retain the selected-row highlight after sorting. self.active = self.selected_model_index(cx); + // Keyboard focus follows identity, independently of selection styling. + if let Some(row) = keyboard_row { + self.active = self + .model_rows(cx) + .iter() + .position(|candidate| { + candidate.harness == row.harness && candidate.model.id == row.model.id + }) + .unwrap_or(self.active); + self.model_scroll + .scroll_to_item(self.active, gpui::ScrollStrategy::Nearest); + } cx.notify(); } @@ -2513,6 +2573,23 @@ impl Pickers { if !self.open.is_open() { return; } + if self.open_kind() == Some(PickerKind::HarnessModel) && self.compact_model_picker(cx) { + // Escape only steps back a page or closes a submenu: it should + // not light focus rings on the controls it returns to. The next + // navigation key brings them back. + self.compact_keyboard = event.keystroke.key != "escape"; + if self.compact_model_list + && event.keystroke.modifiers.platform + && event.keystroke.modifiers.shift + && event.keystroke.key.eq_ignore_ascii_case("f") + { + if let Some(row) = self.model_rows(cx).get(self.active).cloned() { + self.toggle_model_favorite(row.harness, &row.model.id, cx); + } + cx.stop_propagation(); + return; + } + } if self.setting_menu.is_some() { match event.keystroke.key.as_str() { "escape" | "left" => { @@ -2541,7 +2618,23 @@ impl Pickers { cx.notify(); return; } + if self.open_kind() == Some(PickerKind::HarnessModel) && self.compact_model_picker(cx) { + if self.compact_providers { + self.compact_provider_key(event, cx); + return; + } + if !self.compact_model_list { + self.compact_panel_key(event, cx); + return; + } + if event.keystroke.key == "escape" { + self.show_compact_panel(cx); + cx.stop_propagation(); + return; + } + } if event.keystroke.key == "right" + && !self.compact_model_picker(cx) && self.open_kind() == Some(PickerKind::HarnessModel) && self.active >= self.model_rows_len(cx) { @@ -2579,7 +2672,12 @@ impl Pickers { Some(PickerKind::Checkout) => 2, // Continue from model rows into the pinned settings triggers. Some(PickerKind::HarnessModel) => { - self.model_rows_len(cx) + self.setting_groups(cx).len() + self.model_rows_len(cx) + + if self.compact_model_picker(cx) { + 0 + } else { + self.setting_groups(cx).len() + } } Some(PickerKind::Space) => self.filtered_space_rows(cx).len() + 1, Some(PickerKind::Device) => self.filtered_device_rows(cx).len(), @@ -2634,6 +2732,7 @@ impl Pickers { fn measure_trigger(&self, kind: PickerKind, cx: &Context) -> impl IntoElement { let entity = cx.entity().downgrade(); + let compact = kind == PickerKind::HarnessModel && self.compact_model_picker(cx); // New-thread model and workspace menus open down, matching the // centered composer's layout. In-thread menus retain adaptive placement. let below = (self.title.is_some() || self.state.read(cx).selected_chat.is_none()) @@ -2654,21 +2753,25 @@ impl Pickers { // Prefer below on the new-chat canvas only when it can fit // useful content. Tall drafts/attachments can put the trigger // near the window bottom; retain adaptive placement there. - if below && space_below >= 180.0 { + let prefer_below = below && space_below >= 180.0; + if prefer_below { geometry = popover::MenuGeometry { height: space_below, below: true, }; } + // The compact card opens down on the new-chat canvas like the + // standard menu, and up from the docked composer. + if compact && !prefer_below { + geometry = popover::MenuGeometry { + height: (f32::from(bounds.top()) - 14.0).clamp(0.0, 640.0), + below: false, + }; + } entity .update(cx, |this, cx| { if kind == PickerKind::HarnessModel { this.model_trigger_bounds = Some(bounds); - if this.mounted_kind() == Some(kind) && this.open_model_width.is_none() - { - this.open_model_width = Some(bounds.size.width); - cx.notify(); - } } if this.menu_geometry.get(&kind) != Some(&geometry) { this.menu_geometry.insert(kind, geometry); @@ -2685,6 +2788,63 @@ impl Pickers { // ---- render ---- + /// The model chip's content width (padding, brand slot, label, effort + /// and fast mark), eased toward on every change. The chip is anchored at + /// its trailing edge beside Send, as is its popover, so only its leading + /// edge moves and an open card stays put while the selection updates. + #[allow(clippy::too_many_arguments)] + fn model_chip_width( + &mut self, + window: &mut Window, + label: &SharedString, + suffix: Option<&SharedString>, + label_loading: bool, + fast: bool, + cx: &App, + ) -> f32 { + const GAP: f32 = 6.0; + let size = crate::typography::ui_rems(12.0).to_pixels(window.rem_size()); + let mut font = window.text_style().font(); + font.weight = gpui::FontWeight::MEDIUM; + let measure = |text: &SharedString| -> f32 { + let run = gpui::TextRun { + len: text.len(), + font: font.clone(), + color: gpui::black(), + background_color: None, + underline: None, + strikethrough: None, + }; + f32::from( + window + .text_system() + .shape_line(text.clone(), size, &[run], None) + .width, + ) + .ceil() + }; + let label_width = if label_loading { 56.0 } else { measure(label) }; + let mut width = 2.0 * 6.0 + 16.0 + GAP + label_width; + if let Some(suffix) = suffix { + width += GAP + measure(suffix); + } + if fast { + width += GAP + 13.0; + } + let target = width.min(248.0); + let (value, moving) = compact::ScalarTransition::sample( + &mut self.chip_width, + target, + std::time::Instant::now(), + cx.reduce_motion(), + ); + self.chip_resizing = moving; + if moving { + window.request_animation_frame(); + } + value + } + #[allow(clippy::too_many_arguments)] fn trigger_chip( &self, @@ -2714,6 +2874,7 @@ impl Pickers { // this picker's entity. let id: SharedString = format!("{base}-{}", cx.entity_id()).into(); let open = self.open_kind() == Some(kind); + let resizing = kind == PickerKind::HarnessModel && self.chip_resizing; // Ghost pill (zeron composer/styles.tsx `pill`): `h-8 rounded-lg px-2.5 // gap-1.5 text-[12px] font-medium text-muted-foreground`, icons size-4, // hover/open wash — no border, no caret; the actions row stays quiet. @@ -2723,10 +2884,6 @@ impl Pickers { .id(id.clone()) .h(px(32.0)) .max_w(px(248.0)) - .when( - kind == PickerKind::HarnessModel && self.mounted_kind() == Some(kind), - |chip| chip.when_some(self.open_model_width, |chip, width| chip.w(width)), - ) // Shrinkable under row pressure — four footer chips share one // line; without min_w_0 they overflowed and painted overlapped. .min_w_0() @@ -2734,7 +2891,7 @@ impl Pickers { .flex_row() .items_center() .gap(px(6.0)) - // The model trigger sits immediately beside the attachment. + // The model trigger sits beside the right-hand composer actions. // Its own padding participates in that visible gap; footer and // destination triggers keep their wider independent hit areas. .px(px(if kind == PickerKind::HarnessModel { @@ -2793,7 +2950,11 @@ impl Pickers { el.child(popover::skeleton_bar(56.0, cx.entity_id(), cx)) }) .when(!label_loading, |el| { - el.child(div().min_w_0().truncate().child(label)) + el.child(if resizing { + resizing_chip_text(format!("{id}-label").into(), 1.0, None, label) + } else { + div().min_w_0().truncate().child(label).into_any_element() + }) }) // The effort half of the combined model+effort chip (and the space // chip's "@ device" tag): muted, no icon — one button, two tones. @@ -2801,14 +2962,18 @@ impl Pickers { // pressure the suffix yields FIRST (large shrink factor) so the // model name — the run's identity — truncates last. .when_some(suffix, |el, (suffix, tint)| { - el.child( + let color = tint.unwrap_or(theme.text_muted.opacity(0.7)); + el.child(if resizing { + resizing_chip_text(format!("{id}-suffix").into(), 1000.0, Some(color), suffix) + } else { div() .flex_shrink(1000.0) .min_w_0() + .text_color(color) .truncate() - .text_color(tint.unwrap_or(theme.text_muted.opacity(0.7))) - .child(suffix), - ) + .child(suffix) + .into_any_element() + }) }) } @@ -3263,6 +3428,9 @@ impl Pickers { popover::popover_card_flush(&theme) .w(px(width)) .h(px(self.menu_geometry().height.min(self.open_model_height))) + .when(self.compact_model_picker(cx), |el| { + el.h(px(self.compact_motion.frame_height)) + }) .track_focus(&self.focus) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { this.on_key_down(event, window, cx) @@ -3351,7 +3519,9 @@ impl Pickers { /// through the exit animation, and the rail must keep measuring the /// closing menu's own handle, not `menu_scroll`'s idle geometry. fn active_menu_scroll(&self) -> gpui::ScrollHandle { - if self.mounted_kind() == Some(PickerKind::HarnessModel) { + if self.mounted_kind() == Some(PickerKind::HarnessModel) + && (self.model_rail != ModelRail::All || self.compact_model_list) + { self.model_scroll_base() } else { self.menu_scroll.clone() @@ -3580,8 +3750,15 @@ impl Pickers { /// Model picker with favorites and harness tabs above a scoped search. /// Existing chats show only their own harness tab and models. fn render_harness_model_popover(&mut self, cx: &mut Context) -> AnyElement { + let compact = self.compact_model_picker(cx); let height = self.menu_geometry().height.min(self.open_model_height); let (list_height, tray_height) = model_menu_budgets(height, self.setting_groups(cx).len()); + let list_height = if compact { + compact::compact_list_height(self.model_rows_len(cx)) + .min((height - compact::LIST_HEADER - 2.0).max(0.0)) + } else { + list_height + }; let theme = Theme::of(cx).for_popup(); @@ -3745,6 +3922,10 @@ impl Pickers { ); } + if compact { + tabs = self.compact_list_header(cx); + } + // ── search row: icon + borderless input over a full-bleed hairline. // The placeholder names the scope — the query never leaves the // viewed tab (user request; the old global search hid the rail). @@ -3882,7 +4063,8 @@ impl Pickers { // ── traits tray: the reasoning ladder + model options PINNED under // the list (the separate Traits popover folded in here — user // request). Hidden entirely when the selected model has neither. - let has_tray = self.title.is_none() + let has_tray = !compact + && self.title.is_none() && (!self.trait_ladder(cx).is_empty() || self .selected_model(cx) @@ -3907,7 +4089,8 @@ impl Pickers { .flex() .flex_col() .child(tabs) - .child(search_row) + // The compact header carries the filter beside its back button. + .when(!compact, |el| el.child(search_row)) .children(refresh_error) .child(list_host) .children(tray) @@ -3924,6 +4107,9 @@ impl Pickers { row: &ModelRowData, cx: &mut Context, ) -> AnyElement { + if self.compact_model_picker(cx) { + return self.render_compact_model_row(ix, row, cx); + } let theme = Theme::of(cx).for_popup(); let effective = self.effective_harness(cx); let is_selected = Some(row.harness) == effective @@ -4295,12 +4481,20 @@ impl Pickers { } /// The tray's settings. A model configured in place keeps them in its - /// hover card only. + /// hover card only — except in the compact picker, whose panel is the + /// one place the selected model's settings live. fn setting_groups(&self, cx: &App) -> Vec { // Titles always run at minimal reasoning with no model options. - if self.title.is_some() || self.selected_model(cx).is_some_and(configured_in_place) { + if self.title.is_some() { return Vec::new(); } + if self.selected_model(cx).is_some_and(configured_in_place) { + return if self.compact_model_picker(cx) { + card_order(self.live_groups(cx)) + } else { + Vec::new() + }; + } self.live_groups(cx) } @@ -4595,6 +4789,12 @@ impl Pickers { .into_iter() .filter(|g| tray || !is_toggle(g)); for (ix, group) in groups.enumerate() { + if tray + && self.compact_model_picker(cx) + && !Self::compact_option_visible(&group.id, &self.compact_hidden_options(cx)) + { + continue; + } let open = self.setting_scope == scope && self.setting_menu.as_ref() == Some(&group.id); let value = group .choices @@ -4615,7 +4815,11 @@ impl Pickers { ) .id((row_id, ix)) .relative() - .h(px(30.0)) + .h(px(if self.compact_model_picker(cx) { + 26.0 + } else { + 30.0 + })) .py(px(0.0)) .on_click(cx.listener(move |this, _, window, cx| { if tray { @@ -4823,16 +5027,132 @@ impl Pickers { .child(row), ); } + // The compact panel's own inset already closes the card below the + // last row; a second one left the bottom twice as deep as the sides. + let compact = tray && self.compact_model_picker(cx); div() .flex() .flex_col() .gap(px(2.0)) - .py(px(popover::CARD_INSET)) + .pt(px(popover::CARD_INSET)) + .when(!compact, |el| el.pb(px(popover::CARD_INSET))) .children(rows) .into_any_element() } } +/// Readable interaction state for the isolated compact-picker fixture. +#[cfg(feature = "compact-picker-fixture")] +pub struct CompactPickerFixtureState { + pub model_list: bool, + pub explicit_reasoning: Option, + pub reasoning: Option, + pub fast: bool, + pub focused_model: Option, + pub focused_model_favorite: bool, +} + +/// Synthetic catalog and assertions for the isolated compact-picker fixture. +/// None of this is linked into production builds. +#[cfg(feature = "compact-picker-fixture")] +impl Pickers { + pub fn fixture_compact_catalog(&mut self, cx: &mut Context) { + self.config.harness = Some(HarnessId::Codex); + self.config.model = Some("gpt-5.4".into()); + self.config.reasoning = None; + self.defaults + .model_options_mut(HarnessId::Codex, "gpt-5.4") + .clear(); + self.harnesses = Loadable::Ready( + serde_json::from_value(serde_json::json!([ + { + "id":"codex", + "name":"Codex", + "installed":true, + "enabled":true, + "supportsSteering":true, + "steeringMode":"step-boundary", + "reasoningLevels":[] + } + ])) + .unwrap(), + ); + self.models.insert( + HarnessId::Codex, + Loadable::Ready( + serde_json::from_value(serde_json::json!([ + { + "id":"gpt-5.4", + "label":"GPT-5.4", + "description":"Best for complex coding and reasoning", + "reasoningLevels":["low","medium","high","xhigh"], + "options":[ + { + "id":"serviceTier", + "label":"Service tier", + "defaultChoice":"standard", + "choices":[ + {"id":"standard","label":"Standard"}, + {"id":"fast","label":"Fast"} + ] + }, + { + "id":"contextWindow", + "label":"Context window", + "defaultChoice":"standard", + "choices":[ + {"id":"standard","label":"Standard"}, + {"id":"1m","label":"1M tokens"} + ] + } + ] + }, + { + "id":"gpt-5.3-codex", + "label":"GPT-5.3 Codex", + "description":"Optimized for agentic coding" + }, + { + "id":"gpt-5.2", + "label":"GPT-5.2", + "description":"General purpose reasoning" + }, + { + "id":"gpt-5.1-codex-mini", + "label":"GPT-5.1 Codex Mini", + "description":"Fast, efficient coding" + } + ])) + .unwrap(), + ), + ); + self.compact_model_list = false; + self.compact_keyboard = false; + self.active = 0; + self.catalog_rev += 1; + cx.notify(); + } + + pub fn fixture_compact_state(&self, cx: &App) -> CompactPickerFixtureState { + let focused = self + .compact_model_list + .then(|| self.model_rows(cx).get(self.active).cloned()) + .flatten(); + CompactPickerFixtureState { + model_list: self.compact_model_list, + explicit_reasoning: self.config.reasoning, + reasoning: self.effective_reasoning(cx), + fast: self + .compact_fast_choice(cx) + .is_some_and(|(_, _, _, fast)| fast), + focused_model_favorite: focused + .as_ref() + .is_some_and(|row| self.defaults.is_favorite(row.harness, &row.model.id)), + focused_model: focused.map(|row| row.model.id), + } + } +} + /// The floating-scrollbar treatment for every picker list /// ([`popover::rail`] folds the note/hide/metrics/render + pointer listeners /// into one call): one shared rail state, fed by whichever handle @@ -4949,6 +5269,7 @@ fn scoped_model_rows<'a>( ambiguous: false, }; let in_scope = |descriptor: &HarnessDescriptor, model: &Model| match rail { + ModelRail::All => true, ModelRail::Favorites => is_favorite(descriptor.id, &model.id), ModelRail::Harness => Some(descriptor.id) == effective, }; @@ -4985,22 +5306,31 @@ fn scoped_model_rows<'a>( input_ix += 1; } } - ranked.sort_by_key(|(rank, unstarred, ix, _)| (*rank, *unstarred, *ix)); + ranked.sort_by_key(|(rank, unstarred, ix, _)| { + if rail == ModelRail::All { + (*unstarred, *rank, *ix) + } else { + (*rank, *unstarred, *ix) + } + }); return ranked.into_iter().map(|(_, _, _, row)| row).collect(); } match rail { - ModelRail::Favorites => { + ModelRail::All | ModelRail::Favorites => { let mut rows = Vec::new(); for descriptor in descriptors { let Some(models) = models_for(descriptor.id) else { continue; }; for model in models { - if is_favorite(descriptor.id, &model.id) { + if rail == ModelRail::All || is_favorite(descriptor.id, &model.id) { rows.push(row(descriptor, model)); } } } + if rail == ModelRail::All { + rows.sort_by_key(|row| !is_favorite(row.harness, &row.model.id)); + } rows } ModelRail::Harness => { @@ -5220,6 +5550,64 @@ fn model_menu_budgets(height: f32, setting_count: usize) -> (f32, f32) { (body - tray, tray) } +/// Chip text while the chip's width animates: clipped rather than +/// ellipsized (an ellipsis would flicker in and out each frame), with the +/// clip edge faded so a half-shown glyph never ends in a hard cut. The ramp +/// is as wide as the hidden text, so it is zero exactly at the clip edge. +fn resizing_chip_text( + id: SharedString, + shrink: f32, + color: Option, + text: SharedString, +) -> AnyElement { + let overflow = gpui::ScrollHandle::new(); + crate::edge_fade::edge_faded( + 16.0, + false, + false, + div() + .id(id) + .flex_shrink(shrink) + .min_w_0() + .overflow_hidden() + .track_scroll(&overflow) + .flex() + .when_some(color, |el, color| el.text_color(color)) + .child(div().flex_none().whitespace_nowrap().child(text)), + ) + .fade_right(true) + .fade_scroll_x(&overflow) + .into_any_element() +} + +/// Fast mode's `(on, off)` choices, whatever form a harness gives it: a +/// `fastMode`/`fast_mode` on/off toggle (Claude), Cursor's `fast` true/false, +/// or a tier/speed option offering `fast` (Codex, Devin). Off is the default +/// when fast isn't, else the other choice — Cursor runs some models fast by +/// default. Every model then gets the same fast-mode UI. +fn fast_mode_values(option: &zeron_proto::ModelOption) -> Option<(&str, &str)> { + let has = |id: &str| option.choices.iter().any(|choice| choice.id == id); + let on = if matches!(option.id.as_str(), "fastMode" | "fast_mode") && has("on") { + "on" + } else if option.id == "fast" && has("true") { + "true" + } else if has("fast") { + "fast" + } else { + return None; + }; + let off = if option.default_choice != on && has(&option.default_choice) { + option.default_choice.as_str() + } else { + option + .choices + .iter() + .map(|choice| choice.id.as_str()) + .find(|id| *id != on)? + }; + Some((on, off)) +} + /// Attach the (single) open popover above a selector trigger. fn attach_overlay( chip: gpui::Stateful, @@ -5419,38 +5807,81 @@ impl Render for Pickers { | Some(PickerKind::Space) | Some(PickerKind::Device) => None, Some(PickerKind::HarnessModel) => { - let content = self.render_harness_model_popover(cx); - Some(( - PickerKind::HarnessModel, - // Compact single-harness pane (t3 ModelPickerContent - // shrunk to its tabbed layout). - self.popover_frame_flush(304.0, content, cx), - )) + let menu = if self.compact_model_picker(cx) { + self.render_compact_menu(window, cx) + } else { + let content = self.render_harness_model_popover(cx); + self.popover_frame_flush(304.0, content, cx) + }; + Some((PickerKind::HarnessModel, menu)) } None => None, }; - // The composer places this model chip beside the attachment button: + // The composer places this model chip beside Send: // brand icon + model name, then the effort as the chip's muted second - // tone. No suffix when the model has no reasoning ladder, nor for the - // title picker (titles always run at minimal reasoning). - let chip_suffix = effort.filter(|_| self.title.is_none()).map(|level| { - ( - SharedString::from(reasoning_label(level)), - effort_customized.then(|| theme.text.opacity(0.85)), - ) + // tone — the ladder's level, else an effort option's choice (Cursor). + // None for the title picker (titles always run at minimal reasoning). + let chip_suffix = effort + .filter(|_| self.title.is_none()) + .map(|level| { + ( + SharedString::from(reasoning_label(level)), + effort_customized.then(|| theme.text.opacity(0.85)), + ) + }) + .or_else(|| { + self.title + .is_none() + .then(|| self.compact_effort(cx)) + .flatten() + .and_then(|e| e.labels.get(e.selected).cloned()) + .map(|label| (label, None)) + }); + let fast = self.selected_model(cx).is_some_and(|model| { + model.options.iter().any(|option| { + fast_mode_values(option).is_some_and(|(on, _)| { + self.resolved(cx) + .model_options + .get(&option.id) + .and_then(|value| value.as_str()) + .unwrap_or(&option.default_choice) + == on + }) + }) }); - let model_chip = self.trigger_chip( - PickerKind::HarnessModel, - model_label, - true, - Some(harness_icon), - chip_icon_loading, + let chip_width = self.model_chip_width( + window, + &model_label, + chip_suffix.as_ref().map(|(text, _)| text), chip_label_loading, - chip_suffix, - &theme, + fast, cx, ); + let model_chip = self + .trigger_chip( + PickerKind::HarnessModel, + model_label, + true, + Some(harness_icon), + chip_icon_loading, + chip_label_loading, + chip_suffix, + &theme, + cx, + ) + .when(fast, |chip| { + chip.child(motion::fast_tier( + "composer-fast-tier", + div().flex_none().child( + crate::icons::icon(crate::icons::FAST_TIER_BOLD) + .size(px(13.0)) + .text_color(theme.accent), + ), + )) + }); + let model_chip = model_chip.w(px(chip_width)); + // Both presentations align to the chip's trailing edge beside Send. let model_chip = attach_overlay_end( model_chip, &mut overlay, @@ -5999,11 +6430,73 @@ mod tests { } } + #[test] + fn every_fast_mode_encoding_gets_the_same_controls() { + let option = |id: &str, choices: &[&str], default: &str| ModelOption { + id: id.into(), + label: id.into(), + choices: choices + .iter() + .map(|c| ModelOptionChoice { + id: (*c).into(), + label: (*c).into(), + }) + .collect(), + default_choice: default.into(), + }; + // Codex tier, Claude toggle, ACP speed and the snake_case toggle. + assert_eq!( + fast_mode_values(&option("serviceTier", &["default", "fast"], "default")), + Some(("fast", "default")) + ); + assert_eq!( + fast_mode_values(&option("fastMode", &["off", "on"], "off")), + Some(("on", "off")) + ); + assert_eq!( + fast_mode_values(&option("speed", &["standard", "fast"], "standard")), + Some(("fast", "standard")) + ); + assert_eq!( + fast_mode_values(&option("fast_mode", &["off", "on"], "off")), + Some(("on", "off")) + ); + // Cursor's true/false switch, off by default (Opus) and on by + // default (Grok, Composer): both still toggle between the two. + assert_eq!( + fast_mode_values(&option("fast", &["false", "true"], "false")), + Some(("true", "false")) + ); + assert_eq!( + fast_mode_values(&option("fast", &["false", "true"], "true")), + Some(("true", "false")) + ); + assert_eq!( + fast_mode_values(&option("serviceTier", &["default", "fast"], "fast")), + Some(("fast", "default")) + ); + // Not fast mode: other toggles, including Cursor's true/false ones. + assert_eq!( + fast_mode_values(&option("thinking", &["off", "on"], "off")), + None + ); + assert_eq!( + fast_mode_values(&option("thinking", &["false", "true"], "true")), + None + ); + assert_eq!( + fast_mode_values(&option("contextWindow", &["200k", "1m"], "200k")), + None + ); + } + #[gpui::test] - fn open_model_trigger_keeps_its_width_across_model_and_option_changes( - cx: &mut gpui::TestAppContext, - ) { - cx.update(|cx| cx.set_global(Theme::dark())); + fn open_model_trigger_follows_the_selection_while_open(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + cx.set_global(Theme::dark()); + // Settle the width glide in one frame. + motion::set_reduced_motion(cx, true); + }); let handle = cx.add_window(|_, cx| { let state = cx.new(|_| AppState::new()); let mut pickers = Pickers::new(state, cx); @@ -6037,15 +6530,18 @@ mod tests { cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) .unwrap(); handle - .read_with(cx, |pickers, _| pickers.model_trigger_bounds.unwrap()) + .read_with(cx, |pickers, _| { + pickers.model_trigger_bounds.unwrap().size.width + }) .unwrap() }; - let before = draw(cx); + let short = draw(cx); handle .update(cx, |pickers, window, cx| { pickers.open_model_menu(window, cx) }) .unwrap(); + let mut widths = Vec::new(); for (model, fast) in [("long", false), ("long", true), ("short", false)] { handle .update(cx, |pickers, _, cx| { @@ -6060,12 +6556,14 @@ mod tests { cx.notify(); }) .unwrap(); - assert_eq!( - draw(cx), - before, - "open trigger moved for {model}, fast={fast}" - ); + widths.push(draw(cx)); } + // The open chip names the live selection instead of keeping the + // width it opened with: longer for the long model, longer again + // with its effort and fast mark, and back once the short one returns. + assert!(widths[0] > short, "{widths:?} vs {short:?}"); + assert!(widths[1] > widths[0], "{widths:?}"); + assert_eq!(widths[2], short); } #[gpui::test] @@ -7215,6 +7713,569 @@ mod tests { supports_steering: false, } } + #[gpui::test] + fn compact_panel_shortcuts_drive_models_providers_and_effort(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + cx.set_global(Theme::dark()); + let mut settings = crate::settings::UiSettings::default(); + settings.compact_model_picker = true; + crate::settings::init(settings, dir.path(), cx); + }); + let laddered = |id: &str, label: &str| { + let mut model = bare_model(id, label); + model.reasoning_levels = vec![ReasoningLevel::Low, ReasoningLevel::High]; + model + }; + let handle = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + let mut picker = Pickers::new(state, cx); + picker.config.harness = Some(HarnessId::Codex); + picker.harnesses = Loadable::Ready(vec![ + descriptor(HarnessId::Codex, "Codex"), + descriptor(HarnessId::ClaudeCode, "Claude"), + ]); + picker.models.insert( + HarnessId::Codex, + Loadable::Ready(vec![ + laddered("model", "Model"), + laddered("other", "Other model"), + laddered("third", "Third model"), + ]), + ); + picker.models.insert( + HarnessId::ClaudeCode, + Loadable::Ready(vec![laddered("claude", "Claude model")]), + ); + picker + }); + handle + .update(cx, |picker, window, cx| picker.open_model_menu(window, cx)) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + // Down opens the list on the model after the selected one; Enter + // picks it and lands back on the panel. + cx.simulate_keystrokes(handle.into(), "down"); + handle + .read_with(cx, |picker, cx| { + assert!(picker.compact_model_list); + assert_eq!(picker.model_rows(cx)[picker.active].model.id, "other"); + }) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "enter"); + handle + .read_with(cx, |picker, cx| { + assert!(!picker.compact_model_list); + assert_eq!(picker.selected_model(cx).unwrap().id, "other"); + }) + .unwrap(); + // Up opens it on the model before; Escape steps back without a pick. + cx.simulate_keystrokes(handle.into(), "up"); + handle + .read_with(cx, |picker, cx| { + assert!(picker.compact_model_list); + assert_eq!(picker.model_rows(cx)[picker.active].model.id, "model"); + }) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "escape"); + handle + .read_with(cx, |picker, cx| { + assert!(!picker.compact_model_list); + assert_eq!(picker.selected_model(cx).unwrap().id, "other"); + }) + .unwrap(); + // In the list, ⌘⇧F stars the highlighted model without picking it, + // and the highlight follows that model through the re-sort. + cx.simulate_keystrokes(handle.into(), "down"); + cx.simulate_keystrokes(handle.into(), "cmd-shift-f"); + handle + .read_with(cx, |picker, cx| { + assert!(picker.defaults.is_favorite(HarnessId::Codex, "third")); + assert_eq!(picker.active, 0, "Favorite should move to the first row"); + assert_eq!(picker.model_rows(cx)[picker.active].model.id, "third"); + assert_eq!(picker.selected_model(cx).unwrap().id, "other"); + }) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "cmd-shift-f"); + handle + .read_with(cx, |picker, cx| { + assert!(!picker.defaults.is_favorite(HarnessId::Codex, "third")); + assert_eq!( + picker.active, 2, + "Focus must follow the unstarred model back" + ); + assert_eq!(picker.model_rows(cx)[picker.active].model.id, "third"); + }) + .unwrap(); + // Tab cycles providers in place, each at its last-used model. + cx.simulate_keystrokes(handle.into(), "escape"); + cx.simulate_keystrokes(handle.into(), "tab"); + handle + .read_with(cx, |picker, cx| { + assert!(!picker.compact_model_list && !picker.compact_providers); + assert_eq!(picker.resolved(cx).harness, Some(HarnessId::ClaudeCode)); + }) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "tab"); + handle + .read_with(cx, |picker, cx| { + assert_eq!(picker.resolved(cx).harness, Some(HarnessId::Codex)); + assert_eq!(picker.selected_model(cx).unwrap().id, "other"); + }) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "shift-tab"); + handle + .read_with(cx, |picker, cx| { + assert_eq!(picker.resolved(cx).harness, Some(HarnessId::ClaudeCode)); + }) + .unwrap(); + // Left/Right and Home/End set the effort, one haptic per change + // and none at an unchanged endpoint. + let haptics_before = handle + .read_with(cx, |_, _| crate::haptics::step_count()) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "home home"); + handle + .read_with(cx, |picker, cx| { + assert_eq!(picker.effective_reasoning(cx), Some(ReasoningLevel::Low)) + }) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "right right left right"); + handle + .read_with(cx, |picker, cx| { + assert_eq!(picker.effective_reasoning(cx), Some(ReasoningLevel::High)); + assert_eq!(crate::haptics::step_count() - haptics_before, 4); + }) + .unwrap(); + } + + #[gpui::test] + fn compact_panel_lists_settings_of_models_configured_in_place(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + cx.set_global(Theme::dark()); + let mut settings = crate::settings::UiSettings::default(); + settings.compact_model_picker = true; + crate::settings::init(settings, dir.path(), cx); + }); + let handle = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Pickers::new(state, cx) + }); + let option = |id: &str, label: &str, choices: &[(&str, &str)]| ModelOption { + id: id.into(), + label: label.into(), + default_choice: choices[0].0.into(), + choices: choices + .iter() + .map(|(id, label)| ModelOptionChoice { + id: (*id).into(), + label: (*label).into(), + }) + .collect(), + }; + handle + .update(cx, |pickers, _, cx| { + let mut fusion = bare_model("fusion", "Fusion"); + fusion.reasoning_levels = vec![ReasoningLevel::Medium, ReasoningLevel::High]; + fusion.options = vec![ + option( + "sidekick", + "Sidekick", + &[("swe-2", "SWE-2"), ("swe-3", "SWE-3")], + ), + option("lead", "Lead", &[("fable", "Fable"), ("sol", "Sol")]), + ]; + pickers.config.harness = Some(HarnessId::Devin); + pickers.harnesses = Loadable::Ready(vec![descriptor(HarnessId::Devin, "Devin")]); + pickers.models.insert( + HarnessId::Devin, + Loadable::Ready(vec![bare_model("adaptive", "Adaptive"), fusion]), + ); + pickers.pick_model("fusion".into(), cx); + // No hover card in the compact panel: Lead leads, as in the card. + let hidden = pickers.compact_hidden_options(cx); + let visible: Vec<_> = pickers + .setting_groups(cx) + .into_iter() + .filter(|g| Pickers::compact_option_visible(&g.id, &hidden)) + .map(|g| g.id) + .collect(); + assert_eq!( + visible, + [ + ModelSetting::Option("lead".into()), + ModelSetting::Option("sidekick".into()) + ] + ); + }) + .unwrap(); + } + + #[gpui::test] + fn compact_fast_button_toggles_cursor_fast_by_default_models(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + cx.set_global(Theme::dark()); + let mut settings = crate::settings::UiSettings::default(); + settings.compact_model_picker = true; + crate::settings::init(settings, dir.path(), cx); + }); + let handle = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Pickers::new(state, cx) + }); + handle + .update(cx, |pickers, _, cx| { + let mut grok = bare_model("grok-4.6", "Grok 4.6"); + grok.options = vec![ModelOption { + id: "fast".into(), + label: "Fast".into(), + default_choice: "true".into(), + choices: [("false", "false"), ("true", "Fast\u{200b}")] + .iter() + .map(|(id, label)| ModelOptionChoice { + id: (*id).into(), + label: (*label).into(), + }) + .collect(), + }]; + pickers.config.harness = Some(HarnessId::Cursor); + pickers.harnesses = Loadable::Ready(vec![descriptor(HarnessId::Cursor, "Cursor")]); + pickers + .models + .insert(HarnessId::Cursor, Loadable::Ready(vec![grok])); + pickers.pick_model("grok-4.6".into(), cx); + // On by default: the button starts lit and turning it off is + // an explicit pick; turning it back on returns to the default. + let (option, next, default, fast) = pickers.compact_fast_choice(cx).unwrap(); + assert_eq!((next.as_str(), default, fast), ("false", false, true)); + pickers.pick_option(option, next, default, cx); + let (option, next, default, fast) = pickers.compact_fast_choice(cx).unwrap(); + assert_eq!((next.as_str(), default, fast), ("true", true, false)); + pickers.pick_option(option, next, default, cx); + assert!(pickers.compact_fast_choice(cx).unwrap().3); + assert!(!pickers.explicit_options(cx).contains_key("fast")); + }) + .unwrap(); + } + + #[gpui::test] + fn compact_list_hides_loading_and_failed_harnesses(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + cx.set_global(Theme::dark()); + let mut settings = crate::settings::UiSettings::default(); + settings.compact_model_picker = true; + crate::settings::init(settings, dir.path(), cx); + }); + let handle = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + let mut picker = Pickers::new(state, cx); + picker.config.harness = Some(HarnessId::Codex); + picker.harnesses = Loadable::Ready(vec![ + descriptor(HarnessId::Codex, "Codex"), + descriptor(HarnessId::ClaudeCode, "Claude"), + descriptor(HarnessId::Grok, "Grok"), + ]); + picker.models.insert( + HarnessId::Codex, + Loadable::Ready(vec![bare_model("model", "Model")]), + ); + picker.models.insert( + HarnessId::ClaudeCode, + Loadable::Error("Catalog timed out".into()), + ); + picker.models.insert(HarnessId::Grok, Loadable::Loading); + picker + }); + handle + .update(cx, |picker, window, cx| { + picker.open_model_menu(window, cx); + picker.show_compact_models(cx); + assert_eq!(picker.model_rows_len(cx), 1); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + // Only the ready model is navigable: no status rows follow it. + cx.simulate_keystrokes(handle.into(), "down"); + handle + .read_with(cx, |picker, _| assert_eq!(picker.active, 0)) + .unwrap(); + handle + .update(cx, |picker, _, cx| { + picker.models.insert( + HarnessId::ClaudeCode, + Loadable::Ready(vec![bare_model("claude", "Claude model")]), + ); + picker.catalog_rev += 1; + // The list holds one provider's models; the provider page + // switches to the newly loaded one. + assert_eq!(picker.model_rows_len(cx), 1); + picker.pick_compact_provider(HarnessId::ClaudeCode, cx); + picker.show_compact_models(cx); + assert_eq!(picker.model_rows_len(cx), 1); + assert_eq!(picker.model_rows(cx)[0].harness, HarnessId::ClaudeCode); + }) + .unwrap(); + } + + #[gpui::test] + fn compact_slider_drives_cursor_effort_option(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + cx.set_global(Theme::dark()); + let mut settings = crate::settings::UiSettings::default(); + settings.compact_model_picker = true; + crate::settings::init(settings, dir.path(), cx); + }); + let handle = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Pickers::new(state, cx) + }); + handle + .update(cx, |picker, _, cx| { + // Cursor's shape: no reasoning ladder, effort as an option. + let mut opus = bare_model("claude-opus-5", "Claude Opus 5"); + opus.options = vec![ModelOption { + id: "effort".into(), + label: "Effort".into(), + default_choice: "high".into(), + choices: [("low", "Low"), ("medium", "Medium"), ("high", "High")] + .iter() + .map(|(id, label)| ModelOptionChoice { + id: (*id).into(), + label: (*label).into(), + }) + .collect(), + }]; + picker.config.harness = Some(HarnessId::Cursor); + picker.harnesses = Loadable::Ready(vec![descriptor(HarnessId::Cursor, "Cursor")]); + picker + .models + .insert(HarnessId::Cursor, Loadable::Ready(vec![opus])); + picker.pick_model("claude-opus-5".into(), cx); + let effort = picker.compact_effort(cx).unwrap(); + assert_eq!(effort.labels, ["Low", "Medium", "High"]); + assert_eq!(effort.selected, 2); + // The slider owns it: no Effort row in the panel. + assert_eq!(picker.compact_hidden_options(cx), ["effort"]); + picker.pick_compact_effort(effort, 0, cx); + assert_eq!(picker.resolved(cx).model_options["effort"], "low"); + assert_eq!(picker.compact_effort(cx).unwrap().selected, 0); + // Back to the default clears the explicit pick. + let effort = picker.compact_effort(cx).unwrap(); + picker.pick_compact_effort(effort, 2, cx); + assert!(!picker.explicit_options(cx).contains_key("effort")); + }) + .unwrap(); + } + + #[gpui::test] + fn switching_provider_restores_its_last_model_and_effort(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + cx.set_global(Theme::dark()); + let mut settings = crate::settings::UiSettings::default(); + settings.compact_model_picker = true; + crate::settings::init(settings, dir.path(), cx); + }); + let handle = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Pickers::new(state, cx) + }); + let laddered = |id: &str, label: &str| { + let mut model = bare_model(id, label); + model.reasoning_levels = vec![ + ReasoningLevel::Low, + ReasoningLevel::Medium, + ReasoningLevel::High, + ]; + model + }; + handle + .update(cx, |picker, _, cx| { + picker.harnesses = Loadable::Ready(vec![ + descriptor(HarnessId::Codex, "Codex"), + descriptor(HarnessId::ClaudeCode, "Claude"), + ]); + picker.models.insert( + HarnessId::Codex, + Loadable::Ready(vec![laddered("gpt-a", "A"), laddered("gpt-b", "B")]), + ); + picker.models.insert( + HarnessId::ClaudeCode, + Loadable::Ready(vec![laddered("opus", "Opus")]), + ); + picker.pick_harness(HarnessId::Codex, cx); + picker.pick_model("gpt-b".into(), cx); + picker.pick_reasoning(ReasoningLevel::Low, cx); + picker.pick_compact_provider(HarnessId::ClaudeCode, cx); + picker.pick_reasoning(ReasoningLevel::High, cx); + // Back to Codex: its last model, at that model's last effort. + picker.pick_compact_provider(HarnessId::Codex, cx); + assert_eq!(picker.resolved(cx).model.as_deref(), Some("gpt-b")); + assert_eq!(picker.effective_reasoning(cx), Some(ReasoningLevel::Low)); + // A model with no effort of its own takes the last one used + // anywhere (High, on Claude). + picker.pick_model("gpt-a".into(), cx); + assert_eq!(picker.effective_reasoning(cx), Some(ReasoningLevel::High)); + picker.pick_compact_provider(HarnessId::ClaudeCode, cx); + assert_eq!(picker.resolved(cx).model.as_deref(), Some("opus")); + assert_eq!(picker.effective_reasoning(cx), Some(ReasoningLevel::High)); + // Starring adds a Starred entry ahead of the providers; it + // opens the starred models from every provider. + assert!(matches!( + picker.compact_provider_rows(cx).first(), + Some(compact::ProviderRow::Harness(_)) + )); + picker.toggle_model_favorite(HarnessId::Codex, "gpt-a", cx); + picker.show_compact_providers(cx); + assert!(matches!( + picker.compact_provider_rows(cx).first(), + Some(compact::ProviderRow::Starred) + )); + picker.active = 0; + picker.activate_compact_provider(cx); + assert!(picker.compact_model_list && !picker.compact_providers); + let rows = picker.model_rows(cx); + assert_eq!(rows.len(), 1); + assert_eq!( + (rows[0].harness, rows[0].model.id.as_str()), + (HarnessId::Codex, "gpt-a") + ); + }) + .unwrap(); + } + + #[gpui::test] + fn compact_picker_shares_selection_reset_and_thread_restrictions( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + cx.set_global(Theme::dark()); + let mut settings = crate::settings::UiSettings::default(); + settings.compact_model_picker = true; + crate::settings::init(settings, dir.path(), cx); + }); + let handle = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + let mut picker = Pickers::new(state, cx); + picker.config.harness = Some(HarnessId::Codex); + picker.config.model = Some("codex-model".into()); + picker.harnesses = Loadable::Ready(vec![ + descriptor(HarnessId::Codex, "Codex"), + descriptor(HarnessId::ClaudeCode, "Claude"), + ]); + let mut model = bare_model("codex-model", "Codex model"); + model.reasoning_levels = vec![ReasoningLevel::Low, ReasoningLevel::High]; + picker + .models + .insert(HarnessId::Codex, Loadable::Ready(vec![model])); + picker.models.insert( + HarnessId::ClaudeCode, + Loadable::Ready(vec![bare_model("claude-model", "Claude model")]), + ); + picker + }); + handle + .update(cx, |picker, window, cx| { + picker.open_model_menu(window, cx); + assert!(!picker.compact_model_list); + // The list holds the current provider's models only. + picker.show_compact_models(cx); + assert_eq!(picker.model_rows_len(cx), 1); + assert_eq!(picker.model_rows(cx)[0].harness, HarnessId::Codex); + // The provider page lists every provider, highlighting the + // current one, and a pick lands back on the panel. + picker.show_compact_providers(cx); + assert!(picker.compact_providers && !picker.compact_model_list); + assert_eq!(picker.compact_provider_rows(cx).len(), 2); + assert_eq!(picker.active, 0); + picker.pick_compact_provider(HarnessId::ClaudeCode, cx); + assert!(!picker.compact_providers && !picker.compact_model_list); + assert_eq!(picker.resolved(cx).harness, Some(HarnessId::ClaudeCode)); + assert_eq!(picker.resolved(cx).model.as_deref(), Some("claude-model")); + picker.pick_harness(HarnessId::Codex, cx); + picker.pick_model("codex-model".into(), cx); + picker.pick_reasoning(ReasoningLevel::Low, cx); + assert_eq!(picker.config.model.as_deref(), Some("codex-model")); + picker.state.update(cx, |state, cx| { + state.selected_chat = Some("thread".into()); + cx.notify(); + }); + picker.show_compact_models(cx); + assert_eq!(picker.model_rows_len(cx), 1); + assert_eq!(picker.rail_descriptors(cx)[0].id, HarnessId::Codex); + // A chat's provider is fixed: the provider page stays shut. + picker.compact_model_list = false; + picker.show_compact_providers(cx); + assert!(!picker.compact_providers); + picker.focus_on_mount = true; + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + handle + .read_with(cx, |picker, _| assert!(picker.effort_bounds.is_some())) + .unwrap(); + handle + .update(cx, |picker, _, cx| { + picker.show_compact_models(cx); + picker + .search + .update(cx, |input, cx| input.set_text("no-matching-model", cx)); + picker.active = 0; + picker.activate_model_row(cx); + assert!( + picker.setting_menu.is_none(), + "Empty search must not activate hidden settings" + ); + picker.search.update(cx, |input, cx| input.set_text("", cx)); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + } + + #[test] + fn compact_all_models_keeps_favorites_first_in_catalog_and_search() { + let descriptors = vec![ + descriptor(HarnessId::ClaudeCode, "Claude"), + descriptor(HarnessId::Codex, "Codex"), + ]; + let claude = vec![ + bare_model("plain-a", "Model A"), + bare_model("star-a", "My Model A"), + ]; + let codex = vec![ + bare_model("plain-b", "Model B"), + bare_model("star-b", "My Model B"), + ]; + for query in ["", "model"] { + let rows = scoped_model_rows( + query, + ModelRail::All, + Some(HarnessId::Codex), + &descriptors, + |harness| match harness { + HarnessId::ClaudeCode => Some(claude.as_slice()), + HarnessId::Codex => Some(codex.as_slice()), + _ => None, + }, + |_, id| id.starts_with("star"), + ); + assert_eq!( + rows.iter() + .map(|row| row.model.id.as_str()) + .collect::>(), + vec!["star-a", "star-b", "plain-a", "plain-b"] + ); + } + } #[test] fn tab_search_never_leaves_the_viewed_harness() { diff --git a/crates/ui/src/pickers/compact.rs b/crates/ui/src/pickers/compact.rs new file mode 100644 index 0000000000..c242be2e69 --- /dev/null +++ b/crates/ui/src/pickers/compact.rs @@ -0,0 +1,1569 @@ +//! Alternate presentation of the same model and option mutations. +use super::*; + +pub(super) const COMPACT_WIDTH: f32 = 256.0; +/// One-line model rows (plus the 2px gap) so about seven fit at once. +pub(super) const COMPACT_ROW_HEIGHT: f32 = 32.0; +pub(super) const COMPACT_LIST_ROWS: f32 = 7.0; +/// The list fits its rows, up to [`COMPACT_LIST_ROWS`]; empty and loading +/// states keep room for a few skeleton rows. +pub(super) fn compact_list_height(rows: usize) -> f32 { + let rows = if rows == 0 { + 4.0 + } else { + (rows as f32).min(COMPACT_LIST_ROWS) + }; + rows * (COMPACT_ROW_HEIGHT + popover::MENU_GAP) + 2.0 * popover::CARD_INSET +} +// A 28px rail under a 44x32 pill thumb that overhangs it by 2px, so the +// handle reads as the thing to grab. Stops and pointer input share the +// thumb's centre inset; the fill runs a rail radius past the centre, always +// hidden under the thumb. +const SLIDER_HEIGHT: f32 = 36.0; +const RAIL_HEIGHT: f32 = 28.0; +const RAIL_TOP: f32 = (SLIDER_HEIGHT - RAIL_HEIGHT) / 2.0; +const THUMB_WIDTH: f32 = 44.0; +const THUMB_HEIGHT: f32 = 32.0; +const THUMB_INSET: f32 = THUMB_WIDTH / 2.0; +const FILL_PAST: f32 = RAIL_HEIGHT / 2.0; +/// Both header buttons fill the header's height, so they sit one card inset +/// from the card's top and sides and their radius is concentric with its +/// corners. 8pt of padding around the two-line title puts its text 12pt +/// from the card's top and leading edges. +const HEADER_HEIGHT: f32 = 48.0; +/// A model without an effort ladder titles the header with its name alone. +const HEADER_HEIGHT_SINGLE: f32 = 36.0; +/// Flush with the card inset, a 15pt glyph centred in 32pt ends 12pt from +/// the card edge: the slider rail's end. The provider button mirrors it on +/// the leading side. +const FAST_BUTTON_WIDTH: f32 = 32.0; +/// The header inside the card inset, then the slider block and the space +/// above the option rows. The slider's 4pt below its box leaves the rail +/// 12pt from the card's bottom when no options follow. +fn header_block(effort: bool) -> f32 { + 2.0 * popover::CARD_INSET + + if effort { + HEADER_HEIGHT + } else { + HEADER_HEIGHT_SINGLE + } +} +const SLIDER_TOP: f32 = 2.0; +const SLIDER_BOTTOM: f32 = 4.0; +const EFFORT_BLOCK: f32 = SLIDER_TOP + SLIDER_HEIGHT + SLIDER_BOTTOM; +const OPTIONS_GAP: f32 = 4.0; +/// The card's 1pt border, top and bottom, sits inside its height. +const CARD_BORDERS: f32 = 2.0; +/// The list pages' one header row: back button and filter side by side. +pub(super) const LIST_HEADER: f32 = 40.0; +/// Option rows (26pt plus a 2pt gap) all show up to this many, then scroll. +const OPTIONS_MAX_ROWS: usize = 6; +/// The rows' 4pt top inset, then the rows; the panel's inset closes below. +fn options_height(count: usize) -> f32 { + 2.0 + count.min(OPTIONS_MAX_ROWS) as f32 * 28.0 +} + +/// Option ids Cursor uses for an effort ladder (its models carry none). +const EFFORT_OPTION_IDS: [&str; 3] = ["effort", "reasoning", "reasoning_effort"]; + +pub(super) struct CompactEffort { + pub labels: Vec, + pub selected: usize, + stops: EffortStops, +} + +enum EffortStops { + Reasoning(Vec), + Option { + id: String, + choices: Vec, + default: String, + }, +} + +/// One entry on the provider page: the starred models, or a provider. +#[derive(Clone)] +pub(super) enum ProviderRow { + Starred, + Harness(HarnessDescriptor), +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum CompactPage { + Panel, + Models, + Providers, +} + +#[derive(Default)] +pub(super) struct CompactMotion { + effort: Option, + press: Option, + drag_fraction: Option, + energy: Option, + /// Shimmer clock for fast mode and the top effort levels, integrated + /// per frame so changing intensity never jumps the band. + shimmer_phase: f32, + shimmer_last_frame: Option, + fast: Option, + height: Option, + page: Option, + reveal: Option, + pub frame_height: f32, +} + +pub(super) struct ScalarTransition { + from: f32, + to: f32, + started: std::time::Instant, +} +impl ScalarTransition { + pub(super) fn value(&self, now: std::time::Instant) -> f32 { + let progress = motion::RESIZE.progress( + now.duration_since(self.started).as_secs_f32() + / motion::RESIZE + .total() + .mul_f32(motion::speed_scale()) + .as_secs_f32(), + ); + motion::lerp(self.from, self.to, progress) + } + pub(super) fn sample( + state: &mut Option, + target: f32, + now: std::time::Instant, + reduced: bool, + ) -> (f32, bool) { + let Some(transition) = state else { + *state = Some(Self { + from: target, + to: target, + started: now, + }); + return (target, false); + }; + if reduced { + transition.from = target; + transition.to = target; + return (target, false); + } + if transition.to != target { + transition.from = transition.value(now); + transition.to = target; + transition.started = now; + } + let value = transition.value(now); + (value, (value - target).abs() > 0.001) + } +} + +impl Pickers { + pub(super) fn render_compact_model_row( + &mut self, + ix: usize, + row: &ModelRowData, + cx: &mut Context, + ) -> AnyElement { + let theme = Theme::of(cx).for_popup(); + let selected = self.effective_harness(cx) == Some(row.harness) + && self + .selected_model(cx) + .is_some_and(|model| model.id == row.model.id); + let favorite = self.defaults.is_favorite(row.harness, &row.model.id); + let (icon, tint) = harness_brand_icon(row.harness); + let harness = row.harness; + let model = row.model.id.clone(); + let label: SharedString = row.model.label.clone().into(); + // The brand mark names the provider; a description appears only to + // tell identically named rows apart. + let attribution: Option = row + .model + .description + .as_deref() + .map(str::trim) + .filter(|d| row.ambiguous && !d.is_empty()) + .map(|d| SharedString::from(d.to_owned())); + let hovered = self.active == ix; + let details: SharedString = match row.model.description.as_deref() { + Some(description) => format!( + "{} · {}\n{}", + row.model.label, row.harness_name, description + ) + .into(), + None => format!("{} · {}", row.model.label, row.harness_name).into(), + }; + let accessible_name = details.clone(); + let favorite_hint: SharedString = format!( + "{} {} {}", + if favorite { "Remove" } else { "Add" }, + row.model.label, + if favorite { + "from favorites" + } else { + "to favorites" + } + ) + .into(); + let favorite_tooltip: SharedString = + format!("{}\n⌘⇧F on the highlighted model", favorite_hint).into(); + let item = div() + .id(("model-row", ix)) + .role(gpui::Role::ListBoxOption) + .aria_label(accessible_name) + .aria_selected(selected) + .tooltip(move |_, cx| cx.new(|_| PickerHint(details.clone())).into()) + .tooltip_show_delay(std::time::Duration::from_millis(500)) + .h(px(COMPACT_ROW_HEIGHT)) + .pl(px(8.0)) + .pr(px(4.0)) + .rounded(px(popover::MENU_ITEM_RADIUS)) + .flex() + .items_center() + .gap(px(8.0)) + .cursor_pointer() + .text_color(theme.text) + // Every row reserves the glass rim so selection doesn't shift content. + .border_1() + .border_color(gpui::transparent_black()) + .when(selected, |el| crate::glass::light(el, &theme, 1.0)) + .when(!selected && self.active == ix, |el| { + el.bg(crate::theme::ink(0.05)) + }) + .when(self.compact_keyboard && self.active == ix, |el| { + el.aria_active_descendant() + }) + .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { + if *hovered && (this.active != ix || this.compact_keyboard) { + this.compact_keyboard = false; + this.active = ix; + cx.notify(); + } + })) + .on_click(cx.listener(move |this, _, _, cx| this.activate_model_index(ix, cx))) + .child( + crate::icons::icon(icon) + .size(px(14.0)) + .flex_none() + .text_color(tint.unwrap_or(theme.text_muted)), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .items_baseline() + .gap(px(6.0)) + .text_size(crate::typography::ui_rems(12.0)) + .child( + div() + .flex_none() + .max_w_full() + .truncate() + .font_weight(gpui::FontWeight::MEDIUM) + .child(label), + ) + .when_some(attribution, |el, attribution| { + el.child( + div() + .min_w_0() + .truncate() + .text_size(crate::typography::ui_rems(11.0)) + .text_color(theme.text_muted) + .child(attribution), + ) + }), + ) + .child( + div() + .id(("model-star", ix)) + .role(gpui::Role::Button) + .aria_label(favorite_hint) + .aria_toggled(if favorite { + gpui::Toggled::True + } else { + gpui::Toggled::False + }) + .aria_keyshortcuts("Meta+Shift+F") + .tooltip(move |_, cx| cx.new(|_| PickerHint(favorite_tooltip.clone())).into()) + .size(px(24.0)) + .rounded(px(6.0)) + .flex() + .items_center() + .justify_center() + // Quiet until the row is hovered or the model is starred. + .when(!favorite && !hovered, |el| el.opacity(0.0)) + .hover(|s| s.bg(crate::theme::ink(0.08))) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.toggle_model_favorite(harness, &model, cx); + })) + .child( + crate::icons::icon(if favorite { + crate::icons::STAR_BOLD + } else { + crate::icons::STAR + }) + .size(px(13.0)) + .text_color(if favorite { + theme.accent + } else { + theme.text_muted + }), + ), + ); + div() + .pb(px(popover::MENU_GAP)) + .child(item) + .into_any_element() + } + + pub(super) fn compact_model_picker(&self, cx: &App) -> bool { + self.title.is_none() && crate::settings::compact_model_picker(cx) + } + + pub(super) fn show_compact_models(&mut self, cx: &mut Context) { + // The provider button picks the provider; the list holds its models. + self.show_compact_list(ModelRail::Harness, "Search models…", cx); + } + + /// The starred models across providers, opened from the provider page. + fn show_compact_starred(&mut self, cx: &mut Context) { + self.show_compact_list(ModelRail::Favorites, "Search starred…", cx); + } + + fn show_compact_list( + &mut self, + rail: ModelRail, + placeholder: &'static str, + cx: &mut Context, + ) { + self.setting_menu = None; + self.compact_providers = false; + self.compact_model_list = true; + self.model_rail = rail; + self.reset_compact_search(placeholder, cx); + // Open on the current model, at the top when it is near the start. + let selected = self.selected_model_index(cx); + self.active = selected; + if selected < COMPACT_LIST_ROWS as usize - 2 { + self.model_scroll_base().set_offset(gpui::Point::default()); + } else { + self.model_scroll + .scroll_to_item_strict(selected, gpui::ScrollStrategy::Center); + } + self.focus_on_mount = true; + cx.notify(); + } + + pub(super) fn show_compact_providers(&mut self, cx: &mut Context) { + if self.harness_locked(cx) { + return; + } + self.setting_menu = None; + self.compact_model_list = false; + self.compact_providers = true; + self.reset_compact_search("Search providers…", cx); + let effective = self.effective_harness(cx); + self.active = self + .compact_provider_rows(cx) + .iter() + .position(|row| matches!(row, ProviderRow::Harness(d) if Some(d.id) == effective)) + .unwrap_or(0); + popover::reset_menu_scroll(&self.menu_scroll, &mut self.menu_bar); + self.focus_on_mount = true; + cx.notify(); + } + + /// Back from either list page to the panel. + pub(super) fn show_compact_panel(&mut self, cx: &mut Context) { + self.compact_model_list = false; + self.compact_providers = false; + self.focus_on_mount = true; + cx.notify(); + } + + /// Clear the shared filter for a new page without letting the clear's + /// Edited event reset the highlight anchored right after. + fn reset_compact_search(&mut self, placeholder: &'static str, cx: &mut Context) { + self.search_reset_muted = !self.search.read(cx).text().is_empty(); + self.search.update(cx, |input, cx| { + input.set_placeholder(placeholder, cx); + if !input.text().is_empty() { + input.set_text("", cx); + } + }); + } + + /// Starred models first (once any are starred), then the providers on + /// offer, narrowed by the filter. + pub(super) fn compact_provider_rows(&self, cx: &App) -> Vec { + let query = self.search.read(cx).text().trim().to_lowercase(); + let matches = |name: &str| query.is_empty() || name.to_lowercase().contains(&query); + let starred = (!self.defaults.favorites.is_empty() && matches("Starred")) + .then_some(ProviderRow::Starred); + starred + .into_iter() + .chain( + self.rail_descriptors(cx) + .into_iter() + .filter(|d| matches(&d.name)) + .map(ProviderRow::Harness), + ) + .collect() + } + + /// Picking a provider runs its last-used model at that model's last + /// settings (the remembered defaults take over once the harness moves). + pub(super) fn pick_compact_provider(&mut self, harness: HarnessId, cx: &mut Context) { + self.model_rail = ModelRail::Harness; + self.pick_harness(harness, cx); + self.show_compact_panel(cx); + } + + /// Both list pages share one header row: back to the panel, then the + /// page's filter beside it. + pub(super) fn compact_list_header(&mut self, cx: &mut Context) -> gpui::Div { + let theme = Theme::of(cx).for_popup(); + div() + .h(px(40.0)) + .flex_none() + .px(px(popover::CARD_INSET)) + .border_b_1() + .border_color(crate::theme::hairline(0.08)) + .flex() + .items_center() + .gap(px(4.0)) + .child( + popover::menu_row(&theme, false, "compact-list-back") + .id("compact-list-back") + .role(gpui::Role::Button) + .aria_label("Back") + .flex_none() + .on_click(cx.listener(|this, _, _, cx| this.show_compact_panel(cx))) + .child( + crate::icons::icon(crate::icons::ALT_ARROW_LEFT) + .size(px(14.0)) + .text_color(theme.text_muted), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_size(crate::typography::ui_rems(13.0)) + .child(self.search.clone()), + ) + } + + pub(super) fn render_compact_provider_page(&mut self, cx: &mut Context) -> AnyElement { + let theme = Theme::of(cx).for_popup(); + let rows = self.compact_provider_rows(cx); + let effective = self.effective_harness(cx); + let list_height = compact_list_height(rows.len()) + .min((self.menu_geometry().height - LIST_HEADER - CARD_BORDERS).max(0.0)); + let items: Vec = if rows.is_empty() { + vec![empty_list_note(&theme, "No providers found")] + } else { + rows.into_iter() + .enumerate() + .map(|(ix, row)| { + let (icon, tint, name, selected): (_, _, SharedString, bool) = match &row { + ProviderRow::Starred => { + (crate::icons::STAR_BOLD, None, "Starred".into(), false) + } + ProviderRow::Harness(descriptor) => { + let (icon, tint) = harness_brand_icon(descriptor.id); + ( + icon, + tint, + descriptor.name.clone().into(), + effective == Some(descriptor.id), + ) + } + }; + let item = div() + .id(("compact-provider", ix)) + .role(gpui::Role::ListBoxOption) + .aria_label(name.clone()) + .aria_selected(selected) + .h(px(COMPACT_ROW_HEIGHT)) + .pl(px(8.0)) + .pr(px(8.0)) + .rounded(px(popover::MENU_ITEM_RADIUS)) + .flex() + .items_center() + .gap(px(8.0)) + .cursor_pointer() + .text_color(theme.text) + .border_1() + .border_color(gpui::transparent_black()) + .when(selected, |el| crate::glass::light(el, &theme, 1.0)) + .when(!selected && self.active == ix, |el| { + el.bg(crate::theme::ink(0.05)) + }) + .when(self.compact_keyboard && self.active == ix, |el| { + el.aria_active_descendant() + }) + .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { + if *hovered && (this.active != ix || this.compact_keyboard) { + this.compact_keyboard = false; + this.active = ix; + cx.notify(); + } + })) + .on_click(cx.listener(move |this, _, _, cx| { + this.activate_provider_row(row.clone(), cx) + })) + .child( + crate::icons::icon(icon) + .size(px(14.0)) + .flex_none() + .text_color(tint.unwrap_or(theme.text_muted)), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(crate::typography::ui_rems(12.0)) + .font_weight(gpui::FontWeight::MEDIUM) + .child(name), + ); + div() + .pb(px(popover::MENU_GAP)) + .child(item) + .into_any_element() + }) + .collect() + }; + let scrollbar = popover::rail(self, "compact-providers-scrollbar", &theme, cx); + let list = div() + .id("compact-providers-host") + .relative() + .flex_none() + .h(px(list_height)) + .py(px(popover::CARD_INSET)) + .bg(crate::theme::ink(0.02)) + .on_hover(cx.listener(Self::on_menu_list_hover)) + .child(popover::faded_menu_list( + &self.menu_scroll, + popover::menu_scroll_list("compact-providers", &self.menu_scroll) + .size_full() + .child(div().flex().flex_col().children(items)), + )) + .children(scrollbar); + div() + .flex() + .flex_col() + .child(self.compact_list_header(cx)) + .child(list) + .into_any_element() + } + + pub(super) fn compact_provider_key(&mut self, event: &KeyDownEvent, cx: &mut Context) { + let count = self.compact_provider_rows(cx).len(); + match event.keystroke.key.as_str() { + "escape" => self.show_compact_panel(cx), + "up" | "down" => { + let current = (self.active < count).then_some(self.active); + self.active = popover::menu_step( + current, + count, + if event.keystroke.key == "up" { -1 } else { 1 }, + ) + .unwrap_or(0); + self.compact_keyboard = true; + cx.notify(); + } + "enter" => self.activate_compact_provider(cx), + _ => return, + } + cx.stop_propagation(); + } + + pub(super) fn activate_compact_provider(&mut self, cx: &mut Context) { + if let Some(row) = self.compact_provider_rows(cx).get(self.active).cloned() { + self.activate_provider_row(row, cx); + } + } + + fn activate_provider_row(&mut self, row: ProviderRow, cx: &mut Context) { + match row { + ProviderRow::Starred => self.show_compact_starred(cx), + ProviderRow::Harness(descriptor) => self.pick_compact_provider(descriptor.id, cx), + } + } + + pub(super) fn compact_fast_choice(&self, cx: &App) -> Option<(String, String, bool, bool)> { + let (option, on, off) = self + .selected_model(cx)? + .options + .iter() + .find_map(|o| fast_mode_values(o).map(|(on, off)| (o, on, off)))?; + let options = self.explicit_options(cx); + let fast = options + .get(&option.id) + .and_then(|v| v.as_str()) + .unwrap_or(&option.default_choice) + == on; + let next = if fast { off } else { on }; + Some(( + option.id.clone(), + next.to_owned(), + next == option.default_choice, + fast, + )) + } + + /// The selected model's fast-mode option id, whichever form it takes. + pub(super) fn fast_option_id(&self, cx: &App) -> Option { + self.selected_model(cx)? + .options + .iter() + .find(|o| fast_mode_values(o).is_some()) + .map(|o| o.id.clone()) + } + + /// The slider's stops: the model's reasoning ladder, else an option + /// shaped like one (Cursor's `effort`/`reasoning` choices), so every + /// model with an effort gets the same slider. + pub(super) fn compact_effort(&self, cx: &App) -> Option { + let levels = self.trait_ladder(cx); + if !levels.is_empty() { + let current = self.effective_reasoning(cx); + return Some(CompactEffort { + labels: levels.iter().map(|l| reasoning_label(*l).into()).collect(), + selected: levels.iter().position(|l| Some(*l) == current).unwrap_or(0), + stops: EffortStops::Reasoning(levels), + }); + } + let option = self + .selected_model(cx)? + .options + .iter() + .find(|o| EFFORT_OPTION_IDS.contains(&o.id.as_str()) && o.choices.len() > 1)?; + let options = self.explicit_options(cx); + let current = options + .get(&option.id) + .and_then(|v| v.as_str()) + .unwrap_or(&option.default_choice); + Some(CompactEffort { + labels: option + .choices + .iter() + .map(|c| SharedString::from(c.label.clone())) + .collect(), + selected: option + .choices + .iter() + .position(|c| c.id == current) + .unwrap_or(0), + stops: EffortStops::Option { + id: option.id.clone(), + choices: option.choices.iter().map(|c| c.id.clone()).collect(), + default: option.default_choice.clone(), + }, + }) + } + + /// Option ids the compact card draws as its own controls, not rows. + pub(super) fn compact_hidden_options(&self, cx: &App) -> Vec { + let effort = match self.compact_effort(cx).map(|e| e.stops) { + Some(EffortStops::Option { id, .. }) => Some(id), + _ => None, + }; + self.fast_option_id(cx).into_iter().chain(effort).collect() + } + + pub(super) fn pick_effort_at(&mut self, x: gpui::Pixels, cx: &mut Context) { + let Some(bounds) = self.effort_bounds else { + return; + }; + let Some(effort) = self.compact_effort(cx) else { + return; + }; + let count = effort.labels.len(); + let Some(index) = effort_index( + f32::from(x - bounds.left()), + f32::from(bounds.size.width), + count, + ) else { + return; + }; + if self.effort_dragging { + let fraction = + effort_fraction(f32::from(x - bounds.left()), f32::from(bounds.size.width)); + self.compact_motion.drag_fraction = Some(if count > 1 { fraction } else { 0.0 }); + cx.notify(); + } + self.pick_compact_effort(effort, index, cx); + } + + /// Effort and fast mode have their own controls in the compact card. + pub(super) fn compact_option_visible(id: &ModelSetting, hidden: &[String]) -> bool { + !matches!(id, ModelSetting::Reasoning) + && !matches!(id, ModelSetting::Option(id) if hidden.contains(id)) + } + + pub(super) fn pick_compact_effort( + &mut self, + effort: CompactEffort, + index: usize, + cx: &mut Context, + ) { + if index == effort.selected { + return; + } + match effort.stops { + EffortStops::Reasoning(levels) => { + let Some(level) = levels.get(index).copied() else { + return; + }; + self.pick_reasoning(level, cx); + } + EffortStops::Option { + id, + choices, + default, + } => { + let Some(choice) = choices.get(index).cloned() else { + return; + }; + let is_default = choice == default; + self.pick_option(id, choice, is_default, cx); + } + } + crate::haptics::selection_step(); + } + + pub(super) fn render_compact_menu( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let hidden = self.compact_hidden_options(cx); + let options = self + .setting_groups(cx) + .iter() + .filter(|g| Self::compact_option_visible(&g.id, &hidden)) + .count(); + let effort = self.compact_effort(cx).is_some(); + let panel_height = CARD_BORDERS + + header_block(effort) + + if effort { EFFORT_BLOCK } else { 0.0 } + + if options == 0 { + 0.0 + } else { + OPTIONS_GAP + options_height(options) + }; + let page = if self.compact_providers { + CompactPage::Providers + } else if self.compact_model_list { + CompactPage::Models + } else { + CompactPage::Panel + }; + let target = self.menu_geometry().height.min(match page { + CompactPage::Models => { + LIST_HEADER + compact_list_height(self.model_rows_len(cx)) + CARD_BORDERS + } + CompactPage::Providers => { + LIST_HEADER + + compact_list_height(self.compact_provider_rows(cx).len()) + + CARD_BORDERS + } + CompactPage::Panel => panel_height, + }); + let (height, moving) = ScalarTransition::sample( + &mut self.compact_motion.height, + target, + std::time::Instant::now(), + cx.reduce_motion(), + ); + self.compact_motion.frame_height = height; + if moving { + window.request_animation_frame(); + } + let content = match page { + CompactPage::Models => self.render_harness_model_popover(cx), + CompactPage::Providers => self.render_compact_provider_page(cx), + CompactPage::Panel => self.render_compact_model_panel(window, cx), + }; + let now = std::time::Instant::now(); + if self.compact_motion.page.is_some_and(|last| last != page) { + self.compact_motion.reveal = Some(ScalarTransition { + from: 0.0, + to: 1.0, + started: now, + }); + } + self.compact_motion.page = Some(page); + let (reveal, revealing) = ScalarTransition::sample( + &mut self.compact_motion.reveal, + 1.0, + now, + cx.reduce_motion(), + ); + if revealing { + window.request_animation_frame(); + } + // The new page fades in while the card resizes to it: the swap + // never shows at full strength and nothing slides sideways against + // the vertical resize. Initial opening uses the popover entrance. + let content = div().opacity(reveal).child(content).into_any_element(); + self.popover_frame_flush(COMPACT_WIDTH, content, cx) + } + + /// The panel's shortcuts, live only while it is the picker's page: Up + /// and Down open the model list on the model beside the selected one, + /// Tab cycles providers, Left/Right (Home/End) set the effort. + pub(super) fn compact_panel_key(&mut self, event: &KeyDownEvent, cx: &mut Context) { + self.compact_keyboard = true; + match event.keystroke.key.as_str() { + "escape" => self.animate_close(cx), + "up" | "down" => { + self.show_compact_models(cx); + let delta = if event.keystroke.key == "up" { -1 } else { 1 }; + if let Some(next) = + popover::menu_step(Some(self.active), self.model_rows_len(cx), delta) + { + self.active = next; + self.model_scroll + .scroll_to_item(next, gpui::ScrollStrategy::Nearest); + } + } + "enter" | "space" => self.show_compact_models(cx), + "tab" => { + let delta = if event.keystroke.modifiers.shift { + -1 + } else { + 1 + }; + self.cycle_compact_provider(delta, cx); + } + "left" | "right" | "home" | "end" => { + if let Some(effort) = self.compact_effort(cx) { + let last = effort.labels.len().saturating_sub(1); + let next = match event.keystroke.key.as_str() { + "left" => effort.selected.saturating_sub(1), + "right" => (effort.selected + 1).min(last), + "home" => 0, + _ => last, + }; + self.pick_compact_effort(effort, next, cx); + } + } + _ => return, + } + cx.notify(); + cx.stop_propagation(); + } + + /// Tab on the panel: the next provider on offer, wrapping, at its + /// last-used model. A chat's fixed provider stays put. + fn cycle_compact_provider(&mut self, delta: isize, cx: &mut Context) { + if self.harness_locked(cx) { + return; + } + let providers = self.rail_descriptors(cx); + let current = self + .effective_harness(cx) + .and_then(|harness| providers.iter().position(|d| d.id == harness)); + if let Some(next) = popover::menu_step(current, providers.len(), delta) { + self.pick_compact_provider(providers[next].id, cx); + } + } + + pub(super) fn render_compact_model_panel( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let theme = Theme::of(cx).for_popup(); + let (levels, selected) = self + .compact_effort(cx) + .map_or((Vec::new(), 0), |e| (e.labels, e.selected)); + let label: SharedString = self + .selected_model(cx) + .map(|m| m.label.clone()) + .unwrap_or_else(|| "Select model".into()) + .into(); + let effort: SharedString = levels + .get(selected) + .cloned() + .unwrap_or_else(|| "Default".into()); + let model_accessible: SharedString = + format!("{} · {} · Change model", effort, label).into(); + let now = std::time::Instant::now(); + let reduced = cx.reduce_motion(); + // Fast mode alone brings the fill to life: a sheen and speed streaks + // racing toward the thumb, and a breathing glow. + let fast = self + .compact_fast_choice(cx) + .is_some_and(|(_, _, _, fast)| fast); + let (intensity, powering) = ScalarTransition::sample( + &mut self.compact_motion.energy, + if fast { 1.0 } else { 0.0 }, + now, + reduced, + ); + if powering { + window.request_animation_frame(); + } + let dt = self + .compact_motion + .shimmer_last_frame + .replace(now) + .map_or(0.0, |last| now.duration_since(last).as_secs_f32().min(0.05)); + let alive = + intensity > 0.001 && !reduced && window.is_window_active() && self.open.is_open(); + if alive { + self.compact_motion.shimmer_phase = + (self.compact_motion.shimmer_phase + dt * (0.32 + 0.22 * intensity)).fract(); + window.request_animation_frame(); + } + let phase = self.compact_motion.shimmer_phase; + let breath = if alive { + 0.5 + 0.5 * (std::f32::consts::TAU * phase).sin() + } else { + 0.5 + }; + // The effort titles the model's name as one button filling the row + // up to fast mode, its text on the rail's and option rows' edge; fast + // mode is a small icon button whose glyph ends where the rail does. + // The chevron leans toward the list on hover. + let model_key: SharedString = format!("compact-model-link-{}", cx.entity_id()).into(); + let hover = motion::hover_t(&model_key); + let lean = motion::EASE_OUT_QUINT.eval(hover); + let title = div() + .id("compact-select-model") + .role(gpui::Role::Button) + .aria_label(model_accessible) + .flex_1() + .min_w_0() + .h_full() + .px(px(8.0)) + .rounded(px(popover::MENU_ITEM_RADIUS)) + .bg(crate::theme::ink(0.05 * hover)) + .flex() + .flex_col() + .items_start() + .justify_center() + .cursor_pointer() + .on_hover(motion::hover_listener(model_key)) + .on_click(cx.listener(|this, _, _, cx| { + this.compact_keyboard = false; + this.show_compact_models(cx); + })) + .when(!levels.is_empty(), |el| { + el.child( + div() + .text_size(crate::typography::ui_rems(14.0)) + .line_height(px(17.0)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme.text) + .child(effort.clone()), + ) + }) + .child( + div() + .max_w_full() + .min_w_0() + .flex() + .items_center() + .gap(px(3.0)) + .map(|el| { + if levels.is_empty() { + // The name is the whole title: no effort above it. + el.text_size(crate::typography::ui_rems(14.0)) + .line_height(px(17.0)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme.text) + } else { + el.text_size(crate::typography::ui_rems(12.0)) + .line_height(px(15.0)) + .text_color(motion::mix(theme.text_muted, theme.text, hover)) + } + }) + .child(div().min_w_0().truncate().child(label.clone())) + .child( + // Leans 3pt toward the list and firms up on hover. + div() + .flex_none() + .relative() + .left(px(3.0 * lean)) + .opacity(0.55 + 0.45 * lean) + .child( + crate::icons::icon(crate::icons::ALT_ARROW_RIGHT) + .size(px(10.0)) + .text_color(motion::mix(theme.text_muted, theme.text, lean)), + ), + ), + ); + let mut fast_button = None; + if let Some((option, choice, default, fast)) = self.compact_fast_choice(cx) { + let (fast_t, fading) = ScalarTransition::sample( + &mut self.compact_motion.fast, + if fast { 1.0 } else { 0.0 }, + now, + reduced, + ); + if fading { + window.request_animation_frame(); + } + let fast_key: SharedString = format!("compact-fast-{}", cx.entity_id()).into(); + let fast_hover = motion::hover_t(&fast_key); + fast_button = Some( + div() + .id("compact-fast") + .role(gpui::Role::Button) + .aria_label("Fast mode") + .aria_toggled(if fast { + gpui::Toggled::True + } else { + gpui::Toggled::False + }) + .tooltip(move |_, cx| { + cx.new(|_| { + PickerHint( + if fast { + "Fast mode on · Turn off" + } else { + "Fast mode off · Turn on" + } + .into(), + ) + }) + .into() + }) + // Bare like the model button beside it: a plate only on + // hover. The glyph's colour is the state, and the + // slider's fill carries the accent. + .w(px(FAST_BUTTON_WIDTH)) + .h_full() + .flex_none() + .rounded(px(popover::MENU_ITEM_RADIUS)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .bg(crate::theme::ink(0.05 * fast_hover)) + .on_hover(motion::hover_listener(fast_key)) + .on_click(cx.listener(move |this, _, _, cx| { + this.compact_keyboard = false; + this.pick_option(option.clone(), choice.clone(), default, cx); + })) + .child({ + let color = motion::mix( + motion::mix(theme.text_muted, theme.text, fast_hover), + theme.accent, + fast_t, + ); + // On fills the bolt: the solid twin fades in over the + // outline, which shares its silhouette. + div() + .relative() + .size(px(15.0)) + .child( + crate::icons::icon(crate::icons::FAST_TIER) + .size(px(15.0)) + .text_color(color), + ) + .when(fast_t > 0.0, |el| { + el.child( + crate::icons::icon(crate::icons::FAST_TIER_BOLD) + .absolute() + .top_0() + .left_0() + .size(px(15.0)) + .text_color(color.opacity(fast_t)), + ) + }) + }), + ); + } + // The provider is a square button as tall as the title, leading it. + // A chat whose harness is fixed shows the mark without the button. + let header_height = if levels.is_empty() { + HEADER_HEIGHT_SINGLE + } else { + HEADER_HEIGHT + }; + let locked = self.harness_locked(cx); + let provider_button = self.effective_harness(cx).map(|harness| { + let (icon, tint) = harness_brand_icon(harness); + let name: SharedString = self + .rail_descriptors(cx) + .into_iter() + .find(|d| d.id == harness) + .map(|d| d.name) + .unwrap_or_default() + .into(); + let provider_key: SharedString = format!("compact-provider-{}", cx.entity_id()).into(); + let provider_hover = if locked { + 0.0 + } else { + motion::hover_t(&provider_key) + }; + let hint: SharedString = if locked { + name.clone() + } else { + format!("{name} · Change provider").into() + }; + div() + .id("compact-select-provider") + .w(px(FAST_BUTTON_WIDTH)) + .h_full() + .flex_none() + .rounded(px(popover::MENU_ITEM_RADIUS)) + .flex() + .items_center() + .justify_center() + .bg(crate::theme::ink(0.05 * provider_hover)) + .tooltip(move |_, cx| cx.new(|_| PickerHint(hint.clone())).into()) + .when(!locked, |el| { + el.role(gpui::Role::Button) + .aria_label(SharedString::from(format!("{name} · Change provider"))) + .cursor_pointer() + .on_hover(motion::hover_listener(provider_key)) + .on_click(cx.listener(|this, _, _, cx| { + this.compact_keyboard = false; + this.show_compact_providers(cx); + })) + }) + .child( + crate::icons::icon(icon) + .size(px(16.0)) + .text_color(tint.unwrap_or(theme.text_muted)), + ) + }); + let header = div() + .h(px(header_height)) + .flex_none() + .flex() + .gap(px(popover::CARD_INSET)) + .children(provider_button) + .child(title) + .children(fast_button); + let mut panel = div() + .p(px(popover::CARD_INSET)) + .flex() + .flex_col() + .child(header); + if !levels.is_empty() { + let target = if levels.len() > 1 { + selected as f32 / (levels.len() - 1) as f32 + } else { + 0.0 + }; + let (mut fraction, moving) = + ScalarTransition::sample(&mut self.compact_motion.effort, target, now, reduced); + if self.effort_dragging { + fraction = self.compact_motion.drag_fraction.unwrap_or(target); + // Keep the release animation rooted at the actual pointer, not the previous stop. + self.compact_motion.effort = Some(ScalarTransition { + from: fraction, + to: fraction, + started: now, + }); + } + if moving { + window.request_animation_frame(); + } + let (press, pressing) = ScalarTransition::sample( + &mut self.compact_motion.press, + if self.effort_dragging { 1.0 } else { 0.0 }, + now, + reduced, + ); + if pressing { + window.request_animation_frame(); + } + let entity = cx.entity().downgrade(); + let drag_entity = entity.clone(); + let slider = div() + .id("compact-effort-slider") + .role(gpui::Role::Slider) + .aria_label("Reasoning effort") + .aria_value(effort.clone()) + .aria_numeric_value(selected as f64) + .aria_min_numeric_value(0.0) + .aria_max_numeric_value(levels.len().saturating_sub(1) as f64) + .aria_numeric_value_step(1.0) + .aria_orientation(gpui::Orientation::Horizontal) + .aria_description("Use Left and Right to adjust reasoning; Home and End select the first and last levels") + .relative() + .h(px(SLIDER_HEIGHT)) + .cursor_pointer() + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, event: &gpui::MouseDownEvent, window, cx| { + window.focus(&this.focus, cx); + this.compact_keyboard = false; + this.active = NO_ACTIVE_ROW; + this.effort_dragging = true; + this.pick_effort_at(event.position.x, cx); + cx.notify(); + cx.stop_propagation(); + }), + ) + .child( + gpui::canvas( + move |bounds, _, cx| { + let _ = entity.update(cx, |this, _| this.effort_bounds = Some(bounds)); + }, + move |_, _, window, _| { + let release = drag_entity.clone(); + window.on_mouse_event(move |_: &gpui::MouseUpEvent, phase, _, cx| { + if phase == gpui::DispatchPhase::Bubble { + let _ = release.update(cx, |this, cx| { + if this.effort_dragging { + this.effort_dragging = false; + this.compact_motion.drag_fraction = None; + cx.notify(); + } + }); + } + }); + let entity = drag_entity.clone(); + window.on_mouse_event( + move |event: &gpui::MouseMoveEvent, phase, _, cx| { + if phase == gpui::DispatchPhase::Bubble + && event.pressed_button == Some(gpui::MouseButton::Left) + { + let _ = entity.update(cx, |this, cx| { + if this.effort_dragging { + this.pick_effort_at(event.position.x, cx); + } + }); + } + }, + ); + }, + ) + .absolute() + .inset_0(), + ) + .child(crate::glass::light( + div() + .absolute() + .left_0() + .right_0() + .top(px(RAIL_TOP)) + .h(px(RAIL_HEIGHT)) + .rounded_full(), + &theme, + 1.0, + )) + // A contained glow: it must not wash over the option rows. + .child(effort_fill( + &theme, + fraction, + levels.len(), + Shimmer { + intensity, + phase, + breath, + }, + )) + .child({ + // A plain handle that keeps its size; a press only lifts + // it on a softer, wider drop. + let (w, h) = (THUMB_WIDTH, THUMB_HEIGHT); + // Fast mode breathes a soft accent halo under the thumb. + let live = intensity * (0.55 + 0.45 * breath); + let place = |el: gpui::Div| { + el.absolute() + .left(gpui::relative(fraction)) + .ml(px(-w / 2.0)) + .w(px(w)) + .h(px(h)) + .rounded_full() + }; + div() + .absolute() + .left(px(THUMB_INSET)) + .right(px(THUMB_INSET)) + .top(px((SLIDER_HEIGHT - h) / 2.0)) + .when(intensity > 0.001, |el| { + el.child(place(div()).shadow(vec![gpui::BoxShadow { + color: theme.accent.opacity(0.35 * live), + offset: gpui::point(px(0.0), px(1.0)), + blur_radius: px(10.0), + spread_radius: px(0.0), + inset: false, + }])) + }) + .when(press > 0.001, |el| { + el.child(place(div()).shadow(vec![gpui::BoxShadow { + color: gpui::black().opacity(0.12 * press), + offset: gpui::point(px(0.0), px(2.0 * press)), + blur_radius: px(8.0), + spread_radius: px(0.0), + inset: false, + }])) + }) + .child( + crate::glass::thumb(place(div()), &theme), + ) + }); + // The rail starts on the same edge as the title and row labels. + panel = panel.child( + div() + .px(px(8.0)) + .pt(px(SLIDER_TOP)) + .pb(px(SLIDER_BOTTOM)) + .child(slider), + ); + } + let hidden = self.compact_hidden_options(cx); + let option_count = self + .setting_groups(cx) + .iter() + .filter(|g| Self::compact_option_visible(&g.id, &hidden)) + .count(); + if option_count > 0 { + let options = self.render_traits_sections(cx); + let scrollbar = popover::rail(self, "compact-options-scrollbar", &theme, cx); + let chrome = CARD_BORDERS + + header_block(!levels.is_empty()) + + if levels.is_empty() { 0.0 } else { EFFORT_BLOCK } + + OPTIONS_GAP; + // Space, not a rule, separates the options from the effort group. + panel = panel.child( + div().mt(px(OPTIONS_GAP)).child( + popover::menu_scroll_host("compact-options-host") + .on_hover(cx.listener(Self::on_menu_list_hover)) + .child(popover::faded_menu_list( + &self.menu_scroll, + popover::menu_scroll_list("compact-options", &self.menu_scroll) + .max_h(px(options_height(option_count) + .min((self.menu_geometry().height - chrome).max(0.0)))) + .child(options), + )) + .children(scrollbar), + ), + ); + } + panel.into_any_element() + } +} + +/// The accent glass fill from the rail's start to just past the thumb, with +/// the stops painted over rail and fill in one coordinate space. +#[derive(Clone, Copy)] +struct Shimmer { + /// 0 at rest, up to 1 for fast mode on a top level. + intensity: f32, + /// 0–1 position of the band's sweep. + phase: f32, + /// 0–1 breathing of the glow. + breath: f32, +} + +fn effort_fill(theme: &Theme, fraction: f32, count: usize, shimmer: Shimmer) -> AnyElement { + let Shimmer { + intensity, + phase, + breath, + } = shimmer; + // A contained glow that breathes; it must not wash over the option rows. + let fill = crate::glass::accent_plate(theme, 1.0, intensity * (0.2 + 0.25 * breath)); + let accent = theme.accent; + // Quiet stops: a hint of the ladder, not a second focal point. + let filled_stop = gpui::white().opacity(0.4); + let open_stop = theme.text_muted.opacity(0.28); + gpui::canvas( + |_, _, _| (), + move |bounds, _, window, _| { + let width = f32::from(bounds.size.width); + let run = (width - 2.0 * THUMB_INSET).max(0.0); + let center = THUMB_INSET + fraction * run; + let rect = |x: f32, y: f32, w: f32, h: f32| { + gpui::Bounds::new( + bounds.origin + gpui::point(px(x), px(y)), + gpui::size(px(w), px(h)), + ) + }; + fill.paint( + window, + rect(0.0, RAIL_TOP, (center + FILL_PAST).min(width), RAIL_HEIGHT), + RAIL_HEIGHT / 2.0, + ); + // Fast mode: a band of light flows through the whole fill toward + // the thumb. Two slow waves drift at different speeds, shading + // the fill between a lifted accent and a hue-shifted one, and the + // light gathers just before the thumb. It is painted in thin + // strips whose gradients join end to end; the end strips carry + // the fill's corner radius, so it never leaves the rounded ends. + let fill_end = (center + FILL_PAST).min(width); + if intensity > 0.001 && fill_end > RAIL_HEIGHT { + let inset = 1.0; + let height = RAIL_HEIGHT - 2.0 * inset; + let radius = height / 2.0; + let (from, to) = (inset, fill_end - inset); + let base = accent; + let lifted = motion::mix(base, gpui::white(), 0.45); + let shifted = gpui::hsla( + (base.h + 0.07).fract(), + base.s, + (base.l + 0.14).min(0.9), + 1.0, + ); + let tau = std::f32::consts::TAU; + let flow = |x: f32| -> gpui::Hsla { + let slow = 0.5 + 0.5 * (tau * (x / 150.0 - 2.0 * phase)).sin(); + let quick = 0.5 + 0.5 * (tau * (x / 64.0 - 3.0 * phase) + 1.7).sin(); + let glow = (0.65 * slow + 0.35 * quick).powi(2); + let charge = (-(center - x).max(0.0) / 40.0).exp() * (0.6 + 0.4 * breath); + let lead = ((x - from) / 28.0).clamp(0.0, 1.0); + let alpha = intensity * ((0.08 + 0.34 * glow) * lead + 0.22 * charge); + motion::mix(shifted, lifted, slow).opacity(alpha.min(1.0)) + }; + let mut edges = vec![from, (from + radius).min(to)]; + let inner_end = (to - radius).max(edges[1]); + let mut x = edges[1]; + while x + 4.0 < inner_end { + x += 4.0; + edges.push(x); + } + if inner_end > *edges.last().unwrap() { + edges.push(inner_end); + } + if to > *edges.last().unwrap() { + edges.push(to); + } + let last = edges.len().saturating_sub(2); + for (i, pair) in edges.windows(2).enumerate() { + let (x0, x1) = (pair[0], pair[1]); + if x1 - x0 < 0.1 { + continue; + } + let r = px(radius.min(x1 - x0)); + let zero = px(0.0); + let corners = gpui::Corners { + top_left: if i == 0 { r } else { zero }, + bottom_left: if i == 0 { r } else { zero }, + top_right: if i == last { r } else { zero }, + bottom_right: if i == last { r } else { zero }, + }; + window.paint_quad(gpui::quad( + rect(x0, RAIL_TOP + inset, x1 - x0, height), + corners, + gpui::linear_gradient( + 90.0, + gpui::linear_color_stop(flow(x0), 0.0), + gpui::linear_color_stop(flow(x1), 1.0), + ), + px(0.0), + gpui::transparent_black(), + gpui::BorderStyle::default(), + )); + } + } + for i in 0..count { + let stop = if count > 1 { + i as f32 / (count - 1) as f32 + } else { + 0.0 + }; + let x = THUMB_INSET + stop * run; + // Filled stops follow the animated front, never jump ahead of it. + let color = if stop <= fraction { + filled_stop + } else { + open_stop + }; + window.paint_quad(gpui::quad( + rect(x - 2.0, SLIDER_HEIGHT / 2.0 - 2.0, 4.0, 4.0), + px(2.0), + color, + px(0.0), + gpui::transparent_black(), + gpui::BorderStyle::default(), + )); + } + }, + ) + .absolute() + .inset_0() + .into_any_element() +} + +fn effort_fraction(x: f32, width: f32) -> f32 { + ((x - THUMB_INSET) / (width - THUMB_INSET * 2.0).max(1.0)).clamp(0.0, 1.0) +} + +/// Slider stops include both endpoints and only advertised reasoning levels. +fn effort_index(x: f32, width: f32, count: usize) -> Option { + (count > 0) + .then(|| (effort_fraction(x, width) * count.saturating_sub(1) as f32).round() as usize) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compact_transitions_start_settled_and_retarget_without_jumping() { + let start = std::time::Instant::now(); + let mut state = None; + assert_eq!( + ScalarTransition::sample(&mut state, 0.5, start, false), + (0.5, false) + ); + assert_eq!( + ScalarTransition::sample(&mut state, 1.0, start, false).0, + 0.5 + ); + let middle = start + motion::RESIZE.total().mul_f32(motion::speed_scale() * 0.5); + let before = state.as_ref().unwrap().value(middle); + let retargeted = ScalarTransition::sample(&mut state, 0.0, middle, false).0; + assert!((before - retargeted).abs() < 0.0001); + assert_eq!( + ScalarTransition::sample(&mut state, 0.0, middle, true), + (0.0, false) + ); + } + + #[test] + fn continuous_drag_and_stops_share_inset_geometry() { + let width = 228.0; + for count in 2..=8 { + for index in 0..count { + let fraction = index as f32 / (count - 1) as f32; + let x = THUMB_INSET + fraction * (width - THUMB_INSET * 2.0); + assert!((effort_fraction(x, width) - fraction).abs() < 0.00001); + assert_eq!(effort_index(x, width, count), Some(index)); + assert!(x - 2.0 >= 0.0 && x + 2.0 <= width); + } + } + assert!((effort_fraction(68.0, width) - 0.25).abs() < 0.00001); + assert_eq!(effort_fraction(-100.0, width), 0.0); + assert_eq!(effort_fraction(500.0, width), 1.0); + } + + #[test] + fn effort_stops_clamp_and_round_to_advertised_levels() { + assert_eq!(effort_index(100.0, 200.0, 0), None); + assert_eq!(effort_index(100.0, 200.0, 1), Some(0)); + assert_eq!(effort_index(-20.0, 200.0, 5), Some(0)); + assert_eq!(effort_index(100.0, 200.0, 5), Some(2)); + assert_eq!(effort_index(250.0, 200.0, 5), Some(4)); + } +} + +struct PickerHint(SharedString); +impl Render for PickerHint { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::of(cx).for_popup(); + let card = popover::popover_card(&theme) + .max_w(px(320.0)) + .p(px(8.0)) + .text_size(crate::typography::ui_rems(12.0)) + .line_height(px(17.0)) + .text_color(theme.text) + .child(self.0.clone()); + crate::frost::frosted(popover::CARD_RADIUS, crate::frost::MENU_BLUR, card) + } +} diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index 7f11259b44..c9b28e287c 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -513,14 +513,29 @@ fn frosted_menu(exit: Option, content: AnyElement) -> AnyElement { /// and the overlay also keeps stray clicks from reaching whatever sits /// underneath. fn menu_motion(id: SharedString, exit: Option, inner: gpui::Div) -> AnyElement { + menu_motion_from(id, exit, inner, -2.0) +} + +/// [`menu_motion`] with the entrance travelling `from` px (signed) out of +/// the trigger's side, and the exit retreating back toward it. +fn menu_motion_from( + id: SharedString, + exit: Option, + inner: gpui::Div, + from: f32, +) -> AnyElement { if let Some(t) = exit { let inner = inner.relative().child(div().absolute().inset_0().occlude()); - motion::menu_out(SharedString::from(format!("{id}-out")), t, inner).into_any_element() + motion::menu_out_toward(SharedString::from(format!("{id}-out")), from, t, inner) + .into_any_element() } else { - motion::menu_in(id, inner).into_any_element() + motion::menu_in_from(id, from, inner).into_any_element() } } +/// Entrance travel for menus that open away from a trigger edge. +const MENU_TRAVEL: f32 = 4.0; + /// Wrap popover content in a floating anchored layer attached to the trigger: /// the caller `.child(anchored_menu(...))`s this from the trigger element while /// open. Plays `menu-in` (0.14s fade + 2px drop); `closing` (the [`Popup`] @@ -583,10 +598,11 @@ pub fn anchored_menu_below_end( gpui::anchored() .anchor(Anchor::TopRight) .snap_to_window_with_margin(px(8.0)) - .child(menu_motion( + .child(menu_motion_from( id.into(), exit, div().occlude().pt(px(6.0)).child(content), + -MENU_TRAVEL, )), ) .priority(1) @@ -684,10 +700,11 @@ pub fn anchored_menu_below_layer( gpui::anchored() .anchor(Anchor::TopLeft) .snap_to_window_with_margin(px(8.0)) - .child(menu_motion( + .child(menu_motion_from( id.into(), exit, div().occlude().pt(px(gap)).child(content), + -MENU_TRAVEL, )), ) .priority(priority) @@ -711,10 +728,11 @@ pub fn anchored_menu_above( gpui::anchored() .anchor(Anchor::BottomLeft) .snap_to_window_with_margin(px(8.0)) - .child(menu_motion( + .child(menu_motion_from( id.into(), exit, div().occlude().pb(px(6.0)).child(content), + MENU_TRAVEL, )), ) .priority(1) @@ -783,10 +801,11 @@ pub fn anchored_menu_above_end( gpui::anchored() .anchor(Anchor::BottomRight) .snap_to_window_with_margin(px(8.0)) - .child(menu_motion( + .child(menu_motion_from( id.into(), exit, div().occlude().pb(px(6.0)).child(content), + MENU_TRAVEL, )), ) .priority(1) diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index e142454e63..6aaad42a3d 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -348,6 +348,12 @@ pub fn current(cx: &App) -> UiSettings { .unwrap_or_default() } +/// Read the picker preference without cloning the full settings for each model row. +pub fn compact_model_picker(cx: &App) -> bool { + cx.try_global::() + .is_some_and(|store| store.current.compact_model_picker) +} + /// Copy a selected image into Zeron's device-local data directory and make it /// the new-thread canvas background. A unique file name avoids stale image /// caches when the background is replaced. @@ -787,6 +793,8 @@ pub struct UiSettings { pub skills_in_slash_menu: bool, pub skill_completion_by_harness: std::collections::HashMap, + /// Open model selection with an effort slider and a separate model list. + pub compact_model_picker: bool, pub sidebar_width: f32, pub sidebar_collapsed: bool, /// Legacy: the grouped-by-project toggle predates spaces (which group by @@ -998,6 +1006,7 @@ impl Default for UiSettings { composer_send_behavior: ComposerSendBehavior::default(), skills_in_slash_menu: false, skill_completion_by_harness: Default::default(), + compact_model_picker: true, appshots_enabled: false, appshot_sound_enabled: true, appshot_destination: crate::appshots::AppshotDestination::Automatic, @@ -1830,6 +1839,19 @@ mod tests { assert!(UiSettings::load(dir.path()).skills_in_slash_menu); } + #[test] + fn compact_model_picker_is_default_and_opt_out_persists() { + let legacy: UiSettings = serde_json::from_str("{}").unwrap(); + assert!(legacy.compact_model_picker); + let settings = UiSettings { + compact_model_picker: false, + ..legacy + }; + let saved = serde_json::to_string(&settings).unwrap(); + let loaded: UiSettings = serde_json::from_str(&saved).unwrap(); + assert!(!loaded.compact_model_picker); + } + #[test] fn composer_send_behavior_is_opt_in_for_old_and_partial_settings() { let dir = tempfile::tempdir().unwrap(); @@ -2534,6 +2556,7 @@ mod tests { composer_send_behavior: ComposerSendBehavior::ModEnter, skills_in_slash_menu: true, skill_completion_by_harness: Default::default(), + compact_model_picker: true, appshots_enabled: false, appshot_sound_enabled: true, // The destination is only persisted where Appshots exist (macOS and diff --git a/crates/ui/src/settings/composer.rs b/crates/ui/src/settings/composer.rs index 66e6fd85ec..7a10356c4b 100644 --- a/crates/ui/src/settings/composer.rs +++ b/crates/ui/src/settings/composer.rs @@ -49,7 +49,11 @@ pub struct ComposerDefaults { /// Last model picked, per harness (restored on harness switch). pub model_by_harness: HashMap, /// Last reasoning level picked (global, like zeron's `reasoning` key). + /// The fallback for models with no level of their own yet. pub reasoning: Option, + /// Last reasoning level picked per harness and model id, so switching + /// back to a model (or its provider) restores the effort it was run at. + pub reasoning_by_model: HashMap>, /// Last non-default model option picks (option id → choice id), per /// harness and model id. Model-scoped because each pick was validated /// against that model's catalog row, so it stays safe to send before the @@ -143,6 +147,29 @@ impl ComposerDefaults { .or_default() } + /// The remembered reasoning level for one model, else the global one. + pub fn reasoning_for(&self, harness: HarnessId, model: Option<&str>) -> Option { + model + .and_then(|model| self.reasoning_by_model.get(&harness)?.get(model).copied()) + .or(self.reasoning) + } + + /// Remember a reasoning pick for one model (and as the global fallback). + pub fn remember_reasoning( + &mut self, + harness: HarnessId, + model: Option<&str>, + level: ReasoningLevel, + ) { + self.reasoning = Some(level); + if let Some(model) = model { + self.reasoning_by_model + .entry(harness) + .or_default() + .insert(model.to_string(), level); + } + } + /// The cached display label for a model id, if ever seen. pub fn label_for(&self, id: &str) -> Option<&str> { self.model_labels.get(id).map(String::as_str) diff --git a/crates/ui/src/settings/shortcuts.rs b/crates/ui/src/settings/shortcuts.rs index e64a47a486..a461748d1d 100644 --- a/crates/ui/src/settings/shortcuts.rs +++ b/crates/ui/src/settings/shortcuts.rs @@ -594,6 +594,45 @@ impl Render for ShortcutsPage { cx.notify(); })), ); + let compact_model_picker = crate::settings::compact_model_picker(cx); + let compact_model_picker_row = widgets::card_row(&theme, false) + .child( + div() + .flex_1() + .min_w_0() + .child(widgets::row_title(&theme, "Compact model picker")) + .child(widgets::meta_line( + &theme, + vec![ + div() + .child("Adjust effort with a slider, then open the model list when needed.") + .into_any_element(), + ], + )), + ) + .child( + widgets::toggle_switch(&theme, compact_model_picker, "compact-model-picker") + .id("compact-model-picker-toggle") + .tab_index(0) + .role(gpui::Role::Switch) + .aria_label("Compact model picker") + .aria_toggled(if compact_model_picker { + gpui::Toggled::True + } else { + gpui::Toggled::False + }) + .focus_visible(|s| s.border_2().border_color(theme.accent)) + .cursor_pointer() + .on_click(cx.listener(move |_, _, _, cx| { + crate::settings::update( + crate::settings::SavePolicy::Debounced, + cx, + |settings| settings.compact_model_picker = !compact_model_picker, + ); + cx.refresh_windows(); + cx.notify(); + })), + ); let escape_behavior_row = widgets::card_row(&theme, false) .child( div() @@ -655,6 +694,7 @@ impl Render for ShortcutsPage { widgets::section_card(&theme) .child(send_behavior_row) .child(compact_mode_row) + .child(compact_model_picker_row) .child(escape_behavior_row), ) .child(self.thread_naming.clone()), diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 45f1e046ce..b788e66744 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -2763,14 +2763,18 @@ impl Shell { if state.read(cx).selected_chat.is_none() { // A set sidebar filter is an explicit standing choice — the // canvas defaults (project AND its device) follow it, even - // over a remembered "no project" opt-out. Otherwise the last - // selected project stands, unless opted out. + // over a remembered "no project" opt-out. Otherwise the canvas's + // own last pick stands (`last_space_id` also follows opened + // chats, so it is only the fallback), unless opted out. let exists = |id: &String| state.read(cx).space_row(id).is_some(); let filter = self.settings.space_filter.clone().filter(&exists); let target = match filter { Some(filter) => Some(filter), None if !state.read(cx).no_project => { - self.settings.last_space_id.clone().filter(&exists) + crate::settings::composer::ComposerDefaults::load(&self.data_dir) + .project + .filter(&exists) + .or_else(|| self.settings.last_space_id.clone().filter(&exists)) } None => None, }; @@ -4487,6 +4491,7 @@ impl Shell { self.settings.skills_in_slash_menu = current.skills_in_slash_menu; self.settings.reduce_motion = current.reduce_motion; self.settings.pause_animations_in_background = current.pause_animations_in_background; + self.settings.compact_model_picker = current.compact_model_picker; } fn retry_engine(&mut self, cx: &mut Context) { @@ -14999,6 +15004,86 @@ mod exit_regressions { .unwrap(); } + #[gpui::test] + fn new_session_reopens_where_it_was_left_after_visiting_a_chat(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + crate::history::init( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + cx, + ); + settings::init(settings::UiSettings::default(), dir.path(), cx); + }); + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Shell::new( + state, + EngineBootConfig { + data_dir: dir.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }, + cx, + ) + }); + let space = |id: &str, device: &str| zeron_proto::Space { + id: id.into(), + device_id: device.into(), + path: format!("/{id}"), + name: None, + git_detected: false, + git_checked_at: None, + checkout_id: None, + created_at: Utc::now(), + }; + window + .update(cx, |shell, _, cx| { + shell.state.update(cx, |state, cx| { + state.apply_spaces(vec![space("mine", "local"), space("other", "remote")]); + state.apply_chats(vec![zeron_proto::Chat { + id: "elsewhere".into(), + device_id: "remote".into(), + title: None, + archived: false, + cwd: None, + branch: None, + checkout_id: None, + source_context: None, + config: None, + last_message_preview: None, + last_message_at: None, + created_at: Utc::now(), + harness_session_id: None, + harness_session_cwd: None, + parent_chat_id: None, + space_id: Some("other".into()), + last_seen_at: None, + room_gen: None, + }]); + state.select_space(Some("mine".into()), cx); + }); + shell.settings.space_filter = None; + shell.open_chat("elsewhere".into(), cx); + assert_eq!(shell.state.read(cx).selected_space.as_deref(), Some("other")); + shell.open_new_session(cx); + let state = shell.state.read(cx); + assert!(state.selected_chat.is_none()); + assert_eq!(state.selected_space.as_deref(), Some("mine")); + assert_eq!(state.effective_device_id().as_deref(), Some("local")); + }) + .unwrap(); + } + #[cfg(any(target_os = "macos", target_os = "linux"))] #[gpui::test] fn transcript_links_open_new_tabs_and_reject_stale_sessions(cx: &mut TestAppContext) { diff --git a/crates/ui/src/shell/tabs.rs b/crates/ui/src/shell/tabs.rs index 8aa5e6a2a5..88e07be64d 100644 --- a/crates/ui/src/shell/tabs.rs +++ b/crates/ui/src/shell/tabs.rs @@ -213,8 +213,8 @@ impl Shell { } /// `+` in the titlebar: open the new-session canvas. A set sidebar filter - /// re-homes the canvas onto that project; under "All" the current pick - /// (the last selected project, restored from composer defaults) stands. + /// re-homes the canvas onto that project; under "All" the canvas reopens + /// on the project/device it was left at (see `AppState::canvas_target`). /// /// A new chat always starts with the terminal hidden: when the drawer is /// open it just hides (detach, not close — the source chat's tabs and @@ -246,17 +246,20 @@ impl Shell { }; let defaults = crate::settings::composer::ComposerDefaults::load(&self.data_dir); self.state.update(cx, |s, cx| { + // Leaving a chat puts the canvas's own target back; the filter + // (an explicit standing choice) then applies on top of it. + let restores = s.canvas_target.is_some(); + s.select_chat(None, cx); if target.is_some() { s.select_space(target, cx); - } else if defaults.no_project { - // Opening an existing project session (including boot's last - // session) must not erase the saved new-session opt-out. + } else if !restores && defaults.no_project { + // No canvas target was set aside (e.g. after a runtime swap): + // fall back to the saved new-session opt-out. s.select_space(None, cx); if let Some(device) = defaults.device { s.select_device(device, cx); } } - s.select_chat(None, cx); }); // The canvas never restores a drawer: a previously opened canvas // terminal must not pop open on a fresh new chat. diff --git a/crates/ui/src/state.rs b/crates/ui/src/state.rs index a8618b4017..cd485d7336 100644 --- a/crates/ui/src/state.rs +++ b/crates/ui/src/state.rs @@ -661,6 +661,14 @@ pub fn canvas_panel_key(space_id: Option<&str>) -> String { format!("{CANVAS_PANEL_PREFIX}{}", space_id.unwrap_or("")) } +/// The new-session canvas's project/device pick, see [`AppState::canvas_target`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CanvasTarget { + space: Option, + no_project: bool, + device: Option, +} + /// Root application state. Reducer methods (`apply_*`, [`Self::session_for`], …) /// are plain `&mut self` functions so tests construct the struct directly; gpui /// glue ([`Self::bootstrap`], [`Self::select_chat`]) layers subscriptions on top. @@ -712,6 +720,10 @@ pub struct AppState { /// device whose projects the project picker lists. `None` falls back to /// the local device. pub selected_device: Option, + /// The new-session canvas's own target, set aside while a chat is open + /// (a chat implies ITS project/device) and put back on return, so the + /// canvas always reopens where the user left it. + pub(crate) canvas_target: Option, pub selected_chat: Option, /// Boot auto-select happened (or a manual selection superseded it). pub auto_selected: bool, @@ -829,6 +841,7 @@ impl AppState { selected_space: None, no_project: false, selected_device: None, + canvas_target: None, selected_chat: None, transcript: Vec::new(), queue: Vec::new(), @@ -1056,6 +1069,7 @@ impl AppState { self.transcript_baselines.remove(selected); self.prepared_transcripts.remove(selected); self.selected_chat = None; + self.restore_canvas_target(); self.transcript.clear(); self.context_usage = None; self.transcript_revision = self.transcript_revision.wrapping_add(1); @@ -1792,6 +1806,34 @@ impl AppState { } } + fn current_canvas_target(&self) -> CanvasTarget { + CanvasTarget { + space: self.selected_space.clone(), + no_project: self.no_project, + device: self.selected_device.clone(), + } + } + + /// Put back the canvas target set aside when a chat opened. A project + /// deleted meanwhile heals the way [`Self::apply_spaces`] would. + fn restore_canvas_target(&mut self) { + let Some(target) = self.canvas_target.take() else { + return; + }; + self.selected_device = target.device; + self.no_project = target.no_project; + self.selected_space = target.space; + if self.spaces_synced + && !self.no_project + && self + .selected_space + .as_ref() + .is_none_or(|id| !self.spaces.iter().any(|s| &s.id == id)) + { + self.selected_space = self.first_space_on_picked_device(); + } + } + pub fn selected_space_row(&self) -> Option<&Space> { if self.no_project { return None; @@ -2014,6 +2056,7 @@ impl AppState { self.selected_space = None; self.no_project = false; self.selected_device = None; + self.canvas_target = None; self.selected_chat = None; self.auto_selected = false; self.chats_synced = false; @@ -2364,6 +2407,11 @@ impl AppState { } return; } + match (&self.selected_chat, &chat_id) { + (None, Some(_)) => self.canvas_target = Some(self.current_canvas_target()), + (Some(_), None) => self.restore_canvas_target(), + _ => {} + } // Take the destination before trimming: switching to the oldest warm // transcript must not evict the very entry we are about to display. let cached = self @@ -2454,7 +2502,7 @@ impl AppState { self.queue_task = None; if let Some(id) = chat_id.as_deref() { // A chat implies its project (or the lack of one); `select_chat(None)` - // (the new-session canvas) keeps the current project pick. + // (the new-session canvas) restores the pick set aside above. if let Some(chat) = self.chats.iter().find(|c| c.id == id) { match chat.space_id.clone() { Some(space_id) => { @@ -4494,6 +4542,46 @@ mod tests { }); } + #[gpui::test] + fn canvas_reopens_on_its_own_target_after_visiting_chats(cx: &mut gpui::TestAppContext) { + let state = cx.new(|_| AppState::new()); + state.update(cx, |state, cx| { + state.apply_spaces(vec![ + space("s1", "dev", "/a", 1), + space("s2", "remote", "/b", 2), + ]); + let mut remote = chat("remote-chat", 1, None); + remote.device_id = "remote".into(); + remote.space_id = Some("s2".into()); + let mut loose = chat("loose-chat", 2, None); + loose.device_id = "remote".into(); + state.apply_chats(vec![remote, loose.clone()]); + state.select_space(Some("s1".into()), cx); + + // Chats imply their own target while open… + state.select_chat(Some("remote-chat".into()), cx); + assert_eq!(state.selected_space.as_deref(), Some("s2")); + state.select_chat(Some("loose-chat".into()), cx); + assert!(state.no_project); + // …and the canvas returns to where it was left. + state.select_chat(None, cx); + assert!(!state.no_project); + assert_eq!(state.selected_space.as_deref(), Some("s1")); + assert_eq!(state.effective_device_id().as_deref(), Some("dev")); + + // A projectless canvas survives a project chat, and a chat deleted + // elsewhere lands back on it too. + state.select_space(None, cx); + state.select_device("remote".into(), cx); + state.select_chat(Some("remote-chat".into()), cx); + state.apply_chats(vec![loose]); + assert!(state.selected_chat.is_none()); + assert!(state.no_project); + assert!(state.selected_space.is_none()); + assert_eq!(state.effective_device_id().as_deref(), Some("remote")); + }); + } + #[test] fn chats_in_space_filters_and_orders() { let mut state = AppState::new(); diff --git a/docs/screenshots/compact-picker/README.md b/docs/screenshots/compact-picker/README.md new file mode 100644 index 0000000000..d1f58607a9 --- /dev/null +++ b/docs/screenshots/compact-picker/README.md @@ -0,0 +1,16 @@ +# Compact picker runtime evidence + +Captured from clean `codex/compact-picker` source `6ec139a2` (rebased on `main` `26e2b0dd`) on 2026-09-30. The fixture uses synthetic catalogs and temporary settings (default Zeron accent), with no real conversation or IPC server. + +Build and run: `cargo run -p zeron-ui --features compact-picker-fixture --example compact-picker-fixture -- `. +Executable: `target/debug/examples/compact-picker-fixture`; SHA256 `e5d97ac0f1ae1cb9a5b3afe047398435acc2c39460e599dad2c6745f5face7f5`. Exited successfully with the fixture PASS marker. + +All keyboard assertions passed for effort selection, fast tier and favorite identity across dark/light and 840/360 logical widths, height 560. The included captures were visually inspected: both header buttons sit one card inset from the card's top and sides, the title, rail and option labels share one leading edge, fast mode shows an accent glyph on a neutral plate, the slider thumb is a plain glass handle, and fills use the theme's accent fill token. Static screenshots do not establish animation performance or live provider compatibility. + +`switch-states.png` is not produced by the committed fixture: it was captured from the same source with a local, uncommitted fixture patch that adds one on and one off `toggle_switch`, then cropped (dark above, light below). Measured mark contrast against the surface beneath: dark 3.13 (bar) / 2.95 (oval), light 3.02 / 3.23. + +- `compact-panel-dark-wide.png`: SHA256 `9aa1d38d637a3c1fd70b7a616b1d945c30cecc6741913af15c4056940cc590f5` +- `compact-panel-light-wide.png`: SHA256 `e74b07d800de2cf5234d13b3c9d0d283cab2abff68bddfe22b06952ce4dda3f2` +- `compact-fast-keyboard.png`: SHA256 `313331b7f545e9b11fd610796b7b72adada1b4b43cf7662c778ad73764c71073` +- `compact-model-list-light-narrow.png`: SHA256 `3ae45a842492448d02177b4a0a9d5d3a38ff5c1e56d2debd5f74fca8ebb63d31` +- `switch-states.png`: SHA256 `4f5021ea3be5f8cdc297fe7f1e6774f113654da8e71cf8e12a5b8434dcd012ce` diff --git a/docs/screenshots/compact-picker/compact-fast-keyboard.png b/docs/screenshots/compact-picker/compact-fast-keyboard.png new file mode 100644 index 0000000000000000000000000000000000000000..3aa0b443a2af57cab2ec354905ce191b552b17cb GIT binary patch literal 134697 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%0AYIQ0m08D;{|MSHY(MVmy6 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� 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 z zO;N~mH(s{lMVm$=Rf({~DFSXt0$IU-SUi!XDA9*w4h9DF3?fN^InX|{vd6Qi-CxOz zP{c|ISbND;?f`~%=U<9%S-%PEa}(%({;UK9L+>nfeA|Ftbk@SLu$Mf2NrlrllFCT$ z3+f$Erv~dX8yY^3t+ed0y@X;0#m{^TTL&qwyl1?yMimq|iZ76bY1I~7(t4O?pNi(| zU0y!lRzf8C^Qe1m}sFnBXyXa-ad zv!%#~>!b2A3WYZ(irh2{l(pg%%kX}+!*b(vN?dhzm)@~JZ%Bn`z$7`M3{#BX=ct>s zEa1{4R)+kY--xtqCZmAEec9EeLdYhuqNiaBPw{g~)Gi#L7;(sk4-`4_dIacC;k9-N zEk@A{JQ)PKm9$4&DiCGxMsZ|c+Xg)pKMW~p27<$&PN(E0q?U z%<;5vc8J}$QBW5_V=|$SztL2D5Y{#dv+niK_MqLdERv^=LZ*JY4CZu0Ifp_;!J>Qdhln(!t0{iaOx6-6@4vJ)+oN8LyLDh%L8_({Ex@zoL zg1*A42O1Kdu>ZyEol-A6;PT&#s0(xzVgl>Yc7q|1faV$saPa{LL@!<*=|DBA?Shn< zPTB$=8upzIdgRh`l;pUpX}ErUbbkdRj}joeJy?q&D`03)$S~kY8>zJmIx5j4aLo`N z)1Hnyh3SFe2H5Md-<~>nx$%pxh;HKQ=pGl=3K~VV>NtG5ji8|9BflkJ+N}TsPQJwX zo>KFLjz&26E)d=-ZNa>YGfK83tG@s4ySB%JXfjCNk^Yt_DzgoqxqIEDLRD7ZixzY z;53XK*giY)1R!++@=2pG|rJy|7D@`)}`lxVjUT1=CQ#mta= zJz=tr7A+AuR^josTL|@4@-X@qtVBrsI1$Ni^PwgMgAl5x#H6IfEhbKAQ6)2-yc*M{ zsr;E|qcgz9GLty`4Iu5JqQv|C-ch_kSOrL2U_qQ=Esx`}cU5uhp|29;3~$dW_|=6h zk7>sbFK&GDMlbB`-Pw_GmJza?H332LEN+KUCB*v*{P5J|tnBKXneD?$;R>>6i=?N5 zACEV$CJ3{F!6)~`H~a`m0ZlSvB4u^Ra(F8~z98I*+yrt6>cD5y&ZaGw2xs{DL)go2 zApzk@t6kKg=98NYe_;+sro>rj970EdDZet!s*r(?|!EKqKm z$aniENn$Cn=LP;JxyQSIbJHU~Qz0h+-py^w7W-HJ7?~@Hn+nbpZ4c~U#ed;unTM268lmdnCDYge}MS zB!>F%F5?Z-tqK)?7zmzDV)K!MxvqVq)58U?xYb-k;ZpzlvDI^CK|OY~Z$1lJ5&tUq z#%A}lmRCdjALE%Vqs6p7=#691c}GrVU0ZUBkE1VRa@Gi!6(4^V9uwSucQ)TN2Di(O zOOw`w*zeurE5VA(g&$B{mi^jJ3{J6l#|tB0`#;?={vsBgzkUe&>H3B2oqv5C|MTN8 bhupuPe&M$puN6r6wKVvZ#b^HX#`gaL1wm4! literal 0 HcmV?d00001 diff --git a/docs/screenshots/compact-picker/compact-panel-dark-wide.png b/docs/screenshots/compact-picker/compact-panel-dark-wide.png new file mode 100644 index 0000000000000000000000000000000000000000..c2a2f0c9534b482fda8ef6a25f9d28d51731286d GIT binary patch literal 115534 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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/compact-picker/compact-panel-light-wide.png b/docs/screenshots/compact-picker/compact-panel-light-wide.png new file mode 100644 index 0000000000000000000000000000000000000000..7a978423ed9d9d332ad1efb04e1ffad654d8e3f0 GIT binary patch literal 108940 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¥gjXMs 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

Ig?+wv*h>G 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≀E&+OSLn;rn%?sbOP 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 KWa_2N!~P%Xy{O0l literal 0 HcmV?d00001 From b42fc2b8fbf247dd92796c2917f277535cea91ac Mon Sep 17 00:00:00 2001 From: wing-anara Date: Thu, 1 Oct 2026 03:06:41 -0700 Subject: [PATCH 21/74] Bump version to v0.2.101 Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 34 +++++++++++++++++----------------- Cargo.toml | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e67ec7e690..e02ae30400 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12101,7 +12101,7 @@ dependencies = [ [[package]] name = "zeron" -version = "0.2.100" +version = "0.2.101" dependencies = [ "anyhow", "clap", @@ -12123,7 +12123,7 @@ dependencies = [ [[package]] name = "zeron-client" -version = "0.2.100" +version = "0.2.101" dependencies = [ "async-trait", "chrono", @@ -12147,7 +12147,7 @@ dependencies = [ [[package]] name = "zeron-doc" -version = "0.2.100" +version = "0.2.101" dependencies = [ "chrono", "loro", @@ -12161,7 +12161,7 @@ dependencies = [ [[package]] name = "zeron-engine" -version = "0.2.100" +version = "0.2.101" dependencies = [ "anyhow", "async-trait", @@ -12201,7 +12201,7 @@ dependencies = [ [[package]] name = "zeron-harness" -version = "0.2.100" +version = "0.2.101" dependencies = [ "anyhow", "async-trait", @@ -12228,14 +12228,14 @@ dependencies = [ [[package]] name = "zeron-markdown" -version = "0.2.100" +version = "0.2.101" dependencies = [ "pulldown-cmark", ] [[package]] name = "zeron-mcp" -version = "0.2.100" +version = "0.2.101" dependencies = [ "anyhow", "async-trait", @@ -12253,7 +12253,7 @@ dependencies = [ [[package]] name = "zeron-mobile" -version = "0.2.100" +version = "0.2.101" dependencies = [ "serde", "serde_json", @@ -12270,7 +12270,7 @@ dependencies = [ [[package]] name = "zeron-preview" -version = "0.2.100" +version = "0.2.101" dependencies = [ "anyhow", "async-trait", @@ -12298,7 +12298,7 @@ dependencies = [ [[package]] name = "zeron-proto" -version = "0.2.100" +version = "0.2.101" dependencies = [ "chrono", "pulldown-cmark", @@ -12309,7 +12309,7 @@ dependencies = [ [[package]] name = "zeron-rpc" -version = "0.2.100" +version = "0.2.101" dependencies = [ "anyhow", "async-trait", @@ -12328,7 +12328,7 @@ dependencies = [ [[package]] name = "zeron-sync" -version = "0.2.100" +version = "0.2.101" dependencies = [ "block2 0.6.2", "chrono", @@ -12351,7 +12351,7 @@ dependencies = [ [[package]] name = "zeron-syntax" -version = "0.2.100" +version = "0.2.101" dependencies = [ "thiserror 2.0.20", "tree-sitter", @@ -12385,7 +12385,7 @@ dependencies = [ [[package]] name = "zeron-text" -version = "0.2.100" +version = "0.2.101" dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", @@ -12402,7 +12402,7 @@ dependencies = [ [[package]] name = "zeron-theme" -version = "0.2.100" +version = "0.2.101" dependencies = [ "anyhow", "clap", @@ -12417,7 +12417,7 @@ dependencies = [ [[package]] name = "zeron-ui" -version = "0.2.100" +version = "0.2.101" dependencies = [ "alacritty_terminal", "anyhow", @@ -12484,7 +12484,7 @@ dependencies = [ [[package]] name = "zeron-update" -version = "0.2.100" +version = "0.2.101" dependencies = [ "anyhow", "futures", diff --git a/Cargo.toml b/Cargo.toml index ad21a07a80..e0ba7ab3c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ members = [ ] [workspace.package] -version = "0.2.100" +version = "0.2.101" edition = "2024" license = "MIT" publish = false From 5d5d935991d3a659a5bba25f004874d41a74e7ae Mon Sep 17 00:00:00 2001 From: Wing Date: Thu, 1 Oct 2026 12:07:48 -0700 Subject: [PATCH 22/74] CI: cover cursor paths in ui-tests, verify nextest after cache restore, nightly release-profile run - ui-tests.yml also triggers on scripts/*cursor-sdk* and cursor-*.yml, so the Rust half that moved out of cursor-compatibility still runs for those PRs. - linux-ci installs nextest after rust-cache restores ~/.cargo/bin, so the checksum-verified binary is the one used. - release-profile-tests.yml runs the Linux core and UI suites daily with --release (no cache save), backstopping the dev-profile switch. Co-Authored-By: Claude Opus 5.5 --- .github/actions/linux-ci/action.yml | 10 +++-- .github/workflows/release-profile-tests.yml | 47 +++++++++++++++++++++ .github/workflows/ui-tests.yml | 4 ++ scripts/ci/test-core.sh | 3 +- 4 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/release-profile-tests.yml diff --git a/.github/actions/linux-ci/action.yml b/.github/actions/linux-ci/action.yml index 59d0b9f7ed..0e306e4eb1 100644 --- a/.github/actions/linux-ci/action.yml +++ b/.github/actions/linux-ci/action.yml @@ -58,10 +58,6 @@ runs: path: ~/apt-debs/*.deb key: apt-debs-v1-${{ inputs.apt-cache }}-${{ hashFiles('scripts/ci/apt-install.sh') }} - - name: Install cargo-nextest - shell: bash - run: bash scripts/ci/install-nextest.sh - # Exported for the rest of the job. Tests and the browser fixture are built in # the dev profile without debug info (smaller, faster to link). CARGO_* variables # are part of the rust-cache key, so every job sharing a cache must set the same. @@ -76,3 +72,9 @@ runs: add-job-id-key: false save-if: ${{ inputs.save-rust-cache == 'true' && github.ref == 'refs/heads/main' }} cache-on-failure: true + + # After the cache restore, so the checksum-verified binary is the one that + # runs (rust-cache restores ~/.cargo/bin). + - name: Install cargo-nextest + shell: bash + run: bash scripts/ci/install-nextest.sh diff --git a/.github/workflows/release-profile-tests.yml b/.github/workflows/release-profile-tests.yml new file mode 100644 index 0000000000..56d1af48b0 --- /dev/null +++ b/.github/workflows/release-profile-tests.yml @@ -0,0 +1,47 @@ +name: Release-profile tests + +# Pull-request and main CI test the dev profile (fast rebuilds). This runs the +# same Linux suites once a day against the optimized release profile that +# ships, so optimization-only failures still surface. It does not save a Rust +# cache, so it cannot evict the main-seeded caches pull requests depend on. + +on: + schedule: + - cron: '17 7 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-profile-tests + cancel-in-progress: false + +jobs: + core-tests: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/linux-ci + with: + rust-cache: release-profile-core + - name: Harness, engine, sync, update, doc and preview tests (release) + run: scripts/ci/test-core.sh --release + + ui-tests: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/linux-ci + with: + rust-cache: release-profile-ui + apt-cache: gpui + apt-packages: >- + libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev + libx11-dev libxcb1-dev libx11-xcb-dev + libfontconfig1-dev libfreetype-dev libasound2-dev + libvulkan-dev pkg-config cmake libwebkit2gtk-4.1-dev libjson-glib-dev + - name: UI and headless layout regressions (release) + run: cargo nextest run --config-file scripts/ci/nextest.toml --locked --no-fail-fast --release -p zeron-ui --lib diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 29b9e30006..32194852e4 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -9,6 +9,8 @@ on: - 'crates/**' - '.github/actions/linux-ci/**' - 'scripts/ci/**' + - 'scripts/*cursor-sdk*' + - '.github/workflows/cursor-*.yml' - 'scripts/test-linux-browser.sh' - 'scripts/run-macos-browser-fixture.sh' - 'dist/macos/Info.plist' @@ -23,6 +25,8 @@ on: - 'crates/**' - '.github/actions/linux-ci/**' - 'scripts/ci/**' + - 'scripts/*cursor-sdk*' + - '.github/workflows/cursor-*.yml' - 'scripts/test-linux-browser.sh' - 'scripts/run-macos-browser-fixture.sh' - 'dist/macos/Info.plist' diff --git a/scripts/ci/test-core.sh b/scripts/ci/test-core.sh index 539b1a09ab..5a25c8686c 100755 --- a/scripts/ci/test-core.sh +++ b/scripts/ci/test-core.sh @@ -16,6 +16,7 @@ # they are listed instead of globbed to avoid compiling them. # nextest does not run doctests; these crates have none (their doc comments # contain no Rust code blocks). +# Extra arguments are passed to nextest (the nightly job adds `--release`). set -euo pipefail cd "$(dirname "$0")/../.." @@ -30,4 +31,4 @@ done exec cargo nextest run --config-file scripts/ci/nextest.toml --locked --no-fail-fast \ -p zeron-harness -p zeron-engine -p zeron-sync -p zeron-update -p zeron-doc -p zeron-preview \ --features zeron-harness/native-fixture \ - --lib --bins "${tests[@]}" + --lib --bins "${tests[@]}" "$@" From 7aed43b472ef3fbed1cb9d8e74c5fe6c5bc0cd15 Mon Sep 17 00:00:00 2001 From: Wing Date: Thu, 1 Oct 2026 12:09:05 -0700 Subject: [PATCH 23/74] CI(macos): run CoreText tests on macOS, drop /etc/hosts patch, harden nextest download - zeron-text's coretext tests are #![cfg(target_os = "macos")]; moving the mobile core tests to Linux silently dropped them. macos-native now runs binary_id(zeron-text::coretext). - run-macos-fixture.sh no longer writes /etc/hosts: preview hostnames are meant to resolve through WebKit's per-domain proxy, so a DNS fallback could let the fixture pass with that path broken. Retries and warnings stay. - nextest download: https-only, TLS 1.2+, retries, extract only cargo-nextest. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/macos.yml | 15 ++++++++------- scripts/ci/run-macos-fixture.sh | 23 +++++------------------ 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 16c74e478f..fd5b513090 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -97,14 +97,14 @@ jobs: # Pinned release, verified against a checksum. version=0.9.146 sha256=39785160b3c2f6ed9a765049cf4fa79f3b39aa02eb7598a5a0e2a1a0b9ffb9a8 - curl -fsSL -o "$RUNNER_TEMP/nextest.tar.gz" \ + curl --proto '=https' --tlsv1.2 -fsSL --retry 5 -o "$RUNNER_TEMP/nextest.tar.gz" \ "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-$version/cargo-nextest-$version-universal-apple-darwin.tar.gz" echo "$sha256 $RUNNER_TEMP/nextest.tar.gz" | shasum -a 256 -c - - tar -xzf "$RUNNER_TEMP/nextest.tar.gz" -C "$HOME/.cargo/bin" + tar -xzf "$RUNNER_TEMP/nextest.tar.gz" -C "$HOME/.cargo/bin" cargo-nextest - name: Build tests and fixtures run: | cargo nextest run --no-run --locked \ - -p zeron-update -p zeron-preview -p zeron-ui \ + -p zeron-update -p zeron-preview -p zeron-ui -p zeron-text \ --features zeron-ui/browser-fixture --lib --tests cargo build --locked -p zeron-update -p zeron-preview -p zeron-ui \ --features zeron-ui/browser-fixture --example browser-fixture --example preview-fixture @@ -113,12 +113,14 @@ jobs: # Match the old `cargo check --release`: no debug assertions. CARGO_PROFILE_DEV_DEBUG_ASSERTIONS: false run: cargo check --locked -p zeron - - name: Updater, preview and native UI regressions + - name: Updater, preview, CoreText and native UI regressions run: | + # zeron-text's coretext tests are macOS-only (#![cfg(target_os = "macos")]); its other + # tests run on Linux in ios-core. cargo nextest run --no-fail-fast --locked \ - -p zeron-update -p zeron-preview -p zeron-ui \ + -p zeron-update -p zeron-preview -p zeron-ui -p zeron-text \ --features zeron-ui/browser-fixture --lib --tests \ - -E 'package(zeron-update) | package(zeron-preview) | (binary_id(zeron-ui) & (test(native_diff_font_geometry) | test(/notify::tests::/)))' + -E 'package(zeron-update) | package(zeron-preview) | binary_id(zeron-text::coretext) | (binary_id(zeron-ui) & (test(native_diff_font_geometry) | test(/notify::tests::/)))' - name: Native browser regression and screenshots timeout-minutes: 7 env: @@ -143,7 +145,6 @@ jobs: timeout-minutes: 5 env: ZERON_PREVIEW_AUTO_OPEN: 1 - PREVIEW_HOSTS: 1 run: | VITE_BINARY="$GITHUB_WORKSPACE/edge/node_modules/vite/bin/vite.js" scripts/ci/run-macos-fixture.sh target/debug/examples/preview-fixture "$RUNNER_TEMP/preview-captures" test -f "$RUNNER_TEMP/preview-captures/result.txt" diff --git a/scripts/ci/run-macos-fixture.sh b/scripts/ci/run-macos-fixture.sh index 52b1ed2863..ce5c407449 100755 --- a/scripts/ci/run-macos-fixture.sh +++ b/scripts/ci/run-macos-fixture.sh @@ -4,10 +4,9 @@ # untouched; only the wrapper changes: # * bounded retries: each attempt has its own timeout (instead of one long hang) and a failed attempt # is reported with a ::warning:: so flakes stay visible. A real regression fails every attempt. -# * PREVIEW_HOSTS=1 (preview fixture only): WebKit sometimes fails to resolve *.localhost -# (NSURLErrorDomain -1003) although the preview proxy is listening; map the fixture hostnames to -# loopback in /etc/hosts so DNS can never be the reason. The fixture still goes through the same -# per-domain proxy path; this only removes the DNS race. +# The preview fixture deliberately gets no /etc/hosts entries: the app routes *.localhost preview +# hostnames through WebKit's per-domain proxy, and an NSURLErrorDomain -1003 means that path failed, +# which is what the fixture exists to catch. # usage: run-macos-fixture.sh set -uo pipefail ROOT="${ZERON_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}" @@ -16,16 +15,6 @@ OUT="$2" ATTEMPTS="${FIXTURE_ATTEMPTS:-3}" ATTEMPT_TIMEOUT="${FIXTURE_ATTEMPT_TIMEOUT:-70}" -if [ -n "${PREVIEW_HOSTS:-}" ]; then - # Same slug() as crates/preview/src/catalog.rs: device label = slug(hostname). - label=$(hostname | python3 -c 'import re,sys; s=re.sub(r"[^a-z0-9]+","-",sys.stdin.read().strip().lower()).strip("-")[:48].strip("-"); print(s or "project")') - for svc in fieldnotes fieldnotes-vite fieldnotes-api fieldnotes-node fieldnotes-web fieldnotes-server; do - echo "127.0.0.1 $label.$svc.localhost" - echo "::1 $label.$svc.localhost" - done | sudo tee -a /etc/hosts >/dev/null - echo "hosts entries added for device label '$label'" -fi - # macOS has no coreutils `timeout`; do it in bash. run_with_timeout() { local secs=$1; shift @@ -47,10 +36,8 @@ for attempt in $(seq 1 "$ATTEMPTS"); do exit 0 fi echo "::warning::$(basename "$BINARY") attempt $attempt failed" - if [ -n "${PREVIEW_HOSTS:-}" ]; then - dscacheutil -q host -a name "$label.fieldnotes.localhost" || true - lsof -nP -iTCP:7331 -sTCP:LISTEN || true - fi + # Is the preview proxy listening? (Nothing for the browser fixture.) + lsof -nP -iTCP:7331 -sTCP:LISTEN || true mkdir -p "$OUT.failed-$attempt" cp -R "$OUT"/. "$OUT.failed-$attempt"/ 2>/dev/null || true pkill -f 'vite/bin/vite.js' 2>/dev/null || true From a5c00e0b507a7f520bc40be4dd8e15dcedd2141e Mon Sep 17 00:00:00 2001 From: Wing Date: Thu, 1 Oct 2026 13:00:33 -0700 Subject: [PATCH 24/74] CI(macos): run the macOS jobs on macos-latest, as before macos-14 is two releases behind what macos-latest provides (macOS 26), and zeron-text's CoreText tokenizer check fails there (136/20000 differ, limit 20) while passing on macos-latest in main's runs. Keep the native, frame-source and iOS jobs on the image the old jobs used. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/macos.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index fd5b513090..23a0898b18 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -80,7 +80,7 @@ jobs: # Updater and preview tests, native UI regressions and the two native WebKit fixtures. Everything is # compiled once (one cargo invocation, so dependencies are built once) and then run. macos-native: - runs-on: macos-14 + runs-on: macos-latest timeout-minutes: 30 steps: - uses: actions/checkout@v4 @@ -158,7 +158,7 @@ jobs: # gpui_macos lives in the pinned GPUI checkout, a separate workspace with its own cache. macos-frame-source: - runs-on: macos-14 + runs-on: macos-latest timeout-minutes: 30 steps: - uses: actions/checkout@v4 @@ -225,7 +225,7 @@ jobs: ios-tests: needs: [changes, ios-core, macos-frame-source] if: needs.changes.outputs.ios == 'true' - runs-on: macos-15 + runs-on: macos-latest timeout-minutes: 30 steps: - uses: actions/checkout@v4 From 27480d99b9b75d31d01832248bec3f898bd64dcc Mon Sep 17 00:00:00 2001 From: Wing <44917547+wingleeio@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:29:41 -0700 Subject: [PATCH 25/74] OpenCode 2.x: fix instant "Run failed" (cold version probe), show start failures (#686) * OpenCode: show start failures and stop failing runs on an unknown version A run whose harness fails before streaming only reached the live journal, so the chat showed "Run failed" with no assistant entry and no reason. The engine now writes the error as an assistant entry. OpenCode runs (not discovery) probe `opencode --version` to pick the MCP config shape, and that probe's failure is cached per binary. One slow or unparseable probe made every later run fail at startup until restart. An unknown version now starts the server without the Zeron MCP block and logs a warning. Co-Authored-By: Claude Opus 5.5 * OpenCode: retry a cold version probe; own both password variables Root cause of the instant "Run failed" on OpenCode 2.x: runs probe `opencode --version` (2s cap) to pick the MCP config shape, and the shared probe caches a timeout for the binary's lifetime. A freshly upgraded binary's first exec waits on the OS malware scan (1.1s on an M-series Mac, more on slower machines or under Defender), so one slow probe failed every later run in ~100ms until restart. Reproduced against real 2.0.20 with a cold first probe. The OpenCode path now retries past the cached failure with a 30s budget and clears the stale cache entry on success, so the Zeron MCP server stays injected. Only if that also fails does it start without MCP. 2.x also reads OPENCODE_PASSWORD before OPENCODE_SERVER_PASSWORD, so a user's own value locked Zeron out of its server with a 401; both now carry ours. Start failures are logged. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/sessions.rs | 19 +++ crates/engine/tests/e2e.rs | 64 ++++++++++ crates/harness/src/executable.rs | 2 +- crates/harness/src/opencode/mod.rs | 192 +++++++++++++++++++++++++---- 4 files changed, 252 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index eaaf6dd8d4..9504e005b3 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -1892,6 +1892,25 @@ async fn drive_run( Ok(stream) => stream, Err(err) => { let message = err.to_string(); + tracing::warn!(chat = %chat_id, harness = ?harness_id, error = %message, "run failed to start"); + // The journal alone is live-only: without an entry the transcript + // shows "Run failed" with no reason (an OpenCode server that never + // booted looked exactly like that). + let parts = [MessagePart::Error { + id: "e0".into(), + message: message.clone(), + }]; + if let Err(err) = finish_segment( + &doc, + None, + &new_id(), + &device_id, + now_ms(), + &parts, + MessageStatus::Complete, + ) { + tracing::warn!(chat = %chat_id, error = %err, "start failure entry failed"); + } inner.publish( &chat_id, &AgentEvent::Error { diff --git a/crates/engine/tests/e2e.rs b/crates/engine/tests/e2e.rs index 693f58fbc1..18ffb0c5cc 100644 --- a/crates/engine/tests/e2e.rs +++ b/crates/engine/tests/e2e.rs @@ -3223,3 +3223,67 @@ async fn real_image_generation_profile_smoke() { ); core.sessions.shutdown().await; } + +/// A harness that fails before streaming (an OpenCode server that never +/// booted) must leave its reason in the transcript, not only a bare +/// "Run failed" status. +#[tokio::test(flavor = "multi_thread")] +async fn start_failure_lands_in_the_transcript() { + struct FailsToStart; + #[async_trait] + impl Harness for FailsToStart { + fn id(&self) -> HarnessId { + HarnessId::Mock + } + fn display_name(&self) -> &str { + "FailsToStart" + } + fn supports_steering(&self) -> bool { + false + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::TurnBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[] + } + async fn models(&self) -> Result, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + _request: RunRequest, + _controls: RunControls, + ) -> Result>, HarnessError> { + Err(HarnessError::Protocol("server never booted".into())) + } + } + + let dir = tempfile::tempdir().unwrap(); + let core = assemble(dir.path(), Arc::new(FailsToStart)); + let handle = core.doc_host.open(CHAT).unwrap(); + queue_as_viewer( + handle.doc(), + "cmd-run-fails", + SessionCommandPayload::Run { + request: run_request("hello"), + message_id: "m-1".into(), + }, + ); + wait_for( + || core.sessions.session_status(CHAT).map(|s| s.status) == Some(SessionStatus::Errored), + "errored", + ) + .await; + let entries = entries_now(&core); + let assistant = entries + .iter() + .find(|e| e.role == MessageRole::Assistant) + .expect("assistant entry for the failed start"); + assert_eq!(assistant.status, Some(MessageStatus::Complete)); + assert!(matches!( + assistant.parts.as_slice(), + [MessagePart::Error { message, .. }] if message.contains("server never booted") + )); + core.sessions.shutdown().await; +} diff --git a/crates/harness/src/executable.rs b/crates/harness/src/executable.rs index 4971f05541..a4c672d8ad 100644 --- a/crates/harness/src/executable.rs +++ b/crates/harness/src/executable.rs @@ -276,7 +276,7 @@ pub fn binary_version(path: &Path) -> Option { version } -fn parse_version(bytes: &[u8]) -> Option { +pub(crate) fn parse_version(bytes: &[u8]) -> Option { String::from_utf8_lossy(bytes) .split_whitespace() .find_map(|word| { diff --git a/crates/harness/src/opencode/mod.rs b/crates/harness/src/opencode/mod.rs index 252f6da516..a288cbe664 100644 --- a/crates/harness/src/opencode/mod.rs +++ b/crates/harness/src/opencode/mod.rs @@ -605,33 +605,35 @@ impl Server { .arg(port.to_string()) .arg("--hostname") .arg("127.0.0.1") + // 2.x prefers OPENCODE_PASSWORD over the legacy name; a user's + // own value would otherwise lock us out of our server (401). + .env("OPENCODE_PASSWORD", &password) .env("OPENCODE_SERVER_PASSWORD", &password) .env("OPENCODE_CLIENT", "zeron"); if let Some(mcp) = mcp { - let version_exe = exe.to_path_buf(); - let version = tokio::task::spawn_blocking(move || { - crate::executable::binary_version(&version_exe) - }) - .await - .map_err(|e| HarnessError::Protocol(format!("opencode version probe: {e}")))? - .ok_or_else(|| { - HarnessError::Protocol( - "cannot determine opencode version for MCP configuration".into(), - ) - })?; - let protocol = if version.major >= 2 { - Protocol::V2 - } else { - Protocol::V1 - }; - cmd.env( - "OPENCODE_CONFIG_CONTENT", - mcp_config( - std::env::var("OPENCODE_CONFIG_CONTENT").ok().as_deref(), - mcp, - protocol, - )?, - ); + // The config shape differs by generation. If even the cold probe + // can't tell, run without the Zeron MCP server rather than fail. + match opencode_version(exe).await { + Some(version) => { + let protocol = if version.major >= 2 { + Protocol::V2 + } else { + Protocol::V1 + }; + cmd.env( + "OPENCODE_CONFIG_CONTENT", + mcp_config( + std::env::var("OPENCODE_CONFIG_CONTENT").ok().as_deref(), + mcp, + protocol, + )?, + ); + } + None => tracing::warn!( + binary_path = %exe.display(), + "opencode version unknown; starting without the Zeron MCP server" + ), + } } crate::compose_child_path(&mut cmd, exe); if let Some(cwd) = cwd { @@ -4234,6 +4236,46 @@ mod context_tests { } } +/// Budget for a `--version` the shared probe gave up on: a freshly installed +/// binary's first exec waits on the OS malware scan (1.1s on an M-series Mac +/// for 2.0.20, longer on slower machines and under Windows Defender). +const COLD_VERSION_TIMEOUT: Duration = Duration::from_secs(30); + +/// The shared probe caps `--version` at 2s and caches a timeout for the +/// binary's lifetime, so one cold first exec after an upgrade failed every +/// later run instantly until restart. Retry past that cache with a longer +/// budget, and on success clear the cached failure for other callers. +async fn opencode_version(exe: &std::path::Path) -> Option { + let cached_exe = exe.to_path_buf(); + let cached = + tokio::task::spawn_blocking(move || crate::executable::binary_version(&cached_exe)) + .await + .ok() + .flatten(); + if cached.is_some() { + return cached; + } + let mut cmd = Command::new(exe); + cmd.arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let output = tokio::time::timeout(COLD_VERSION_TIMEOUT, cmd.output()) + .await + .ok()? + .ok()?; + if !output.status.success() { + return None; + } + let version = crate::executable::parse_version(&output.stdout) + .or_else(|| crate::executable::parse_version(&output.stderr))?; + if let Some(stem) = exe.file_stem().and_then(|s| s.to_str()) { + crate::executable::invalidate_versions(&[stem]); + } + Some(version) +} + /// Inline config is the final user config layer. Preserve inherited overrides /// and other MCP servers; never write chat identity into a shared config file. fn mcp_config( @@ -4346,6 +4388,108 @@ http.createServer((req, res) => {{ } } + #[cfg(unix)] + #[tokio::test] + async fn unknown_version_starts_without_mcp_instead_of_failing() { + use std::os::unix::fs::PermissionsExt; + let fixture = tempfile::tempdir().unwrap(); + let exe = fixture.path().join("opencode"); + // `--version` fails (a probe that timed out or printed no version). + let script = r#"#!/usr/bin/env node +const http = require('node:http'); +if (process.argv.includes('--version')) process.exit(1); +const config = process.env.OPENCODE_CONFIG_CONTENT ?? null; +const port = Number(process.argv[process.argv.indexOf('--port') + 1]); +http.createServer((req, res) => { + res.setHeader('content-type', 'application/json'); + if (req.url === '/config-probe') { res.end(JSON.stringify({config})); return; } + if (req.url === '/api/info') { res.end(JSON.stringify({version: '2.0.20'})); return; } + res.statusCode = 404; res.end('{}'); +}).listen(port, '127.0.0.1'); +"#; + std::fs::write(&exe, script).unwrap(); + std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o700)).unwrap(); + let mcp = zeron_proto::McpServer { + name: "zeron".into(), + command: "zeron".into(), + args: vec!["mcp".into()], + env: Default::default(), + }; + let mut server = Server::spawn( + &exe, + fixture.path().to_str(), + Duration::from_secs(5), + Some(&mcp), + ) + .await + .expect("an unknown version must not fail the run"); + let probe = server.get_json("/config-probe", None).await.unwrap(); + server.shutdown(Duration::from_millis(100)).await; + assert_eq!(probe["config"], Value::Null); + } + + /// A freshly installed binary's first `--version` outlasts the shared + /// 2s probe (OS malware scan). That cached failure used to fail every + /// run; the cold retry must still find 2.x and inject the MCP config. + #[cfg(unix)] + #[tokio::test] + async fn cold_first_version_probe_still_injects_mcp() { + use std::os::unix::fs::PermissionsExt; + let fixture = tempfile::tempdir().unwrap(); + let exe = fixture.path().join("opencode"); + let warmed = fixture.path().join("warmed"); + let script = format!( + r#"#!/usr/bin/env node +const fs = require('node:fs'); +const http = require('node:http'); +if (process.argv.includes('--version')) {{ + const cold = !fs.existsSync({warmed:?}); + fs.writeFileSync({warmed:?}, ''); + setTimeout(() => {{ console.log('opencode v2.0.20'); process.exit(0); }}, cold ? 3000 : 0); +}} else {{ + const config = JSON.parse(process.env.OPENCODE_CONFIG_CONTENT); + const auth = {{ + password: process.env.OPENCODE_PASSWORD, + legacy: process.env.OPENCODE_SERVER_PASSWORD, + }}; + const port = Number(process.argv[process.argv.indexOf('--port') + 1]); + http.createServer((req, res) => {{ + res.setHeader('content-type', 'application/json'); + if (req.url === '/config-probe') {{ + res.end(JSON.stringify({{server: config.mcp.servers.zeron, auth}})); return; + }} + if (req.url === '/api/info') {{ res.end(JSON.stringify({{version: '2.0.20'}})); return; }} + res.statusCode = 404; res.end('{{}}'); + }}).listen(port, '127.0.0.1'); +}} +"# + ); + std::fs::write(&exe, script).unwrap(); + std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o700)).unwrap(); + let mcp = zeron_proto::McpServer { + name: "zeron".into(), + command: "zeron".into(), + args: vec!["mcp".into()], + env: Default::default(), + }; + for _ in 0..2 { + let mut server = Server::spawn( + &exe, + fixture.path().to_str(), + Duration::from_secs(10), + Some(&mcp), + ) + .await + .expect("a slow first --version must not fail the run"); + let probe = server.get_json("/config-probe", None).await.unwrap(); + server.shutdown(Duration::from_millis(100)).await; + assert_eq!(probe["server"]["command"], json!(["zeron", "mcp"])); + // 2.x reads OPENCODE_PASSWORD first: both must carry ours. + assert!(probe["auth"]["password"].is_string()); + assert_eq!(probe["auth"]["password"], probe["auth"]["legacy"]); + } + } + #[test] fn mcp_injection_preserves_config_and_scopes_identity_for_both_protocols() { let mut mcp = zeron_proto::McpServer { From 80b946b153e431f8e3fdd7a99faf81b23c77706c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Cado?= <130941423+gaelcado@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:16:51 +0200 Subject: [PATCH 26/74] Add opt-in on-device dictation to the desktop composer (#591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(voice): add local Parakeet v3 capture and transcription backend * feat(ui): add opt-in dictation settings and composer controls * build(macos): package microphone permissions and voice attribution * docs: describe desktop dictation behavior and runtime limits * fix(ui): clarify dictation states and cancellation controls * fix(ui): keep composer model controls right aligned * feat(ui): give Voice a dedicated settings page * fix(voice): support all native microphone sample formats * fix(voice): retain model cleanup after interrupted downloads * fix(ui): preserve dictation while pasted references resolve * fix(ui): cancel dictation with Escape across composer controls * fix(ui): refresh dictation state after empty undo and redo * fix(ui): preserve dictated words on blank recognition results * fix(voice): clarify retries while native inference finishes * test(ui): verify repeated Send preserves unfinished dictation * style(ui): format settings modules * feat(voice): add microphone selection and streamline Voice settings * feat(ui): visualize live dictation with a waveform and recording clock * fix(ui): preserve waveform paint opacity * feat(ui): morph composer controls into dictation track * test(voice): exercise real inference on every desktop release target * fix(voice): keep device choices fresh and cancellation accessible * ci(voice): verify Linux release packages on x86 and ARM * ci(voice): keep model and package probes out of permanent CI * fix(voice): detect missing ALSA before activating Linux installs * fix(voice): capture audio while Parakeet loads Run capture independently of serialized model loading and inference. Retain stopped audio until the model is ready, close capture before reporting load failure, and hold admission through native work and stream teardown. Cover loading, cancellation, device startup, rapid Stop, admission, and recording limits with deterministic coordinator tests. * test(ui): verify and measure nonblocking dictation startup Cover selected-draft preservation, delayed final results, single Send delivery, and late results after loading failures or finalization timeouts. Add transcript-free debug timing from dictation activation to the Listening state and clarify asynchronous capture cancellation. * docs(voice): explain concurrent capture and startup timing Document audio retention during loading, cancellation and model-removal limits, race coverage, and the native startup timing procedure. * fix(ui): stop Shell saves from reverting settings chosen elsewhere The Shell republished its whole cached UiSettings on every save (navigation, pane resize) and only refreshed a hand-kept list of fields first. Anything missing from that list was silently reverted: leaving Settings → Voice turned dictation off, and the microphone choice, compact transcript, fit-content code fences, and diff split/wrap toggles had the same bug. Replace the list with a three-way merge against the store snapshot the Shell last synced: fields the Shell changed win, every other field keeps the store's value. The merge destructures UiSettings exhaustively, so a new field fails to compile until it is merged too. Co-Authored-By: Claude Opus 5.5 * feat(voice): hold to talk, default Cmd+D, shortcut on the Voice page Dictation is now hold to talk. Holding the microphone (pointer, or Enter/Space while it is focused) or the shortcut records; releasing transcribes. A pointer release outside the button still counts. For the shortcut, letting go of its key or any of its modifiers ends the hold, because macOS does not deliver key-up for Command chords. A release within 300 ms explains hold to talk instead of transcribing a blip, and a release that only answered the permission prompt records nothing. Assistive-technology Click still starts and then finishes, since it cannot hold. The default shortcut is Cmd/Ctrl+D (unbound elsewhere). Its rebinding row moves from Settings → Shortcuts to Settings → Voice through a reusable ShortcutField that shares the Shortcuts page's chip and conflict rules. Settings → Shortcuts re-reads the stored keymap while not recording, so its own commits cannot revert a binding changed on the Voice page. Co-Authored-By: Claude Opus 5.5 * fix(voice): harden capture, worker and download against edge cases - Keep the capture flags outside the sample lock. The capture loop polled them under the mutex every 10 ms, so the real-time callback's try_lock could fail and drop a buffer of speech. - Catch a native panic on the worker. A panicking job killed the only worker thread, leaving every later session "unavailable" until restart. - Keep loaded weights when a session fails for a microphone or recording reason; dropping them made the next attempt reload 670 MB. - Time out a download request whose response headers never arrive. Co-Authored-By: Claude Opus 5.5 * fix(ui): dictation review fixes for focus, shortcuts and Windows - Enter/Space hold on the focused microphone no longer moves focus to the editor, which sent its key-up (and auto-repeat spaces, or Enter-to-send) to the draft and left the microphone live. - A key release caused by focus leaving no longer pulls focus back, and an unparseable stored shortcut falls back to the default when matching its release, as apply_keymap already does. - Another source can take over a hold whose release went missing, so the microphone or shortcut can always end it. - Dictation cannot start while a queued row is acquiring its edit lease, which would replace the draft and silently drop the transcript. - With dictation off (the default) ⌘D propagates to any other binding of the chord instead of being swallowed in the composer. - The Voice page's shortcut row is always shown, conflicts with a feature page's shortcut name that page, and recording stops if the window closes so Appshots' capture shortcut is never left suspended. - Show Settings → Voice on Windows; the PR ships the Windows backend and only Appshots is macOS/Linux-only. Co-Authored-By: Claude Opus 5.5 * fix(ui): give the composer microphone its siblings' hover At rest the microphone snapped to a different surface colour on hover; it now uses the paperclip's eased ink wash. While live it dims like Send, and its focus ring and tooltip match the neighbouring composer buttons. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Wing Co-authored-by: Claude Opus 5.5 --- ARCHITECTURE.md | 1 + Cargo.lock | 752 ++++++++++++- Cargo.toml | 1 + README.md | 2 +- THIRD_PARTY_NOTICES.md | 6 + crates/ui/Cargo.toml | 1 + crates/ui/assets/icons/microphone.svg | 1 + crates/ui/build.rs | 26 + crates/ui/src/composer.rs | 1195 +++++++++++++++++++- crates/ui/src/composer_dictation_tests.rs | 1199 +++++++++++++++++++++ crates/ui/src/dictation.rs | 323 ++++++ crates/ui/src/dictation/glass.rs | 89 ++ crates/ui/src/dictation/meter.rs | 247 +++++ crates/ui/src/dictation/model.rs | 560 ++++++++++ crates/ui/src/dictation/permission.m | 17 + crates/ui/src/dictation/waveform.rs | 151 +++ crates/ui/src/icons.rs | 1 + crates/ui/src/lib.rs | 1 + crates/ui/src/pickers.rs | 2 +- crates/ui/src/queue.rs | 9 + crates/ui/src/settings.rs | 120 ++- crates/ui/src/settings/accounts.rs | 17 +- crates/ui/src/settings/appearance.rs | 4 +- crates/ui/src/settings/devices.rs | 12 +- crates/ui/src/settings/shortcuts.rs | 539 ++++++--- crates/ui/src/settings/thread_naming.rs | 13 +- crates/ui/src/settings/widgets.rs | 6 +- crates/ui/src/shell.rs | 140 ++- crates/ui/src/shell/actions_ui.rs | 10 +- crates/voice/Cargo.toml | 21 + crates/voice/NOTICE.md | 17 + crates/voice/examples/verify.rs | 30 + crates/voice/model.json | 23 + crates/voice/src/lib.rs | 602 +++++++++++ crates/voice/src/resample.rs | 81 ++ crates/voice/src/session_tests.rs | 298 +++++ dist/macos/Dictation.entitlements | 3 + dist/macos/Info-dev.plist | 2 + dist/macos/Info.plist | 2 + docs/design/desktop-parakeet-v3.md | 17 + docs/reference/desktop-dictation.md | 35 + edge/src/install.sh | 10 +- scripts/package-linux.sh | 5 + scripts/package-macos.sh | 5 +- scripts/package-windows.ps1 | 1 + scripts/run-macos-dev.sh | 14 +- 46 files changed, 6376 insertions(+), 235 deletions(-) create mode 100644 crates/ui/assets/icons/microphone.svg create mode 100644 crates/ui/src/composer_dictation_tests.rs create mode 100644 crates/ui/src/dictation.rs create mode 100644 crates/ui/src/dictation/glass.rs create mode 100644 crates/ui/src/dictation/meter.rs create mode 100644 crates/ui/src/dictation/model.rs create mode 100644 crates/ui/src/dictation/permission.m create mode 100644 crates/ui/src/dictation/waveform.rs create mode 100644 crates/voice/Cargo.toml create mode 100644 crates/voice/NOTICE.md create mode 100644 crates/voice/examples/verify.rs create mode 100644 crates/voice/model.json create mode 100644 crates/voice/src/lib.rs create mode 100644 crates/voice/src/resample.rs create mode 100644 crates/voice/src/session_tests.rs create mode 100644 dist/macos/Dictation.entitlements create mode 100644 docs/design/desktop-parakeet-v3.md create mode 100644 docs/reference/desktop-dictation.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 417de516c5..e1fa1c6223 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -152,6 +152,7 @@ zeron/ # sockets ({s,k,to,from} frames) theme/ zeron-theme # source-neutral theme schema + built-in/custom registry, # validation, provenance, and local VS Code compiler + voice/ zeron-voice # desktop-local optional Parakeet model, capture and inference; no RPC/sync ui/ zeron-ui # gpui app: shell, sidebar, conversation, composer, # terminal view, diff pane, settings, animation kit apps/ diff --git a/Cargo.lock b/Cargo.lock index e02ae30400..8cdd5d1839 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,6 +163,7 @@ dependencies = [ "const-random", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -225,6 +226,28 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" +dependencies = [ + "alsa-sys", + "bitflags 2.13.1", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android_system_properties" version = "0.1.6" @@ -827,7 +850,7 @@ dependencies = [ "log", "num-rational", "num-traits", - "pastey", + "pastey 0.1.1", "rayon", "thiserror 2.0.20", "v_frame", @@ -878,6 +901,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -1243,6 +1272,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1498,7 +1536,7 @@ dependencies = [ "cocoa-foundation 0.1.2", "core-foundation 0.9.4", "core-graphics 0.23.2", - "foreign-types", + "foreign-types 0.5.0", "libc", "objc", ] @@ -1514,7 +1552,7 @@ dependencies = [ "cocoa-foundation 0.2.0", "core-foundation 0.10.1", "core-graphics 0.24.0", - "foreign-types", + "foreign-types 0.5.0", "libc", "objc", ] @@ -1590,6 +1628,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.38" @@ -1716,7 +1769,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1729,7 +1782,7 @@ dependencies = [ "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types 0.2.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1742,7 +1795,7 @@ dependencies = [ "bitflags 2.13.1", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1789,7 +1842,7 @@ checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1816,6 +1869,20 @@ dependencies = [ "libm", ] +[[package]] +name = "coreaudio-rs" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" +dependencies = [ + "bitflags 2.13.1", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + [[package]] name = "cosmic-text" version = "0.19.0" @@ -1840,6 +1907,36 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpal" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" +dependencies = [ + "alsa", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.4", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -2079,6 +2176,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "daachorse" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5614204febbc33cc07a2806aa6440b904ac012b68eecc37f4493ea4a76455a3d" + [[package]] name = "darling" version = "0.20.11" @@ -2114,6 +2217,21 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "data-encoding" version = "2.11.1" @@ -2133,7 +2251,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", - "pem-rfc7468", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "pem-rfc7468 1.0.0", "zeroize", ] @@ -2182,6 +2310,37 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -2382,7 +2541,7 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest", "elliptic-curve", "rfc6979", @@ -2409,7 +2568,7 @@ dependencies = [ "generic-array", "group", "hkdf", - "pem-rfc7468", + "pem-rfc7468 0.7.0", "pkcs8", "rand_core 0.6.4", "sec1", @@ -2579,6 +2738,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "etagere" version = "0.2.15" @@ -2635,6 +2800,17 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "eyre" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3" +dependencies = [ + "autocfg", + "indenter", + "once_cell", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -2839,6 +3015,15 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -2846,7 +3031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -2860,6 +3045,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -3416,7 +3607,7 @@ dependencies = [ "derive_more", "embed-resource", "etagere", - "foreign-types", + "foreign-types 0.5.0", "futures", "futures-concurrency", "getrandom 0.3.4", @@ -3579,7 +3770,7 @@ dependencies = [ "derive_more", "dispatch2", "etagere", - "foreign-types", + "foreign-types 0.5.0", "futures", "gpui", "gpui_util", @@ -3993,6 +4184,12 @@ dependencies = [ "digest", ] +[[package]] +name = "hmac-sha256" +version = "1.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad320b3b96fb2a455a0726d16efe0a5afdbd34b71dea5bc53b05ea057714d4e" + [[package]] name = "home" version = "0.5.12" @@ -4002,6 +4199,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "html5ever" version = "0.39.0" @@ -4394,6 +4597,12 @@ version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "2.14.0" @@ -5140,6 +5349,12 @@ dependencies = [ "twox-hash", ] +[[package]] +name = "lzma-rust2" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" + [[package]] name = "mac-notification-sys" version = "0.6.15" @@ -5163,6 +5378,22 @@ dependencies = [ "libc", ] +[[package]] +name = "macro_rules_attribute" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +dependencies = [ + "macro_rules_attribute-proc_macro", + "pastey 0.2.3", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" + [[package]] name = "malloc_buf" version = "0.0.6" @@ -5192,6 +5423,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -5228,7 +5469,7 @@ dependencies = [ "core-foundation 0.10.1", "core-video", "ctor", - "foreign-types", + "foreign-types 0.5.0", "metal", "objc", ] @@ -5292,7 +5533,7 @@ dependencies = [ "bitflags 2.13.1", "block", "core-graphics-types 0.2.0", - "foreign-types", + "foreign-types 0.5.0", "log", "objc", "paste", @@ -5344,6 +5585,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -5400,6 +5663,38 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -5415,6 +5710,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -5791,6 +6092,31 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.13.1", + "libc", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-cloud-kit" version = "0.3.2" @@ -5802,6 +6128,29 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", +] + [[package]] name = "objc2-core-data" version = "0.2.2" @@ -5832,7 +6181,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.1", + "block2 0.6.2", "dispatch2", + "libc", "objc2 0.6.4", ] @@ -6135,6 +6486,28 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags 2.13.1", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "oo7" version = "0.6.0" @@ -6192,6 +6565,49 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -6217,6 +6633,30 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ort" +version = "2.0.0-rc.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336a1e2b38848325241c72889086886004e589b7c74f335e60a8e8db5138a0b" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", + "ureq", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf211e3776eea6aec988552fa118dd746d70e1b1e5e244058d1c98015f3e5872" +dependencies = [ + "hmac-sha256", + "lzma-rust2", + "ureq", +] + [[package]] name = "p256" version = "0.13.2" @@ -6266,6 +6706,22 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parakeet-rs" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f54545a861dca6b43c8d44e3dd932764ca196d84bcf0d1b48ea212b43b29666" +dependencies = [ + "eyre", + "hound", + "ndarray", + "ort", + "realfft", + "serde", + "serde_json", + "tokenizers", +] + [[package]] name = "parking" version = "2.2.1" @@ -6307,6 +6763,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pathfinder_geometry" version = "0.5.1" @@ -6355,6 +6817,15 @@ dependencies = [ "base64ct", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -6521,7 +6992,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", + "der 0.7.10", "spki", ] @@ -6786,6 +7257,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -7338,6 +7818,12 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.12.0" @@ -7348,6 +7834,17 @@ dependencies = [ "rayon-core", ] +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools 0.14.0", + "rayon", +] + [[package]] name = "rayon-core" version = "1.13.0" @@ -7394,6 +7891,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + [[package]] name = "reborrow" version = "0.5.5" @@ -7913,6 +8419,18 @@ dependencies = [ "sansio", ] +[[package]] +name = "rubato" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5258099699851cfd0082aeb645feb9c084d9a5e1f1b8d5372086b989fc5e56a1" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "realfft", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -7988,6 +8506,20 @@ dependencies = [ "semver", ] +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -8126,6 +8658,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c62751faa8bc286982334a082fe125184a29fc89d17775766e4f891b7d726980" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scheduler" version = "0.1.0" @@ -8235,13 +8776,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der", + "der 0.7.10", "generic-array", "pkcs8", "subtle", "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.38.0" @@ -8715,6 +9279,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "soup3" version = "0.5.0" @@ -8775,7 +9350,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", ] [[package]] @@ -8836,6 +9423,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "strict-num" version = "0.1.1" @@ -9403,6 +9996,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7afbf6e88718afcc138bad01d6ccc3051dbbc3b2ce9793d8b8a3aeb610969cfc" +dependencies = [ + "ahash", + "compact_str", + "daachorse", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "itertools 0.14.0", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.5", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.20", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.53.1" @@ -9692,6 +10318,16 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "tree-sitter" version = "0.26.11" @@ -10092,6 +10728,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -10128,6 +10773,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "uniffi" version = "0.32.2" @@ -10271,6 +10922,36 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "der 0.8.2", + "log", + "native-tls", + "percent-encoding", + "rustls-pki-types", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-root-certs", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -10316,6 +10997,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -10759,6 +11446,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -12432,7 +13128,7 @@ dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", "fontdb", - "foreign-types", + "foreign-types 0.5.0", "futures", "gpui", "gpui-base", @@ -12480,6 +13176,7 @@ dependencies = [ "zeron-syntax", "zeron-theme", "zeron-update", + "zeron-voice", ] [[package]] @@ -12498,6 +13195,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "zeron-voice" +version = "0.2.101" +dependencies = [ + "anyhow", + "cpal", + "hound", + "parakeet-rs", + "reqwest", + "rubato", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", +] + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index e0ba7ab3c9..5532ceaee7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "2" members = [ + "crates/voice", "crates/theme", "crates/proto", "crates/preview", diff --git a/README.md b/README.md index 1e4476709e..8ff826fcfd 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ curl -fsSL https://zeron.sh/install.sh | sh zeron status ``` -The installer starts the daemon immediately and keeps it running across reboots. No sign-in or sync configuration is required. It also adds Zeron to your application launcher: a per-user `zeron.desktop` and icon under `~/.local/share` (or `$XDG_DATA_HOME`), rewritten each time the installer runs. +The installer starts the daemon immediately and keeps it running across reboots. No sign-in or sync configuration is required. It also adds Zeron to your application launcher: a per-user `zeron.desktop` and icon under `~/.local/share` (or `$XDG_DATA_HOME`), rewritten each time the installer runs. Linux requires the system ALSA runtime (`libasound.so.2`), including for headless mode because it shares the desktop executable. The installer checks that the binary starts before activating it and reports missing runtime libraries. The desktop sidebar browser also needs the [Linux browser runtime](docs/reference/linux-browser.md). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b12292308a..886febec29 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -141,3 +141,9 @@ Zeron code. The Zui native overlay renderer adapts Apache-2.0 GPUI code from [`egoist/zed` at `57bd4fe`](https://github.com/egoist/zed/tree/57bd4fe181639797d395978d5de17bc9e10a6219/crates/gpui_macos). Attribution is retained in the pinned Zui dependency’s `NOTICE`. + +## Optional desktop dictation + +Parakeet TDT 0.6B v3 model weights are by NVIDIA under CC BY 4.0. The optional download uses Ivan Stupakov's INT8 ONNX conversion, pinned to revision `8f23f0c03c8761650bdb5b40aaf3e40d2c15f1ce`, with per-file SHA-256 verification. Original: https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3. Conversion: https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx. License: https://creativecommons.org/licenses/by/4.0/. + +Native runtime dependencies: parakeet-rs 0.3.8 (MIT OR Apache-2.0), ort/ort-sys 2.0.0-rc.13 (MIT OR Apache-2.0), ONNX Runtime 1.28.0 (MIT), cpal 0.17.3 (Apache-2.0), and rubato 0.16.2 (MIT). See `crates/voice/NOTICE.md` for exact conversion provenance, changes, runtime/platform limitations and supported languages. Models are not included in the application bundle. diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 3d66f80f3d..d03039263a 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -47,6 +47,7 @@ name = "compact-picker-fixture" required-features = ["compact-picker-fixture"] [dependencies] +zeron-voice = { path = "../voice" } url = "2" zeron-proto.workspace = true similar.workspace = true diff --git a/crates/ui/assets/icons/microphone.svg b/crates/ui/assets/icons/microphone.svg new file mode 100644 index 0000000000..fb6b33de20 --- /dev/null +++ b/crates/ui/assets/icons/microphone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/ui/build.rs b/crates/ui/build.rs index ecf17357da..8f446fcdbe 100644 --- a/crates/ui/build.rs +++ b/crates/ui/build.rs @@ -1,6 +1,32 @@ use std::{env, path::PathBuf, process::Command}; fn main() { + if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") { + println!("cargo:rerun-if-changed=src/dictation/permission.m"); + let out = PathBuf::from(env::var_os("OUT_DIR").unwrap()); + let object = out.join("voice-permission.o"); + assert!( + Command::new("clang") + .args(["-fobjc-arc", "-c", "src/dictation/permission.m", "-o"]) + .arg(&object) + .status() + .unwrap() + .success() + ); + assert!( + Command::new("ar") + .arg("crus") + .arg(out.join("libvoice-permission.a")) + .arg(object) + .status() + .unwrap() + .success() + ); + println!("cargo:rustc-link-search=native={}", out.display()); + println!("cargo:rustc-link-lib=static=voice-permission"); + println!("cargo:rustc-link-lib=framework=AVFoundation"); + } + println!("cargo:rerun-if-changed=src/browser/linux/helper.c"); if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("linux") { return; diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 658db0e7df..29a6cd49ca 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -16,11 +16,11 @@ use std::time::{Duration, Instant}; use gpui::{ AnyTooltip, App, BorderStyle, Bounds, ClipboardEntry, ClipboardItem, Context, CursorStyle, DispatchPhase, ElementInputHandler, Entity, EntityInputHandler, EventEmitter, FocusHandle, - Focusable, GlobalElementId, KeyBinding, KeyDownEvent, LayoutId, MouseButton, MouseDownEvent, - MouseMoveEvent, MouseUpEvent, ObjectFit, PaintQuad, PathPromptOptions, Pixels, Point, Role, - ScrollWheelEvent, SharedString, Style, StyledImage as _, Subscription, Task, TextRun, - TextStyle, UTF16Selection, UnderlineStyle, Window, WrappedLine, actions, div, fill, img, point, - prelude::*, px, quad, relative, size, + Focusable, GlobalElementId, KeyBinding, KeyDownEvent, KeyUpEvent, Keystroke, LayoutId, + ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ObjectFit, + PaintQuad, PathPromptOptions, Pixels, Point, Role, ScrollWheelEvent, SharedString, Style, + StyledImage as _, Subscription, Task, TextRun, TextStyle, UTF16Selection, UnderlineStyle, + Window, WrappedLine, actions, div, fill, img, point, prelude::*, px, quad, relative, size, }; use unicode_segmentation::UnicodeSegmentation; @@ -844,6 +844,7 @@ actions!( Undo, Redo, MentionTab, + ToggleDictation, OutdentList, ] ); @@ -1695,6 +1696,104 @@ pub enum ComposerInputEvent { }, } +#[derive(Clone)] +enum DictationInputEvent { + /// The dictation shortcut went down (auto-repeat is ignored)… + Press, + /// …and came back up, or focus left the editor while it was held. + Release, + Changed, + Submit(u64), +} + +/// A held dictation shortcut. `keystroke` is the binding, used to recognise +/// its release: the key coming up, or any of its modifiers being let go +/// (macOS does not deliver key-up for Command chords). +struct DictationKey { + keystroke: Option, + _blur: Subscription, +} + +/// Dictation is hold to talk, from the microphone (pointer or Enter/Space +/// while it is focused) and from the shortcut. Each releases only its own hold. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HoldSource { + Pointer, + Button, + Key, +} + +struct DictationHold { + source: HoldSource, + /// When this hold started dictation; `None` when it took over a session + /// started another way (assistive Click), which it then only finishes. + started: Option, +} + +/// A release sooner than this is a click, not speech: explain hold to talk +/// instead of transcribing a fraction of a second of audio. +const DICTATION_TAP: Duration = Duration::from_millis(300); +impl EventEmitter for ComposerInput {} + +/// The composer's morph into and out of dictation, and the clock's warning +/// window before the recording limit. +const VOICE_MORPH: Duration = Duration::from_millis(420); +const VOICE_CURVE: motion::CubicBezier = motion::CubicBezier::new(0.2, 0.0, 0.0, 1.0); +const VOICE_LIMIT_WARNING: Duration = Duration::from_secs(10); +/// Height of the voice track and its gap to the Stop control. +const VOICE_TRACK_HEIGHT: f32 = 32.0; +const VOICE_TRACK_GAP: f32 = 8.0; + +/// Everything the voice track draws, captured from the dictation meter. +#[derive(Clone)] +struct VoiceFrame { + label: String, + mode: crate::dictation::waveform::Mode, + bars: Vec, + level: f32, + elapsed: Option, +} + +/// Interruptible 0↔1 progress of the dictation morph: a retarget starts from +/// the current value, so toggling mid-flight reverses without a jump. +#[derive(Clone, Copy)] +struct VoiceTween { + from: f32, + to: f32, + start: Instant, +} + +impl Default for VoiceTween { + fn default() -> Self { + Self { + from: 0.0, + to: 0.0, + start: Instant::now(), + } + } +} + +impl VoiceTween { + fn value(&self, now: Instant) -> f32 { + let raw = now.saturating_duration_since(self.start).as_secs_f32() + / (VOICE_MORPH.as_secs_f32() * motion::speed_scale()); + motion::lerp(self.from, self.to, VOICE_CURVE.eval(raw)) + } + + fn retarget(&mut self, to: f32, now: Instant, reduced: bool) { + if self.to == to { + return; + } + self.from = if reduced { to } else { self.value(now) }; + self.to = to; + self.start = now; + } + + fn settled(&self, now: Instant) -> bool { + self.value(now) == self.to + } +} + /// Shaping inputs excluding mutable viewport and selection geometry. #[derive(Clone, PartialEq)] struct InputLayoutKey { @@ -1802,6 +1901,11 @@ pub struct ComposerInput { blink_anchor: Instant, /// Half-period repaint driver, alive only while the input is focused. blink_task: Option>, + dictation: crate::dictation::Dictation, + transcriber: Option>, + dictation_task: Option>, + /// The dictation shortcut while it is held down. + dictation_key: Option, // -- undo history -- undo_stack: Vec, redo_stack: Vec, @@ -1890,6 +1994,10 @@ impl ComposerInput { display_is_placeholder: true, blink_anchor: Instant::now(), blink_task: None, + dictation: Default::default(), + transcriber: None, + dictation_task: None, + dictation_key: None, undo_stack: Vec::new(), redo_stack: Vec::new(), last_edit: None, @@ -2173,6 +2281,7 @@ impl ComposerInput { } pub fn set_text(&mut self, text: impl Into, cx: &mut Context) { + self.cancel_dictation(); self.invalidate_mention_tooltip(); self.edit_revision = self.edit_revision.wrapping_add(1); self.content = text.into(); @@ -2325,6 +2434,242 @@ impl ComposerInput { } } + pub(crate) fn cancel_dictation(&mut self) { + self.dictation.cancel(); + self.transcriber = None; // Drop signals capture cancellation and invalidates pending results. + self.dictation_task = None; + } + + fn begin_dictation( + &mut self, + transcriber: Box, + cx: &mut Context, + ) { + self.cancel_dictation(); + self.dictation.begin( + &self.content, + self.projection.normalize_range(self.selected_range.clone()), + ); + self.transcriber = Some(transcriber); + self.last_edit = None; + let generation = self.dictation.generation; + let started = Instant::now(); + self.dictation_task = Some(cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(40)) + .await; + if !this + .update(cx, |input, cx| { + let requesting = + input.dictation.phase == crate::dictation::Phase::Requesting; + let active = input.poll_dictation(generation, cx); + if requesting && input.dictation.phase == crate::dictation::Phase::Listening + { + tracing::debug!( + target: "zeron_ui::dictation", + activation_to_listening_ms = started.elapsed().as_millis(), + "Dictation capture ready" + ); + } + active + }) + .unwrap_or(false) + { + break; + } + } + })); + cx.emit(DictationInputEvent::Changed); + cx.notify(); + } + + pub(crate) fn finish_dictation(&mut self, send: bool, cx: &mut Context) -> bool { + if !self.dictation.phase.active() { + return false; + } + // Repeated clicks/Enter only mark one pending send and end audio once. + if self.dictation.finish(send, Instant::now()) { + if let Some(transcriber) = &mut self.transcriber { + transcriber.finish(); + } + } + cx.emit(DictationInputEvent::Changed); + cx.notify(); + true + } + + fn complete_dictation(&mut self, phase: crate::dictation::Phase, cx: &mut Context) { + let send = self.dictation.complete(phase); + self.transcriber = None; + self.last_edit = None; + cx.emit(DictationInputEvent::Changed); + if send { + cx.emit(DictationInputEvent::Submit(self.dictation.generation)); + } + cx.notify(); + } + + fn apply_dictation(&mut self, text: &str, cx: &mut Context) { + let before = (!self.dictation.has_partial && !text.is_empty()).then(|| self.snapshot()); + if let Some(cursor) = self.dictation.replace(&mut self.content, text) { + if let Some(before) = before { + self.undo_stack.push(before); + if self.undo_stack.len() > UNDO_LIMIT { + self.undo_stack.remove(0); + } + self.redo_stack.clear(); + } + // Moving the selection explicitly stops dictation (see move_to / + // select_to). The original selection collapses on the first result. + self.selected_range = cursor..cursor; + self.selection_reversed = false; + self.caret_affinity = CaretAffinity::Downstream; + self.preferred_column = None; + self.edit_revision = self.edit_revision.wrapping_add(1); + self.refresh_projection(); + self.invalidate_mention_tooltip(); + self.follow_cursor = true; + self.reset_blink(); + self.needs_measure = true; + cx.emit(ComposerInputEvent::Edited); + cx.notify(); + } + } + + fn poll_dictation(&mut self, generation: u64, cx: &mut Context) -> bool { + use crate::dictation::{Event, Phase}; + if generation != self.dictation.generation || !self.dictation.phase.active() { + return false; + } + while let Some(event) = self.transcriber.as_mut().and_then(|service| service.poll()) { + match event { + Event::Listening => { + if self.dictation.phase == Phase::Requesting { + self.dictation.phase = Phase::Listening; + self.dictation.meter.start(Instant::now()); + } + cx.emit(DictationInputEvent::Changed); + cx.notify(); + } + Event::Finalizing => { + self.finish_dictation(false, cx); + } + Event::Partial(text) => self.apply_dictation(&text, cx), + Event::Final(text) => { + if text.trim().is_empty() && !self.dictation.has_partial { + self.complete_dictation(Phase::NoSpeech, cx); + return false; + } + self.apply_dictation(&text, cx); + self.complete_dictation(Phase::Idle, cx); + return false; + } + Event::Denied(message) => self.complete_dictation(Phase::Denied(message), cx), + Event::Unavailable(message) => { + self.complete_dictation(Phase::Unavailable(message), cx) + } + Event::Failed(message) => self.complete_dictation(Phase::Failed(message), cx), + Event::Cancelled => { + self.cancel_dictation(); + cx.emit(DictationInputEvent::Changed); + cx.notify(); + } + } + if !self.dictation.phase.active() { + self.transcriber = None; + return false; + } + } + if self.dictation.phase == Phase::Listening + && let Some(service) = self.transcriber.as_mut() + { + let level = service.level(); + if self.dictation.meter.record(Instant::now(), level) { + // The composer drives smooth frames itself; this keeps the + // stepped reduced-motion waveform current. + cx.emit(DictationInputEvent::Changed); + } + } + if self.dictation.timed_out(Instant::now()) { + self.complete_dictation( + Phase::Failed( + "Dictation took too long. Your draft is safe. Wait a moment, then try a shorter recording.".into(), + ), + cx, + ); + return false; + } + true + } + + fn toggle_dictation_action( + &mut self, + _: &ToggleDictation, + window: &mut Window, + cx: &mut Context, + ) { + if self.dictation_key.is_some() { + return; // Auto-repeat while held. + } + if !crate::dictation::enabled(cx) && !self.dictation.phase.active() { + // Off by default: let another binding of the same chord run. + cx.propagate(); + return; + } + // Mirror `apply_keymap`, which binds the default for an unparseable combo. + let parse = |combo: &str| Keystroke::parse(&crate::settings::platform_combo(combo)).ok(); + let keystroke = parse(&crate::settings::current(cx).keymap.toggle_dictation) + .or_else(|| parse(crate::settings::ShortcutId::ToggleDictation.default_combo())); + let blur = cx.on_blur(&self.focus_handle, window, |input, _, cx| { + input.release_dictation_key(cx); + }); + self.dictation_key = Some(DictationKey { + keystroke, + _blur: blur, + }); + cx.emit(DictationInputEvent::Press); + } + + fn release_dictation_key(&mut self, cx: &mut Context) { + if self.dictation_key.take().is_some() { + cx.emit(DictationInputEvent::Release); + } + } + + fn on_dictation_key_up(&mut self, event: &KeyUpEvent, _: &mut Window, cx: &mut Context) { + let released = self.dictation_key.as_ref().is_some_and(|held| { + held.keystroke + .as_ref() + .is_none_or(|binding| binding.key.eq_ignore_ascii_case(&event.keystroke.key)) + }); + if released { + self.release_dictation_key(cx); + } + } + + fn on_dictation_modifiers( + &mut self, + event: &ModifiersChangedEvent, + _: &mut Window, + cx: &mut Context, + ) { + let released = self + .dictation_key + .as_ref() + .and_then(|held| held.keystroke.as_ref()) + .is_some_and(|binding| { + let (bound, now) = (&binding.modifiers, &event.modifiers); + (bound.platform && !now.platform) + || (bound.control && !now.control) + || (bound.alt && !now.alt) + || (bound.shift && !now.shift) + }); + if released { + self.release_dictation_key(cx); + } + } + // ---- undo history ---- fn snapshot(&self) -> EditSnapshot { @@ -2339,6 +2684,7 @@ impl ComposerInput { /// Called with the range about to be replaced, BEFORE the content changes, /// so the pushed snapshot is the pre-edit state. fn record_edit(&mut self, range: &Range, new_text: &str) { + self.cancel_dictation(); let kind = if new_text.is_empty() { EditKind::Delete } else { @@ -2385,6 +2731,7 @@ impl ComposerInput { } fn restore(&mut self, snapshot: EditSnapshot, cx: &mut Context) { + self.cancel_dictation(); self.invalidate_mention_tooltip(); self.edit_revision = self.edit_revision.wrapping_add(1); self.content = snapshot.content; @@ -2404,6 +2751,11 @@ impl ComposerInput { } fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context) { + if self.dictation.phase.active() { + cx.emit(DictationInputEvent::Changed); + cx.notify(); + } + self.cancel_dictation(); if self.read_only { return; } @@ -2415,6 +2767,11 @@ impl ComposerInput { } fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context) { + if self.dictation.phase.active() { + cx.emit(DictationInputEvent::Changed); + cx.notify(); + } + self.cancel_dictation(); if self.read_only { return; } @@ -2436,6 +2793,7 @@ impl ComposerInput { } fn move_to(&mut self, offset: usize, cx: &mut Context) { + self.cancel_dictation(); self.last_edit = None; let offset = self.projection.normalize_range(offset..offset).start; self.selected_range = offset..offset; @@ -2449,6 +2807,7 @@ impl ComposerInput { } fn select_to(&mut self, offset: usize, cx: &mut Context) { + self.cancel_dictation(); self.last_edit = None; self.extend_selection(offset, cx); } @@ -2883,6 +3242,9 @@ impl ComposerInput { cx: &mut Context, ) { if self.read_only + // A delayed paste lookup must not rewrite the range captured by + // dictation and make its final transcript fail the draft guard. + || self.dictation.phase.active() || self.edit_revision != revision || self.text() != original || self.marked_range.is_some() @@ -3038,6 +3400,13 @@ impl ComposerInput { } fn on_key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context) { + if event.keystroke.key == "escape" && self.dictation.phase.active() { + self.cancel_dictation(); + cx.emit(DictationInputEvent::Changed); + cx.notify(); + cx.stop_propagation(); + return; + } if escape_dismisses_completion(&event.keystroke.key, self.mention_open) { cx.emit(ComposerInputEvent::MentionDismiss); cx.stop_propagation(); @@ -3933,6 +4302,7 @@ impl EntityInputHandler for ComposerInput { if self.read_only { return; } + self.cancel_dictation(); let single_line_text; let new_text = if self.single_line { single_line_text = new_text.replace(['\r', '\n'], " "); @@ -3981,6 +4351,7 @@ impl EntityInputHandler for ComposerInput { if self.read_only { return; } + self.cancel_dictation(); let single_line_text; let new_text = if self.single_line { single_line_text = new_text.replace(['\r', '\n'], " "); @@ -4556,6 +4927,9 @@ impl Render for ComposerInput { .on_action(cx.listener(Self::message_newline_or_accept)) .on_action(cx.listener(Self::modified_submit)) .on_action(cx.listener(Self::submit)) + .on_action(cx.listener(Self::toggle_dictation_action)) + .on_key_up(cx.listener(Self::on_dictation_key_up)) + .on_modifiers_changed(cx.listener(Self::on_dictation_modifiers)) .on_action(cx.listener(Self::undo)) .on_action(cx.listener(Self::redo)) .on_key_down(cx.listener(Self::on_key_down)) @@ -5316,6 +5690,7 @@ pub struct Composer { pub(crate) queue_edit_renew_task: Option>, /// Focus once on mount, navigation, or after opening/closing a queue edit. pub(crate) focus_pending: bool, + dictation_hold: Option, /// Live drag over the queue panel: which row, and where it would land. pub(crate) queue_drag: Option, pub(crate) queue_scroll: gpui::ScrollHandle, @@ -5389,6 +5764,14 @@ pub struct Composer { side_chat: bool, _picker_focus: Subscription, _input_events: Subscription, + _dictation_events: Subscription, + dictation_activation: Option, + dictation_blur: Option, + dictation_focus: FocusHandle, + /// The last live frame of the voice pill, kept so it can settle away + /// after the transcript lands instead of vanishing. + voice_last: Option, + voice_tween: VoiceTween, } impl EventEmitter for Composer {} @@ -5449,8 +5832,11 @@ impl Composer { } pub fn new(state: Entity, cx: &mut Context) -> Self { - cx.on_release(|this, cx| this.release_queue_previews(cx)) - .detach(); + cx.on_release(|this, cx| { + this.input.update(cx, |input, _| input.cancel_dictation()); + this.release_queue_previews(cx); + }) + .detach(); let input = cx.new(|cx| { let mut input = ComposerInput::with_context("Do anything…", MESSAGE_COMPOSER_CONTEXT, cx); @@ -5519,9 +5905,28 @@ impl Composer { } }); cx.observe_global::(|this, cx| { + if !crate::settings::current(cx).dictation_enabled { + this.input.update(cx, |input, _| input.cancel_dictation()); + } this.on_input_edited(cx); }) .detach(); + let dictation_events = cx.subscribe(&input, |this: &mut Self, _, event, cx| match event { + DictationInputEvent::Press => this.press_dictation(HoldSource::Key, cx), + DictationInputEvent::Release => this.release_dictation(HoldSource::Key, cx), + DictationInputEvent::Changed => cx.notify(), + DictationInputEvent::Submit(generation) => { + if this.input.read(cx).dictation.generation == *generation + && composer_has_content( + this.input.read(cx).text(), + this.staged().len() + this.staged_appshots().len(), + this.staged_comments(cx).len(), + ) + { + this.on_submit(cx); + } + } + }); let current_key = state.read(cx).selected_chat.clone().unwrap_or_default(); let mut composer = Self { state, @@ -5568,6 +5973,7 @@ impl Composer { queue_edit_task: None, queue_edit_renew_task: None, focus_pending: true, + dictation_hold: None, queue_drag: None, queue_scroll: gpui::ScrollHandle::new(), queue_full_preview: None, @@ -5601,6 +6007,12 @@ impl Composer { side_chat: false, _picker_focus: picker_focus, _input_events: input_events, + _dictation_events: dictation_events, + dictation_activation: None, + dictation_blur: None, + dictation_focus: cx.focus_handle(), + voice_last: None, + voice_tween: VoiceTween::default(), }; // Dev knob: pre-stage attachments (drop/paste can't be synthesized on // a rig) — `ZERON_ATTACH=/path/a.png[,/path/b.png]`, and @@ -7374,6 +7786,7 @@ impl Composer { .as_ref() .is_some_and(|w| w.request_id == request_id); if !same { + self.input.update(cx, |input, _| input.cancel_dictation()); if self.wizard.is_none() { self.drafts.insert( self.current_key.clone(), @@ -7490,11 +7903,12 @@ impl Composer { if self.editing_queued.is_some() { return SendButtonMode::Send; } - let has_text = composer_has_content( - self.input.read(cx).text(), - self.staged().len() + self.staged_appshots().len(), - self.staged_comments(cx).len(), - ); + let has_text = self.input.read(cx).dictation.phase.active() + || composer_has_content( + self.input.read(cx).text(), + self.staged().len() + self.staged_appshots().len(), + self.staged_comments(cx).len(), + ); send_button_mode(self.run_live(cx), has_text) } @@ -7516,6 +7930,12 @@ impl Composer { } fn on_submit(&mut self, cx: &mut Context) { + if self + .input + .update(cx, |input, cx| input.finish_dictation(true, cx)) + { + return; + } if self.commit_queue_edit(cx) { return; } @@ -7563,6 +7983,12 @@ impl Composer { /// content. With a truly empty composer it instead activates the most /// recently queued row, and never turns an empty chord into Stop. fn on_modified_submit(&mut self, cx: &mut Context) { + if self + .input + .update(cx, |input, cx| input.finish_dictation(true, cx)) + { + return; + } if self.commit_queue_edit(cx) { return; } @@ -8799,6 +9225,512 @@ impl Composer { crate::frost::frosted(COMPOSER_RADIUS, crate::frost::MENU_BLUR, panel).into_any_element() } + /// `refocus` returns focus to the editor afterwards. Enter/Space on the + /// focused microphone keeps focus there so its key-up ends the hold. + fn toggle_dictation(&mut self, refocus: bool, cx: &mut Context) { + use crate::dictation::Phase; + if !crate::dictation::enabled(cx) && !self.input.read(cx).dictation.phase.active() { + return; + } + // A queue row still acquiring its edit lease is about to replace the + // draft, which would discard the dictation. + if self.wizard.is_some() + || self.queue_edit_finishing + || self.queue_edit_pending_id.is_some() + || self.sending + || self.input.read(cx).read_only + { + return; + } + self.input + .update(cx, |input, cx| match input.dictation.phase { + Phase::Requesting => { + input.cancel_dictation(); + cx.emit(DictationInputEvent::Changed); + cx.notify(); + } + Phase::Listening => { + input.finish_dictation(false, cx); + } + // The voice track's Cancel stops transcription. + Phase::Finalizing => {} + _ if input.marked_range.is_some() => { + input.dictation.phase = Phase::Unavailable( + "Finish composing the current character before starting dictation.".into(), + ); + cx.emit(DictationInputEvent::Changed); + } + _ => { + if let Some(service) = crate::dictation::start(cx) { + input.begin_dictation(service, cx); + } + } + }); + self.focus_pending |= refocus; + cx.notify(); + } + + /// Hold to talk: the microphone or the shortcut going down starts + /// dictation. Pressing during a session started by assistive Click takes + /// it over, so releasing finishes it. + fn press_dictation(&mut self, source: HoldSource, cx: &mut Context) { + use crate::dictation::Phase; + let phase = self.input.read(cx).dictation.phase.clone(); + // Repeats from the same source are ignored. Another source takes the + // session over, so a hold whose release went missing (focus moved + // within the composer) can always be ended. + if phase.active() + && self + .dictation_hold + .as_ref() + .is_some_and(|hold| hold.source == source) + { + return; + } + self.dictation_hold = None; + match phase { + Phase::Finalizing => {} + Phase::Requesting | Phase::Listening => { + self.dictation_hold = Some(DictationHold { + source, + started: None, + }); + } + _ => { + self.toggle_dictation(source != HoldSource::Button, cx); + if self.input.read(cx).dictation.phase.active() { + self.dictation_hold = Some(DictationHold { + source, + started: Some(Instant::now()), + }); + } + } + } + } + + /// Letting go transcribes what was said. A tap explains hold to talk, + /// and a release that only answered the permission prompt records nothing. + fn release_dictation(&mut self, source: HoldSource, cx: &mut Context) { + use crate::dictation::Phase; + if self + .dictation_hold + .as_ref() + .is_none_or(|hold| hold.source != source) + { + return; + } + let tapped = self + .dictation_hold + .take() + .and_then(|hold| hold.started) + .is_some_and(|started| started.elapsed() < DICTATION_TAP); + self.input + .update(cx, |input, cx| match input.dictation.phase { + Phase::Requesting if crate::dictation::permission_pending() => { + input.cancel_dictation(); + cx.emit(DictationInputEvent::Changed); + cx.notify(); + } + Phase::Requesting | Phase::Listening if tapped => { + input.cancel_dictation(); + input.dictation.phase = Phase::Tapped; + cx.emit(DictationInputEvent::Changed); + cx.notify(); + } + Phase::Requesting | Phase::Listening => { + input.finish_dictation(false, cx); + } + _ => {} + }); + // Only the pointer moved focus to the microphone. A key hold already + // sits in the editor (or focus has left it on purpose), and Enter/Space + // keeps the microphone focused. + self.focus_pending |= source == HoldSource::Pointer; + cx.notify(); + } + + /// Microphone at rest; the one Stop control while dictating. `t` morphs + /// the mic glyph out and the accent glass plate, stop square or spinner + /// in; the glow follows the live voice level. + fn render_dictation_button( + &self, + t: f32, + frame: Option<&VoiceFrame>, + cx: &mut Context, + ) -> Option { + use crate::dictation::{Phase, glass, waveform::Mode}; + if !crate::dictation::enabled(cx) || self.wizard.is_some() { + return None; + } + let theme = Theme::of(cx).clone(); + let phase = self.input.read(cx).dictation.phase.clone(); + let active = phase.active(); + let label = phase.action_label().to_owned(); + let live = frame.is_some_and(|f| f.mode == Mode::Live); + let glow = frame.map_or(0.0, |f| f.level * f.level.sqrt()); + // Spinner while the microphone opens or the model transcribes; the + // stop square once the voice is live (and while retracting from it). + let busy = matches!(phase, Phase::Requesting | Phase::Finalizing) + || (!active && frame.is_some_and(|f| f.mode != Mode::Live)); + let tooltip = label.clone(); + let composer = cx.entity().downgrade(); + let centered = || { + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + }; + let button = div() + .id("composer-dictation") + .debug_selector(|| "composer-dictation".into()) + .relative() + .size(px(28.0)) + .flex_none() + .rounded_full() + .role(Role::Button) + .aria_label(label) + .aria_toggled(if active { + gpui::Toggled::True + } else { + gpui::Toggled::False + }) + .tab_index(0) + .cursor_pointer() + // At rest it is a sibling of the paperclip: the same eased ink + // wash. Live, it is the accent plate and dims like Send. + .on_hover(motion::hover_listener("composer-dictation")) + .when(t <= 0.0, |el| { + el.bg(motion::hover_blend( + "composer-dictation", + gpui::transparent_black(), + crate::theme::ink(0.10), + )) + }) + .when(t > 0.0, |el| { + glass::accent(el, &theme, t, glow).hover(|style| style.opacity(0.85)) + }) + .focus_visible(|style| style.border_1().border_color(theme.accent)) + .tooltip(crate::settings::widgets::text_tooltip(tooltip)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| this.press_dictation(HoldSource::Pointer, cx)), + ) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, cx| this.release_dictation(HoldSource::Pointer, cx)), + ) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|this, _, _, cx| this.release_dictation(HoldSource::Pointer, cx)), + ) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + if !event.is_held { + this.press_dictation(HoldSource::Button, cx); + } + } + })) + .on_key_up(cx.listener(|this, event: &KeyUpEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + this.release_dictation(HoldSource::Button, cx); + } + })) + // Assistive technology cannot hold: Click starts, then finishes. + .on_a11y_action(gpui::AccessibleAction::Click, move |_, _, cx| { + composer + .update(cx, |this, cx| this.toggle_dictation(true, cx)) + .ok(); + }) + .when(t < 1.0, |el| { + let scale = 1.0 - 0.75 * t; + el.child( + centered().child( + crate::icons::icon(crate::icons::MICROPHONE) + .size(px(18.0)) + .text_color(theme.text_muted.opacity(1.0 - t)) + .with_transformation(gpui::Transformation::scale(gpui::size( + scale, scale, + ))), + ), + ) + }) + .when(t > 0.0 && busy, |el| { + el.child( + centered() + .opacity(t) + .child(crate::loaders::mini_mono_spinner( + "dictation-progress", + 2.0, + theme.on_accent, + cx.entity_id(), + cx, + )), + ) + }) + .when(t > 0.0 && !busy, |el| { + // Square grows from a quarter of its size, as the mic shrinks. + let side = 9.0 * (0.25 + 0.75 * t) * if live { 1.0 + 0.08 * glow } else { 1.0 }; + el.child( + centered().child( + div() + .size(px(side)) + .rounded(px(2.5)) + .bg(theme.on_accent.opacity(t)), + ), + ) + }); + Some(button.into_any_element()) + } + + fn dismiss_dictation(&mut self, cx: &mut Context) { + self.input.update(cx, |input, cx| { + input.cancel_dictation(); + cx.emit(DictationInputEvent::Changed); + cx.notify(); + }); + self.focus_pending = true; + cx.notify(); + } + + fn dictation_action_button( + &self, + active: bool, + cx: &mut Context, + ) -> gpui::Stateful { + let theme = Theme::of(cx); + let action_label = if active { + "Cancel dictation" + } else { + "Dismiss dictation message" + }; + let composer = cx.entity().downgrade(); + div() + .id("dictation-dismiss") + .debug_selector(|| "dictation-dismiss".into()) + .role(Role::Button) + .aria_label(action_label) + .tab_index(0) + .flex_none() + .h(px(24.0)) + .px(px(8.0)) + .flex() + .items_center() + .rounded_full() + .text_color(theme.text_muted) + .cursor_pointer() + .hover(|s| s.bg(theme.surface_raised_hover).text_color(theme.text)) + .focus_visible(|s| s.border_1().border_color(theme.accent)) + .on_click(cx.listener(|this, _, _, cx| this.dismiss_dictation(cx))) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + this.dismiss_dictation(cx); + } + })) + .on_a11y_action(gpui::AccessibleAction::Click, move |_, _, cx| { + composer + .update(cx, |this, cx| this.dismiss_dictation(cx)) + .ok(); + }) + } + + fn render_dictation_status(&self, cx: &mut Context) -> Option { + // Live dictation renders inside the composer; only outcomes that + // need reading (no speech, errors) appear below it. + let phase = self.input.read(cx).dictation.phase.clone(); + if phase.active() { + return None; + } + let (title, detail) = phase.status()?; + let (title, detail) = (title.to_owned(), detail.to_owned()); + let phase = &phase; + let failed = matches!( + phase, + crate::dictation::Phase::Denied(_) + | crate::dictation::Phase::Unavailable(_) + | crate::dictation::Phase::Failed(_) + ); + let theme = Theme::of(cx); + Some( + motion::fade_in( + "dictation-message-enter", + div() + .id("dictation-status") + .flex() + .items_start() + .gap(px(8.0)) + .px(px(12.0)) + .text_size(px(12.0)) + .line_height(px(18.0)) + .child( + div() + .flex_none() + .mt(px(6.0)) + .size(px(6.0)) + .rounded_full() + .bg(if failed { + theme.warning + } else { + theme.text_faint + }), + ) + .child( + div() + .id("dictation-live-status") + .role(Role::Status) + .aria_label(format!("{title}. {detail}")) + .flex_1() + .min_w_0() + .flex() + .flex_wrap() + .gap_x(px(8.0)) + .child(div().text_color(theme.text).child(title)) + .child(div().min_w_0().text_color(theme.text_muted).child(detail)), + ) + .child( + self.dictation_action_button(false, cx) + .mt(px(-3.0)) + .child("Dismiss"), + ), + ) + .into_any_element(), + ) + } + + /// Advances the dictation morph. Returns its eased progress and the frame + /// to draw: live while dictating, the last live frame while retracting. + fn update_voice(&mut self, window: &mut Window, cx: &App) -> (f32, Option) { + use crate::dictation::{Phase, waveform::Mode}; + let now = Instant::now(); + let reduced = motion::reduced_motion(cx); + let dictation = &self.input.read(cx).dictation; + let active = dictation.phase.active(); + self.voice_tween + .retarget(if active { 1.0 } else { 0.0 }, now, reduced); + let t = self.voice_tween.value(now); + if active { + let meter = &dictation.meter; + let mode = match dictation.phase { + Phase::Listening => Mode::Live, + Phase::Finalizing if meter.since_start(now).is_some() => Mode::Processing, + _ => Mode::Waiting, + }; + let label = dictation + .phase + .status() + .map(|(title, detail)| format!("{title}. {detail}")) + .unwrap_or_default(); + self.voice_last = Some(VoiceFrame { + label, + mode, + bars: meter.bars(now, !reduced), + level: if mode == Mode::Live { + meter.level(now) + } else { + 0.0 + }, + elapsed: meter.since_start(now).map(|_| meter.elapsed(now)), + }); + } else if t <= 0.0 { + self.voice_last = None; + } + if !reduced && (active || !self.voice_tween.settled(now)) { + // Bars scroll, the glow follows the voice, and the morph advances. + window.request_animation_frame(); + } + (t, self.voice_last.clone()) + } + + /// The waveform track that unrolls from Stop across the action row. `t` + /// is the morph progress; the box keeps its final geometry and occludes + /// the controls fading out beneath it. + fn render_voice_track( + &self, + t: f32, + frame: &VoiceFrame, + left: f32, + right: f32, + top: f32, + cx: &mut Context, + ) -> gpui::AnyElement { + use crate::dictation::{glass, waveform}; + let theme = Theme::of(cx).clone(); + let animate = !motion::reduced_motion(cx); + let active = self.input.read(cx).dictation.phase.active(); + let live = frame.mode == waveform::Mode::Live && active; + let clock = frame.elapsed.map(|elapsed| { + let limit = Duration::from_secs(zeron_voice::MAX_SECONDS as u64); + div() + .flex_none() + .font_family(theme.font_mono.clone()) + .text_size(px(11.0)) + .opacity(t) + .text_color(if live && elapsed + VOICE_LIMIT_WARNING >= limit { + theme.warning + } else if live { + theme.text_muted + } else { + theme.text_faint + }) + .child(waveform::clock(elapsed)) + }); + let track = glass::light( + div() + .id("dictation-live-status") + .role(Role::Status) + .aria_label(frame.label.clone()) + .h_full() + .w(gpui::relative(t.max(0.0))) + .min_w(px(VOICE_TRACK_HEIGHT * t.min(1.0))) + .rounded_full() + .overflow_hidden() + .flex() + .items_center() + .gap(px(10.0)) + .pl(px(12.0)) + .pr(px(12.0)), + &theme, + t, + ) + .child( + div() + .flex_1() + .min_w_0() + .h(px(16.0)) + .flex() + .child(waveform::waveform( + frame.bars.clone(), + frame.mode, + waveform::Paint { + ink: theme.text.opacity(0.8), + quiet: theme.text_faint.opacity(0.5), + }, + waveform::Phase { + intro: t, + collapse: if active { 0.0 } else { 1.0 - t }, + }, + animate, + )), + ) + .children(clock); + div() + .id("dictation-track") + .absolute() + .left(px(left)) + .right(px(right)) + .top(px(top)) + .h(px(VOICE_TRACK_HEIGHT)) + .flex() + .justify_end() + .occlude() + .child(track) + .into_any_element() + } + fn render_send_button( &mut self, mode: SendButtonMode, @@ -8829,6 +9761,7 @@ impl Composer { let blocked = self.send_blocked(cx); div() .id("composer-send") + .debug_selector(|| "composer-send".into()) .size(px(28.0)) .flex_none() .rounded_full() @@ -8883,6 +9816,48 @@ impl Focusable for Composer { impl Render for Composer { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.dictation_blur.is_none() { + // Stop, Send and keyboard navigation within the composer must not + // discard capture. Only leaving the whole composer invalidates it. + let focus = self.dictation_focus.clone(); + self.dictation_blur = Some(cx.on_focus_out(&focus, window, |this, _, _, cx| { + this.input.update(cx, |input, cx| { + if !input.dictation.phase.active() { + return; + } + if input.dictation.phase == crate::dictation::Phase::Requesting + && crate::dictation::permission_pending() + { + return; + } + input.cancel_dictation(); + cx.emit(DictationInputEvent::Changed); + cx.notify(); + }); + })); + } + if self.dictation_activation.is_none() { + self.dictation_activation = + Some(cx.observe_window_activation(window, |this, window, cx| { + if !window.is_window_active() { + this.input.update(cx, |input, cx| { + if !input.dictation.phase.active() { + return; + } + // The permission bridge checks the originating key + // window before starting any capture after a prompt. + if input.dictation.phase == crate::dictation::Phase::Requesting + && crate::dictation::permission_pending() + { + return; + } + input.cancel_dictation(); + cx.notify(); + }); + cx.notify(); + } + })); + } if self.focus_pending { self.focus_pending = false; let focus = self.input.focus_handle(cx); @@ -9339,6 +10314,10 @@ impl Render for Composer { }); let send_button = self.render_send_button(mode, cx); + let (voice_t, voice_frame) = self.update_voice(window, cx); + let dictating = self.input.read(cx).dictation.phase.active(); + let microphone = self.render_dictation_button(voice_t, voice_frame.as_ref(), cx); + let attach_action = cx.entity().downgrade(); // Attach button — opens the native image picker (the original's hidden // ``); paste/drop also feed // the same strip. The leading utility group owns the spacing between @@ -9359,16 +10338,95 @@ impl Render for Composer { crate::theme::ink(0.10), )) .on_hover(motion::hover_listener("composer-attach")) - .on_click(cx.listener(|this, _, _, cx| this.open_file_picker(cx))) - .tooltip(crate::settings::widgets::text_tooltip("Attach images")) - .child( - crate::icons::icon(crate::icons::PAPERCLIP) - // Its painted bounds are centered in the 24px viewbox; - // a larger glyph balances the brand icon without moving - // it off-center inside the unchanged 28px hit target. - .size(px(18.0)) - .text_color(theme.text_muted), - ); + .relative() + // While dictating, the attachment slot becomes Cancel: the + // paperclip and the cross swap with a scale and fade. The action + // follows the dictation phase, never the animation. + .role(Role::Button) + .aria_label(if dictating { + "Cancel dictation" + } else { + "Attach images" + }) + .tab_index(0) + .focus_visible(|style| style.border_1().border_color(theme.accent)) + .when(dictating, |el| { + el.debug_selector(|| "dictation-dismiss".into()) + }) + .tooltip(crate::settings::widgets::text_tooltip(if dictating { + "Cancel dictation" + } else { + "Attach images" + })) + .on_click(cx.listener(move |this, _, _, cx| { + if this.input.read(cx).dictation.phase.active() { + this.dismiss_dictation(cx); + } else { + this.open_file_picker(cx); + } + })) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + if this.input.read(cx).dictation.phase.active() { + this.dismiss_dictation(cx); + } else { + this.open_file_picker(cx); + } + } + })) + .on_a11y_action(gpui::AccessibleAction::Click, move |_, _, cx| { + attach_action + .update(cx, |this, cx| { + if this.input.read(cx).dictation.phase.active() { + this.dismiss_dictation(cx); + } else { + this.open_file_picker(cx); + } + }) + .ok(); + }) + .when(voice_t < 1.0, |el| { + let scale = 1.0 - 0.75 * voice_t; + el.child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child( + crate::icons::icon(crate::icons::PAPERCLIP) + // Its painted bounds are centered in the 24px viewbox; + // a larger glyph balances the brand icon without moving + // it off-center inside the unchanged 28px hit target. + .size(px(18.0)) + .text_color(theme.text_muted.opacity(1.0 - voice_t)) + .with_transformation(gpui::Transformation::scale(gpui::size( + scale, scale, + ))), + ), + ) + }) + .when(voice_t > 0.0, |el| { + let scale = 0.25 + 0.75 * voice_t; + el.child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child( + crate::icons::icon(crate::icons::CLOSE) + .size(px(16.0)) + .text_color(theme.text_muted.opacity(voice_t)) + .with_transformation(gpui::Transformation::scale(gpui::size( + scale, scale, + ))), + ), + ) + }); // Staged-thumbnail strip (attachment-ui.tsx AttachmentStrip), above // the input inside the pill in both modes. let strip = self.render_attachment_strip(&theme, cx); @@ -9386,6 +10444,7 @@ impl Render for Composer { // Open menus keep their own keyboard/search focus. if !this.pickers.read(cx).is_open() { window.focus(&this.input.focus_handle(cx), cx); + window.prevent_default(); } }), ) @@ -9427,6 +10486,38 @@ impl Render for Composer { .inset_0(), ) }; + // The model chip already contributes 6px of trailing padding. Remove + // that from its gap to the narrow microphone glyph so the visible + // label–mic and mic–Send spaces balance without shrinking hit targets. + let model_action_gap = if microphone.is_some() { + ACTION_PRIMARY_GAP - 6.0 + } else { + ACTION_PRIMARY_GAP + }; + // The voice track spans from the attachment slot to Stop, over the + // model selector (and, compact, the text), which fade beneath it. + let voice_track = voice_frame + .as_ref() + .filter(|_| voice_t > 0.0 && microphone.is_some()) + .map(|frame| { + // Expanded: the row's 2px top pad; compact: centred in the + // 47px line, riding the same cluster glide as the buttons. + let top = if expanded { + 2.0 + } else { + (COMPACT_TOTAL_HEIGHT - PILL_BORDER_V - VOICE_TRACK_HEIGHT) / 2.0 - cluster_dy + }; + self.render_voice_track( + voice_t, + frame, + action_inset + 28.0 + VOICE_TRACK_GAP, + action_inset + 28.0 + ACTION_PRIMARY_GAP + 28.0 + VOICE_TRACK_GAP, + top, + cx, + ) + }); + let beneath_voice = 1.0 - voice_t.clamp(0.0, 1.0); + let model_picker = model_picker.opacity(beneath_voice); let body = if expanded { // Expanded: textarea on top (`px-4 pb-1 pt-4`), actions row // (8px bottom + 2px top, 32px chips → 42px) ABSOLUTE at the pill's @@ -9464,8 +10555,8 @@ impl Render for Composer { .flex() .flex_row() .items_center() - // Attachment stays left; model and Send stay right. - .gap(px(ACTION_PRIMARY_GAP)) + // Attachment stays left; model, microphone and Send stay right. + .gap(px(model_action_gap)) .px(px(action_inset)) .pt(px(2.0)) .pb(px(ACTIONS_BOTTOM_PAD)) @@ -9480,16 +10571,25 @@ impl Render for Composer { .child(attach), ) .child(model_picker) - .child(send_button), + .child( + div() + .flex_none() + .flex() + .items_center() + .gap(px(ACTION_PRIMARY_GAP)) + .children(microphone) + .child(send_button), + ) + .children(voice_track), ) } else { // Compact pill: attachment on the left, input in the middle, - // then model and Send on the right, all on one 47px line. + // then model, microphone and Send on the right, all on one 47px line. // The row is BOTTOM-justified: during the collapse morph the pill // top sweeps down over a stationary row, the text walks down from // its expanded resting place via a decaying relative offset, and // attachment/Send hold their spots (the centering delta gliding - // in). The model selector remains beside Send. + // in). The model selector remains beside microphone and Send. let text_glide = if dock_owns_layout { collapse_text_glide(dock_layout.hero_height, dock_compact_amount) } else { @@ -9509,6 +10609,7 @@ impl Render for Composer { .child( div() .h(px(COMPACT_TOTAL_HEIGHT - PILL_BORDER_V)) + .relative() .flex() .flex_row() .items_center() @@ -9518,6 +10619,9 @@ impl Render for Composer { .pl(px(action_inset)) .relative() .top(px(-cluster_dy)) + .flex() + .items_center() + .gap(px(ACTION_UTILITY_GAP)) .child(attach), ) .child( @@ -9527,6 +10631,9 @@ impl Render for Composer { .px(px(8.0)) .relative() .top(px(-text_glide)) + // The draft waits under the voice track and + // fades back as the transcript lands in it. + .opacity(beneath_voice) .child(self.render_input_with_completion()), ) .child( @@ -9540,12 +10647,17 @@ impl Render for Composer { .child( div() .flex_none() - .pl(px(ACTION_PRIMARY_GAP)) + .pl(px(model_action_gap)) .pr(px(action_inset)) .relative() .top(px(-cluster_dy)) + .flex() + .items_center() + .gap(px(ACTION_PRIMARY_GAP)) + .children(microphone) .child(send_button), - ), + ) + .children(voice_track), ) }; let new_thread_target_selectors = (new_thread_chrome_opacity > 0.0).then(|| { @@ -9626,7 +10738,24 @@ impl Render for Composer { } else { container }; - let container = container.child(pill_surface); + let dictation_status = self.render_dictation_status(cx); + let container = container.child( + div() + .track_focus(&self.dictation_focus) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| { + if event.keystroke.key == "escape" + && this.input.read(cx).dictation.phase.active() + { + this.dismiss_dictation(cx); + cx.stop_propagation(); + } + })) + .flex() + .flex_col() + .gap(px(Theme::SPACE_SM)) + .child(pill_surface) + .children(dictation_status), + ); // The lower slot keeps a stable footprint for Git projects while its // old floating checkout/ref controls dissolve into the session footer. @@ -9737,7 +10866,7 @@ mod modal_selection_tests; mod tests { use super::*; - fn composer_focus_window( + pub(super) fn composer_focus_window( cx: &mut gpui::TestAppContext, ) -> (tempfile::TempDir, gpui::WindowHandle) { let dir = tempfile::tempdir().unwrap(); @@ -14111,3 +15240,7 @@ impl Composer { cx.notify(); } } + +#[cfg(test)] +#[path = "composer_dictation_tests.rs"] +mod dictation_tests; diff --git a/crates/ui/src/composer_dictation_tests.rs b/crates/ui/src/composer_dictation_tests.rs new file mode 100644 index 0000000000..30e0f19914 --- /dev/null +++ b/crates/ui/src/composer_dictation_tests.rs @@ -0,0 +1,1199 @@ +use super::*; +use crate::dictation::{Event, Phase, Transcriber}; +use gpui::{AppContext, TestAppContext}; +use std::{cell::RefCell, collections::VecDeque}; + +#[derive(Default)] +struct FakeState { + events: VecDeque, + finishes: usize, + drops: usize, +} + +struct Fake(Rc>); +impl Transcriber for Fake { + fn poll(&mut self) -> Option { + self.0.borrow_mut().events.pop_front() + } + fn finish(&mut self) { + self.0.borrow_mut().finishes += 1; + } +} +impl Drop for Fake { + fn drop(&mut self) { + self.0.borrow_mut().drops += 1; + } +} + +fn start(input: &mut ComposerInput, cx: &mut Context) -> Rc> { + let fake = Rc::new(RefCell::new(FakeState::default())); + input.begin_dictation(Box::new(Fake(fake.clone())), cx); + fake +} + +fn deliver( + input: &mut ComposerInput, + fake: &Rc>, + events: impl IntoIterator, + cx: &mut Context, +) { + fake.borrow_mut().events.extend(events); + input.poll_dictation(input.dictation.generation, cx); +} + +#[gpui::test] +fn dictation_replaces_unicode_selection_and_undoes_as_one_edit(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_global(Theme::dark())); + let window = cx.add_window(|_, cx| ComposerInput::new("Draft", cx)); + window + .update(cx, |input, window, cx| { + input.set_text("👩🏽‍💻 replace café", cx); + let begin = input.content.find("replace").unwrap(); + input.selected_range = begin..begin + "replace".len(); + let fake = start(input, cx); + deliver( + input, + &fake, + [ + Event::Listening, + Event::Partial("你好".into()), + Event::Partial("bonjour 🌍".into()), + ], + cx, + ); + assert_eq!(input.content, "👩🏽‍💻 bonjour 🌍 café"); + assert_eq!(input.undo_stack.len(), 1); + input.finish_dictation(false, cx); + deliver(input, &fake, [Event::Final("salut 🌍".into())], cx); + assert_eq!(input.content, "👩🏽‍💻 salut 🌍 café"); + assert_eq!(fake.borrow().drops, 1); + input.undo(&Undo, window, cx); + assert_eq!(input.content, "👩🏽‍💻 replace café"); + assert_eq!(input.selected_range, begin..begin + 7); + input.redo(&Redo, window, cx); + assert_eq!(input.content, "👩🏽‍💻 salut 🌍 café"); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_manual_edit_stops_before_replacing_partial(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_global(Theme::dark())); + let window = cx.add_window(|_, cx| ComposerInput::new("Draft", cx)); + window + .update(cx, |input, window, cx| { + input.set_text("before after", cx); + input.selected_range = 7..7; + let fake = start(input, cx); + let old_generation = input.dictation.generation; + deliver(input, &fake, [Event::Partial("speech ".into())], cx); + input.replace_text_in_range(Some(7..13), "typed", window, cx); + assert_eq!(input.content, "before typed after"); + assert_eq!(fake.borrow().drops, 1); + fake.borrow_mut() + .events + .push_back(Event::Final("stale".into())); + assert!(!input.poll_dictation(old_generation, cx)); + assert_eq!(input.content, "before typed after"); + input.undo(&Undo, window, cx); + assert_eq!(input.content, "before speech after"); + input.undo(&Undo, window, cx); + assert_eq!(input.content, "before after"); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_ime_commit_preserves_composition_and_history(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_global(Theme::dark())); + let window = cx.add_window(|_, cx| ComposerInput::new("Draft", cx)); + window + .update(cx, |input, window, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Partial("Hello ".into())], cx); + input.replace_and_mark_text_in_range(None, "に", None, window, cx); + assert_eq!(fake.borrow().drops, 1); + input.replace_and_mark_text_in_range(None, "日本", None, window, cx); + input.replace_text_in_range(None, "日本語", window, cx); + assert_eq!(input.content, "Hello 日本語"); + input.undo(&Undo, window, cx); + assert_eq!(input.content, "Hello "); + input.undo(&Undo, window, cx); + assert_eq!(input.content, ""); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_selection_move_and_undo_during_permissions_cancel(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_global(Theme::dark())); + let window = cx.add_window(|_, cx| ComposerInput::new("Draft", cx)); + window + .update(cx, |input, window, cx| { + input.set_text("draft", cx); + let fake = start(input, cx); + input.undo(&Undo, window, cx); // No history is still a deliberate cancellation. + assert_eq!(fake.borrow().drops, 1); + let fake = start(input, cx); + deliver(input, &fake, [Event::Partial(" words".into())], cx); + input.move_to(0, cx); + assert_eq!(fake.borrow().drops, 1); + assert_eq!(input.content, "draft words"); + assert_eq!(input.selected_range, 0..0); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_send_finalizes_once_and_keeps_latest_final(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver( + input, + &fake, + [Event::Listening, Event::Partial("hel".into())], + cx, + ); + assert!(input.finish_dictation(true, cx)); + assert!(input.finish_dictation(true, cx)); + assert!(input.finish_dictation(false, cx)); + assert_eq!(fake.borrow().finishes, 1); + deliver( + input, + &fake, + [ + Event::Final("hello".into()), + Event::Final("duplicate".into()), + ], + cx, + ); + assert_eq!(input.content, "hello"); + assert_eq!(input.dictation.phase, Phase::Idle); + assert_eq!(fake.borrow().drops, 1); + }); + let mut sends = 0; + while let Ok(event) = events.try_recv() { + if matches!(event, DictationInputEvent::Submit(_)) { + sends += 1; + } + } + assert_eq!(sends, 1); +} + +#[gpui::test] +fn dictation_failure_cancels_send_and_retry_preserves_draft(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Partial("keep me".into())], cx); + input.finish_dictation(true, cx); + deliver(input, &fake, [Event::Failed("Disconnected".into())], cx); + assert_eq!(input.content, "keep me"); + assert_eq!(input.dictation.phase, Phase::Failed("Disconnected".into())); + assert_eq!(fake.borrow().drops, 1); + for error in [ + Event::Denied("Permission denied".into()), + Event::Unavailable("Locale unavailable".into()), + ] { + let fake = start(input, cx); + deliver(input, &fake, [error], cx); + assert_eq!(input.content, "keep me"); + assert!(!input.dictation.phase.active()); + assert_eq!(fake.borrow().drops, 1); + } + let fake = start(input, cx); + deliver( + input, + &fake, + [ + Event::Partial(" again".into()), + Event::Final(" again".into()), + ], + cx, + ); + assert_eq!(input.content, "keep me again"); + }); + while let Ok(event) = events.try_recv() { + assert!(!matches!(event, DictationInputEvent::Submit(_))); + } +} + +#[gpui::test] +fn dictation_empty_final_retains_selection_or_partial(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + input.update(cx, |input, cx| { + for partial in [None, Some("replacement")] { + input.set_text("selected", cx); + input.selected_range = 0..8; + let fake = start(input, cx); + if let Some(text) = partial { + deliver(input, &fake, [Event::Partial(text.into())], cx); + } + input.finish_dictation(false, cx); + deliver(input, &fake, [Event::Final(String::new())], cx); + assert_eq!(input.content, partial.unwrap_or("selected")); + assert_eq!(input.undo_stack.len(), usize::from(partial.is_some())); + } + }); +} + +#[gpui::test] +fn dictation_draft_swap_invalidates_permissions_finalization_and_stale_results( + cx: &mut TestAppContext, +) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + for finalizing in [false, true] { + input.set_text("original", cx); + let fake = start(input, cx); + let generation = input.dictation.generation; + if finalizing { + input.finish_dictation(true, cx); + } + input.set_text("different chat / queued draft", cx); + fake.borrow_mut() + .events + .extend([Event::Listening, Event::Final("late".into())]); + assert!(!input.poll_dictation(generation, cx)); + assert_eq!(fake.borrow().drops, 1); + assert_eq!(input.content, "different chat / queued draft"); + assert!(input.undo_stack.is_empty()); + } + }); + while let Ok(event) = events.try_recv() { + assert!(!matches!(event, DictationInputEvent::Submit(_))); + } +} + +#[gpui::test] +fn dictation_cancelled_native_capture_drops_pending_send(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Partial("keep draft".into())], cx); + input.finish_dictation(true, cx); + deliver(input, &fake, [Event::Cancelled], cx); + assert_eq!(fake.borrow().drops, 1); + assert_eq!(input.content, "keep draft"); + assert_eq!(input.dictation.phase, Phase::Idle); + }); + while let Ok(event) = events.try_recv() { + assert!(!matches!(event, DictationInputEvent::Submit(_))); + } +} + +#[gpui::test] +fn dictation_releasing_input_releases_microphone(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let fake = input.update(cx, |input, cx| start(input, cx)); + drop(input); + cx.update(|_| {}); // Entity cleanup runs at the end of the update. + assert_eq!(fake.borrow().drops, 1); +} + +#[gpui::test] +fn dictation_empty_send_does_not_interrupt_or_send(cx: &mut TestAppContext) { + let state = cx.new(|_| AppState::new()); + let composer = cx.new(|cx| Composer::new(state, cx)); + composer.update(cx, |composer, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + input.finish_dictation(true, cx); + deliver(input, &fake, [Event::Final(String::new())], cx); + }); + }); + composer.read_with(cx, |composer, _| { + assert!( + composer.failure.is_none(), + "must not attempt a send to the disconnected engine" + ); + assert!(!composer.sending); + assert!(composer.interrupting.is_empty()); + }); +} + +#[gpui::test] +fn dictation_timeout_preserves_latest_partial_without_sending(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Partial("latest".into())], cx); + // Drive the same deadline without a wall-clock sleep or real audio. + input + .dictation + .finish(true, Instant::now() - crate::dictation::FINALIZE_TIMEOUT); + let generation = input.dictation.generation; + assert!(!input.poll_dictation(generation, cx)); + assert!(!input.poll_dictation(generation, cx)); + assert_eq!(input.content, "latest"); + assert_eq!(fake.borrow().drops, 1); + }); + let mut sends = 0; + while let Ok(event) = events.try_recv() { + if matches!(event, DictationInputEvent::Submit(_)) { + sends += 1; + } + } + assert_eq!(sends, 0); +} + +#[gpui::test] +fn dictation_mention_and_slash_completion_commit_partial_first(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + input.update(cx, |input, cx| { + input.enable_mentions(); + input.set_text("@sr", cx); + let fake = start(input, cx); + deliver(input, &fake, [Event::Partial(" extra".into())], cx); + input.replace_mention(0..3, "src/lib.rs", false, cx); + assert_eq!(fake.borrow().drops, 1); + assert!(input.content.contains("src/lib.rs")); + assert!(input.content.ends_with(" extra")); + input.set_text("/com", cx); + let fake = start(input, cx); + input.replace_plain_token(0..4, "/compact", cx); + assert_eq!(fake.borrow().drops, 1); + assert_eq!(input.content, "/compact "); + }); +} + +#[gpui::test] +fn dictation_queue_save_waits_for_final_and_cancel_drops_capture(cx: &mut TestAppContext) { + let state = cx.new(|_| AppState::new()); + let composer = cx.new(|cx| Composer::new(state, cx)); + let fake = composer.update(cx, |composer, cx| { + composer.editing_queued = Some("queued-row".into()); + let fake = composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver( + input, + &fake, + [Event::Listening, Event::Partial("draft".into())], + cx, + ); + fake + }); + assert!(composer.commit_queue_edit(cx)); + assert!(composer.commit_queue_edit(cx)); + assert!( + composer.failure.is_none(), + "queue save must wait for the final result" + ); + assert_eq!(fake.borrow().finishes, 1); + fake + }); + composer.update(cx, |composer, cx| { + composer.input.update(cx, |input, cx| { + deliver(input, &fake, [Event::Final("final queue draft".into())], cx) + }); + }); + composer.read_with(cx, |composer, cx| { + assert_eq!(composer.input.read(cx).text(), "final queue draft"); + // A disconnected test host reports a lease error only AFTER finalization. + assert!( + composer + .failure + .as_ref() + .is_some_and(|s| s.contains("lease")) + ); + }); + composer.update(cx, |composer, cx| { + let fake = composer.input.update(cx, |input, cx| start(input, cx)); + composer.cancel_queue_edit(cx); + assert_eq!(fake.borrow().drops, 1); + let fake = composer.input.update(cx, |input, cx| start(input, cx)); + composer.clear_queue_edit(cx); + assert_eq!( + fake.borrow().drops, + 1, + "clearing an edit without a saved draft must still stop capture" + ); + }); +} + +#[gpui::test] +fn dictation_completed_send_event_cannot_send_a_replacement_draft(cx: &mut TestAppContext) { + let state = cx.new(|_| AppState::new()); + let composer = cx.new(|cx| Composer::new(state, cx)); + composer.update(cx, |composer, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + input.finish_dictation(true, cx); + deliver(input, &fake, [Event::Final("old draft".into())], cx); + // The Submit effect is queued; invalidate it before effects flush. + input.set_text("new draft", cx); + }); + }); + composer.read_with(cx, |composer, cx| { + assert!(composer.failure.is_none()); + assert!(!composer.sending); + assert_eq!(composer.input.read(cx).text(), "new draft"); + }); +} + +#[cfg(target_os = "macos")] +#[gpui::test] +fn dictation_shortcut_holds_until_its_modifier_is_released(cx: &mut TestAppContext) { + let (_dir, handle) = super::tests::composer_focus_window(cx); + cx.update(|cx| { + crate::shell::apply_keymap( + cx, + &crate::settings::KeymapConfig::default(), + ComposerSendBehavior::Enter, + ) + }); + let fake = handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver( + input, + &fake, + [Event::Listening, Event::Partial("keyboard".into())], + cx, + ); + fake + }) + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + // Key down and its auto-repeat hold the recording open. + cx.simulate_keystrokes(handle.into(), "cmd-d cmd-d"); + cx.run_until_parked(); + assert_eq!(fake.borrow().finishes, 0); + let mut visual = gpui::VisualTestContext::from_window(handle.into(), cx); + visual.simulate_modifiers_change(gpui::Modifiers::command()); + assert_eq!( + fake.borrow().finishes, + 0, + "D up alone never arrives on macOS" + ); + // Letting go of Command ends the hold. + visual.simulate_modifiers_change(gpui::Modifiers::none()); + assert_eq!(fake.borrow().finishes, 1); + handle + .update(cx, |composer, window, cx| { + assert!(composer.input.read(cx).focus_handle.is_focused(window)); + assert_eq!(composer.input.read(cx).dictation.phase, Phase::Finalizing); + composer.input.update(cx, |input, cx| { + deliver(input, &fake, [Event::Final("keyboard".into())], cx) + }); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_pointer_hold_finishes_on_release_even_outside_the_button(cx: &mut TestAppContext) { + let (dir, handle) = super::tests::composer_focus_window(cx); + enable_dictation(dir.path(), cx); + for release_outside in [false, true] { + let fake = handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + fake + }) + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + let mut visual = gpui::VisualTestContext::from_window(handle.into(), cx); + let button = visual.debug_bounds("composer-dictation").unwrap(); + visual.simulate_mouse_down( + button.center(), + MouseButton::Left, + gpui::Modifiers::default(), + ); + assert_eq!(fake.borrow().finishes, 0, "holding keeps recording"); + let up = if release_outside { + button.origin - point(px(40.0), px(40.0)) + } else { + button.center() + }; + visual.simulate_mouse_up(up, MouseButton::Left, gpui::Modifiers::default()); + assert_eq!(fake.borrow().drops, 0, "release must finish, not cancel"); + assert_eq!(fake.borrow().finishes, 1, "outside={release_outside}"); + handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + assert_eq!(input.dictation.phase, Phase::Finalizing); + deliver(input, &fake, [Event::Final("Bonjour".into())], cx); + }); + }) + .unwrap(); + } +} + +#[gpui::test] +fn dictation_tap_explains_hold_to_talk_without_transcribing(cx: &mut TestAppContext) { + let (dir, handle) = super::tests::composer_focus_window(cx); + enable_dictation(dir.path(), cx); + let fake = handle + .update(cx, |composer, _, cx| { + let fake = composer.input.update(cx, |input, cx| { + input.set_text("keep", cx); + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + fake + }); + // As if this press had just started the session. + composer.dictation_hold = Some(DictationHold { + source: HoldSource::Pointer, + started: Some(Instant::now()), + }); + fake + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + let mut visual = gpui::VisualTestContext::from_window(handle.into(), cx); + let button = visual.debug_bounds("composer-dictation").unwrap(); + visual.simulate_mouse_up( + button.center(), + MouseButton::Left, + gpui::Modifiers::default(), + ); + assert_eq!(fake.borrow().finishes, 0); + assert_eq!(fake.borrow().drops, 1, "a tap releases the microphone"); + handle + .read_with(cx, |composer, cx| { + let input = composer.input.read(cx); + assert_eq!(input.dictation.phase, Phase::Tapped); + assert_eq!(input.text(), "keep"); + assert!(composer.dictation_hold.is_none()); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_switching_windows_releases_capture_and_retains_draft(cx: &mut TestAppContext) { + let (_dir, handle) = super::tests::composer_focus_window(cx); + handle + .update(cx, |_, window, _| window.activate_window()) + .unwrap(); + cx.run_until_parked(); + let fake = handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver( + input, + &fake, + [Event::Listening, Event::Partial("keep on switch".into())], + cx, + ); + input.finish_dictation(true, cx); + fake + }) + }) + .unwrap(); + let other = cx.add_window(|_, cx| ComposerInput::new("Other window", cx)); + other + .update(cx, |_, window, _| window.activate_window()) + .unwrap(); + cx.run_until_parked(); + assert_eq!(fake.borrow().drops, 1); + handle + .read_with(cx, |composer, cx| { + assert_eq!(composer.input.read(cx).text(), "keep on switch"); + assert_eq!(composer.input.read(cx).dictation.phase, Phase::Idle); + assert!(composer.failure.is_none()); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_question_takeover_stops_capture_without_losing_draft(cx: &mut TestAppContext) { + let state = cx.new(|_| AppState::new()); + let composer = cx.new(|cx| Composer::new(state.clone(), cx)); + let fake = composer.update(cx, |composer, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Partial("preserved draft".into())], cx); + fake + }) + }); + state.update(cx, |state, cx| { + state.transcript = vec![SessionMessageEntry { + duration_ms: None, + id: "question-message".into(), + role: MessageRole::Assistant, + created_at: 0, + device_id: "host".into(), + status: None, + continuation_of: None, + parts: vec![MessagePart::Input { + id: "input".into(), + request_id: "request".into(), + resolved: false, + questions: vec![UserInputQuestion { + id: "q".into(), + header: "Choose".into(), + question: "Continue?".into(), + options: vec!["Yes".into()], + multi_select: false, + prefill: None, + multiline: false, + }], + }], + }]; + cx.notify(); + }); + assert_eq!(fake.borrow().drops, 1); + composer.read_with(cx, |composer, cx| { + assert!(composer.wizard.is_some()); + // The question borrows the editor; the dictated draft waits under the + // chat's key until the question closes. + assert_eq!(composer.input.read(cx).text(), ""); + assert_eq!( + composer + .drafts + .get(&composer.current_key) + .map(String::as_str), + Some("preserved draft") + ); + }); +} + +#[gpui::test] +fn dictation_pointer_stop_keeps_capture_until_final_result(cx: &mut TestAppContext) { + let (dir, handle) = super::tests::composer_focus_window(cx); + enable_dictation(dir.path(), cx); + let fake = handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + fake + }) + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + let mut visual = gpui::VisualTestContext::from_window(handle.into(), cx); + let button = visual.debug_bounds("composer-dictation").unwrap(); + visual.simulate_click(button.center(), gpui::Modifiers::default()); + assert_eq!( + fake.borrow().drops, + 0, + "Stop must finish, not cancel, the recording" + ); + assert_eq!(fake.borrow().finishes, 1); + handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + assert_eq!(input.dictation.phase, Phase::Finalizing); + deliver(input, &fake, [Event::Final("Bonjour".into())], cx); + assert_eq!(input.text(), "Bonjour"); + }) + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_pointer_send_waits_for_final_and_submits_once(cx: &mut TestAppContext) { + let (_dir, handle) = super::tests::composer_focus_window(cx); + let (input, fake) = handle + .update(cx, |composer, _, cx| { + let fake = composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver( + input, + &fake, + [Event::Listening, Event::Partial("unfinished words".into())], + cx, + ); + fake + }); + (composer.input.clone(), fake) + }) + .unwrap(); + let mut events = cx.events::(&input); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + let mut visual = gpui::VisualTestContext::from_window(handle.into(), cx); + let button = visual.debug_bounds("composer-send").unwrap(); + visual.simulate_click(button.center(), gpui::Modifiers::default()); + visual.simulate_click(button.center(), gpui::Modifiers::default()); + assert_eq!(fake.borrow().drops, 0); + assert_eq!(fake.borrow().finishes, 1); + handle + .read_with(cx, |composer, cx| { + assert!( + composer.failure.is_none(), + "neither click may attempt an early send" + ); + assert_eq!(composer.input.read(cx).text(), "unfinished words"); + }) + .unwrap(); + input.update(cx, |input, cx| { + assert_eq!(input.dictation.phase, Phase::Finalizing); + deliver(input, &fake, [Event::Final("send this once".into())], cx); + }); + let mut sends = 0; + while let Ok(event) = events.try_recv() { + if matches!(event, DictationInputEvent::Submit(_)) { + sends += 1; + } + } + assert_eq!(sends, 1); +} + +#[gpui::test] +fn dictation_silence_explains_empty_result_and_never_sends_existing_draft(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + input.set_text("keep my draft", cx); + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + input.finish_dictation(true, cx); + deliver(input, &fake, [Event::Final(String::new())], cx); + assert_eq!(input.text(), "keep my draft"); + assert_eq!(input.dictation.phase, Phase::NoSpeech); + }); + while let Ok(event) = events.try_recv() { + assert!(!matches!(event, DictationInputEvent::Submit(_))); + } +} + +fn enable_dictation(dir: &std::path::Path, cx: &mut TestAppContext) { + let model = dir.join("models/parakeet-tdt-0.6b-v3-int8"); + std::fs::create_dir_all(&model).unwrap(); + for file in zeron_voice::manifest().files { + std::fs::File::create(model.join(file.name)) + .unwrap() + .set_len(file.size) + .unwrap(); + } + std::fs::write(model.join("verified"), zeron_voice::manifest().revision).unwrap(); + cx.update(|cx| { + crate::dictation::init(dir.into(), cx); + crate::settings::update(crate::settings::SavePolicy::Immediate, cx, |s| { + s.dictation_enabled = true + }); + }); +} + +#[gpui::test] +fn dictation_focus_within_composer_keeps_capture_but_leaving_cancels(cx: &mut TestAppContext) { + let (dir, handle) = super::tests::composer_focus_window(cx); + enable_dictation(dir.path(), cx); + handle + .update(cx, |_, window, _| window.activate_window()) + .unwrap(); + cx.run_until_parked(); + let fake = handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + fake + }) + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + handle + .update(cx, |composer, window, cx| { + // The composer surface is the ancestor of the editor and its controls. + window.focus(&composer.dictation_focus, cx); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + cx.run_until_parked(); + assert_eq!( + fake.borrow().drops, + 0, + "focus within the composer must retain capture" + ); + let elsewhere = cx.update(|cx| cx.focus_handle()); + handle + .update(cx, |composer, window, cx| { + window.focus(&elsewhere, cx); + assert!(composer.input.read(cx).dictation.phase.active()); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + cx.run_until_parked(); + assert_eq!(fake.borrow().drops, 1); + handle + .read_with(cx, |composer, cx| { + assert_eq!(composer.input.read(cx).dictation.phase, Phase::Idle) + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_cancel_control_preserves_draft_and_clears_pending_send(cx: &mut TestAppContext) { + let (_dir, handle) = super::tests::composer_focus_window(cx); + let (input, fake) = handle + .update(cx, |composer, _, cx| { + let fake = composer.input.update(cx, |input, cx| { + input.set_text("Keep this draft", cx); + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + input.finish_dictation(true, cx); + fake + }); + (composer.input.clone(), fake) + }) + .unwrap(); + let mut events = cx.events::(&input); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + let mut visual = gpui::VisualTestContext::from_window(handle.into(), cx); + let cancel = visual.debug_bounds("dictation-dismiss").unwrap(); + visual.simulate_click(cancel.center(), gpui::Modifiers::default()); + assert_eq!(fake.borrow().drops, 1); + input.read_with(cx, |input, _| { + assert_eq!(input.text(), "Keep this draft"); + assert_eq!(input.dictation.phase, Phase::Idle); + }); + while let Ok(event) = events.try_recv() { + assert!(!matches!(event, DictationInputEvent::Submit(_))); + } +} + +#[gpui::test] +fn dictation_cancel_control_is_keyboard_operable(cx: &mut TestAppContext) { + let (_dir, handle) = super::tests::composer_focus_window(cx); + let fake = handle + .update(cx, |composer, window, cx| { + let fake = composer.input.update(cx, |input, cx| { + input.set_text("Keep this draft", cx); + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + fake + }); + window.focus(&composer.input.focus_handle(cx), cx); + fake + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| { + window.draw(cx).clear(); + window.focus_next(cx); + window.draw(cx).clear(); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!( + fake.borrow().drops, + 0, + "tabbing to Cancel must retain capture" + ); + cx.simulate_keystrokes(handle.into(), "space"); + cx.run_until_parked(); + assert_eq!(fake.borrow().drops, 1); + assert_eq!(fake.borrow().finishes, 0, "Cancel must not transcribe"); + handle + .read_with(cx, |composer, cx| { + assert_eq!(composer.input.read(cx).text(), "Keep this draft"); + assert_eq!(composer.input.read(cx).dictation.phase, Phase::Idle); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_keeps_draft_stable_when_pasted_references_resolve(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + input.set_text("@README.md ", cx); + let original = input.text().to_owned(); + let revision = input.edit_revision; + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + input.finish_dictation(true, cx); + input.apply_pasted_references( + &original, + original.len(), + revision, + vec![(0..10, local_file_link("README.md", false))], + cx, + ); + assert_eq!(input.text(), original); + deliver(input, &fake, [Event::Final("explain this".into())], cx); + assert_eq!(input.text(), "@README.md explain this"); + }); + let mut sends = 0; + while let Ok(event) = events.try_recv() { + if matches!(event, DictationInputEvent::Submit(_)) { + sends += 1; + } + } + assert_eq!(sends, 1); +} + +#[gpui::test] +fn dictation_escape_from_composer_controls_cancels_pending_send(cx: &mut TestAppContext) { + let (_dir, handle) = super::tests::composer_focus_window(cx); + let fake = handle + .update(cx, |composer, window, cx| { + let fake = composer.input.update(cx, |input, cx| { + input.set_text("Keep this draft", cx); + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + input.finish_dictation(true, cx); + fake + }); + window.focus(&composer.dictation_focus, cx); + fake + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + cx.simulate_keystrokes(handle.into(), "escape"); + cx.run_until_parked(); + assert_eq!(fake.borrow().drops, 1); + handle + .read_with(cx, |composer, cx| { + assert_eq!(composer.input.read(cx).dictation.phase, Phase::Idle); + assert_eq!(composer.input.read(cx).text(), "Keep this draft"); + assert!(composer.failure.is_none()); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_empty_undo_and_redo_notify_cancellation(cx: &mut TestAppContext) { + cx.update(|cx| cx.set_global(Theme::dark())); + let window = cx.add_window(|_, cx| ComposerInput::new("Draft", cx)); + let input = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + let mut events = cx.events::(&input); + for redo in [false, true] { + let fake = input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + fake + }); + while events.try_recv().is_ok() {} + window + .update(cx, |input, window, cx| { + if redo { + input.redo(&Redo, window, cx); + } else { + input.undo(&Undo, window, cx); + } + assert_eq!(input.dictation.phase, Phase::Idle); + }) + .unwrap(); + assert_eq!(fake.borrow().drops, 1); + assert!(matches!( + events.try_recv(), + Ok(DictationInputEvent::Changed) + )); + } +} + +#[gpui::test] +fn dictation_blank_results_preserve_partial_and_pending_send(cx: &mut TestAppContext) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver( + input, + &fake, + [Event::Partial("Keep these words".into())], + cx, + ); + deliver(input, &fake, [Event::Partial(" ".into())], cx); + assert_eq!(input.text(), "Keep these words"); + input.finish_dictation(true, cx); + deliver(input, &fake, [Event::Final(" \n\t ".into())], cx); + assert_eq!(input.text(), "Keep these words"); + assert_eq!(input.dictation.phase, Phase::Idle); + }); + let mut sends = 0; + while let Ok(event) = events.try_recv() { + if matches!(event, DictationInputEvent::Submit(_)) { + sends += 1; + } + } + assert_eq!(sends, 1); +} + +#[test] +fn voice_morph_reverses_mid_flight_without_jumping() { + let start = std::time::Instant::now(); + let mut tween = super::VoiceTween::default(); + tween.retarget(1.0, start, false); + let mid = start + super::VOICE_MORPH / 3; + let at_reverse = tween.value(mid); + assert!(at_reverse > 0.0 && at_reverse < 1.0, "{at_reverse}"); + tween.retarget(0.0, mid, false); + assert!((tween.value(mid) - at_reverse).abs() < 1e-6); + assert_eq!(tween.value(mid + super::VOICE_MORPH), 0.0); + // Reduced motion snaps to the target. + tween.retarget(1.0, mid, true); + assert_eq!(tween.value(mid), 1.0); +} + +#[gpui::test] +fn dictation_stop_while_loading_keeps_selection_until_final_and_sends_once( + cx: &mut TestAppContext, +) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + input.set_text("keep selected suffix", cx); + input.selected_range = 5..13; + let fake = start(input, cx); + assert_eq!(input.dictation.phase, Phase::Requesting); + deliver(input, &fake, [Event::Listening], cx); + input.finish_dictation(false, cx); + input.finish_dictation(true, cx); + // A queued readiness event cannot put a stopped session back in Listening. + deliver(input, &fake, [Event::Listening, Event::Finalizing], cx); + assert_eq!(input.dictation.phase, Phase::Finalizing); + assert_eq!(input.content, "keep selected suffix"); + assert_eq!(input.selected_range, 5..13); + assert_eq!(fake.borrow().finishes, 1); + deliver( + input, + &fake, + [Event::Final("speech".into()), Event::Final("late".into())], + cx, + ); + assert_eq!(input.content, "keep speech suffix"); + assert_eq!(input.dictation.phase, Phase::Idle); + assert_eq!(fake.borrow().drops, 1); + }); + let mut sends = 0; + while let Ok(event) = events.try_recv() { + sends += usize::from(matches!(event, DictationInputEvent::Submit(_))); + } + assert_eq!(sends, 1); +} + +#[gpui::test] +fn dictation_load_failure_or_timeout_preserves_selected_draft_and_rejects_late_final( + cx: &mut TestAppContext, +) { + let input = cx.new(|cx| ComposerInput::new("Draft", cx)); + let mut events = cx.events::(&input); + input.update(cx, |input, cx| { + for timeout in [false, true] { + input.set_text("keep my draft", cx); + input.selected_range = 0..13; + let fake = start(input, cx); + let generation = input.dictation.generation; + deliver(input, &fake, [Event::Listening], cx); + if timeout { + input + .dictation + .finish(true, Instant::now() - crate::dictation::FINALIZE_TIMEOUT); + fake.borrow_mut().events.push_back(Event::Finalizing); + input.poll_dictation(generation, cx); + } else { + input.finish_dictation(true, cx); + deliver( + input, + &fake, + [Event::Failed("Could not load the model".into())], + cx, + ); + } + assert!(matches!(input.dictation.phase, Phase::Failed(_))); + fake.borrow_mut() + .events + .push_back(Event::Final("late speech".into())); + assert!(!input.poll_dictation(generation, cx)); + assert_eq!(input.content, "keep my draft"); + assert_eq!(input.selected_range, 0..13); + assert!(input.undo_stack.is_empty()); + assert_eq!(fake.borrow().drops, 1); + } + }); + while let Ok(event) = events.try_recv() { + assert!(!matches!(event, DictationInputEvent::Submit(_))); + } +} + +#[gpui::test] +fn dictation_microphone_key_hold_keeps_focus_and_queue_lease_blocks_start(cx: &mut TestAppContext) { + let (dir, handle) = super::tests::composer_focus_window(cx); + enable_dictation(dir.path(), cx); + handle + .update(cx, |composer, _, cx| { + // Enter/Space on the focused microphone must leave focus there, + // or its key-up never reaches the hold. + composer.focus_pending = false; + composer.press_dictation(HoldSource::Button, cx); + assert!(composer.input.read(cx).dictation.phase.active()); + assert!(!composer.focus_pending); + composer + .input + .update(cx, |input, _| input.cancel_dictation()); + composer.dictation_hold = None; + // An edit lease still in flight is about to replace the draft. + composer.queue_edit_pending_id = Some("row".into()); + composer.press_dictation(HoldSource::Pointer, cx); + assert!(!composer.input.read(cx).dictation.phase.active()); + assert!(composer.dictation_hold.is_none()); + }) + .unwrap(); +} + +#[gpui::test] +fn dictation_another_source_takes_over_a_hold_whose_release_was_lost(cx: &mut TestAppContext) { + let (dir, handle) = super::tests::composer_focus_window(cx); + enable_dictation(dir.path(), cx); + let fake = handle + .update(cx, |composer, _, cx| { + let fake = composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + fake + }); + composer.dictation_hold = Some(DictationHold { + source: HoldSource::Button, + started: None, + }); + composer.press_dictation(HoldSource::Pointer, cx); + composer.release_dictation(HoldSource::Pointer, cx); + fake + }) + .unwrap(); + assert_eq!(fake.borrow().finishes, 1); +} + +#[gpui::test] +fn dictation_shortcut_falls_through_while_dictation_is_off(cx: &mut TestAppContext) { + gpui::actions!(dictation_test, [Probe]); + let (_dir, handle) = super::tests::composer_focus_window(cx); + let fired = Rc::new(std::cell::Cell::new(false)); + let probe = fired.clone(); + cx.update(|cx| { + crate::shell::apply_keymap( + cx, + &crate::settings::KeymapConfig::default(), + ComposerSendBehavior::Enter, + ); + // Another binding of the same chord, as a user may already have. + cx.bind_keys([KeyBinding::new("secondary-d", Probe, None)]); + cx.on_action(move |_: &Probe, _| probe.set(true)); + }); + cx.simulate_keystrokes(handle.into(), "secondary-d"); + assert!( + fired.get(), + "dictation is off by default and must not eat ⌘D" + ); + handle + .read_with(cx, |composer, cx| { + assert!(composer.dictation_hold.is_none()); + assert!(composer.input.read(cx).dictation_key.is_none()); + }) + .unwrap(); +} diff --git a/crates/ui/src/dictation.rs b/crates/ui/src/dictation.rs new file mode 100644 index 0000000000..b9b7d40d34 --- /dev/null +++ b/crates/ui/src/dictation.rs @@ -0,0 +1,323 @@ +//! Viewport-local dictation. Only transcript strings cross this boundary; audio +//! never enters the engine, a document, an attachment, or the RPC transport. +use std::ops::Range; +use std::time::{Duration, Instant}; + +pub(crate) mod glass; +mod meter; +mod model; +pub(crate) mod waveform; +pub(crate) use meter::{Bar, FLOOR, Meter}; +pub(crate) use model::{card, enabled, init}; + +pub(crate) const FINALIZE_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +#[serde(tag = "kind", content = "text", rename_all = "snake_case")] +pub(crate) enum Event { + Listening, + Finalizing, + Partial(String), + Final(String), + Denied(String), + Unavailable(String), + Failed(String), + Cancelled, +} + +/// Implementations and their callbacks are owned by the UI thread. Tests inject +/// a fake; permission prompts and microphone access are never needed in CI. +pub(crate) trait Transcriber { + fn poll(&mut self) -> Option; + fn finish(&mut self); + /// Peak input RMS since the previous call, for the live waveform. + fn level(&mut self) -> f32 { + 0.0 + } +} + +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn zeron_microphone_permission() -> i32; + fn zeron_request_microphone(); + fn zeron_microphone_window() -> usize; +} +fn permission() -> i32 { + #[cfg(target_os = "macos")] + { + unsafe { zeron_microphone_permission() } + } + #[cfg(not(target_os = "macos"))] + { + 1 + } +} +pub(crate) fn permission_pending() -> bool { + permission() == 0 +} +fn origin_window() -> usize { + #[cfg(target_os = "macos")] + { + unsafe { zeron_microphone_window() } + } + #[cfg(not(target_os = "macos"))] + { + 1 + } +} +struct Native { + pending: Option, + origin_window: usize, + dir: std::path::PathBuf, + device: Option, + session: Option, + finished: bool, +} +impl Transcriber for Native { + fn poll(&mut self) -> Option { + if let Some(event) = self.pending.take() { + return Some(event); + } + if self.finished { + return None; + } + if self.session.is_none() { + match permission() { + 0 => return None, + -2 => { + self.finished = true; + return Some(Event::Unavailable( + "Dictation requires the packaged Zeron app.".into(), + )); + } + -1 => { + self.finished = true; + return Some(Event::Denied("Allow Microphone access for Zeron in System Settings → Privacy & Security, then retry.".into())); + } + _ if self.origin_window == 0 || origin_window() != self.origin_window => { + self.finished = true; + return Some(Event::Cancelled); + } + _ => match zeron_voice::Session::start(self.dir.clone(), self.device.clone()) { + Ok(s) => self.session = Some(s), + Err(e) => { + self.finished = true; + return Some(Event::Failed(e.to_string())); + } + }, + } + } + self.session.as_mut()?.poll().map(|event| match event { + zeron_voice::Event::Listening => Event::Listening, + zeron_voice::Event::Finalizing => Event::Finalizing, + zeron_voice::Event::Final(t) => Event::Final(t), + zeron_voice::Event::Failed(e) => Event::Failed(e), + }) + } + fn level(&mut self) -> f32 { + self.session.as_ref().map_or(0.0, |s| s.take_level()) + } + fn finish(&mut self) { + if let Some(s) = &mut self.session { + s.finish(); + } else { + self.finished = true; + self.pending = Some(Event::Final(String::new())); + } + } +} +pub(crate) fn start(cx: &gpui::App) -> Option> { + if !enabled(cx) { + return None; + } + let origin_window = origin_window(); + #[cfg(target_os = "macos")] + if permission() == 0 { + unsafe { + zeron_request_microphone(); + } + } + Some(Box::new(Native { + pending: None, + origin_window, + dir: model::directory(cx), + device: crate::settings::current(cx).dictation_input.clone(), + session: None, + finished: false, + })) +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) enum Phase { + #[default] + Idle, + Requesting, + Listening, + Finalizing, + NoSpeech, + /// Released almost immediately: dictation is hold to talk. + Tapped, + Denied(String), + Unavailable(String), + Failed(String), +} + +impl Phase { + pub fn active(&self) -> bool { + matches!(self, Self::Requesting | Self::Listening | Self::Finalizing) + } + + /// Button names describe the action, independently of the live status. + pub fn action_label(&self) -> &'static str { + match self { + Self::Idle | Self::Tapped => "Hold to dictate", + Self::Requesting | Self::Listening => "Release to transcribe", + Self::Finalizing => "Transcribing", + Self::NoSpeech | Self::Denied(_) | Self::Unavailable(_) | Self::Failed(_) => { + "Hold to retry dictation" + } + } + } + + pub fn status(&self) -> Option<(&str, &str)> { + Some(match self { + Self::Idle => return None, + Self::Requesting => ("Getting ready…", "Wait for Listening before speaking."), + Self::Listening => ("Listening", "Release when you’re done · Up to 1 minute"), + Self::Finalizing => ("Transcribing…", "Processing on this device."), + Self::NoSpeech => ( + "No speech detected", + "Check your microphone, then try again.", + ), + Self::Tapped => ( + "Hold to dictate", + "Keep holding the microphone or the shortcut while you speak.", + ), + Self::Denied(message) | Self::Unavailable(message) => { + ("Dictation unavailable", message) + } + Self::Failed(message) => ("Dictation stopped", message), + }) + } +} + +#[derive(Default)] +pub(crate) struct Dictation { + pub phase: Phase, + pub generation: u64, + range: Range, + expected: String, + pub has_partial: bool, + pending_send: bool, + finish_started: Option, + pub meter: Meter, +} + +impl Dictation { + pub fn begin(&mut self, content: &str, selection: Range) { + self.cancel(); + self.phase = Phase::Requesting; + self.range = selection; + self.expected = content.to_owned(); + self.has_partial = false; + } + + /// Invalidates late callbacks AND pending sends, preserving committed text. + pub fn cancel(&mut self) { + self.generation = self.generation.wrapping_add(1); + self.phase = Phase::Idle; + self.pending_send = false; + self.finish_started = None; + self.expected.clear(); + self.meter.reset(); + } + + /// Returns true only on the transition that must finish the audio stream. + pub fn finish(&mut self, send: bool, now: Instant) -> bool { + if !self.phase.active() { + return false; + } + self.pending_send |= send; + if self.phase == Phase::Finalizing { + return false; + } + self.phase = Phase::Finalizing; + self.finish_started = Some(now); + self.meter.freeze(now); + true + } + + pub fn timed_out(&self, now: Instant) -> bool { + self.finish_started + .is_some_and(|at| now.duration_since(at) >= FINALIZE_TIMEOUT) + } + + /// Empty recognition does not erase selected text or the latest partial. + /// A changed draft cannot be overwritten, even if a caller missed cancellation. + pub fn replace(&mut self, content: &mut String, transcript: &str) -> Option { + if !self.phase.active() || *content != self.expected { + self.cancel(); + return None; + } + if transcript.trim().is_empty() { + return None; + } + content.replace_range(self.range.clone(), transcript); + self.range.end = self.range.start + transcript.len(); + self.expected.clone_from(content); + self.has_partial = true; + Some(self.range.end) + } + + pub fn complete(&mut self, phase: Phase) -> bool { + let send = self.pending_send && phase == Phase::Idle; + self.cancel(); + self.phase = phase; + send + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finishing_before_permission_does_not_wait_or_start_capture() { + let mut native = Native { + pending: None, + origin_window: 0, + dir: std::path::PathBuf::new(), + device: None, + session: None, + finished: false, + }; + native.finish(); + assert_eq!(native.poll(), Some(Event::Final(String::new()))); + assert_eq!(native.poll(), None); + assert!(native.session.is_none()); + } + + #[test] + fn finalization_deadline_does_not_restart_when_send_follows_stop() { + let mut state = Dictation::default(); + state.begin("draft", 5..5); + let start = Instant::now(); + assert!(state.finish(false, start)); + assert!(!state.finish(true, start + Duration::from_secs(1))); + assert!(!state.timed_out(start + FINALIZE_TIMEOUT - Duration::from_millis(1))); + assert!(state.timed_out(start + FINALIZE_TIMEOUT)); + assert!(state.complete(Phase::Idle)); + assert!(!state.complete(Phase::Idle)); + } + + #[test] + fn unexpected_draft_change_rejects_late_partial_and_send() { + let mut state = Dictation::default(); + state.begin("old", 0..3); + state.finish(true, Instant::now()); + let mut current = "new draft".to_owned(); + assert_eq!(state.replace(&mut current, "late"), None); + assert_eq!(current, "new draft"); + assert!(!state.complete(Phase::Idle)); + } +} diff --git a/crates/ui/src/dictation/glass.rs b/crates/ui/src/dictation/glass.rs new file mode 100644 index 0000000000..e39eae3cc0 --- /dev/null +++ b/crates/ui/src/dictation/glass.rs @@ -0,0 +1,89 @@ +//! Glass surfaces for the voice controls: a light plate for the waveform track +//! and an accent plate for Stop. Each is a vertical gradient (lit from above), +//! a hairline rim, an inner top highlight, and a soft drop. Dark appearances +//! keep the structure with translucent white instead of opaque fills. +use gpui::{ + Background, BoxShadow, Hsla, Styled, hsla, linear_color_stop, linear_gradient, point, px, +}; + +use crate::theme::Theme; + +fn shadow(color: Hsla, y: f32, blur: f32, spread: f32, inset: bool) -> BoxShadow { + BoxShadow { + color, + offset: point(px(0.0), px(y)), + blur_radius: px(blur), + spread_radius: px(spread), + inset, + } +} + +fn white(alpha: f32) -> Hsla { + hsla(0.0, 0.0, 1.0, alpha) +} + +fn black(alpha: f32) -> Hsla { + hsla(0.0, 0.0, 0.0, alpha) +} + +fn vertical(top: Hsla, bottom: Hsla) -> Background { + linear_gradient( + 180.0, + linear_color_stop(top, 0.0), + linear_color_stop(bottom, 1.0), + ) +} + +/// A lighter tint of `color` for the lit top of a gradient. +fn lift(color: Hsla, amount: f32) -> Hsla { + crate::motion::mix(color, white(color.a), amount) +} + +/// Neutral glass plate. `t` fades the whole treatment in with the morph. +pub(crate) fn light(el: E, theme: &Theme, t: f32) -> E { + let dark = theme.appearance.is_dark(); + let (top, bottom, rim, highlight, drop) = if dark { + ( + white(0.09), + white(0.04), + white(0.10), + white(0.10), + black(0.35), + ) + } else { + ( + hsla(0.0, 0.0, 0.985, 1.0), + hsla(0.0, 0.0, 0.925, 1.0), + black(0.10), + white(0.95), + black(0.08), + ) + }; + el.bg(vertical(top.opacity(t), bottom.opacity(t))) + .border_1() + .border_color(rim.opacity(t)) + .shadow(vec![ + shadow(highlight.opacity(t), 1.0, 0.0, 0.0, true), + shadow(drop.opacity(t), 1.0, 3.0, 0.0, false), + ]) +} + +/// Accent glass plate. `t` fades it in over the resting control; `glow` +/// (0–1, the live voice level) spreads its coloured shadow. +pub(crate) fn accent(el: E, theme: &Theme, t: f32, glow: f32) -> E { + let dark = theme.appearance.is_dark(); + let base = theme.accent; + let top = lift(base, if dark { 0.18 } else { 0.32 }); + let bottom = base; + let rim = lift(base, 0.45).opacity(if dark { 0.45 } else { 0.7 }); + let highlight = white(if dark { 0.22 } else { 0.38 }); + let halo = base.opacity((0.22 + 0.4 * glow) * t); + el.bg(vertical(top.opacity(t), bottom.opacity(t))) + .border_1() + .border_color(rim.opacity(t)) + .shadow(vec![ + shadow(highlight.opacity(t), 1.0, 0.0, 0.0, true), + shadow(white(0.12 * t), 0.0, 0.0, 1.0, true), + shadow(halo, 2.0 + 2.0 * glow, 6.0 + 14.0 * glow, 0.0, false), + ]) +} diff --git a/crates/ui/src/dictation/meter.rs b/crates/ui/src/dictation/meter.rs new file mode 100644 index 0000000000..7ddf7152f5 --- /dev/null +++ b/crates/ui/src/dictation/meter.rs @@ -0,0 +1,247 @@ +//! Live input level for the dictation waveform. Only a loudness envelope is +//! kept here: one normalized amplitude per fixed time slot, never audio. +//! +//! Slots sit on a fixed clock from the moment capture starts, so bars stay +//! evenly spaced however irregularly the UI polls. Drawing trails the clock by +//! [`LAG`], longer than one poll, which means a slot is always filled before +//! it scrolls into view and the waveform can glide continuously. +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +/// One bar per slot. +pub(crate) const STEP: Duration = Duration::from_millis(60); +const LAG: Duration = Duration::from_millis(70); +/// Enough slots for the widest composer; older bars have scrolled away. +const CAPACITY: usize = 320; +/// A bar never drops below its neighbour faster than this, so syllables leave +/// a short tail instead of flickering between loud and silent. +const RELEASE: f32 = 0.62; +/// The quietest bar still reads as a dot on the baseline. +pub(crate) const FLOOR: f32 = 0.08; + +#[derive(Debug, Default, Clone)] +pub(crate) struct Meter { + started: Option, + frozen: Option, + slots: VecDeque, + filled: u64, + pending: f32, +} + +/// One visible bar: `age` in slots behind the trailing edge (0 = newest) and +/// its normalized height. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Bar { + pub age: f32, + pub amplitude: f32, +} + +/// Maps microphone RMS onto a perceptual 0–1 scale: −50 dBFS is silence and +/// −12 dBFS, a close, raised voice, fills the bar. +pub(crate) fn normalize(rms: f32) -> f32 { + if !rms.is_finite() || rms <= 1e-6 { + return 0.0; + } + ((20.0 * rms.log10() + 50.0) / 38.0).clamp(0.0, 1.0) +} + +impl Meter { + pub fn start(&mut self, now: Instant) { + *self = Self { + started: Some(now), + ..Self::default() + }; + } + + /// Time since capture began; `None` until the microphone is live. + pub fn since_start(&self, now: Instant) -> Option { + self.started.map(|at| now.saturating_duration_since(at)) + } + + pub fn reset(&mut self) { + *self = Self::default(); + } + + /// Stops the clock where capture ended; bars and timer hold still. + pub fn freeze(&mut self, now: Instant) { + if self.started.is_some() && self.frozen.is_none() { + self.frozen = Some(now); + } + } + + /// Folds in the loudest RMS since the previous call. Returns true when a + /// new slot was filled. + pub fn record(&mut self, now: Instant, rms: f32) -> bool { + let Some(started) = self.started.filter(|_| self.frozen.is_none()) else { + return false; + }; + self.pending = self.pending.max(normalize(rms)); + let due = now.saturating_duration_since(started).as_nanos() / STEP.as_nanos() + 1; + let due = due as u64; + if self.filled >= due { + return false; + } + while self.filled < due { + let previous = self.slots.back().copied().unwrap_or(0.0); + self.slots.push_back(self.pending.max(previous * RELEASE)); + if self.slots.len() > CAPACITY { + self.slots.pop_front(); + } + self.filled += 1; + } + self.pending = 0.0; + true + } + + pub fn elapsed(&self, now: Instant) -> Duration { + self.started.map_or(Duration::ZERO, |started| { + self.frozen + .unwrap_or(now) + .saturating_duration_since(started) + }) + } + + /// Bars visible at `now`, newest first. `continuous` false snaps the + /// scroll to whole slots for reduced motion. + pub fn bars(&self, now: Instant, continuous: bool) -> Vec { + let Some(started) = self.started else { + return Vec::new(); + }; + let clock = self + .frozen + .map_or(now, |frozen| frozen.min(now)) + .saturating_duration_since(started); + let Some(clock) = clock.checked_sub(LAG) else { + return Vec::new(); + }; + let head = clock.as_secs_f32() / STEP.as_secs_f32(); + let first = self.filled - self.slots.len() as u64; + self.slots + .iter() + .enumerate() + .rev() + .filter_map(|(i, amplitude)| { + let age = head - (first + i as u64) as f32; + (age >= 0.0).then(|| Bar { + age: if continuous { age } else { age.floor() }, + amplitude: *amplitude, + }) + }) + .collect() + } + + /// Current loudness for the glow, blended between the two newest visible + /// slots as the newest one scrolls in, so it changes without steps. + pub fn level(&self, now: Instant) -> f32 { + let bars = self.bars(now, true); + match bars.as_slice() { + [] => 0.0, + [only] => only.amplitude * only.age.clamp(0.0, 1.0), + [newest, previous, ..] => { + let t = newest.age.clamp(0.0, 1.0); + previous.amplitude + (newest.amplitude - previous.amplitude) * t + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ms(n: u64) -> Duration { + Duration::from_millis(n) + } + + #[test] + fn normalizes_speech_range_and_rejects_invalid_levels() { + assert_eq!(normalize(0.0), 0.0); + assert_eq!(normalize(f32::NAN), 0.0); + assert_eq!(normalize(10f32.powf(-50.0 / 20.0)), 0.0); + assert_eq!(normalize(1.0), 1.0); + let mid = normalize(0.02); + assert!(mid > 0.2 && mid < 0.8, "{mid}"); + } + + #[test] + fn slots_stay_on_the_clock_when_polls_are_irregular() { + let t0 = Instant::now(); + let mut meter = Meter::default(); + meter.start(t0); + assert!(meter.record(t0 + ms(5), 0.1)); + assert!(!meter.record(t0 + ms(30), 0.0)); + // A late poll fills every slot it skipped. + assert!(meter.record(t0 + STEP * 3 + ms(1), 0.0)); + assert_eq!(meter.filled, 4); + let bars = meter.bars(t0 + STEP * 3 + LAG + ms(1), true); + assert_eq!(bars.len(), 4); + for pair in bars.windows(2) { + assert!((pair[1].age - pair[0].age - 1.0).abs() < 1e-3); + } + // Loud slot decays into its neighbours instead of dropping to zero. + let loud = normalize(0.1); + assert_eq!(bars[3].amplitude, loud); + assert!((bars[2].amplitude - loud * RELEASE).abs() < 1e-6); + } + + #[test] + fn peak_between_slots_is_kept_for_the_next_slot() { + let t0 = Instant::now(); + let mut meter = Meter::default(); + meter.start(t0); + meter.record(t0, 0.0); + assert!(!meter.record(t0 + ms(20), 0.2)); + assert!(meter.record(t0 + STEP, 0.0)); + assert_eq!(meter.slots.back().copied(), Some(normalize(0.2))); + } + + #[test] + fn freezing_stops_scroll_and_timer() { + let t0 = Instant::now(); + let mut meter = Meter::default(); + meter.start(t0); + meter.record(t0 + STEP * 5, 0.05); + meter.freeze(t0 + STEP * 5); + assert!(!meter.record(t0 + STEP * 9, 0.5)); + let later = t0 + Duration::from_secs(10); + assert_eq!(meter.elapsed(later), STEP * 5); + assert_eq!(meter.bars(later, true), meter.bars(later + ms(500), true)); + } + + #[test] + fn level_glides_between_slots() { + let t0 = Instant::now(); + let mut meter = Meter::default(); + meter.start(t0); + meter.record(t0, 0.0); + meter.record(t0 + STEP, 0.1); + let at = |offset: Duration| meter.level(t0 + STEP + LAG + ms(1) + offset); + let loud = normalize(0.1); + assert!(at(Duration::ZERO) < 0.03); + let half = at(STEP / 2); + assert!((half - loud / 2.0).abs() < 0.03, "{half}"); + assert!((at(STEP) - loud).abs() < 1e-6); + } + + #[test] + fn reduced_motion_snaps_to_whole_slots() { + let t0 = Instant::now(); + let mut meter = Meter::default(); + meter.start(t0); + meter.record(t0 + STEP * 2, 0.05); + let bars = meter.bars(t0 + STEP * 2 + LAG + ms(20), false); + assert!(bars.iter().all(|bar| bar.age.fract() == 0.0)); + } + + #[test] + fn capacity_bounds_history() { + let t0 = Instant::now(); + let mut meter = Meter::default(); + meter.start(t0); + meter.record(t0 + STEP * (CAPACITY as u32 * 2), 0.05); + assert_eq!(meter.slots.len(), CAPACITY); + let bars = meter.bars(t0 + STEP * (CAPACITY as u32 * 2) + LAG + ms(1), true); + assert_eq!(bars.len(), CAPACITY); + assert!(bars[0].age < 1.0); + } +} diff --git a/crates/ui/src/dictation/model.rs b/crates/ui/src/dictation/model.rs new file mode 100644 index 0000000000..88a635116b --- /dev/null +++ b/crates/ui/src/dictation/model.rs @@ -0,0 +1,560 @@ +use crate::{ + popover, + settings::{self, widgets}, + theme::Theme, +}; +use gpui::{App, Context, Entity, Global, Task, Window, div, prelude::*, px}; +use std::{ + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + time::{Duration, Instant}, +}; +struct VoiceGlobal { + card: Entity, + directory: PathBuf, +} +impl Global for VoiceGlobal {} +pub(crate) fn init(root: PathBuf, cx: &mut App) { + let directory = root.join("models/parakeet-tdt-0.6b-v3-int8"); + let card = cx.new(|_| VoiceCard { + scroll: widgets::PageScroll::default(), + ready: zeron_voice::installed(&directory), + cache_present: directory.exists(), + directory: directory.clone(), + cancel: None, + task: None, + progress: 0, + error: None, + inputs: Inputs::default(), + input_select: widgets::SelectState::default(), + shortcut: None, + }); + cx.set_global(VoiceGlobal { card, directory }); +} +pub(crate) fn directory(cx: &App) -> PathBuf { + cx.global::().directory.clone() +} +pub(crate) fn enabled(cx: &App) -> bool { + settings::current(cx).dictation_enabled + && cx + .try_global::() + .is_some_and(|g| g.card.read(cx).ready) +} +pub(crate) fn card(cx: &mut App) -> Entity { + if !cx.has_global::() { + init(std::env::temp_dir().join("zeron-voice-test"), cx); + } + cx.global::().card.clone() +} +pub(crate) struct VoiceCard { + scroll: widgets::PageScroll, + directory: PathBuf, + ready: bool, + cache_present: bool, + cancel: Option>, + task: Option>, + progress: u64, + error: Option, + inputs: Inputs, + input_select: widgets::SelectState, + /// Created on first render (it needs this card's context). + shortcut: Option>, +} +/// Microphones as last enumerated, refreshed off the UI thread while the page +/// is visible so newly connected devices appear. +#[derive(Default)] +struct Inputs { + devices: Vec, + default: Option, + checked: Option, + task: Option>, +} +const INPUT_REFRESH: Duration = Duration::from_secs(3); +impl VoiceCard { + fn set_enabled(&mut self, enabled: bool, cx: &mut Context) { + settings::update(settings::SavePolicy::Immediate, cx, |s| { + s.dictation_enabled = enabled + }); + cx.refresh_windows(); + cx.notify(); + } + fn primary(&mut self, cx: &mut Context) { + if let Some(cancel) = &self.cancel { + cancel.store(true, Ordering::Release); + } else if self.ready { + self.set_enabled(!settings::current(cx).dictation_enabled, cx); + } else { + self.download(cx); + } + cx.notify(); + } + fn download(&mut self, cx: &mut Context) { + if self.cancel.is_some() { + return; + } + self.error = None; + self.progress = 0; + let cancel = Arc::new(AtomicBool::new(false)); + self.cancel = Some(cancel.clone()); + let progress = Arc::new(AtomicU64::new(0)); + let counter = progress.clone(); + let dir = self.directory.clone(); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let r = zeron_voice::download(&dir, &cancel, |n| counter.store(n, Ordering::Relaxed)) + .map_err(|_| { + if cancel.load(Ordering::Acquire) { "Download cancelled.".to_owned() } + else { "Couldn’t download or verify the model. Check your connection and free storage, then retry.".to_owned() } + }); + let _ = tx.send(r); + }); + self.task = Some(cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(100)) + .await; + let result = rx.try_recv(); + let done = !matches!(result, Err(std::sync::mpsc::TryRecvError::Empty)); + if this + .update(cx, |this, cx| { + this.progress = progress.load(Ordering::Relaxed); + if done { + this.cache_present = this.directory.exists(); + } + match result { + Ok(Ok(())) => { + this.ready = true; + let cancelled = this + .cancel + .as_ref() + .is_some_and(|c| c.load(Ordering::Acquire)); + this.cancel = None; + this.set_enabled(!cancelled, cx); + } + Ok(Err(e)) => { + this.cancel = None; + this.error = Some(e); + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + this.cancel = None; + this.error = Some("Download interrupted. Try again.".into()); + } + _ => {} + } + cx.notify(); + }) + .is_err() + || done + { + break; + } + } + })); + cx.notify(); + } + fn remove(&mut self, cx: &mut Context) { + if zeron_voice::busy() { + self.error = Some("Stop dictation before removing the model.".into()); + cx.notify(); + return; + } + self.set_enabled(false, cx); + zeron_voice::unload(); + match std::fs::remove_dir_all(&self.directory) { + Ok(()) => { + self.cache_present = false; + self.ready = false; + self.progress = 0; + self.error = None + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + self.cache_present = false; + self.ready = false; + self.progress = 0; + self.error = None; + } + Err(_) => { + self.error = + Some("Could not remove the model. Check folder permissions and retry.".into()) + } + } + cx.notify(); + } +} +impl popover::ScrollRailHost for VoiceCard { + fn rail_bar(&mut self) -> &mut popover::MenuScrollbarState { + self.scroll.rail_bar() + } + fn rail_scroll(&self) -> Option { + self.scroll.rail_scroll() + } +} +impl VoiceCard { + fn refresh_inputs(&mut self, cx: &mut Context) { + if self.inputs.task.is_some() + || self + .inputs + .checked + .is_some_and(|at| at.elapsed() < INPUT_REFRESH) + { + return; + } + let scan = cx.background_executor().spawn(async { + ( + zeron_voice::input_devices(), + zeron_voice::default_input_device(), + ) + }); + self.inputs.task = Some(cx.spawn(async move |this, cx| { + let (devices, default) = scan.await; + this.update(cx, |this, cx| { + let changed = this.inputs.devices != devices || this.inputs.default != default; + this.inputs.devices = devices; + this.inputs.default = default; + this.inputs.checked = Some(Instant::now()); + if changed { + cx.notify(); + } + }) + .ok(); + // A static settings page otherwise has no reason to render again, + // so checking the age only in render misses hot-plugged devices. + // Invalidate once when the scan expires. Only a visible card starts + // the next scan, so leaving Settings does not keep polling devices. + cx.background_executor().timer(INPUT_REFRESH).await; + this.update(cx, |this, cx| { + this.inputs.task = None; + cx.notify(); + }) + .ok(); + })); + } + + /// Options for the microphone select and the index of the current one. A + /// saved device that is unplugged stays listed so the choice is visible; + /// recording falls back to the system default meanwhile. + fn input_options(&self, saved: Option<&str>) -> (Vec, usize) { + let default_name = self.inputs.default.as_ref().and_then(|id| { + self.inputs + .devices + .iter() + .find(|d| &d.id == id) + .map(|d| d.name.clone()) + }); + let mut options = vec![match default_name { + Some(name) => widgets::SelectOption::new("System default").detail(name), + None => widgets::SelectOption::new("System default"), + }]; + options.extend( + self.inputs + .devices + .iter() + .map(|d| widgets::SelectOption::new(d.name.clone())), + ); + let selected = match saved { + None => 0, + Some(id) => match self.inputs.devices.iter().position(|d| d.id == id) { + Some(ix) => ix + 1, + None => { + options.push(widgets::SelectOption::new("Disconnected microphone")); + options.len() - 1 + } + }, + }; + (options, selected) + } +} +impl Render for VoiceCard { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::of(cx).for_settings_surface(); + let enabled = settings::current(cx).dictation_enabled; + let downloading = self.cancel.is_some(); + let on = downloading || (enabled && self.ready); + let size_mb = zeron_voice::download_size() as f64 / 1e6; + let meta = if downloading { + Some(format!( + "Downloading · {:.0} of {size_mb:.0} MB", + self.progress as f64 / 1e6 + )) + } else if !self.ready { + Some(format!("{size_mb:.0} MB download")) + } else { + None + }; + let weak = cx.entity().downgrade(); + let switch = widgets::toggle_switch(&theme, on, "voice-dictation") + .id("voice-enable") + .cursor_pointer() + .tab_index(0) + .role(gpui::Role::Switch) + .aria_label("Dictation") + .aria_toggled(if on { + gpui::Toggled::True + } else { + gpui::Toggled::False + }) + .focus_visible(|s| s.border_2().border_color(theme.accent).opacity(1.0)) + .on_click(cx.listener(|this, _, _, cx| this.primary(cx))) + .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + this.primary(cx); + } + })) + .on_a11y_action(gpui::AccessibleAction::Click, move |_, _, cx| { + weak.update(cx, |this, cx| this.primary(cx)).ok(); + }); + let progress = downloading.then(|| { + div() + .mt(px(8.0)) + .w_full() + .h(px(3.0)) + .rounded_full() + .bg(theme.border) + .child( + div() + .h_full() + .rounded_full() + .w(gpui::relative( + (self.progress as f32 / zeron_voice::download_size() as f32) + .clamp(0.0, 1.0), + )) + .bg(theme.accent), + ) + }); + let dictation_row = widgets::card_row(&theme, true) + .child( + div() + .flex_1() + .min_w(px(160.0)) + .flex() + .flex_col() + .child(widgets::row_title(&theme, "Dictation")) + .children(meta.map(|meta| { + div() + .id("voice-status") + .role(gpui::Role::Status) + .aria_label(meta.clone()) + .child(widgets::meta_line(&theme, vec![meta.into_any_element()])) + })) + .children(progress), + ) + .child(switch); + let microphone_row = (enabled && self.ready).then(|| { + self.refresh_inputs(cx); + let saved = settings::current(cx).dictation_input.clone(); + let (options, selected) = self.input_options(saved.as_deref()); + let ids: Vec> = std::iter::once(None) + .chain(self.inputs.devices.iter().map(|d| Some(d.id.clone()))) + .chain(std::iter::once(saved)) + .collect(); + let control = widgets::select( + "voice-microphone", + "Microphone", + &theme, + |card: &mut Self| &mut card.input_select, + ) + .options(options, selected) + .width(200.0) + .on_select(move |_, ix, _, cx| { + let input = ids.get(ix).cloned().flatten(); + settings::update(settings::SavePolicy::Immediate, cx, |s| { + s.dictation_input = input + }); + cx.notify(); + }) + .render(&self.input_select, cx); + widgets::card_row(&theme, false) + .child( + div() + .flex_1() + .min_w(px(160.0)) + .child(widgets::row_title(&theme, "Microphone")), + ) + .child(control) + }); + // Always visible, so ⌘D can be rebound (and freed) before the model + // is downloaded. + let shortcut_row = { + let field = self + .shortcut + .get_or_insert_with(|| { + cx.new(|cx| { + settings::shortcuts::ShortcutField::new( + settings::ShortcutId::ToggleDictation, + cx, + ) + }) + }) + .clone(); + widgets::card_row(&theme, false) + .child( + div() + .flex_1() + .min_w(px(160.0)) + .flex() + .flex_col() + .child(widgets::row_title(&theme, "Shortcut")) + .child(widgets::meta_line( + &theme, + vec!["Hold to talk, release to transcribe".into_any_element()], + )), + ) + .child(field) + }; + let model_card = (self.cache_present && !downloading).then(|| { + let remove_weak = cx.entity().downgrade(); + widgets::section_card(&theme).child( + widgets::card_row(&theme, true) + .child( + div() + .flex_1() + .min_w(px(160.0)) + .flex() + .flex_col() + .child(widgets::row_title(&theme, "Speech model")) + .child(widgets::meta_line( + &theme, + vec![ + "Parakeet v3".into_any_element(), + format!("{size_mb:.0} MB").into_any_element(), + ], + )), + ) + .child( + widgets::action_button(&theme, widgets::ActionTone::Quiet) + .id("voice-remove") + .role(gpui::Role::Button) + .aria_label("Remove speech model") + .tab_index(0) + .child("Remove") + .focus_visible(|s| s.border_2().border_color(theme.accent)) + .on_click(cx.listener(|this, _, _, cx| this.remove(cx))) + .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + cx.stop_propagation(); + this.remove(cx); + } + })) + .on_a11y_action(gpui::AccessibleAction::Click, move |_, _, cx| { + remove_weak.update(cx, |this, cx| this.remove(cx)).ok(); + }), + ), + ) + }); + let error = self.error.as_ref().map(|e| { + div() + .id("voice-error") + .role(gpui::Role::Status) + .aria_label(e.clone()) + .child(widgets::error_strip(&theme, e.clone())) + }); + let scrollbar = popover::rail(self, "voice-page-scrollbar", &theme, cx); + div() + .id("voice-page-host") + .relative() + .size_full() + .on_hover(cx.listener(|this, hovered: &bool, _, cx| { + if this.scroll.set_list_hovered(*hovered) { + cx.notify(); + } + })) + .child( + crate::edge_fade::edge_faded( + 16.0, + true, + true, + div() + .id("voice-page") + .size_full() + .overflow_y_scroll() + .track_scroll(&self.scroll.scroll) + .child( + widgets::page_column() + .child(widgets::page_header(&theme, "Voice", None)) + .child(widgets::page_subtitle( + &theme, + "Transcribed on this device. Audio is never saved.", + )) + .child( + widgets::section_card(&theme) + .child(dictation_row) + .children(microphone_row) + .child(shortcut_row), + ) + .children(error) + .children(model_card), + ), + ) + .fade_overflow_y(&self.scroll.scroll), + ) + .children(scrollbar) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[gpui::test] + fn dictation_partial_cache_is_removable_after_restart(cx: &mut gpui::TestAppContext) { + let root = tempfile::tempdir().unwrap(); + let cache = root.path().join("models/parakeet-tdt-0.6b-v3-int8"); + std::fs::create_dir_all(&cache).unwrap(); + std::fs::write(cache.join("encoder-model.int8.onnx.part"), b"partial").unwrap(); + cx.update(|cx| settings::init(settings::UiSettings::default(), root.path(), cx)); + let card = cx.update(card); + card.update(cx, |card, cx| { + assert!(!card.ready); + assert_eq!(card.progress, 0); + assert!(card.cache_present); + card.remove(cx); + assert!(!card.cache_present); + assert!(!card.ready); + }); + assert!(!cache.exists()); + } + #[gpui::test] + fn microphone_choice_survives_disconnection(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| settings::init(settings::UiSettings::default(), dir.path(), cx)); + let card = cx.update(card); + card.update(cx, |card, _| { + card.inputs.devices = vec![ + zeron_voice::InputDevice { + id: "coreaudio:built-in".into(), + name: "MacBook Pro Microphone".into(), + }, + zeron_voice::InputDevice { + id: "coreaudio:usb".into(), + name: "USB Microphone".into(), + }, + ]; + let (options, selected) = card.input_options(None); + assert_eq!((options.len(), selected), (3, 0)); + assert_eq!(card.input_options(Some("coreaudio:usb")).1, 2); + let (options, selected) = card.input_options(Some("coreaudio:gone")); + assert_eq!((options.len(), selected), (4, 3)); + }); + } + #[gpui::test] + fn dictation_is_opt_in_and_disabling_preserves_download(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| settings::init(settings::UiSettings::default(), dir.path(), cx)); + cx.update(|cx| assert!(!enabled(cx))); + let card = cx.update(card); + card.update(cx, |card, cx| { + card.ready = true; + assert!(!settings::current(cx).dictation_enabled); + card.primary(cx); + }); + cx.update(|cx| assert!(enabled(cx))); + card.update(cx, |card, cx| { + card.primary(cx); + assert!(card.ready); + }); + cx.update(|cx| assert!(!enabled(cx))); + } +} diff --git a/crates/ui/src/dictation/permission.m b/crates/ui/src/dictation/permission.m new file mode 100644 index 0000000000..00bf5d8f26 --- /dev/null +++ b/crates/ui/src/dictation/permission.m @@ -0,0 +1,17 @@ +#import +#import +// No Rust callbacks or audio retained by permission blocks. +int zeron_microphone_permission(void) { + NSBundle *bundle = NSBundle.mainBundle; + if (![bundle.bundlePath.pathExtension isEqualToString:@"app"] || ![bundle objectForInfoDictionaryKey:@"NSMicrophoneUsageDescription"]) return -2; + AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]; + if (status == AVAuthorizationStatusAuthorized) return 1; + if (status == AVAuthorizationStatusNotDetermined) return 0; + return -1; +} +void zeron_request_microphone(void) { + [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) {}]; +} +uintptr_t zeron_microphone_window(void) { + return NSApp.active ? (uintptr_t)(__bridge void *)NSApp.keyWindow : 0; +} diff --git a/crates/ui/src/dictation/waveform.rs b/crates/ui/src/dictation/waveform.rs new file mode 100644 index 0000000000..8dfe87ef3e --- /dev/null +++ b/crates/ui/src/dictation/waveform.rs @@ -0,0 +1,151 @@ +//! Painted dictation waveform: one rounded bar per meter slot, scrolling from +//! the trailing edge. A single canvas keeps a full-width waveform to one +//! element and a handful of quads per frame. +use std::time::Instant; + +use gpui::{Bounds, Hsla, IntoElement, Styled, point, px, size}; + +use super::{Bar, FLOOR}; + +pub(crate) const BAR_WIDTH: f32 = 3.0; +const BAR_GAP: f32 = 3.0; +const PITCH: f32 = BAR_WIDTH + BAR_GAP; +/// Bars dissolve over this distance at the leading edge instead of clipping. +const EDGE_FADE: f32 = 28.0; +/// Seconds for the transcribing highlight to cross the bars once. +const SWEEP_PERIOD: f32 = 1.6; +/// Seconds per breath of the placeholder baseline while capture starts. +const BREATH_PERIOD: f32 = 1.4; + +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum Mode { + /// Microphone is opening: a quiet baseline breathes. + Waiting, + /// Live input: bars follow the voice. + Live, + /// Capture ended: bars hold still while a highlight sweeps across them. + Processing, +} + +pub(crate) struct Paint { + pub ink: Hsla, + pub quiet: Hsla, +} + +/// A process-wide clock so repeated renders stay phase-continuous. +pub(crate) fn seconds() -> f32 { + static EPOCH: std::sync::OnceLock = std::sync::OnceLock::new(); + EPOCH.get_or_init(Instant::now).elapsed().as_secs_f32() +} + +pub(crate) fn ease_out(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + 1.0 - (1.0 - t).powi(3) +} + +/// Entrance and exit progress, each 0–1. +#[derive(Clone, Copy, Default)] +pub(crate) struct Phase { + /// Baseline draws in from the trailing edge as the pill appears. + pub intro: f32, + /// Bars settle back into the baseline as the transcript lands. + pub collapse: f32, +} + +/// Bars ride a fixed grid of dots, so live input rises out of the same +/// baseline the waiting state shows. `motion` false freezes the waiting +/// breath and the processing sweep; the indicator still names the state. +pub(crate) fn waveform( + bars: Vec, + mode: Mode, + paint: Paint, + phase: Phase, + motion: bool, +) -> impl IntoElement { + let now = if motion { seconds() } else { 0.0 }; + gpui::canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let height = f32::from(bounds.size.height); + let left = f32::from(bounds.left()); + let right = f32::from(bounds.right()) - BAR_WIDTH; + let mid = f32::from(bounds.center().y); + let width = (right - left).max(1.0); + // Newest slot's scroll position; the dots scroll with the bars. + let offset = match mode { + Mode::Waiting => 0.0, + _ => bars.first().map_or(0.0, |bar| bar.age.fract()), + }; + let count = ((width + BAR_WIDTH) / PITCH).ceil() as usize + 1; + let sweep = (now / SWEEP_PERIOD).fract(); + let settle = 1.0 - ease_out(phase.collapse); + for k in 0..count { + let x = right - (k as f32 + offset) * PITCH; + let fade = ((x - left) / EDGE_FADE).clamp(0.0, 1.0); + // Draw in from the trailing edge: 0 there, 1 at the leading edge. + let distance = 1.0 - (x - left) / width; + let reveal = ease_out((phase.intro - 0.5 * distance) / 0.5); + let alpha = fade * reveal; + if alpha <= 0.0 { + continue; + } + let (amplitude, color) = match (mode, bars.get(k)) { + (Mode::Waiting, _) | (_, None) => { + // A soft swell travels toward the leading edge. + let swell = if mode == Mode::Waiting { + let wave = now / BREATH_PERIOD - k as f32 / 18.0; + (0.5 + 0.5 * (wave * std::f32::consts::TAU).sin()).powi(3) + } else { + 0.0 + }; + (FLOOR + 0.1 * swell, paint.quiet) + } + (_, Some(bar)) => { + // The newest bar rises out of the baseline as it enters. + let grow = if motion { ease_out(bar.age) } else { 1.0 }; + let color = if mode == Mode::Live { + paint.ink + } else { + // Right-to-left highlight, matching the scroll. + let position = 1.0 - (x - left) / width; + let distance = (position - (sweep * 1.4 - 0.2)).abs(); + let glow = (1.0 - distance / 0.18).clamp(0.0, 1.0); + crate::motion::mix(paint.quiet, paint.ink, ease_out(glow)) + }; + let lift = (1.0 - FLOOR) * bar.amplitude * grow * settle; + (FLOOR + lift, crate::motion::mix(paint.quiet, color, settle)) + } + }; + let h = + (amplitude.clamp(FLOOR, 1.0) * height * (0.4 + 0.6 * reveal)).max(BAR_WIDTH); + window.paint_quad( + gpui::fill( + Bounds::new(point(px(x), px(mid - h / 2.0)), size(px(BAR_WIDTH), px(h))), + color.opacity(alpha), + ) + .corner_radii(px(BAR_WIDTH / 2.0)), + ); + } + }, + ) + .flex_1() + .min_w_0() + .h_full() +} + +/// Display time for the recording clock: `0:07`. +pub(crate) fn clock(elapsed: std::time::Duration) -> String { + let seconds = elapsed.as_secs(); + format!("{}:{:02}", seconds / 60, seconds % 60) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clock_formats_minutes_and_padded_seconds() { + assert_eq!(clock(std::time::Duration::from_millis(7_900)), "0:07"); + assert_eq!(clock(std::time::Duration::from_secs(60)), "1:00"); + } +} diff --git a/crates/ui/src/icons.rs b/crates/ui/src/icons.rs index b211fc05e1..20d990664a 100644 --- a/crates/ui/src/icons.rs +++ b/crates/ui/src/icons.rs @@ -48,6 +48,7 @@ macro_rules! icon_assets { } icon_assets![ + (MICROPHONE, "microphone"), (PROJECT_DEFAULT, "project-default"), (REMOTE_SERVER, "remote-server"), // Service-tier bolt, drawn in the toolbar family's linear weight; the diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index e41a30df09..9abec8dd97 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -28,6 +28,7 @@ pub mod composer; mod composer_dock; mod composer_markdown; mod context_usage; +mod dictation; pub mod edge_fade; pub mod file_icons; pub mod files; diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index 1bd2389404..df67c24519 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -5818,7 +5818,7 @@ impl Render for Pickers { None => None, }; - // The composer places this model chip beside Send: + // The composer places this model chip beside microphone and Send: // brand icon + model name, then the effort as the chip's muted second // tone — the ladder's level, else an effort option's choice (Cursor). // None for the title picker (titles always run at minimal reasoning). diff --git a/crates/ui/src/queue.rs b/crates/ui/src/queue.rs index b727c7bf54..db2dfcdcef 100644 --- a/crates/ui/src/queue.rs +++ b/crates/ui/src/queue.rs @@ -1492,6 +1492,12 @@ impl Composer { if self.editing_queued.is_none() { return false; } + if self + .input + .update(cx, |input, cx| input.finish_dictation(true, cx)) + { + return true; + } let text = self.input.read(cx).text().to_string(); if !self.check_reference_delivery(&text, cx) { return true; @@ -1509,6 +1515,7 @@ impl Composer { if self.editing_queued.is_none() { return false; } + self.input.update(cx, |input, _| input.cancel_dictation()); self.finish_queue_edit("cancel", None, cx); true } @@ -1527,6 +1534,7 @@ impl Composer { self.queue_edit_pending_id = None; self.queue_edit_finishing = false; self.input.update(cx, |input, cx| { + input.cancel_dictation(); input.read_only = false; cx.notify(); }); @@ -1577,6 +1585,7 @@ impl Composer { }); self.queue_edit_finishing = true; self.input.update(cx, |input, cx| { + input.cancel_dictation(); input.read_only = true; cx.notify(); }); diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index 6aaad42a3d..c578b1f3e8 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -331,6 +331,8 @@ impl SettingsStore { } pub fn init(settings: UiSettings, data_dir: impl Into, cx: &mut App) { + let data_dir = data_dir.into(); + crate::dictation::init(data_dir.clone(), cx); cx.set_global(SettingsStore { current: settings, data_dir: data_dir.into(), @@ -785,6 +787,11 @@ pub const SKILL_COMPLETION_HARNESSES: [(zeron_proto::HarnessId, &str); 9] = [ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiSettings { + pub dictation_enabled: bool, + /// Dictation microphone as a `zeron_voice::InputDevice` id; `None` + /// follows the system default. + #[serde(skip_serializing_if = "Option::is_none")] + pub dictation_input: Option, #[serde(skip_serializing_if = "Option::is_none")] pub window_geometry: Option, /// Submit using Enter or the platform modifier plus Enter. @@ -967,6 +974,8 @@ pub struct UiSettings { impl Default for UiSettings { fn default() -> Self { Self { + dictation_enabled: false, + dictation_input: None, window_geometry: None, sidebar_width: SIDEBAR_DEFAULT, sidebar_collapsed: false, @@ -1077,6 +1086,7 @@ const JUMP_LABELS: [&str; JUMP_SLOTS] = [ /// rather than panicking. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ShortcutId { + ToggleDictation, CaptureAppshot, RandomWallpaper, SaveFile, @@ -1095,7 +1105,8 @@ pub enum ShortcutId { } impl ShortcutId { - pub const ALL: [ShortcutId; 14 + JUMP_SLOTS] = [ + pub const ALL: [ShortcutId; 15 + JUMP_SLOTS] = [ + ShortcutId::ToggleDictation, ShortcutId::CaptureAppshot, ShortcutId::RandomWallpaper, ShortcutId::SaveFile, @@ -1128,6 +1139,7 @@ impl ShortcutId { /// Row label (zeron lib/shortcuts.ts `SHORTCUT_DEFINITIONS`, verbatim). pub fn label(self) -> &'static str { match self { + ShortcutId::ToggleDictation => "Hold to dictate", ShortcutId::RandomWallpaper => "Random wallpaper", ShortcutId::CaptureAppshot => "Capture Appshot", ShortcutId::SaveFile => "Save file", @@ -1155,6 +1167,7 @@ impl ShortcutId { /// this guards against only exists off macOS). pub fn default_combo_on(self, mac: bool) -> &'static str { match self { + ShortcutId::ToggleDictation => "mod-d", ShortcutId::RandomWallpaper => "mod-u", ShortcutId::CaptureAppshot if mac => "ctrl-alt-space", ShortcutId::CaptureAppshot => "mod-alt-space", @@ -1203,6 +1216,7 @@ impl ShortcutId { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct KeymapConfig { + pub toggle_dictation: String, #[cfg_attr(not(any(target_os = "macos", target_os = "linux")), serde(skip))] pub capture_appshot: String, pub random_wallpaper: String, @@ -1269,6 +1283,7 @@ pub fn sidebar_pin_profile_key( impl Default for KeymapConfig { fn default() -> Self { Self { + toggle_dictation: ShortcutId::ToggleDictation.default_combo().into(), capture_appshot: ShortcutId::CaptureAppshot.default_combo().into(), random_wallpaper: ShortcutId::RandomWallpaper.default_combo().into(), save_file: ShortcutId::SaveFile.default_combo().into(), @@ -1291,6 +1306,7 @@ impl Default for KeymapConfig { impl KeymapConfig { pub fn get(&self, id: ShortcutId) -> &str { match id { + ShortcutId::ToggleDictation => &self.toggle_dictation, ShortcutId::CaptureAppshot => &self.capture_appshot, ShortcutId::RandomWallpaper => &self.random_wallpaper, ShortcutId::SaveFile => &self.save_file, @@ -1315,6 +1331,7 @@ impl KeymapConfig { pub fn set(&mut self, id: ShortcutId, combo: String) { match id { + ShortcutId::ToggleDictation => self.toggle_dictation = combo, ShortcutId::CaptureAppshot => self.capture_appshot = combo, ShortcutId::RandomWallpaper => self.random_wallpaper = combo, ShortcutId::SaveFile => self.save_file = combo, @@ -1581,6 +1598,104 @@ impl UiSettings { } } + /// Three-way merge for a view that keeps a working copy of the settings: + /// fields `edited` changed since `base` win, every other field keeps + /// `current`. A stale copy therefore never reverts a choice another + /// surface saved meanwhile. The destructure is exhaustive, so a new field + /// does not compile until it is merged here too. + pub fn merge_changes(base: &Self, edited: &Self, mut current: Self) -> Self { + macro_rules! merge { + ($($field:ident),* $(,)?) => {{ + let Self { $($field),* } = edited; + $(if *$field != base.$field { + current.$field = $field.clone(); + })* + }}; + } + merge!( + dictation_enabled, + dictation_input, + window_geometry, + composer_send_behavior, + skills_in_slash_menu, + skill_completion_by_harness, + compact_model_picker, + sidebar_width, + sidebar_collapsed, + sidebar_grouped, + sidebar_organization, + sidebar_sort, + sidebar_show_project_label, + sidebar_compact, + sidebar_show_project_icon, + sidebar_show_harness, + sidebar_show_branch, + sidebar_show_pull_request, + github_star_banner_dismissed, + last_space_id, + last_project_action_by_space_id, + open_tabs, + space_filter, + sidebar_sections_by_profile, + sidebar_pinned_session_ids_by_profile, + tab_order, + space_order, + sound_enabled, + sound_completion_enabled, + sound_input_enabled, + sound_attention_enabled, + notifications_enabled, + notifications_background_only, + files_panel_width, + agent_update_notifications, + right_pane_width, + right_pane_open, + terminal_height, + terminal_open, + keymap, + appshots_enabled, + appshot_sound_enabled, + appshot_destination, + escape_stops_active_agent, + settings_section, + appearance, + git_history_columns, + git_history_column_widths, + git_history_column_order, + git_history_author_display, + ui_font_family, + ui_font_size, + terminal_font_family, + terminal_font_size, + code_font_family, + code_font_size, + theme_selection, + diff_split, + diff_wrap, + code_fences_fit_content, + transcript_width, + open_web_links_in_zeron, + transcript_compact_mode, + files_autosave_enabled, + files_autosave_delay_ms, + files_word_wrap, + files_show_all, + accent, + surface, + new_thread_composer_background, + wallpaper_folder, + wallpaper_source, + wallpaper_history, + wallpaper_theme_colors, + wallpaper_color, + new_thread_background_effect, + reduce_motion, + pause_animations_in_background, + legacy_accent_color, + ); + current + } + /// Clamp widths into their legal ranges (also heals NaN to defaults). pub fn clamped(mut self) -> Self { self.transcript_width = normalize_transcript_width(self.transcript_width); @@ -1893,6 +2008,7 @@ mod tests { height: 800.0, }; let settings = UiSettings { + dictation_enabled: false, window_geometry: Some(geometry), ..Default::default() }; @@ -2499,6 +2615,8 @@ mod tests { fn round_trip() { let dir = tempfile::tempdir().unwrap(); let settings = UiSettings { + dictation_enabled: false, + dictation_input: Some("coreaudio:usb-mic".into()), window_geometry: None, sidebar_width: 300.0, sidebar_collapsed: true, diff --git a/crates/ui/src/settings/accounts.rs b/crates/ui/src/settings/accounts.rs index 35eaddaf39..260c8afd45 100644 --- a/crates/ui/src/settings/accounts.rs +++ b/crates/ui/src/settings/accounts.rs @@ -121,7 +121,9 @@ pub fn is_openable_login_url(url: &str) -> bool { "https" => true, "http" => { let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); - let authority = authority.rsplit_once('@').map_or(authority, |(_, host)| host); + let authority = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); let host = match authority.strip_prefix('[') { Some(v6) => v6.split(']').next().unwrap_or(""), None => authority.split(':').next().unwrap_or(""), @@ -1185,7 +1187,10 @@ impl AccountsPage { // line, so a failed probe never changes the row's height. let usage: AnyElement = if account.usage_windows.is_empty() { meta.extend(self.render_usage_missing(account, theme)); - div().w(px(USAGE_COLUMN_WIDTH)).flex_none().into_any_element() + div() + .w(px(USAGE_COLUMN_WIDTH)) + .flex_none() + .into_any_element() } else { let resets = account .usage_windows @@ -1371,7 +1376,9 @@ impl AccountsPage { .flex_1() .min_w_0() .child(widgets::row_title(theme, email.clone()).truncate()) - .when(!meta.is_empty(), |el| el.child(widgets::meta_line(theme, meta))), + .when(!meta.is_empty(), |el| { + el.child(widgets::meta_line(theme, meta)) + }), ) .child(usage) .child( @@ -2218,7 +2225,9 @@ impl Render for AccountsPage { mod tests { #[test] fn only_web_and_loopback_login_urls_open() { - assert!(is_openable_login_url("https://claude.com/cai/oauth/authorize?x=1")); + assert!(is_openable_login_url( + "https://claude.com/cai/oauth/authorize?x=1" + )); assert!(is_openable_login_url("http://localhost:1455/auth/callback")); assert!(is_openable_login_url("http://127.0.0.1:8080/")); assert!(is_openable_login_url("http://[::1]:9000/cb")); diff --git a/crates/ui/src/settings/appearance.rs b/crates/ui/src/settings/appearance.rs index 3fc10efcce..06eea23100 100644 --- a/crates/ui/src/settings/appearance.rs +++ b/crates/ui/src/settings/appearance.rs @@ -4526,7 +4526,9 @@ mod tests { }); let (_page, cx) = cx.add_window_view(|_, cx| AppearancePage::new(cx)); cx.update(|window, cx| window.draw(cx).clear()); - let trigger = cx.debug_bounds("appearance-surface").expect("glass trigger"); + let trigger = cx + .debug_bounds("appearance-surface") + .expect("glass trigger"); cx.simulate_click(trigger.center(), gpui::Modifiers::default()); cx.update(|window, cx| window.draw(cx).clear()); let frosted = cx diff --git a/crates/ui/src/settings/devices.rs b/crates/ui/src/settings/devices.rs index 7777ca7061..a851c40fdd 100644 --- a/crates/ui/src/settings/devices.rs +++ b/crates/ui/src/settings/devices.rs @@ -289,7 +289,9 @@ impl Render for DevicesPage { let rename_name = device.name.clone(); let mut meta: Vec = vec![ div() - .child(SharedString::from(platform_label(&device.platform).to_string())) + .child(SharedString::from( + platform_label(&device.platform).to_string(), + )) .into_any_element(), ]; if let Some(version) = device.version.as_deref().filter(|v| !v.is_empty()) { @@ -340,9 +342,11 @@ impl Render for DevicesPage { .role(gpui::Role::Button) .aria_label(format!("Copy device ID {}", device.id)) .focus_visible(|s| s.border_2().border_color(theme.accent)) - .on_click(cx.listener(move |this, _, _, cx| { - this.copy_id(copy_id.clone(), cx); - })), + .on_click(cx.listener( + move |this, _, _, cx| { + this.copy_id(copy_id.clone(), cx); + }, + )), ) .child( widgets::text_action(&theme, widgets::ActionTone::Filled, "Rename") diff --git a/crates/ui/src/settings/shortcuts.rs b/crates/ui/src/settings/shortcuts.rs index a461748d1d..de18c564f7 100644 --- a/crates/ui/src/settings/shortcuts.rs +++ b/crates/ui/src/settings/shortcuts.rs @@ -230,53 +230,14 @@ impl ShortcutsPage { } RecordOutcome::Ignored => {} RecordOutcome::Set(combo) => { - if recording == ShortcutId::CaptureAppshot - && crate::appshots::validate_shortcut(&combo).is_err() - { - self.conflict_notice = Some( - format!( - "Use {} with a letter, number, function key or navigation key.", - if cfg!(target_os = "macos") { - "Control, Option or Command" - } else { - "Control or Alt" - } - ) - .into(), - ); - self.stop_recording(); - cx.notify(); - cx.stop_propagation(); - return; - } - if send_combo_is_reserved(self.composer_send_behavior, &combo) { - self.conflict_notice = Some( - format!("{} is reserved for the composer.", display_combo(&combo)).into(), - ); - self.stop_recording(); - cx.notify(); - cx.stop_propagation(); - return; - } - // A combo already bound elsewhere is REFUSED, naming the owner - // (zeron settings.shortcuts.tsx: "… is already assigned to …"). - if let Some(owner) = conflict_owner(&self.keymap, recording, &combo) { - self.conflict_notice = Some( - format!( - "{} is already assigned to {}.", - display_combo(&combo), - owner.label() - ) - .into(), - ); - self.stop_recording(); - cx.notify(); - } else { + self.stop_recording(); + self.conflict_notice = + refusal(&self.keymap, self.composer_send_behavior, recording, &combo); + if self.conflict_notice.is_none() { self.keymap.set(recording, combo); - self.stop_recording(); - self.conflict_notice = None; self.commit(cx); } + cx.notify(); } } cx.stop_propagation(); @@ -329,87 +290,28 @@ impl ShortcutsPage { theme: &Theme, cx: &mut Context, ) -> gpui::Div { - let accent = theme.accent; - let combo = self.keymap.get(id).to_string(); - let is_recording = recording == Some(id); - let non_default = combo != id.default_combo(); - let chip_text: SharedString = if is_recording { - "Press keys…".into() - } else { - display_combo(&combo).into() - }; - div() - .flex() - .flex_none() - .items_center() - .gap(px(20.0)) - .when(non_default && !is_recording, |el| { - el.child( - div() - .id(("shortcut-reset", ix)) - .role(gpui::Role::Button) - .aria_label(format!("Reset {} shortcut", id.label())) - .min_h(px(24.0)) - .tab_index(0) - .focus_visible(move |style| style.border_2().border_color(accent)) - .text_size(crate::typography::ui_rems(11.0)) - .text_color(theme.text_muted) - .cursor_pointer() - .hover(|s| s.text_color(theme.text)) - .on_click(cx.listener(move |this, _, _, cx| { - this.keymap.reset(id); - this.stop_recording(); - this.commit(cx); - })) - .child(SharedString::from("Reset")), - ) - }) - .child( - div() - .id(("shortcut-combo", ix)) - .role(gpui::Role::Button) - .aria_label(format!( - "Change {} shortcut: {}", - id.label(), - display_combo(&combo) - )) - .tab_index(0) - .min_w(px(96.0)) - .h(px(widgets::SELECT_HEIGHT)) - .px(px(12.0)) - .rounded(px(8.0)) - // Same glass wash as the settings dropdowns; the - // transparent edge is held for the focus ring. - .border_1() - .border_color(gpui::transparent_black()) - .focus_visible(move |style| style.border_color(accent)) - .flex() - .items_center() - .justify_center() - .font_family(theme.font_mono.clone()) - .text_size(crate::typography::ui_rems(12.0)) - .cursor_pointer() - .map(|el| { - if is_recording { - el.bg(accent.opacity(0.16)) - .border_color(accent.opacity(0.55)) - .text_color(theme.text) - } else { - let hover_key = format!("shortcut-combo-{ix}-hover"); - el.bg(crate::motion::hover_blend( - &hover_key, - widgets::select_fill(theme, false), - widgets::select_fill(theme, true), - )) - .on_hover(crate::motion::hover_listener(hover_key)) - .text_color(theme.text) - } + let page = cx.entity().downgrade(); + let reset_page = page.clone(); + binding_control( + id, + ix, + self.keymap.get(id), + recording == Some(id), + theme, + move |_, cx| { + reset_page + .update(cx, |this, cx| { + this.keymap.reset(id); + this.stop_recording(); + this.commit(cx); }) - .on_click(cx.listener(move |this, _, window, cx| { - this.start_recording(id, window, cx); - })) - .child(chip_text), - ) + .ok(); + }, + move |window, cx| { + page.update(cx, |this, cx| this.start_recording(id, window, cx)) + .ok(); + }, + ) } fn on_scroll_hovered(&mut self, hovered: &bool, _: &mut Window, cx: &mut Context) { @@ -459,6 +361,303 @@ impl popover::ScrollRailHost for ShortcutsPage { } } +/// One rebindable shortcut on the settings page of the feature it drives +/// (Settings → Voice). Records and refuses combos exactly like a Shortcuts +/// row, then saves straight to the store and re-applies the keymap. +pub struct ShortcutField { + id: ShortcutId, + focus: FocusHandle, + recording: bool, + recording_subscriptions: Option<[gpui::Subscription; 3]>, + notice: Option, +} + +impl ShortcutField { + pub fn new(id: ShortcutId, cx: &mut Context) -> Self { + cx.on_release(|field, _| { + if field.recording { + crate::appshots::set_recording(false); + } + }) + .detach(); + Self { + id, + focus: cx.focus_handle(), + recording: false, + recording_subscriptions: None, + notice: None, + } + } + + fn start_recording(&mut self, window: &mut Window, cx: &mut Context) { + self.recording = true; + crate::appshots::set_recording(true); + let field = cx.entity().downgrade(); + // Bound actions run before Div key listeners; intercept first so the + // chord being recorded (⌘D itself) never starts dictation. + let interceptor = cx.intercept_keystrokes(move |event, window, cx| { + let _ = field.update(cx, |field, cx| { + if field.focus.is_focused(window) { + field.record_keystroke(&event.keystroke, cx); + } + }); + }); + let blur = cx.on_blur(&self.focus, window, |field, _, cx| { + field.stop_recording(); + cx.notify(); + }); + // The Voice page outlives its window (closing it keeps the app + // running), and a close does not blur. Never leave Appshots' + // capture shortcut suspended. + let field = cx.entity().downgrade(); + let closed = cx.on_window_closed(move |cx, _| { + field + .update(cx, |field, cx| { + field.stop_recording(); + cx.notify(); + }) + .ok(); + }); + self.recording_subscriptions = Some([interceptor, blur, closed]); + self.notice = None; + window.focus(&self.focus, cx); + cx.notify(); + } + + fn stop_recording(&mut self) { + if self.recording { + crate::appshots::set_recording(false); + } + self.recording = false; + self.recording_subscriptions = None; + } + + fn record_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context) { + let mods = &keystroke.modifiers; + match record_key( + &keystroke.key, + mods.control, + mods.alt, + mods.shift, + mods.platform, + ) { + RecordOutcome::Cancelled => self.stop_recording(), + RecordOutcome::Ignored => {} + RecordOutcome::Set(combo) => { + self.stop_recording(); + let current = crate::settings::current(cx); + self.notice = refusal( + ¤t.keymap, + current.composer_send_behavior, + self.id, + &combo, + ); + if self.notice.is_none() { + self.save(Some(combo), cx); + } + } + } + cx.notify(); + cx.stop_propagation(); + } + + /// `None` restores the default binding. + fn save(&mut self, combo: Option, cx: &mut Context) { + let id = self.id; + crate::settings::update( + crate::settings::SavePolicy::Immediate, + cx, + |settings| match combo { + Some(combo) => settings.keymap.set(id, combo), + None => settings.keymap.reset(id), + }, + ); + let current = crate::settings::current(cx); + crate::shell::apply_keymap(cx, ¤t.keymap, current.composer_send_behavior); + cx.notify(); + } +} + +impl Render for ShortcutField { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::of(cx).for_settings_surface(); + let combo = crate::settings::current(cx).keymap.get(self.id).to_string(); + let ix = ShortcutId::ALL + .iter() + .position(|&id| id == self.id) + .unwrap_or(0); + let reset = cx.entity().downgrade(); + let record = reset.clone(); + let helper: Option = if self.recording { + Some("Press Escape to cancel.".into()) + } else { + self.notice.clone() + }; + div() + .id("shortcut-field") + .track_focus(&self.focus) + .flex() + .flex_col() + .items_end() + .gap(px(4.0)) + .child(binding_control( + self.id, + ix, + &combo, + self.recording, + &theme, + move |_, cx| { + reset + .update(cx, |field, cx| { + field.stop_recording(); + field.notice = None; + field.save(None, cx); + }) + .ok(); + }, + move |window, cx| { + record + .update(cx, |field, cx| field.start_recording(window, cx)) + .ok(); + }, + )) + .children(helper.map(|helper| { + div() + .text_size(crate::typography::ui_rems(12.0)) + .text_color(theme.text_muted) + .child(helper) + })) + } +} + +/// Reset (when customized) plus the click-to-record combo chip; recording +/// inverts it to the accent wash. Shared by Settings → Shortcuts and the +/// feature pages that own a shortcut ([`ShortcutField`]). +fn binding_control( + id: ShortcutId, + ix: usize, + combo: &str, + is_recording: bool, + theme: &Theme, + on_reset: impl Fn(&mut Window, &mut gpui::App) + 'static, + on_record: impl Fn(&mut Window, &mut gpui::App) + 'static, +) -> gpui::Div { + let accent = theme.accent; + let combo = combo.to_string(); + let non_default = combo != id.default_combo(); + let chip_text: SharedString = if is_recording { + "Press keys…".into() + } else { + display_combo(&combo).into() + }; + div() + .flex() + .flex_none() + .items_center() + .gap(px(20.0)) + .when(non_default && !is_recording, |el| { + el.child( + div() + .id(("shortcut-reset", ix)) + .role(gpui::Role::Button) + .aria_label(format!("Reset {} shortcut", id.label())) + .min_h(px(24.0)) + .tab_index(0) + .focus_visible(move |style| style.border_2().border_color(accent)) + .text_size(crate::typography::ui_rems(11.0)) + .text_color(theme.text_muted) + .cursor_pointer() + .hover(|s| s.text_color(theme.text)) + .on_click(move |_, window, cx| on_reset(window, cx)) + .child(SharedString::from("Reset")), + ) + }) + .child( + div() + .id(("shortcut-combo", ix)) + .role(gpui::Role::Button) + .aria_label(format!( + "Change {} shortcut: {}", + id.label(), + display_combo(&combo) + )) + .tab_index(0) + .min_w(px(96.0)) + .h(px(widgets::SELECT_HEIGHT)) + .px(px(12.0)) + .rounded(px(8.0)) + // Same glass wash as the settings dropdowns; the + // transparent edge is held for the focus ring. + .border_1() + .border_color(gpui::transparent_black()) + .focus_visible(move |style| style.border_color(accent)) + .flex() + .items_center() + .justify_center() + .font_family(theme.font_mono.clone()) + .text_size(crate::typography::ui_rems(12.0)) + .cursor_pointer() + .map(|el| { + if is_recording { + el.bg(accent.opacity(0.16)) + .border_color(accent.opacity(0.55)) + .text_color(theme.text) + } else { + let hover_key = format!("shortcut-combo-{ix}-hover"); + el.bg(crate::motion::hover_blend( + &hover_key, + widgets::select_fill(theme, false), + widgets::select_fill(theme, true), + )) + .on_hover(crate::motion::hover_listener(hover_key)) + .text_color(theme.text) + } + }) + .on_click(move |_, window, cx| on_record(window, cx)) + .child(chip_text), + ) +} + +/// Why `combo` cannot be bound to `id`, if it cannot. Conflicts never +/// persist; they're refused at record time, naming the owner (zeron +/// settings.shortcuts.tsx: "… is already assigned to …"). Pure. +fn refusal( + keymap: &KeymapConfig, + behavior: ComposerSendBehavior, + id: ShortcutId, + combo: &str, +) -> Option { + if id == ShortcutId::CaptureAppshot && crate::appshots::validate_shortcut(combo).is_err() { + return Some( + format!( + "Use {} with a letter, number, function key or navigation key.", + if cfg!(target_os = "macos") { + "Control, Option or Command" + } else { + "Control or Alt" + } + ) + .into(), + ); + } + if send_combo_is_reserved(behavior, combo) { + return Some(format!("{} is reserved for the composer.", display_combo(combo)).into()); + } + conflict_owner(keymap, id, combo).map(|owner| { + // Name the page for shortcuts that live on their feature's page. + let page = match group(owner) { + page @ ("Appshots" | "Voice") => format!(" in Settings → {page}"), + _ => String::new(), + }; + format!( + "{} is already assigned to {}{page}.", + display_combo(combo), + owner.label() + ) + .into() + }) +} + /// The shortcut (other than `id`) already bound to `combo`, if any. Pure. pub fn conflict_owner(keymap: &KeymapConfig, id: ShortcutId, combo: &str) -> Option { ShortcutId::ALL @@ -479,7 +678,7 @@ pub fn modifier_send_label(is_macos: bool) -> &'static str { /// extends the match and appears on the page by construction /// (`every_shortcut_lands_in_a_rendered_group` holds the other half: its group /// name must be listed here). -const GROUP_ORDER: [&str; 8] = [ +const GROUP_ORDER: [&str; 9] = [ "Appearance", "Files", "Browser", @@ -488,6 +687,7 @@ const GROUP_ORDER: [&str; 8] = [ "Projects", "Jump to session", "Appshots", + "Voice", ]; /// The section a shortcut's row renders under. @@ -502,6 +702,7 @@ fn group(id: ShortcutId) -> &'static str { | ShortcutId::ToggleFiles | ShortcutId::ToggleTerminal => "Panels", ShortcutId::NewProject => "Projects", + ShortcutId::ToggleDictation => "Voice", ShortcutId::OpenModelPicker | ShortcutId::NewSession | ShortcutId::NextSession @@ -513,6 +714,13 @@ fn group(id: ShortcutId) -> &'static str { impl Render for ShortcutsPage { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Feature pages (Voice) rebind their shortcut through the store. Read + // it back so a later commit here never republishes an older keymap. + if self.recording.is_none() + && let Some(store) = cx.try_global::() + { + self.keymap.clone_from(&store.current.keymap); + } if self.appshots_page { if std::mem::take(&mut self.appshots_focus_pending) { window.focus(&self.focus, cx); @@ -712,7 +920,8 @@ impl Render for ShortcutsPage { // own top margin is zeroed. let mut groups: Vec = Vec::new(); for name in GROUP_ORDER { - if name == "Appshots" { + // Feature pages own these: Settings → Appshots and → Voice. + if name == "Appshots" || name == "Voice" { continue; } let mut card = widgets::section_card(&theme).mt_0(); @@ -839,6 +1048,84 @@ impl Render for ShortcutsPage { mod tests { use super::*; + #[gpui::test] + fn voice_shortcut_records_on_its_own_page_and_shortcuts_reads_it_back( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::settings::init(crate::settings::UiSettings::default(), dir.path(), cx); + }); + assert_eq!(ShortcutId::ToggleDictation.default_combo(), "mod-d"); + assert_eq!(group(ShortcutId::ToggleDictation), "Voice"); + let modifier = if cfg!(target_os = "macos") { + "cmd" + } else { + "ctrl" + }; + let (field, cx) = + cx.add_window_view(|_, cx| ShortcutField::new(ShortcutId::ToggleDictation, cx)); + // A chord another shortcut owns is refused and the binding is kept. + field.update_in(cx, |field, window, cx| field.start_recording(window, cx)); + cx.simulate_keystrokes(&format!("{modifier}-b")); + field.update(cx, |field, cx| { + assert!(!field.recording); + assert!(field.notice.is_some()); + assert_eq!( + crate::settings::current(cx).keymap.toggle_dictation, + "mod-d" + ); + }); + field.update_in(cx, |field, window, cx| field.start_recording(window, cx)); + cx.simulate_keystrokes(&format!("{modifier}-shift-v")); + field.update(cx, |field, cx| { + assert!(field.notice.is_none()); + assert_eq!( + crate::settings::current(cx).keymap.toggle_dictation, + "mod-shift-v" + ); + }); + // Closing the window mid-recording (the app keeps running) must not + // leave Appshots' capture shortcut suspended. + field.update_in(cx, |field, window, cx| field.start_recording(window, cx)); + cx.update(|window, _| window.remove_window()); + cx.run_until_parked(); + field.update(cx, |field, _| assert!(!field.recording)); + // Elsewhere, a clash with a feature page's shortcut says where it lives. + let notice = refusal( + &KeymapConfig::default(), + ComposerSendBehavior::Enter, + ShortcutId::ToggleSidebar, + "mod-d", + ) + .unwrap(); + assert!( + notice.ends_with("Hold to dictate in Settings → Voice."), + "{notice}" + ); + // Settings → Shortcuts, opened later with an older copy, adopts the + // store's binding so its own commits cannot revert Voice's. + let (page, cx) = cx.add_window_view(|_, cx| { + let state = cx.new(|_| AppState::new()); + ShortcutsPage::new( + state, + KeymapConfig::default(), + false, + ComposerSendBehavior::Enter, + false, + false, + AppshotDestination::Automatic, + cx, + ) + }); + cx.update(|window, cx| window.draw(cx).clear()); + page.update(cx, |page, _| { + assert_eq!(page.keymap.toggle_dictation, "mod-shift-v"); + }); + } + #[gpui::test] fn conversation_controls_work_after_moving_out_of_shortcuts(cx: &mut gpui::TestAppContext) { cx.update(|cx| { diff --git a/crates/ui/src/settings/thread_naming.rs b/crates/ui/src/settings/thread_naming.rs index 2c7012230c..7e566afbdf 100644 --- a/crates/ui/src/settings/thread_naming.rs +++ b/crates/ui/src/settings/thread_naming.rs @@ -27,9 +27,12 @@ impl ThreadNamingCard { let state = state.clone(); cx.new(|cx| Pickers::new_for_titles(state, TitleSettings::default(), cx)) }; - let picked = cx.subscribe(&picker, |this: &mut Self, _, event: &TitleModelPicked, cx| { - this.call(Some(event.0.clone()), cx); - }); + let picked = cx.subscribe( + &picker, + |this: &mut Self, _, event: &TitleModelPicked, cx| { + this.call(Some(event.0.clone()), cx); + }, + ); // Settings can open before the engine connects: load once it does. let state_sub = cx.observe(&state, |this: &mut Self, _, cx| { if matches!(this.settings, Loadable::Idle) { @@ -155,7 +158,9 @@ impl Render for ThreadNamingCard { .child(control), ) .when_some(self.error.clone(), |card, error| { - card.child(widgets::card_row(&theme, false).child(widgets::error_strip(&theme, error))) + card.child( + widgets::card_row(&theme, false).child(widgets::error_strip(&theme, error)), + ) }) } } diff --git a/crates/ui/src/settings/widgets.rs b/crates/ui/src/settings/widgets.rs index fdbd8928db..e649bf555a 100644 --- a/crates/ui/src/settings/widgets.rs +++ b/crates/ui/src/settings/widgets.rs @@ -1652,7 +1652,11 @@ impl RenderOnce for SettingsScroll { pub struct TextTooltip(pub SharedString); impl Render for TextTooltip { - fn render(&mut self, _window: &mut gpui::Window, cx: &mut gpui::Context) -> impl IntoElement { + fn render( + &mut self, + _window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> impl IntoElement { let theme = Theme::of(cx); let card = div() .max_w(px(320.0)) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index b788e66744..ed2749e2e9 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -418,6 +418,14 @@ pub fn apply_keymap( // rebuilding Zeron's bindings so the file editor keymap remains active. gpui_base::init(cx); crate::composer::init(cx, composer_send_behavior); + cx.bind_keys([KeyBinding::new( + &valid_or_default( + &keymap.toggle_dictation, + ShortcutId::ToggleDictation.default_combo(), + ), + crate::composer::ToggleDictation, + Some("MessageComposer"), + )]); // Fixed app-level shortcuts (Settings on every platform; ⌘Q quit, ⌘W // close, ⌘M minimize, ⌘H hide on macOS) — these back the native menu // key equivalents and must survive keymap re-application. @@ -523,6 +531,7 @@ pub enum SettingsSection { Appearance, Files, Notifications, + Voice, Shortcuts, /// Composer and conversation behavior plus thread naming. #[default] @@ -533,10 +542,11 @@ pub enum SettingsSection { impl SettingsSection { /// Sections shown in Settings. `Agents` is a legacy Accounts route alias. - pub const ALL: [SettingsSection; 9] = [ + pub const ALL: [SettingsSection; 10] = [ SettingsSection::General, SettingsSection::Appearance, SettingsSection::Notifications, + SettingsSection::Voice, SettingsSection::Shortcuts, SettingsSection::Harnesses, SettingsSection::Devices, @@ -561,7 +571,7 @@ impl SettingsSection { /// Where a generic "open Settings" lands for a remembered section: legacy /// aliases resolve to their page, and a section this build does not show - /// (Appshots off-desktop) falls back to General. + /// (Appshots off macOS/Linux) falls back to General. pub(crate) fn reopenable(self) -> Self { let section = self.canonical(); if section.visible_in_nav() { @@ -580,6 +590,7 @@ impl SettingsSection { SettingsSection::Appearance => "appearance", SettingsSection::Files => "files", SettingsSection::Notifications => "notifications", + SettingsSection::Voice => "voice", SettingsSection::Shortcuts => "shortcuts", SettingsSection::General => "general", SettingsSection::Appshots => "appshots", @@ -596,6 +607,7 @@ impl SettingsSection { "appearance" => SettingsSection::Appearance, "files" => SettingsSection::Files, "notifications" => SettingsSection::Notifications, + "voice" => SettingsSection::Voice, "shortcuts" => SettingsSection::Shortcuts, "general" | "conversations" => SettingsSection::General, "appshots" => SettingsSection::Appshots, @@ -620,6 +632,7 @@ impl SettingsSection { SettingsSection::Appearance => "Appearance", SettingsSection::Files => "Files", SettingsSection::Notifications => "Notifications", + SettingsSection::Voice => "Voice", SettingsSection::Shortcuts => "Shortcuts", SettingsSection::General => "General", SettingsSection::Appshots => "Appshots", @@ -1958,6 +1971,9 @@ pub struct Shell { boot: EngineBootConfig, data_dir: PathBuf, settings: UiSettings, + /// The store's settings when `settings` last synced with it. Fields that + /// differ from this are the Shell's edits; all others follow the store. + settings_base: UiSettings, /// Session-scoped panel open flags (terminal / changes per chat; §1.10-1.11 /// parity — heights stay in [`UiSettings`]). panels: SessionPanels, @@ -2363,6 +2379,7 @@ impl Shell { import_current: None, boot, data_dir, + settings_base: settings::current(cx), settings, panels: SessionPanels::default(), active_chat: String::new(), @@ -4460,38 +4477,20 @@ impl Shell { self.settings.theme_selection = crate::appearance::themes(cx); self.settings.accent = crate::appearance::accent(cx); self.settings.surface = crate::appearance::surface(cx); - self.sync_independent_settings(cx); + self.pull_settings(cx); settings::replace(self.settings.clone(), SavePolicy::Debounced, cx); + self.settings_base = settings::current(cx); } - /// Controls outside the Shell mutate these choices directly. A geometry - /// save must never publish the Shell's older values over those selections. - /// The typography globals own the font choices but persist every change - /// immediately, so the central store is an equally canonical read and - /// keeps this block on a single source. - fn sync_independent_settings(&mut self, cx: &App) { + /// Controls outside the Shell (Voice, typography, diff and transcript + /// toggles, …) save to the central store directly. Fold those into the + /// working copy while keeping the Shell's own edits, so a geometry save + /// can never publish an older value over another surface's choice. + fn pull_settings(&mut self, cx: &App) { let current = settings::current(cx); - self.settings.window_geometry = current.window_geometry; - self.settings.new_thread_composer_background = current.new_thread_composer_background; - self.settings.new_thread_background_effect = current.new_thread_background_effect; - self.settings.wallpaper_folder = current.wallpaper_folder; - self.settings.wallpaper_source = current.wallpaper_source; - self.settings.wallpaper_history = current.wallpaper_history; - self.settings.wallpaper_theme_colors = current.wallpaper_theme_colors; - self.settings.wallpaper_color = current.wallpaper_color; - self.settings.open_web_links_in_zeron = current.open_web_links_in_zeron; - self.settings.ui_font_family = current.ui_font_family; - self.settings.ui_font_size = current.ui_font_size; - self.settings.terminal_font_family = current.terminal_font_family; - self.settings.terminal_font_size = current.terminal_font_size; - self.settings.code_font_family = current.code_font_family; - self.settings.code_font_size = current.code_font_size; - self.settings.transcript_width = current.transcript_width; - self.settings.skill_completion_by_harness = current.skill_completion_by_harness; - self.settings.skills_in_slash_menu = current.skills_in_slash_menu; - self.settings.reduce_motion = current.reduce_motion; - self.settings.pause_animations_in_background = current.pause_animations_in_background; - self.settings.compact_model_picker = current.compact_model_picker; + self.settings = + UiSettings::merge_changes(&self.settings_base, &self.settings, current.clone()); + self.settings_base = current; } fn retry_engine(&mut self, cx: &mut Context) { @@ -4899,9 +4898,8 @@ impl Shell { None => Empty.into_any_element(), } } - SettingsSection::Shortcuts - | SettingsSection::General - | SettingsSection::Appshots => { + SettingsSection::Voice => crate::dictation::card(cx).into_any_element(), + SettingsSection::Shortcuts | SettingsSection::General | SettingsSection::Appshots => { if self.shortcuts_page.is_none() { let state = self.state.clone(); let keymap = self.settings.keymap.clone(); @@ -6847,6 +6845,7 @@ impl Shell { SettingsSection::Appearance => icons::TUNING, SettingsSection::Files => icons::FOLDER, SettingsSection::Notifications => icons::BELL, + SettingsSection::Voice => icons::MICROPHONE, SettingsSection::Shortcuts => icons::KEYBOARD, SettingsSection::General => icons::SETTINGS, SettingsSection::Appshots => icons::MONITOR, @@ -12239,7 +12238,7 @@ impl Render for Shell { self.settings.theme_selection = crate::appearance::themes(cx); self.settings.accent = crate::appearance::accent(cx); self.settings.surface = crate::appearance::surface(cx); - self.sync_independent_settings(cx); + self.pull_settings(cx); let theme = Theme::of(cx); // The shell frost sits over native desktop blur on macOS and Windows. // Content surfaces add their own backgrounds over this shared tint. @@ -14709,6 +14708,78 @@ mod exit_regressions { .unwrap(); } + #[gpui::test] + fn shell_saves_keep_every_setting_chosen_outside_the_shell(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + settings::init(settings::UiSettings::default(), dir.path(), cx); + crate::history::init( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + cx, + ); + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + }); + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Shell::new( + state, + EngineBootConfig { + data_dir: dir.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }, + cx, + ) + }); + window + .update(cx, |shell, _, cx| { + // Voice, transcript and diff controls save straight to the + // store. None of them is known to the Shell. + settings::update(SavePolicy::Immediate, cx, |settings| { + settings.dictation_enabled = true; + settings.dictation_input = Some("coreaudio:usb".into()); + settings.transcript_compact_mode = true; + settings.code_fences_fit_content = true; + settings.diff_split = true; + settings.diff_wrap = true; + }); + // Leaving Settings and resizing both republish the Shell's copy. + shell.settings.settings_section = SettingsSection::Appearance; + shell.settings.sidebar_width = 300.0; + shell.schedule_save(cx); + let saved = settings::current(cx); + assert!(saved.dictation_enabled); + assert_eq!(saved.dictation_input.as_deref(), Some("coreaudio:usb")); + assert!(saved.transcript_compact_mode); + assert!(saved.code_fences_fit_content); + assert!(saved.diff_split); + assert!(saved.diff_wrap); + assert_eq!(saved.settings_section, SettingsSection::Appearance); + assert_eq!(saved.sidebar_width, 300.0); + // An outside change to a field the Shell also holds still wins + // once the Shell has nothing newer of its own. + settings::update(SavePolicy::Immediate, cx, |settings| { + settings.dictation_enabled = false; + settings.sidebar_width = 320.0; + }); + shell.schedule_save(cx); + let saved = settings::current(cx); + assert!(!saved.dictation_enabled); + assert_eq!(saved.sidebar_width, 320.0); + assert_eq!(shell.settings.sidebar_width, 320.0); + }) + .unwrap(); + } + #[gpui::test] fn sidebar_settings_trigger_toggles_the_settings_route(cx: &mut TestAppContext) { // Just the footer row: the full shell's settings modal would cover it. @@ -16443,6 +16514,7 @@ mod settings_modal_regressions { ("settings/agents", SettingsSection::Harnesses), ("settings/harnesses", SettingsSection::Harnesses), ("settings/general", SettingsSection::General), + ("settings/voice", SettingsSection::Voice), ("settings/conversations", SettingsSection::General), ("settings/files", SettingsSection::Files), ("settings/appshots", SettingsSection::Appshots), diff --git a/crates/ui/src/shell/actions_ui.rs b/crates/ui/src/shell/actions_ui.rs index c2b5df9358..217625c8e2 100644 --- a/crates/ui/src/shell/actions_ui.rs +++ b/crates/ui/src/shell/actions_ui.rs @@ -188,8 +188,14 @@ impl Shell { name.update(cx, |input, cx| input.set_text(draft.name, cx)); let command = cx.new(|cx| ComposerInput::new("Command", cx)); command.update(cx, |input, cx| input.set_text(draft.command, cx)); - let name_events = cx.subscribe(&name, |_: &mut Shell, _, _, cx| cx.notify()); - let command_events = cx.subscribe(&command, |_: &mut Shell, _, _, cx| cx.notify()); + let name_events = cx.subscribe( + &name, + |_: &mut Shell, _, _: &crate::composer::ComposerInputEvent, cx| cx.notify(), + ); + let command_events = cx.subscribe( + &command, + |_: &mut Shell, _, _: &crate::composer::ComposerInputEvent, cx| cx.notify(), + ); self.project_actions.editor = Some(ProjectActionEditor { key, action_id, diff --git a/crates/voice/Cargo.toml b/crates/voice/Cargo.toml new file mode 100644 index 0000000000..0730ba7a63 --- /dev/null +++ b/crates/voice/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "zeron-voice" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +parakeet-rs = "=0.3.8" +cpal = "=0.17.3" +rubato = "=0.16.2" +reqwest.workspace = true +tokio.workspace = true +sha2.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +hound = "3.5" +tempfile.workspace = true diff --git a/crates/voice/NOTICE.md b/crates/voice/NOTICE.md new file mode 100644 index 0000000000..e780303383 --- /dev/null +++ b/crates/voice/NOTICE.md @@ -0,0 +1,17 @@ +# Parakeet v3 attribution and runtime + +NVIDIA Parakeet TDT 0.6B v3 weights © NVIDIA, licensed under Creative Commons Attribution 4.0 International: https://creativecommons.org/licenses/by/4.0/ + +Original model: https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3 +Source model card inspected at revision `541d1f99c6b0c3cd0b11a95167540bb8edefd82b`. + +ONNX conversion by Ivan Stupakov: https://huggingface.co/istupakov/parakeet-tdt-0.6b-v3-onnx/tree/8f23f0c03c8761650bdb5b40aaf3e40d2c15f1ce +The conversion card identifies NVIDIA v3 as its base and documents NeMo ASRModel export and vocabulary extraction. Its exact original-weight revision and INT8 conversion tool version are not published. Zeron pins the conversion's immutable revision and SHA-256 of every consumed artifact in model.json; it does not claim independent numerical equivalence to NVIDIA's FP32 weights. The change is an INT8 ONNX conversion; no fine-tuned model is used. + +Runtime: parakeet-rs 0.3.8 (MIT OR Apache-2.0), ONNX Runtime 1.28.0 via ort/ort-sys 2.0.0-rc.13, CPU execution. Cargo.lock pins registry checksums and the transitive graph. ONNX Runtime is MIT licensed. Capture: cpal 0.17.3 (Apache-2.0). Sample-rate conversion: rubato 0.16.2 (MIT), using its anti-aliasing FFT resampler to convert device-rate mono audio to 16 kHz. Runtime libraries are linked into the application by ort-sys; model weights are an optional download, never bundled. + +TDT v3 is an offline model. Zeron records at most 60 seconds and decodes once on Stop. There are no live partial hypotheses in the production adapter. The editor's partial-result seam exists for deterministic lifecycle tests and future adapter work; it does not imply native streaming support. + +Supported languages: Bulgarian, Croatian, Czech, Danish, Dutch, English, Estonian, Finnish, French, German, Greek, Hungarian, Italian, Latvian, Lithuanian, Maltese, Polish, Portuguese, Romanian, Russian, Slovak, Slovenian, Spanish, Swedish, Ukrainian. + +Platform baseline: native CPU ONNX and CPAL on macOS, Windows, and Linux. Linux build hosts need ALSA development headers in addition to existing desktop dependencies. macOS requires a packaged app with NSMicrophoneUsageDescription and the audio-input entitlement under hardened runtime. No Apple Speech entitlement, cloud provider, Python installation, or engine-host audio transport is used. Only macOS arm64 is verified locally; other targets require their native CI/package validation. diff --git a/crates/voice/examples/verify.rs b/crates/voice/examples/verify.rs new file mode 100644 index 0000000000..794aa1b2f2 --- /dev/null +++ b/crates/voice/examples/verify.rs @@ -0,0 +1,30 @@ +use std::{path::PathBuf, sync::atomic::AtomicBool, time::Instant}; +fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let dir = PathBuf::from(args.next().expect("model directory")); + if !zeron_voice::installed(&dir) { + zeron_voice::download(&dir, &AtomicBool::new(false), |_| {})?; + } + let start = Instant::now(); + let mut model = zeron_voice::Recognizer::load(&dir)?; + println!("load_ms={}", start.elapsed().as_millis()); + for file in args { + let mut wav = hound::WavReader::open(&file)?; + let spec = wav.spec(); + anyhow::ensure!( + spec.channels == 1 && spec.bits_per_sample == 16, + "mono PCM16 fixture required" + ); + let samples: Vec = wav + .samples::() + .map(|s| s.unwrap() as f32 / 32768.) + .collect(); + let start = Instant::now(); + let text = model.transcribe(samples, spec.sample_rate)?; + println!( + "fixture={file} inference_ms={} transcript={text:?}", + start.elapsed().as_millis() + ); + } + Ok(()) +} diff --git a/crates/voice/model.json b/crates/voice/model.json new file mode 100644 index 0000000000..50779f63e3 --- /dev/null +++ b/crates/voice/model.json @@ -0,0 +1,23 @@ +{ + "repository": "istupakov/parakeet-tdt-0.6b-v3-onnx", + "revision": "8f23f0c03c8761650bdb5b40aaf3e40d2c15f1ce", + "source": "nvidia/parakeet-tdt-0.6b-v3", + "license": "CC-BY-4.0", + "files": [ + { + "name": "decoder_joint-model.int8.onnx", + "size": 18202004, + "sha256": "eea7483ee3d1a30375daedc8ed83e3960c91b098812127a0d99d1c8977667a70" + }, + { + "name": "encoder-model.int8.onnx", + "size": 652183999, + "sha256": "6139d2fa7e1b086097b277c7149725edbab89cc7c7ae64b23c741be4055aff09" + }, + { + "name": "vocab.txt", + "size": 93939, + "sha256": "d58544679ea4bc6ac563d1f545eb7d474bd6cfa467f0a6e2c1dc1c7d37e3c35d" + } + ] +} diff --git a/crates/voice/src/lib.rs b/crates/voice/src/lib.rs new file mode 100644 index 0000000000..e475e93c2a --- /dev/null +++ b/crates/voice/src/lib.rs @@ -0,0 +1,602 @@ +//! Desktop-local Parakeet v3. No engine, document, RPC or audio persistence. +use anyhow::{Context, Result, bail}; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use parakeet_rs::{ParakeetTDT, Transcriber}; +use sha2::{Digest, Sha256}; +use std::{ + io::Write, + path::Path, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU32, Ordering}, + }, +}; + +mod resample; + +pub const MAX_SECONDS: usize = 60; +#[derive(serde::Deserialize)] +pub struct Artifact { + pub name: String, + pub size: u64, + pub sha256: String, +} +#[derive(serde::Deserialize)] +pub struct Manifest { + pub repository: String, + pub revision: String, + pub files: Vec, +} +pub fn manifest() -> Manifest { + serde_json::from_str(include_str!("../model.json")).expect("pinned model manifest") +} +pub fn download_size() -> u64 { + manifest().files.iter().map(|f| f.size).sum() +} +pub fn installed(dir: &Path) -> bool { + manifest() + .files + .iter() + .all(|f| std::fs::metadata(dir.join(&f.name)).is_ok_and(|m| m.len() == f.size)) + && std::fs::read_to_string(dir.join("verified")).is_ok_and(|r| r == manifest().revision) +} + +/// Called only on a worker. Temporary files never establish readiness. +pub fn download(dir: &Path, cancel: &AtomicBool, mut progress: impl FnMut(u64)) -> Result<()> { + tokio::runtime::Builder::new_current_thread().enable_all().build()?.block_on(async { + std::fs::create_dir_all(dir)?; + let client = reqwest::Client::builder().connect_timeout(std::time::Duration::from_secs(20)).build()?; + let m=manifest(); let mut total=0; + for file in m.files { + if cancel.load(Ordering::Acquire) { bail!("Download cancelled") } + let staging=dir.join(format!("{}.part",file.name)); + let result=async { + let mut response=tokio::select! { + _=cancelled(cancel)=>bail!("Download cancelled"), + response=tokio::time::timeout(std::time::Duration::from_secs(30),client.get(format!("https://huggingface.co/{}/resolve/{}/{}",m.repository,m.revision,file.name)).send())=>response??.error_for_status()?, + }; + let mut out=std::fs::File::create(&staging)?;let mut hash=Sha256::new();let mut size=0; + loop { + let chunk=tokio::select! { + _=cancelled(cancel)=>bail!("Download cancelled"), + chunk=tokio::time::timeout(std::time::Duration::from_secs(30),response.chunk())=>chunk??, + }; + let Some(chunk)=chunk else {break}; + size+=chunk.len() as u64;if size>file.size {bail!("Unexpected model size")} + hash.update(&chunk);out.write_all(&chunk)?;total+=chunk.len() as u64;progress(total); + } + if size!=file.size || format!("{:x}",hash.finalize())!=file.sha256 {bail!("Model checksum verification failed")} + out.sync_all()?;std::fs::rename(&staging,dir.join(&file.name))?;Ok::<_,anyhow::Error>(()) + }.await; + if result.is_err() {let _=std::fs::remove_file(staging);} result?; + } + if cancel.load(Ordering::Acquire) {bail!("Download cancelled")} + std::fs::write(dir.join("verified"),manifest().revision)?;Ok(()) + }) +} +async fn cancelled(cancel: &AtomicBool) { + while !cancel.load(Ordering::Acquire) { + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + } +} + +pub struct Recognizer(ParakeetTDT); +impl Recognizer { + pub fn load(dir: &Path) -> Result { + // Verify before handing bytes to the native runtime, including after restart. + for f in manifest().files { + let mut input = std::fs::File::open(dir.join(&f.name))?; + let mut hash = Sha256::new(); + std::io::copy(&mut input, &mut hash)?; + if format!("{:x}", hash.finalize()) != f.sha256 { + bail!("Model is damaged. Remove it in Settings and download again.") + } + } + Ok(Self(ParakeetTDT::from_pretrained(dir, None).map_err( + |_| anyhow::anyhow!("Could not load Parakeet v3"), + )?)) + } + pub fn transcribe(&mut self, samples: Vec, rate: u32) -> Result { + if !(8_000..=192_000).contains(&rate) { + bail!("Unsupported microphone sample rate") + } + if samples.len() > rate as usize * MAX_SECONDS { + bail!("Recording exceeds one minute") + } + if samples.len() < rate as usize / 5 || samples.iter().all(|s| s.abs() < 0.0001) { + return Ok(String::new()); + } + let samples = resample::for_model(samples, rate)?; + self.0 + .transcribe_samples(samples, resample::MODEL_RATE, 1, None) + .map(|r| r.text) + .map_err(|_| anyhow::anyhow!("Could not transcribe this recording")) + } +} + +struct Audio { + /// Only the audio callback touches this until the stream is dropped. + samples: Mutex>, + // Outside the lock: the capture loop polls these every 10 ms and must + // never make the real-time callback's `try_lock` drop a buffer. + failed: AtomicBool, + full: AtomicBool, +} +impl Audio { + fn with_capacity(capacity: usize) -> Self { + Self { + samples: Mutex::new(Vec::with_capacity(capacity)), + failed: AtomicBool::new(false), + full: AtomicBool::new(false), + } + } +} +/// Loudest RMS since the UI last read it. Non-negative `f32` bit patterns sort +/// like their values, so `fetch_max` keeps the peak without a lock. +fn meter(data: &[T], channels: usize, level: &AtomicU32) +where + f32: cpal::FromSample, +{ + let frames = data.len() / channels.max(1); + if frames == 0 { + return; + } + let energy = data + .chunks_exact(channels) + .map(|frame| { + let s = frame.iter().map(|s| s.to_sample::()).sum::() / channels as f32; + s * s + }) + .sum::(); + let rms = (energy / frames as f32).sqrt(); + if rms.is_finite() { + level.fetch_max(rms.to_bits(), Ordering::Relaxed); + } +} +fn append(data: &[T], channels: usize, rate: u32, a: &Audio) +where + f32: cpal::FromSample, +{ + if let Ok(mut samples) = a.samples.try_lock() { + for frame in data.chunks_exact(channels) { + if samples.len() >= rate as usize * MAX_SECONDS { + a.full.store(true, Ordering::Release); + break; + } + samples.push(frame.iter().map(|s| s.to_sample::()).sum::() / channels as f32); + } + } +} +/// A microphone the user can choose: a stable identifier and a display name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InputDevice { + pub id: String, + pub name: String, +} + +/// Every input device the default host reports. Enumeration can block on the +/// audio server, so call it off the UI thread. +pub fn input_devices() -> Vec { + let Ok(devices) = cpal::default_host().input_devices() else { + return Vec::new(); + }; + let mut devices: Vec<_> = devices + .filter_map(|device| { + Some(InputDevice { + id: device.id().ok()?.to_string(), + name: device.description().ok()?.name().to_owned(), + }) + }) + .collect(); + devices.dedup_by(|a, b| a.id == b.id); + devices +} + +/// The id of the device the system currently records from by default. +pub fn default_input_device() -> Option { + Some( + cpal::default_host() + .default_input_device()? + .id() + .ok()? + .to_string(), + ) +} + +/// The chosen device when it is connected, the system default otherwise. +fn input_device(id: Option<&str>) -> Option { + let host = cpal::default_host(); + id.and_then(|id| id.parse::().ok()) + .and_then(|id| host.device_by_id(&id)) + .or_else(|| host.default_input_device()) +} + +pub struct Capture { + stream: Option, + audio: Arc