From ede5e98a9c8574aa291f01be8172254224226cc7 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Tue, 8 Sep 2026 19:28:37 +0800 Subject: [PATCH 1/5] Reset a project thread expansion when its folder collapses --- crates/ui/src/sidebar.rs | 170 +++++++++++++++++++++++++++++++++++++-- docs/DESIGN.md | 7 +- 2 files changed, 170 insertions(+), 7 deletions(-) diff --git a/crates/ui/src/sidebar.rs b/crates/ui/src/sidebar.rs index 9e9f0382..c8976b2f 100644 --- a/crates/ui/src/sidebar.rs +++ b/crates/ui/src/sidebar.rs @@ -667,6 +667,16 @@ impl SessionsSidebar { crate::add_project_dialog::open(self.store.clone(), window, cx); } + fn toggle_project(&mut self, project_id: &str, cx: &mut Context) { + if !self.store.read(cx).is_project_collapsed(project_id) { + self.expanded_groups.remove(project_id); + } + self.store.update(cx, |store, _| { + store.toggle_project_collapsed(project_id.to_string()); + }); + cx.notify(); + } + fn toggle_group(&mut self, project_id: &str, window: &mut Window, cx: &mut Context) { if self.expanded_groups.remove(project_id) { if self @@ -1549,6 +1559,10 @@ impl SessionsSidebar { cx, ) .aria_expanded(!collapsed) + .debug_selector({ + let project_id = project_id.clone(); + move || format!("project-header-{project_id}") + }) .group(group_key.clone()) .h(px(30.)) .items_center() @@ -1558,9 +1572,7 @@ impl SessionsSidebar { .cursor_pointer() .hover(|s| s.bg(cx.theme().sidebar_accent)) .on_click(cx.listener(move |this, _, _, cx| { - this.store.update(cx, |store, _cx| { - store.toggle_project_collapsed(header_toggle_id.clone()); - }); + this.toggle_project(&header_toggle_id, cx); })) .child( Icon::new(if collapsed { @@ -1676,6 +1688,10 @@ impl SessionsSidebar { cx, ) .aria_expanded(expanded) + .debug_selector({ + let project_id = project_id.clone(); + move || format!("show-more-{project_id}") + }) .pl(px(30.)) .py_1() .text_size(px(12.)) @@ -2806,9 +2822,7 @@ impl SessionsSidebar { .gap(px(8.)) .cursor_pointer() .on_click(cx.listener(move |this, _, _, cx| { - this.store.update(cx, |store, _cx| { - store.toggle_project_collapsed(project_id.clone()); - }); + this.toggle_project(&project_id, cx); })) .text_size(px(13.)) .text_color(cx.theme().muted_foreground) @@ -3440,6 +3454,150 @@ mod tests { assert!(callbacks > 0, "spring did not request an animation frame"); } + #[gpui::test] + fn project_header_resets_only_its_own_thread_expansion(cx: &mut TestAppContext) { + cx.update(crate::theme::init); + let root = std::env::temp_dir().join(format!( + "tcode-project-collapse-{}", + tcode_services::store::now_millis() + )); + let host = spawn_host( + SessionStore::open_at(root.clone()).unwrap(), + HostServices::default(), + ) + .unwrap(); + let projects = ["a", "b"].map(|id| { + let mut project = Project::from_root(root.join(id)); + project.id = id.into(); + project + }); + let ids = projects.each_ref().map(|project| project.id.clone()); + smol::block_on(host.update_state_for_test(move |state, _| { + state.settings.sidebar_layout = SidebarLayout::Grouped; + state.settings.auto_archive_disabled = true; + for project in &projects { + for index in 0..8 { + let parent = format!("{}-0", project.id); + let mut meta = session( + &format!("{}-{index}", project.id), + (index == 7).then_some(parent.as_str()), + ); + meta.project_id = Some(project.id.clone()); + state.sessions.push(meta); + } + } + state.projects = projects.to_vec(); + })) + .unwrap(); + let store = cx.new(|cx| WorkspaceStore::new(host.link(), cx)); + let window_state = cx.new(|_| WindowState::new(false)); + let (sidebar, cx) = cx + .add_window_view(|_, cx| SessionsSidebar::new(store.clone(), window_state.clone(), cx)); + let cx: &mut VisualTestContext = cx; + cx.simulate_resize(size(px(320.), px(1400.))); + draw(cx); + let folds = sidebar.read_with(cx, |sidebar, _| sidebar.collapsed_parents.clone()); + assert_eq!(folds.len(), 2); + for compact in [false, true] { + // Expand both lists through their production controls before testing + // either layout's project-header action. + window_state.update(cx, |state, _| state.compact = false); + sidebar.update(cx, |_, cx| cx.notify()); + draw(cx); + for id in &ids { + if !sidebar.read_with(cx, |sidebar, _| sidebar.expanded_groups.contains(id)) { + let toggle = cx + .debug_bounds(if id == "a" { + "show-more-a" + } else { + "show-more-b" + }) + .unwrap(); + cx.simulate_click(toggle.center(), gpui::Modifiers::default()); + draw(cx); + } + } + window_state.update(cx, |state, _| state.compact = compact); + sidebar.update(cx, |_, cx| cx.notify()); + draw(cx); + let selector = if compact { + "compact-group-header" + } else { + "project-header-a" + }; + let header = cx.debug_bounds(selector).unwrap(); + cx.simulate_click(header.center(), gpui::Modifiers::default()); + draw(cx); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let collapsed_id = loop { + store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + draw(cx); + if let Some(id) = ids + .iter() + .find(|id| store.read_with(cx, |store, _| store.is_project_collapsed(id))) + { + break id.clone(); + } + assert!( + std::time::Instant::now() < deadline, + "folder collapse reaches replica" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + sidebar.read_with(cx, |sidebar, _| { + assert!( + !sidebar.expanded_groups.contains(&collapsed_id), + "collapsing a folder must reset its expanded thread list" + ); + assert!( + ids.iter() + .filter(|id| **id != collapsed_id) + .all(|id| sidebar.expanded_groups.contains(id)), + "other project expansions survive" + ); + assert_eq!(sidebar.collapsed_parents, folds, "child folds survive"); + }); + let header = cx.debug_bounds(selector).unwrap(); + cx.simulate_click(header.center(), gpui::Modifiers::default()); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + store.update(cx, |store, cx| store.drain_host_events_for_test(cx)); + draw(cx); + if !store.read_with(cx, |store, _| store.is_project_collapsed(&collapsed_id)) { + break; + } + assert!(std::time::Instant::now() < deadline); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!sidebar.read_with(cx, |sidebar, _| { + sidebar.expanded_groups.contains(&collapsed_id) + })); + } + window_state.update(cx, |state, _| state.compact = false); + sidebar.update(cx, |_, cx| cx.notify()); + draw(cx); + // The other project remains expanded; its direct Show less control + // still collapses the list without folding the project or its children. + let expanded_id = sidebar.read_with(cx, |sidebar, _| { + sidebar.expanded_groups.iter().next().unwrap().clone() + }); + let selector = if expanded_id == "a" { + "show-more-a" + } else { + "show-more-b" + }; + let toggle = cx.debug_bounds(selector).unwrap(); + cx.simulate_click(toggle.center(), gpui::Modifiers::default()); + draw(cx); + sidebar.read_with(cx, |sidebar, _| { + assert!(!sidebar.expanded_groups.contains(&expanded_id)); + assert_eq!(sidebar.collapsed_parents, folds); + }); + assert!(!store.read_with(cx, |store, _| store.is_project_collapsed(&expanded_id))); + host.shutdown_blocking().unwrap(); + let _ = std::fs::remove_dir_all(root); + } + #[gpui::test] fn canceling_inline_rename_discards_the_unsaved_title(cx: &mut TestAppContext) { let root = std::env::temp_dir().join(format!( diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 74b7ba9b..54025782 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -543,7 +543,12 @@ in both states. On hover, time swaps to the archive icon; active = persistent accent bg; a running session shows "● Working" (green, 11px) left of the title; >6 threads → "Show more" / "Show less" toggle row (the row remains available after - expansion so the list can be collapsed again). + expansion so the list can be collapsed again). Collapsing a project folder + resets only that project's expanded thread list, including when collapsed + in compact layout; reopening in wide layout shows at most six visible threads. + Compact layout keeps its existing full thread list. Other + projects' expansions and parent/child folds stay unchanged. Children hidden + by a parent fold do not consume the six-thread limit. 6. Footer: gear + "Settings" → settings route. In wide layout, the sidebar switches the content route directly: Machines From 8b0cb2fe87f17063ea61619bbc82401a43de0f04 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Tue, 8 Sep 2026 19:28:49 +0800 Subject: [PATCH 2/5] Reproduce layered chat selection gesture cancellation --- crates/ui/src/markdown/view.rs | 251 +++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) diff --git a/crates/ui/src/markdown/view.rs b/crates/ui/src/markdown/view.rs index d0ae06db..8c2c48e7 100644 --- a/crates/ui/src/markdown/view.rs +++ b/crates/ui/src/markdown/view.rs @@ -332,6 +332,54 @@ mod tests { use super::*; use gpui_base::TextSelectionLayer; + struct LayeredSelectionRoot { + sidebar: Entity, + markdown: Entity, + } + + impl Render for LayeredSelectionRoot { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .child(TextSelectionLayer) + .child( + div() + .flex() + .size_full() + .child(div().w(px(300.)).flex_none().child(self.sidebar.clone())) + .child( + div() + .flex_1() + .p_2() + .child(MarkdownView::new(&self.markdown).selectable(true)), + ), + ) + .child( + div() + .absolute() + .right_0() + .top(px(400.)) + .debug_selector(|| "selection-popover-trigger".into()) + .child( + crate::widgets::Popover::new("selection-probe-popover") + .trigger( + crate::widgets::Button::new("selection-popover-button") + .label("Popover"), + ) + .content(|_, _, _| { + div() + .w(px(120.)) + .debug_selector(|| "selection-popover-action".into()) + .child( + crate::widgets::Button::new("selection-popover-action") + .label("Keep open"), + ) + }), + ), + ) + } + } + struct TestRoot { markdown: Entity, } @@ -712,6 +760,209 @@ mod tests { ); } + #[gpui::test] + fn sidebar_and_overlay_gestures_do_not_start_chat_selection(cx: &mut TestAppContext) { + use crate::overlay::{Notification, OverlayExt as _, OverlayHost}; + use tcode_runtime::pipe::{HostServices, spawn_host}; + use tcode_services::store::SessionStore; + cx.update(crate::theme::init); + cx.update(crate::markdown::init); + let profile = std::env::temp_dir().join(format!( + "tcode-selection-layers-{}", + tcode_services::store::now_millis() + )); + let host = spawn_host( + SessionStore::open_at(profile.clone()).unwrap(), + HostServices::default(), + ) + .unwrap(); + let mut project = tcode_core::project::Project::from_root(profile.join("project")); + project.id = "selection-fixture".into(); + smol::block_on(host.update_state_for_test(move |state, _| { + state.settings.sidebar_layout = tcode_core::settings::SidebarLayout::Grouped; + state.settings.auto_archive_disabled = true; + state.projects = vec![project]; + })) + .unwrap(); + let (_, cx) = cx.add_window_view(|window, cx| { + let body = cx.new(|cx| { + let store = cx.new(|cx| crate::store::WorkspaceStore::new(host.link(), cx)); + let window_state = cx.new(|_| crate::window_state::WindowState::new(false)); + LayeredSelectionRoot { + sidebar: cx + .new(|cx| crate::sidebar::SessionsSidebar::new(store, window_state, cx)), + markdown: cx.new(|cx| { + MarkdownState::new(&"Alpha beta gamma delta epsilon\n\n".repeat(15), cx) + }), + } + }); + OverlayHost::new(body, window, cx) + }); + let cx: &mut VisualTestContext = cx; + cx.simulate_resize(gpui::size(px(1200.), px(800.))); + cx.update(|window, _| window.activate_window()); + let draw = |cx: &mut VisualTestContext| { + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + }; + draw(cx); + let header = cx.debug_bounds("project-header-selection-fixture").unwrap(); + cx.simulate_mouse_down(header.center(), MouseButton::Left, Modifiers::default()); + cx.simulate_mouse_move( + point(px(420.), px(120.)), + Some(MouseButton::Left), + Modifiers::default(), + ); + draw(cx); + assert!( + cx.update(gpui_base::TextSelection::selected_text) + .is_empty(), + "dragging from the sidebar into chat cannot start text selection" + ); + cx.simulate_mouse_up( + point(px(420.), px(120.)), + MouseButton::Left, + Modifiers::default(), + ); + for surface in ["sidebar", "dialog", "popover", "notification"] { + cx.update(gpui_base::TextSelection::clear); + let target = match surface { + "sidebar" => cx + .debug_bounds("project-header-selection-fixture") + .unwrap() + .center(), + "dialog" => { + cx.update(|window, cx| { + window.open_dialog(cx, |dialog, _, _| { + dialog.title("Selection probe").child( + div() + .debug_selector(|| "selection-dialog-action".into()) + .child( + crate::widgets::Button::new("probe-dialog") + .label("Keep open"), + ), + ) + }) + }); + draw(cx); + cx.debug_bounds("selection-dialog-action").unwrap().center() + } + "popover" => { + let trigger = cx.debug_bounds("selection-popover-trigger").unwrap(); + cx.simulate_click(trigger.center(), Modifiers::default()); + draw(cx); + cx.debug_bounds("selection-popover-action") + .unwrap() + .center() + } + _ => { + cx.update(|window, cx| { + window.push_notification( + Notification::info("Selection probe").autohide(false), + cx, + ) + }); + draw(cx); + cx.executor() + .advance_clock(std::time::Duration::from_millis(250)); + draw(cx); + let toast = cx.debug_bounds("wide-toast").unwrap(); + point(toast.right() - px(14.), toast.top() + px(14.)) + } + }; + cx.simulate_mouse_down(target, MouseButton::Left, Modifiers::default()); + draw(cx); + cx.simulate_mouse_up(target, MouseButton::Left, Modifiers::default()); + draw(cx); + if surface == "dialog" { + cx.update(|window, cx| window.close_dialog(cx)); + draw(cx); + } else if surface == "popover" { + cx.simulate_keystrokes("escape"); + draw(cx); + } else if surface == "notification" { + cx.executor() + .advance_clock(std::time::Duration::from_millis(250)); + draw(cx); + assert!( + cx.debug_bounds("wide-toast").is_none(), + "the actual notification close button was clicked" + ); + } + cx.simulate_mouse_move(point(px(420.), px(120.)), None, Modifiers::default()); + draw(cx); + let selection = cx.update(gpui_base::TextSelection::selected_text); + assert!( + selection.is_empty(), + "{surface} gesture armed chat selection: {selection:?}" + ); + } + cx.update(gpui_base::TextSelection::clear); + cx.simulate_mouse_down( + point(px(310.), px(15.)), + MouseButton::Left, + Modifiers::default(), + ); + draw(cx); + cx.simulate_mouse_move( + point(px(400.), px(50.)), + Some(MouseButton::Left), + Modifiers::default(), + ); + draw(cx); + let dragged = cx.update(gpui_base::TextSelection::selected_text); + assert!(!dragged.is_empty(), "legitimate markdown drag selects text"); + cx.update(|window, cx| { + window.open_dialog(cx, |dialog, _, _| { + dialog.title("Release over dialog").child("Dialog content") + }) + }); + draw(cx); + cx.simulate_mouse_up( + point(px(600.), px(110.)), + MouseButton::Left, + Modifiers::default(), + ); + draw(cx); + cx.update(|window, cx| window.close_dialog(cx)); + draw(cx); + let released = cx.update(gpui_base::TextSelection::selected_text); + cx.simulate_mouse_move(point(px(450.), px(240.)), None, Modifiers::default()); + draw(cx); + assert_eq!( + cx.update(gpui_base::TextSelection::selected_text), + released, + "release owned by a dialog must terminate selection before button-up movement" + ); + cx.update(gpui_base::TextSelection::clear); + cx.simulate_mouse_down( + point(px(310.), px(15.)), + MouseButton::Left, + Modifiers::default(), + ); + draw(cx); + cx.simulate_mouse_move( + point(px(400.), px(50.)), + Some(MouseButton::Left), + Modifiers::default(), + ); + draw(cx); + let before_deactivation = cx.update(gpui_base::TextSelection::selected_text); + cx.deactivate_window(); + draw(cx); + cx.simulate_mouse_move(point(px(450.), px(240.)), None, Modifiers::default()); + draw(cx); + assert_eq!( + cx.update(gpui_base::TextSelection::selected_text), + before_deactivation, + "focus loss cancels the selection gesture" + ); + host.shutdown_blocking().unwrap(); + let _ = std::fs::remove_dir_all(profile); + } + #[gpui::test] fn clipped_markdown_cannot_start_selection(cx: &mut TestAppContext) { cx.update(crate::theme::init); From 70e04a3e5fb00704d188e023fc553b2b1fe23935 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Tue, 8 Sep 2026 19:35:33 +0800 Subject: [PATCH 3/5] Pin upstream interaction fixes and document caret and selection behavior --- Cargo.lock | 2 +- crates/ui/Cargo.toml | 6 +++++- docs/DESIGN.md | 10 ++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4dd14e8d..f8cbf0e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3196,7 +3196,7 @@ dependencies = [ [[package]] name = "gpui-base" version = "0.6.0" -source = "git+https://github.com/longbridge/gpui-kit?rev=9d9bd9bfa9b4c3af25475078458b3e3c7abd2981#9d9bd9bfa9b4c3af25475078458b3e3c7abd2981" +source = "git+https://github.com/Tryanks/gpui-kit?rev=e12d6962f8b5b8807b7539e362608e79f55d73e1#e12d6962f8b5b8807b7539e362608e79f55d73e1" dependencies = [ "aho-corasick", "anyhow", diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 18b9cc6d..4ef004f2 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -58,7 +58,11 @@ tcode-core = { path = "../core", default-features = false } tcode-protocol = { path = "../protocol", default-features = false } qrcode = { version = "0.14", default-features = false } gpui = { package = "gpui-pre", version = "0.3.1" } -gpui-base = { git = "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/longbridge/gpui-kit", rev = "9d9bd9bfa9b4c3af25475078458b3e3c7abd2981" } # 0.6.0 on crates.io pulls an unused gpui-pre-platform that breaks iOS/Android; switch back once 0.6.1 ships +# Temporary fork until upstream accepts caret reveal and selection cancellation: +# https://github.com/longbridge/gpui-kit/pull/3011 +# https://github.com/longbridge/gpui-kit/pull/3012 +# Retains the mobile dependency fix absent from crates.io 0.6.0. +gpui-base = { git = "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/Tryanks/gpui-kit", rev = "e12d6962f8b5b8807b7539e362608e79f55d73e1" } gpui-component-assets = { package = "gpui-kit-assets", version = "0.6.0" } gpui-component-macros = "0.6.0" image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff"] } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 54025782..1cd95f10 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -97,6 +97,16 @@ The centered chat/composer column is 720px wide at most. Desktop prose and composer text use 13.5px type with a 21px line height; metadata is smaller and muted, with monospace for paths, command text and numeric evidence. +After a long paste or manual scroll, the next accepted composer edit reveals the +caret fully in the viewport. Insertion, replacement, deletion and IME composition +use the same behavior, even when an edit leaves the caret offset unchanged. +Manual scrolling stays where the user places it until another edit. + +Chat text selection begins only on hit text. Sidebar controls, dialogs, popovers +and notifications cannot start a selection underneath them. A released mouse +button or window focus loss ends the drag while preserving already selected +text for copying. Normal markdown links and copy actions retain their behavior. + The material layers are: | Layer | Use | Treatment | From 32237dd93bbb3116c6f253b6086d554c374a003f Mon Sep 17 00:00:00 2001 From: Tryanks Date: Tue, 8 Sep 2026 19:40:32 +0800 Subject: [PATCH 4/5] Revert "Pin upstream interaction fixes and document caret and selection behavior" This reverts commit 70e04a3e5fb00704d188e023fc553b2b1fe23935. --- Cargo.lock | 2 +- crates/ui/Cargo.toml | 6 +----- docs/DESIGN.md | 10 ---------- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f8cbf0e3..4dd14e8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3196,7 +3196,7 @@ dependencies = [ [[package]] name = "gpui-base" version = "0.6.0" -source = "git+https://github.com/Tryanks/gpui-kit?rev=e12d6962f8b5b8807b7539e362608e79f55d73e1#e12d6962f8b5b8807b7539e362608e79f55d73e1" +source = "git+https://github.com/longbridge/gpui-kit?rev=9d9bd9bfa9b4c3af25475078458b3e3c7abd2981#9d9bd9bfa9b4c3af25475078458b3e3c7abd2981" dependencies = [ "aho-corasick", "anyhow", diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 4ef004f2..18b9cc6d 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -58,11 +58,7 @@ tcode-core = { path = "../core", default-features = false } tcode-protocol = { path = "../protocol", default-features = false } qrcode = { version = "0.14", default-features = false } gpui = { package = "gpui-pre", version = "0.3.1" } -# Temporary fork until upstream accepts caret reveal and selection cancellation: -# https://github.com/longbridge/gpui-kit/pull/3011 -# https://github.com/longbridge/gpui-kit/pull/3012 -# Retains the mobile dependency fix absent from crates.io 0.6.0. -gpui-base = { git = "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/Tryanks/gpui-kit", rev = "e12d6962f8b5b8807b7539e362608e79f55d73e1" } +gpui-base = { git = "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/longbridge/gpui-kit", rev = "9d9bd9bfa9b4c3af25475078458b3e3c7abd2981" } # 0.6.0 on crates.io pulls an unused gpui-pre-platform that breaks iOS/Android; switch back once 0.6.1 ships gpui-component-assets = { package = "gpui-kit-assets", version = "0.6.0" } gpui-component-macros = "0.6.0" image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff"] } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1cd95f10..54025782 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -97,16 +97,6 @@ The centered chat/composer column is 720px wide at most. Desktop prose and composer text use 13.5px type with a 21px line height; metadata is smaller and muted, with monospace for paths, command text and numeric evidence. -After a long paste or manual scroll, the next accepted composer edit reveals the -caret fully in the viewport. Insertion, replacement, deletion and IME composition -use the same behavior, even when an edit leaves the caret offset unchanged. -Manual scrolling stays where the user places it until another edit. - -Chat text selection begins only on hit text. Sidebar controls, dialogs, popovers -and notifications cannot start a selection underneath them. A released mouse -button or window focus loss ends the drag while preserving already selected -text for copying. Normal markdown links and copy actions retain their behavior. - The material layers are: | Layer | Use | Treatment | From 0391c0b37598258a82ec29d5d09b1503cf8b8d8c Mon Sep 17 00:00:00 2001 From: Tryanks Date: Tue, 8 Sep 2026 19:40:32 +0800 Subject: [PATCH 5/5] Revert "Reproduce layered chat selection gesture cancellation" This reverts commit 8b0cb2fe87f17063ea61619bbc82401a43de0f04. --- crates/ui/src/markdown/view.rs | 251 --------------------------------- 1 file changed, 251 deletions(-) diff --git a/crates/ui/src/markdown/view.rs b/crates/ui/src/markdown/view.rs index 8c2c48e7..d0ae06db 100644 --- a/crates/ui/src/markdown/view.rs +++ b/crates/ui/src/markdown/view.rs @@ -332,54 +332,6 @@ mod tests { use super::*; use gpui_base::TextSelectionLayer; - struct LayeredSelectionRoot { - sidebar: Entity, - markdown: Entity, - } - - impl Render for LayeredSelectionRoot { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div() - .size_full() - .child(TextSelectionLayer) - .child( - div() - .flex() - .size_full() - .child(div().w(px(300.)).flex_none().child(self.sidebar.clone())) - .child( - div() - .flex_1() - .p_2() - .child(MarkdownView::new(&self.markdown).selectable(true)), - ), - ) - .child( - div() - .absolute() - .right_0() - .top(px(400.)) - .debug_selector(|| "selection-popover-trigger".into()) - .child( - crate::widgets::Popover::new("selection-probe-popover") - .trigger( - crate::widgets::Button::new("selection-popover-button") - .label("Popover"), - ) - .content(|_, _, _| { - div() - .w(px(120.)) - .debug_selector(|| "selection-popover-action".into()) - .child( - crate::widgets::Button::new("selection-popover-action") - .label("Keep open"), - ) - }), - ), - ) - } - } - struct TestRoot { markdown: Entity, } @@ -760,209 +712,6 @@ mod tests { ); } - #[gpui::test] - fn sidebar_and_overlay_gestures_do_not_start_chat_selection(cx: &mut TestAppContext) { - use crate::overlay::{Notification, OverlayExt as _, OverlayHost}; - use tcode_runtime::pipe::{HostServices, spawn_host}; - use tcode_services::store::SessionStore; - cx.update(crate::theme::init); - cx.update(crate::markdown::init); - let profile = std::env::temp_dir().join(format!( - "tcode-selection-layers-{}", - tcode_services::store::now_millis() - )); - let host = spawn_host( - SessionStore::open_at(profile.clone()).unwrap(), - HostServices::default(), - ) - .unwrap(); - let mut project = tcode_core::project::Project::from_root(profile.join("project")); - project.id = "selection-fixture".into(); - smol::block_on(host.update_state_for_test(move |state, _| { - state.settings.sidebar_layout = tcode_core::settings::SidebarLayout::Grouped; - state.settings.auto_archive_disabled = true; - state.projects = vec![project]; - })) - .unwrap(); - let (_, cx) = cx.add_window_view(|window, cx| { - let body = cx.new(|cx| { - let store = cx.new(|cx| crate::store::WorkspaceStore::new(host.link(), cx)); - let window_state = cx.new(|_| crate::window_state::WindowState::new(false)); - LayeredSelectionRoot { - sidebar: cx - .new(|cx| crate::sidebar::SessionsSidebar::new(store, window_state, cx)), - markdown: cx.new(|cx| { - MarkdownState::new(&"Alpha beta gamma delta epsilon\n\n".repeat(15), cx) - }), - } - }); - OverlayHost::new(body, window, cx) - }); - let cx: &mut VisualTestContext = cx; - cx.simulate_resize(gpui::size(px(1200.), px(800.))); - cx.update(|window, _| window.activate_window()); - let draw = |cx: &mut VisualTestContext| { - cx.run_until_parked(); - cx.update(|window, cx| { - let _ = window.draw(cx); - }); - }; - draw(cx); - let header = cx.debug_bounds("project-header-selection-fixture").unwrap(); - cx.simulate_mouse_down(header.center(), MouseButton::Left, Modifiers::default()); - cx.simulate_mouse_move( - point(px(420.), px(120.)), - Some(MouseButton::Left), - Modifiers::default(), - ); - draw(cx); - assert!( - cx.update(gpui_base::TextSelection::selected_text) - .is_empty(), - "dragging from the sidebar into chat cannot start text selection" - ); - cx.simulate_mouse_up( - point(px(420.), px(120.)), - MouseButton::Left, - Modifiers::default(), - ); - for surface in ["sidebar", "dialog", "popover", "notification"] { - cx.update(gpui_base::TextSelection::clear); - let target = match surface { - "sidebar" => cx - .debug_bounds("project-header-selection-fixture") - .unwrap() - .center(), - "dialog" => { - cx.update(|window, cx| { - window.open_dialog(cx, |dialog, _, _| { - dialog.title("Selection probe").child( - div() - .debug_selector(|| "selection-dialog-action".into()) - .child( - crate::widgets::Button::new("probe-dialog") - .label("Keep open"), - ), - ) - }) - }); - draw(cx); - cx.debug_bounds("selection-dialog-action").unwrap().center() - } - "popover" => { - let trigger = cx.debug_bounds("selection-popover-trigger").unwrap(); - cx.simulate_click(trigger.center(), Modifiers::default()); - draw(cx); - cx.debug_bounds("selection-popover-action") - .unwrap() - .center() - } - _ => { - cx.update(|window, cx| { - window.push_notification( - Notification::info("Selection probe").autohide(false), - cx, - ) - }); - draw(cx); - cx.executor() - .advance_clock(std::time::Duration::from_millis(250)); - draw(cx); - let toast = cx.debug_bounds("wide-toast").unwrap(); - point(toast.right() - px(14.), toast.top() + px(14.)) - } - }; - cx.simulate_mouse_down(target, MouseButton::Left, Modifiers::default()); - draw(cx); - cx.simulate_mouse_up(target, MouseButton::Left, Modifiers::default()); - draw(cx); - if surface == "dialog" { - cx.update(|window, cx| window.close_dialog(cx)); - draw(cx); - } else if surface == "popover" { - cx.simulate_keystrokes("escape"); - draw(cx); - } else if surface == "notification" { - cx.executor() - .advance_clock(std::time::Duration::from_millis(250)); - draw(cx); - assert!( - cx.debug_bounds("wide-toast").is_none(), - "the actual notification close button was clicked" - ); - } - cx.simulate_mouse_move(point(px(420.), px(120.)), None, Modifiers::default()); - draw(cx); - let selection = cx.update(gpui_base::TextSelection::selected_text); - assert!( - selection.is_empty(), - "{surface} gesture armed chat selection: {selection:?}" - ); - } - cx.update(gpui_base::TextSelection::clear); - cx.simulate_mouse_down( - point(px(310.), px(15.)), - MouseButton::Left, - Modifiers::default(), - ); - draw(cx); - cx.simulate_mouse_move( - point(px(400.), px(50.)), - Some(MouseButton::Left), - Modifiers::default(), - ); - draw(cx); - let dragged = cx.update(gpui_base::TextSelection::selected_text); - assert!(!dragged.is_empty(), "legitimate markdown drag selects text"); - cx.update(|window, cx| { - window.open_dialog(cx, |dialog, _, _| { - dialog.title("Release over dialog").child("Dialog content") - }) - }); - draw(cx); - cx.simulate_mouse_up( - point(px(600.), px(110.)), - MouseButton::Left, - Modifiers::default(), - ); - draw(cx); - cx.update(|window, cx| window.close_dialog(cx)); - draw(cx); - let released = cx.update(gpui_base::TextSelection::selected_text); - cx.simulate_mouse_move(point(px(450.), px(240.)), None, Modifiers::default()); - draw(cx); - assert_eq!( - cx.update(gpui_base::TextSelection::selected_text), - released, - "release owned by a dialog must terminate selection before button-up movement" - ); - cx.update(gpui_base::TextSelection::clear); - cx.simulate_mouse_down( - point(px(310.), px(15.)), - MouseButton::Left, - Modifiers::default(), - ); - draw(cx); - cx.simulate_mouse_move( - point(px(400.), px(50.)), - Some(MouseButton::Left), - Modifiers::default(), - ); - draw(cx); - let before_deactivation = cx.update(gpui_base::TextSelection::selected_text); - cx.deactivate_window(); - draw(cx); - cx.simulate_mouse_move(point(px(450.), px(240.)), None, Modifiers::default()); - draw(cx); - assert_eq!( - cx.update(gpui_base::TextSelection::selected_text), - before_deactivation, - "focus loss cancels the selection gesture" - ); - host.shutdown_blocking().unwrap(); - let _ = std::fs::remove_dir_all(profile); - } - #[gpui::test] fn clipped_markdown_cannot_start_selection(cx: &mut TestAppContext) { cx.update(crate::theme::init);