From d72a03827d4bfa85c404cc846b6fa7d6b0537220 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Sat, 28 Mar 2026 17:48:34 +0000 Subject: [PATCH 1/2] sidebar: Switch with `ctrl-tab` (#52423) Overrides `ctrl-tab` when the sidebar/agent panel is focused to switch between recently viewed threads Release Notes: - N/A or Added/Fixed/Improved ... --- assets/keymaps/default-linux.json | 11 + assets/keymaps/default-macos.json | 11 + assets/keymaps/default-windows.json | 11 + crates/agent_ui/src/agent_panel.rs | 5 + .../src/conversation_view/thread_view.rs | 5 + crates/sidebar/src/sidebar.rs | 385 +++++++++++++++++- crates/sidebar/src/sidebar_tests.rs | 312 ++++++++++++++ crates/sidebar/src/thread_switcher.rs | 378 +++++++++++++++++ crates/title_bar/src/title_bar.rs | 1 + crates/workspace/src/multi_workspace.rs | 51 ++- crates/zed_actions/src/lib.rs | 13 +- 11 files changed, 1161 insertions(+), 22 deletions(-) create mode 100644 crates/sidebar/src/thread_switcher.rs diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 617d7a6d0662264858ac3066d40481135dab9ae6..1bc3b666beaaac78740c335bbcac9603d6a4d07f 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -265,6 +265,8 @@ "ctrl-y": "agent::AllowOnce", "ctrl-alt-a": "agent::OpenPermissionDropdown", "ctrl-alt-z": "agent::RejectOnce", + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, { @@ -701,6 +703,8 @@ "ctrl-f": "agents_sidebar::FocusSidebarFilter", "ctrl-g": "agents_sidebar::ToggleArchive", "shift-backspace": "agent::RemoveSelectedThread", + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, { @@ -710,6 +714,13 @@ "space": "menu::Confirm", }, }, + { + "context": "ThreadSwitcher", + "bindings": { + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }] + } + }, { "context": "Workspace && debugger_running", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index d3dda49c9a52a8c9b52dfddc04ae573f2fa4cf28..e2073c170b375baea22f5018c2c7dba632dd9b05 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -304,6 +304,8 @@ "cmd-y": "agent::AllowOnce", "cmd-alt-a": "agent::OpenPermissionDropdown", "cmd-alt-z": "agent::RejectOnce", + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, { @@ -767,6 +769,8 @@ "cmd-f": "agents_sidebar::FocusSidebarFilter", "cmd-g": "agents_sidebar::ToggleArchive", "shift-backspace": "agent::RemoveSelectedThread", + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, { @@ -776,6 +780,13 @@ "space": "menu::Confirm", }, }, + { + "context": "ThreadSwitcher", + "bindings": { + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }] + } + }, { "context": "Workspace && debugger_running", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index e665d26aaf0c90d6c2fa4ee66284687c843fcd62..aa31629bcb1739288dcdaef16ad1af5b116d68f1 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -266,6 +266,8 @@ "shift-alt-a": "agent::AllowOnce", "ctrl-alt-a": "agent::OpenPermissionDropdown", "shift-alt-z": "agent::RejectOnce", + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, { @@ -703,6 +705,8 @@ "ctrl-f": "agents_sidebar::FocusSidebarFilter", "ctrl-g": "agents_sidebar::ToggleArchive", "shift-backspace": "agent::RemoveSelectedThread", + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, }, { @@ -712,6 +716,13 @@ "space": "menu::Confirm", }, }, + { + "context": "ThreadSwitcher", + "bindings": { + "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", + "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }] + } + }, { "context": "ApplicationMenu", "use_key_equivalents": true, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 67ddd5a09c14c36f06f2dd19abd486d7c346892e..8ab640a55ef72ed29efbac21ee3b69cfc84c15dc 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2234,6 +2234,10 @@ impl AgentPanel { AcpThreadViewEvent::FirstSendRequested { content } => { this.handle_first_send_requested(view.clone(), content.clone(), window, cx); } + AcpThreadViewEvent::MessageSentOrQueued => { + let session_id = view.read(cx).thread.read(cx).session_id().clone(); + cx.emit(AgentPanelEvent::MessageSentOrQueued { session_id }); + } }, ) }) @@ -3113,6 +3117,7 @@ pub enum AgentPanelEvent { ActiveViewChanged, ThreadFocused, BackgroundThreadChanged, + MessageSentOrQueued { session_id: acp::SessionId }, } impl EventEmitter for AgentPanel {} diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 7caeb687bebb32083c2647a2bc0d359b36e03b58..a295cb562f0d46b8d979412ced39abf5f94d1ad9 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -166,6 +166,7 @@ impl ThreadFeedbackState { pub enum AcpThreadViewEvent { FirstSendRequested { content: Vec }, + MessageSentOrQueued, } impl EventEmitter for ThreadView {} @@ -907,6 +908,7 @@ impl ThreadView { }); if intercept_first_send { + cx.emit(AcpThreadViewEvent::MessageSentOrQueued); let content_task = self.resolve_message_contents(&message_editor, cx); cx.spawn(async move |this, cx| match content_task.await { @@ -938,6 +940,7 @@ impl ThreadView { let has_queued = self.has_queued_messages(); if is_editor_empty && self.can_fast_track_queue && has_queued { self.can_fast_track_queue = false; + cx.emit(AcpThreadViewEvent::MessageSentOrQueued); self.send_queued_message_at_index(0, true, window, cx); return; } @@ -947,6 +950,7 @@ impl ThreadView { } if is_generating { + cx.emit(AcpThreadViewEvent::MessageSentOrQueued); self.queue_message(message_editor, window, cx); return; } @@ -988,6 +992,7 @@ impl ThreadView { } } + cx.emit(AcpThreadViewEvent::MessageSentOrQueued); self.send_impl(message_editor, window, cx) } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 420942da64acec08f9d7acf2d618764e36066aa0..345a8f4ac8c6f518073879b8e72b067fd4cc5fb0 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -1,3 +1,5 @@ +mod thread_switcher; + use acp_thread::ThreadStatus; use action_log::DiffStats; use agent_client_protocol::{self as acp}; @@ -9,7 +11,7 @@ use agent_ui::threads_archive_view::{ use agent_ui::{ Agent, AgentPanel, AgentPanelEvent, DEFAULT_THREAD_TITLE, NewThread, RemoveSelectedThread, }; -use chrono::Utc; +use chrono::{DateTime, Utc}; use editor::Editor; use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; use gpui::{ @@ -44,7 +46,9 @@ use workspace::{ use zed_actions::OpenRecent; use zed_actions::editor::{MoveDown, MoveUp}; -use zed_actions::agents_sidebar::FocusSidebarFilter; +use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher}; + +use crate::thread_switcher::{ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent}; use crate::project_group_builder::ProjectGroupBuilder; @@ -312,6 +316,16 @@ pub struct Sidebar { hovered_thread_index: Option, collapsed_groups: HashSet, expanded_groups: HashMap, + /// Updated only in response to explicit user actions (clicking a + /// thread, confirming in the thread switcher, etc.) — never from + /// background data changes. Used to sort the thread switcher popup. + thread_last_accessed: HashMap>, + /// Updated when the user presses a key to send or queue a message. + /// Used for sorting threads in the sidebar and as a secondary sort + /// key in the thread switcher. + thread_last_message_sent_or_queued: HashMap>, + thread_switcher: Option>, + _thread_switcher_subscriptions: Vec, view: SidebarView, recent_projects_popover_handle: PopoverMenuHandle, project_header_menu_ix: Option, @@ -404,6 +418,10 @@ impl Sidebar { hovered_thread_index: None, collapsed_groups: HashSet::new(), expanded_groups: HashMap::new(), + thread_last_accessed: HashMap::new(), + thread_last_message_sent_or_queued: HashMap::new(), + thread_switcher: None, + _thread_switcher_subscriptions: Vec::new(), view: SidebarView::default(), recent_projects_popover_handle: PopoverMenuHandle::default(), project_header_menu_ix: None, @@ -506,6 +524,10 @@ impl Sidebar { AgentPanelEvent::ThreadFocused | AgentPanelEvent::BackgroundThreadChanged => { this.update_entries(cx); } + AgentPanelEvent::MessageSentOrQueued { session_id } => { + this.record_thread_message_sent(session_id); + this.update_entries(cx); + } }, ) .detach(); @@ -779,7 +801,7 @@ impl Sidebar { } } - // Load threads from linked git worktrees whose + // Load threads from linked git worktrees // canonical paths belong to this group. let linked_worktree_queries = group .workspaces @@ -872,8 +894,18 @@ impl Sidebar { } threads.sort_by(|a, b| { - let a_time = a.session_info.created_at.or(a.session_info.updated_at); - let b_time = b.session_info.created_at.or(b.session_info.updated_at); + let a_time = self + .thread_last_message_sent_or_queued + .get(&a.session_info.session_id) + .copied() + .or(a.session_info.created_at) + .or(a.session_info.updated_at); + let b_time = self + .thread_last_message_sent_or_queued + .get(&b.session_info.session_id) + .copied() + .or(b.session_info.created_at) + .or(b.session_info.updated_at); b_time.cmp(&a_time) }); } else { @@ -1022,6 +1054,11 @@ impl Sidebar { // the build pass (no extra scan needed). notified_threads.retain(|id| current_session_ids.contains(id)); + self.thread_last_accessed + .retain(|id, _| current_session_ids.contains(id)); + self.thread_last_message_sent_or_queued + .retain(|id, _| current_session_ids.contains(id)); + self.contents = SidebarContents { entries, notified_threads, @@ -1881,6 +1918,7 @@ impl Sidebar { workspace: &Entity, agent: Agent, session_info: acp_thread::AgentSessionInfo, + focus: bool, window: &mut Window, cx: &mut App, ) { @@ -1895,7 +1933,7 @@ impl Sidebar { session_info.session_id, session_info.work_dirs, session_info.title, - true, + focus, window, cx, ); @@ -1919,12 +1957,13 @@ impl Sidebar { // immediately, rather than waiting for a deferred AgentPanel // event which can race with ActiveWorkspaceChanged clearing it. self.focused_thread = Some(session_info.session_id.clone()); + self.record_thread_access(&session_info.session_id); multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.activate(workspace.clone(), cx); }); - Self::load_agent_thread_in_workspace(workspace, agent, session_info, window, cx); + Self::load_agent_thread_in_workspace(workspace, agent, session_info, true, window, cx); self.update_entries(cx); } @@ -1943,7 +1982,14 @@ impl Sidebar { .update(cx, |multi_workspace, window, cx| { window.activate_window(); multi_workspace.activate(workspace.clone(), cx); - Self::load_agent_thread_in_workspace(&workspace, agent, session_info, window, cx); + Self::load_agent_thread_in_workspace( + &workspace, + agent, + session_info, + true, + window, + cx, + ); }) .log_err() .is_some(); @@ -1958,7 +2004,8 @@ impl Sidebar { .and_then(|sidebar| sidebar.downcast::().ok()) { target_sidebar.update(cx, |sidebar, cx| { - sidebar.focused_thread = Some(target_session_id); + sidebar.focused_thread = Some(target_session_id.clone()); + sidebar.record_thread_access(&target_session_id); sidebar.update_entries(cx); }); } @@ -2287,8 +2334,10 @@ impl Sidebar { }); if let Some(next) = next_thread { - self.focused_thread = Some(next.session_info.session_id.clone()); - + let next_session_id = next.session_info.session_id.clone(); + let next_agent = next.agent.clone(); + let next_work_dirs = next.session_info.work_dirs.clone(); + let next_title = next.session_info.title.clone(); // Use the thread's own workspace when it has one open (e.g. an absorbed // linked worktree thread that appears under the main workspace's header // but belongs to its own workspace). Loading into the wrong panel binds @@ -2298,15 +2347,17 @@ impl Sidebar { ThreadEntryWorkspace::Open(ws) => Some(ws.clone()), ThreadEntryWorkspace::Closed(_) => group_workspace, }; + self.focused_thread = Some(next_session_id.clone()); + self.record_thread_access(&next_session_id); if let Some(workspace) = target_workspace { if let Some(agent_panel) = workspace.read(cx).panel::(cx) { agent_panel.update(cx, |panel, cx| { panel.load_agent_thread( - next.agent.clone(), - next.session_info.session_id.clone(), - next.session_info.work_dirs.clone(), - next.session_info.title.clone(), + next_agent, + next_session_id, + next_work_dirs, + next_title, true, window, cx, @@ -2349,6 +2400,292 @@ impl Sidebar { self.archive_thread(&session_id, window, cx); } + fn record_thread_access(&mut self, session_id: &acp::SessionId) { + self.thread_last_accessed + .insert(session_id.clone(), Utc::now()); + } + + fn record_thread_message_sent(&mut self, session_id: &acp::SessionId) { + self.thread_last_message_sent_or_queued + .insert(session_id.clone(), Utc::now()); + } + + fn mru_threads_for_switcher(&self, _cx: &App) -> Vec { + let mut current_header_workspace: Option> = None; + let mut entries: Vec = self + .contents + .entries + .iter() + .filter_map(|entry| match entry { + ListEntry::ProjectHeader { workspace, .. } => { + current_header_workspace = Some(workspace.clone()); + None + } + ListEntry::Thread(thread) => { + let workspace = match &thread.workspace { + ThreadEntryWorkspace::Open(workspace) => workspace.clone(), + ThreadEntryWorkspace::Closed(_) => { + current_header_workspace.as_ref()?.clone() + } + }; + let notified = self + .contents + .is_thread_notified(&thread.session_info.session_id); + let timestamp: SharedString = self + .thread_last_message_sent_or_queued + .get(&thread.session_info.session_id) + .copied() + .or(thread.session_info.created_at) + .or(thread.session_info.updated_at) + .map(format_history_entry_timestamp) + .unwrap_or_default() + .into(); + Some(ThreadSwitcherEntry { + session_id: thread.session_info.session_id.clone(), + title: thread + .session_info + .title + .clone() + .unwrap_or_else(|| "Untitled".into()), + icon: thread.icon, + icon_from_external_svg: thread.icon_from_external_svg.clone(), + status: thread.status, + agent: thread.agent.clone(), + session_info: thread.session_info.clone(), + workspace, + worktree_name: thread.worktrees.first().map(|wt| wt.name.clone()), + + diff_stats: thread.diff_stats, + is_title_generating: thread.is_title_generating, + notified, + timestamp, + }) + } + _ => None, + }) + .collect(); + + entries.sort_by(|a, b| { + let a_accessed = self.thread_last_accessed.get(&a.session_id); + let b_accessed = self.thread_last_accessed.get(&b.session_id); + + match (a_accessed, b_accessed) { + (Some(a_time), Some(b_time)) => b_time.cmp(a_time), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => { + let a_sent = self.thread_last_message_sent_or_queued.get(&a.session_id); + let b_sent = self.thread_last_message_sent_or_queued.get(&b.session_id); + + match (a_sent, b_sent) { + (Some(a_time), Some(b_time)) => b_time.cmp(a_time), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => { + let a_time = a.session_info.created_at.or(a.session_info.updated_at); + let b_time = b.session_info.created_at.or(b.session_info.updated_at); + b_time.cmp(&a_time) + } + } + } + } + }); + + entries + } + + fn dismiss_thread_switcher(&mut self, cx: &mut Context) { + self.thread_switcher = None; + self._thread_switcher_subscriptions.clear(); + if let Some(mw) = self.multi_workspace.upgrade() { + mw.update(cx, |mw, cx| { + mw.set_sidebar_overlay(None, cx); + }); + } + } + + fn on_toggle_thread_switcher( + &mut self, + action: &ToggleThreadSwitcher, + window: &mut Window, + cx: &mut Context, + ) { + self.toggle_thread_switcher_impl(action.select_last, window, cx); + } + + fn toggle_thread_switcher_impl( + &mut self, + select_last: bool, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(thread_switcher) = &self.thread_switcher { + thread_switcher.update(cx, |switcher, cx| { + if select_last { + switcher.select_last(cx); + } else { + switcher.cycle_selection(cx); + } + }); + return; + } + + let entries = self.mru_threads_for_switcher(cx); + if entries.len() < 2 { + return; + } + + let weak_multi_workspace = self.multi_workspace.clone(); + + let original_agent = self + .focused_thread + .as_ref() + .and_then(|focused_id| entries.iter().find(|e| &e.session_id == focused_id)) + .map(|e| e.agent.clone()); + let original_session_info = self + .focused_thread + .as_ref() + .and_then(|focused_id| entries.iter().find(|e| &e.session_id == focused_id)) + .map(|e| e.session_info.clone()); + let original_workspace = self + .multi_workspace + .upgrade() + .map(|mw| mw.read(cx).workspace().clone()); + + let thread_switcher = cx.new(|cx| ThreadSwitcher::new(entries, select_last, window, cx)); + + let mut subscriptions = Vec::new(); + + subscriptions.push(cx.subscribe_in(&thread_switcher, window, { + let thread_switcher = thread_switcher.clone(); + move |this, _emitter, event: &ThreadSwitcherEvent, window, cx| match event { + ThreadSwitcherEvent::Preview { + agent, + session_info, + workspace, + } => { + if let Some(mw) = weak_multi_workspace.upgrade() { + mw.update(cx, |mw, cx| { + mw.activate(workspace.clone(), cx); + }); + } + this.focused_thread = Some(session_info.session_id.clone()); + this.update_entries(cx); + Self::load_agent_thread_in_workspace( + workspace, + agent.clone(), + session_info.clone(), + false, + window, + cx, + ); + let focus = thread_switcher.focus_handle(cx); + window.focus(&focus, cx); + } + ThreadSwitcherEvent::Confirmed { + agent, + session_info, + workspace, + } => { + if let Some(mw) = weak_multi_workspace.upgrade() { + mw.update(cx, |mw, cx| { + mw.activate(workspace.clone(), cx); + }); + } + this.record_thread_access(&session_info.session_id); + this.focused_thread = Some(session_info.session_id.clone()); + this.update_entries(cx); + Self::load_agent_thread_in_workspace( + workspace, + agent.clone(), + session_info.clone(), + false, + window, + cx, + ); + this.dismiss_thread_switcher(cx); + workspace.update(cx, |workspace, cx| { + workspace.focus_panel::(window, cx); + }); + } + ThreadSwitcherEvent::Dismissed => { + if let Some(mw) = weak_multi_workspace.upgrade() { + if let Some(original_ws) = &original_workspace { + mw.update(cx, |mw, cx| { + mw.activate(original_ws.clone(), cx); + }); + } + } + if let Some(session_info) = &original_session_info { + this.focused_thread = Some(session_info.session_id.clone()); + this.update_entries(cx); + let agent = original_agent.clone().unwrap_or(Agent::NativeAgent); + if let Some(original_ws) = &original_workspace { + Self::load_agent_thread_in_workspace( + original_ws, + agent, + session_info.clone(), + false, + window, + cx, + ); + } + } + this.dismiss_thread_switcher(cx); + } + } + })); + + subscriptions.push(cx.subscribe_in( + &thread_switcher, + window, + |this, _emitter, _event: &gpui::DismissEvent, _window, cx| { + this.dismiss_thread_switcher(cx); + }, + )); + + let focus = thread_switcher.focus_handle(cx); + let overlay_view = gpui::AnyView::from(thread_switcher.clone()); + + // Replay the initial preview that was emitted during construction + // before subscriptions were wired up. + let initial_preview = thread_switcher.read(cx).selected_entry().map(|entry| { + ( + entry.agent.clone(), + entry.session_info.clone(), + entry.workspace.clone(), + ) + }); + + self.thread_switcher = Some(thread_switcher); + self._thread_switcher_subscriptions = subscriptions; + if let Some(mw) = self.multi_workspace.upgrade() { + mw.update(cx, |mw, cx| { + mw.set_sidebar_overlay(Some(overlay_view), cx); + }); + } + + if let Some((agent, session_info, workspace)) = initial_preview { + if let Some(mw) = self.multi_workspace.upgrade() { + mw.update(cx, |mw, cx| { + mw.activate(workspace.clone(), cx); + }); + } + self.focused_thread = Some(session_info.session_id.clone()); + self.update_entries(cx); + Self::load_agent_thread_in_workspace( + &workspace, + agent, + session_info, + false, + window, + cx, + ); + } + + window.focus(&focus, cx); + } + fn render_thread( &self, ix: usize, @@ -2381,9 +2718,11 @@ impl Sidebar { let id = SharedString::from(format!("thread-entry-{}", ix)); - let timestamp = thread - .session_info - .created_at + let timestamp = self + .thread_last_message_sent_or_queued + .get(&thread.session_info.session_id) + .copied() + .or(thread.session_info.created_at) .or(thread.session_info.updated_at) .map(format_history_entry_timestamp); @@ -3017,6 +3356,15 @@ impl WorkspaceSidebar for Sidebar { self.selection = None; cx.notify(); } + + fn toggle_thread_switcher( + &mut self, + select_last: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.toggle_thread_switcher_impl(select_last, window, cx); + } } impl Focusable for Sidebar { @@ -3060,6 +3408,7 @@ impl Render for Sidebar { .on_action(cx.listener(Self::new_thread_in_group)) .on_action(cx.listener(Self::toggle_archive)) .on_action(cx.listener(Self::focus_sidebar_filter)) + .on_action(cx.listener(Self::on_toggle_thread_switcher)) .on_action(cx.listener(|this, _: &OpenRecent, window, cx| { this.recent_projects_popover_handle.toggle(window, cx); })) diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 8170a2956886f1bc0b90acd2f83b5a9ccd2c979b..57edbd4212acf7a40e7c29f02403e9cb2b00e11d 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -4127,6 +4127,318 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test ); } +#[gpui::test] +async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + let switcher_ids = + |sidebar: &Entity, cx: &mut gpui::VisualTestContext| -> Vec { + sidebar.read_with(cx, |sidebar, cx| { + let switcher = sidebar + .thread_switcher + .as_ref() + .expect("switcher should be open"); + switcher + .read(cx) + .entries() + .iter() + .map(|e| e.session_id.clone()) + .collect() + }) + }; + + let switcher_selected_id = + |sidebar: &Entity, cx: &mut gpui::VisualTestContext| -> acp::SessionId { + sidebar.read_with(cx, |sidebar, cx| { + let switcher = sidebar + .thread_switcher + .as_ref() + .expect("switcher should be open"); + let s = switcher.read(cx); + s.selected_entry() + .expect("should have selection") + .session_id + .clone() + }) + }; + + // ── Setup: create three threads with distinct created_at times ────── + // Thread C (oldest), Thread B, Thread A (newest) — by created_at. + // We send messages in each so they also get last_message_sent_or_queued timestamps. + let connection_c = StubAgentConnection::new(); + connection_c.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done C".into()), + )]); + open_thread_with_connection(&panel, connection_c, cx); + send_message(&panel, cx); + let session_id_c = active_session_id(&panel, cx); + cx.update(|_, cx| { + SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save( + ThreadMetadata { + session_id: session_id_c.clone(), + agent_id: None, + title: "Thread C".into(), + updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0) + .unwrap(), + created_at: Some( + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + ), + folder_paths: path_list.clone(), + }, + cx, + ) + }) + }); + cx.run_until_parked(); + + let connection_b = StubAgentConnection::new(); + connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done B".into()), + )]); + open_thread_with_connection(&panel, connection_b, cx); + send_message(&panel, cx); + let session_id_b = active_session_id(&panel, cx); + cx.update(|_, cx| { + SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save( + ThreadMetadata { + session_id: session_id_b.clone(), + agent_id: None, + title: "Thread B".into(), + updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0) + .unwrap(), + created_at: Some( + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + ), + folder_paths: path_list.clone(), + }, + cx, + ) + }) + }); + cx.run_until_parked(); + + let connection_a = StubAgentConnection::new(); + connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done A".into()), + )]); + open_thread_with_connection(&panel, connection_a, cx); + send_message(&panel, cx); + let session_id_a = active_session_id(&panel, cx); + cx.update(|_, cx| { + SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save( + ThreadMetadata { + session_id: session_id_a.clone(), + agent_id: None, + title: "Thread A".into(), + updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0) + .unwrap(), + created_at: Some( + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + ), + folder_paths: path_list.clone(), + }, + cx, + ) + }) + }); + cx.run_until_parked(); + + // All three threads are now live. Thread A was opened last, so it's + // the one being viewed. Opening each thread called record_thread_access, + // so all three have last_accessed_at set. + // Access order is: A (most recent), B, C (oldest). + + // ── 1. Open switcher: threads sorted by last_accessed_at ─────────── + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx); + }); + cx.run_until_parked(); + + // All three have last_accessed_at, so they sort by access time. + // A was accessed most recently (it's the currently viewed thread), + // then B, then C. + assert_eq!( + switcher_ids(&sidebar, cx), + vec![ + session_id_a.clone(), + session_id_b.clone(), + session_id_c.clone() + ], + ); + // First ctrl-tab selects the second entry (B). + assert_eq!(switcher_selected_id(&sidebar, cx), session_id_b); + + // Dismiss the switcher without confirming. + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.dismiss_thread_switcher(cx); + }); + cx.run_until_parked(); + + // ── 2. Confirm on Thread C: it becomes most-recently-accessed ────── + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx); + }); + cx.run_until_parked(); + + // Cycle twice to land on Thread C (index 2). + sidebar.read_with(cx, |sidebar, cx| { + let switcher = sidebar.thread_switcher.as_ref().unwrap(); + assert_eq!(switcher.read(cx).selected_index(), 1); + }); + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar + .thread_switcher + .as_ref() + .unwrap() + .update(cx, |s, cx| s.cycle_selection(cx)); + }); + cx.run_until_parked(); + assert_eq!(switcher_selected_id(&sidebar, cx), session_id_c); + + // Confirm on Thread C. + sidebar.update_in(cx, |sidebar, window, cx| { + let switcher = sidebar.thread_switcher.as_ref().unwrap(); + let focus = switcher.focus_handle(cx); + focus.dispatch_action(&menu::Confirm, window, cx); + }); + cx.run_until_parked(); + + // Switcher should be dismissed after confirm. + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + sidebar.thread_switcher.is_none(), + "switcher should be dismissed" + ); + }); + + // Re-open switcher: Thread C is now most-recently-accessed. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + switcher_ids(&sidebar, cx), + vec![ + session_id_c.clone(), + session_id_a.clone(), + session_id_b.clone() + ], + ); + + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.dismiss_thread_switcher(cx); + }); + cx.run_until_parked(); + + // ── 3. Add a historical thread (no last_accessed_at, no message sent) ── + // This thread was never opened in a panel — it only exists in metadata. + cx.update(|_, cx| { + SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save( + ThreadMetadata { + session_id: acp::SessionId::new(Arc::from("thread-historical")), + agent_id: None, + title: "Historical Thread".into(), + updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0) + .unwrap(), + created_at: Some( + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), + ), + folder_paths: path_list.clone(), + }, + cx, + ) + }) + }); + cx.run_until_parked(); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx); + }); + cx.run_until_parked(); + + // Historical Thread has no last_accessed_at and no last_message_sent_or_queued, + // so it falls to tier 3 (sorted by created_at). It should appear after all + // accessed threads, even though its created_at (June 2024) is much later + // than the others. + // + // But the live threads (A, B, C) each had send_message called which sets + // last_message_sent_or_queued. So for the accessed threads (tier 1) the + // sort key is last_accessed_at; for Historical Thread (tier 3) it's created_at. + let session_id_hist = acp::SessionId::new(Arc::from("thread-historical")); + let ids = switcher_ids(&sidebar, cx); + assert_eq!( + ids, + vec![ + session_id_c.clone(), + session_id_a.clone(), + session_id_b.clone(), + session_id_hist.clone() + ], + ); + + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.dismiss_thread_switcher(cx); + }); + cx.run_until_parked(); + + // ── 4. Add another historical thread with older created_at ───────── + cx.update(|_, cx| { + SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save( + ThreadMetadata { + session_id: acp::SessionId::new(Arc::from("thread-old-historical")), + agent_id: None, + title: "Old Historical Thread".into(), + updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0) + .unwrap(), + created_at: Some( + chrono::TimeZone::with_ymd_and_hms(&Utc, 2023, 6, 1, 0, 0, 0).unwrap(), + ), + folder_paths: path_list.clone(), + }, + cx, + ) + }) + }); + cx.run_until_parked(); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx); + }); + cx.run_until_parked(); + + // Both historical threads have no access or message times. They should + // appear after accessed threads, sorted by created_at (newest first). + let session_id_old_hist = acp::SessionId::new(Arc::from("thread-old-historical")); + let ids = switcher_ids(&sidebar, cx); + assert_eq!( + ids, + vec![ + session_id_c.clone(), + session_id_a.clone(), + session_id_b.clone(), + session_id_hist, + session_id_old_hist, + ], + ); + + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.dismiss_thread_switcher(cx); + }); + cx.run_until_parked(); +} + mod property_test { use super::*; use gpui::EntityId; diff --git a/crates/sidebar/src/thread_switcher.rs b/crates/sidebar/src/thread_switcher.rs new file mode 100644 index 0000000000000000000000000000000000000000..56767a5adfc6c73ac21b8968b7ed35587faa36f4 --- /dev/null +++ b/crates/sidebar/src/thread_switcher.rs @@ -0,0 +1,378 @@ +use acp_thread; +use action_log::DiffStats; +use agent_client_protocol as acp; +use agent_ui::Agent; +use gpui::{ + Action as _, Animation, AnimationExt, AnyElement, DismissEvent, Entity, EventEmitter, + FocusHandle, Focusable, Hsla, Modifiers, ModifiersChangedEvent, Render, SharedString, + prelude::*, pulsating_between, +}; +use std::time::Duration; +use ui::{ + AgentThreadStatus, Color, CommonAnimationExt, DecoratedIcon, DiffStat, Icon, IconDecoration, + IconDecorationKind, IconName, IconSize, Label, LabelSize, prelude::*, +}; +use workspace::{ModalView, Workspace}; +use zed_actions::agents_sidebar::ToggleThreadSwitcher; + +const PANEL_WIDTH_REMS: f32 = 28.; + +pub(crate) struct ThreadSwitcherEntry { + pub session_id: acp::SessionId, + pub title: SharedString, + pub icon: IconName, + pub icon_from_external_svg: Option, + pub status: AgentThreadStatus, + pub agent: Agent, + pub session_info: acp_thread::AgentSessionInfo, + pub workspace: Entity, + pub worktree_name: Option, + pub diff_stats: DiffStats, + pub is_title_generating: bool, + pub notified: bool, + pub timestamp: SharedString, +} + +pub(crate) enum ThreadSwitcherEvent { + Preview { + agent: Agent, + session_info: acp_thread::AgentSessionInfo, + workspace: Entity, + }, + Confirmed { + agent: Agent, + session_info: acp_thread::AgentSessionInfo, + workspace: Entity, + }, + Dismissed, +} + +pub(crate) struct ThreadSwitcher { + focus_handle: FocusHandle, + entries: Vec, + selected_index: usize, + init_modifiers: Option, +} + +impl ThreadSwitcher { + pub fn new( + entries: Vec, + select_last: bool, + window: &mut gpui::Window, + cx: &mut Context, + ) -> Self { + let init_modifiers = window.modifiers().modified().then_some(window.modifiers()); + let selected_index = if entries.is_empty() { + 0 + } else if select_last { + entries.len() - 1 + } else { + 1.min(entries.len().saturating_sub(1)) + }; + + if let Some(entry) = entries.get(selected_index) { + cx.emit(ThreadSwitcherEvent::Preview { + agent: entry.agent.clone(), + session_info: entry.session_info.clone(), + workspace: entry.workspace.clone(), + }); + } + + let focus_handle = cx.focus_handle(); + cx.on_focus_out(&focus_handle, window, |_this, _event, _window, cx| { + cx.emit(ThreadSwitcherEvent::Dismissed); + cx.emit(DismissEvent); + }) + .detach(); + + Self { + focus_handle, + entries, + selected_index, + init_modifiers, + } + } + + pub fn selected_entry(&self) -> Option<&ThreadSwitcherEntry> { + self.entries.get(self.selected_index) + } + + #[cfg(test)] + pub fn entries(&self) -> &[ThreadSwitcherEntry] { + &self.entries + } + + #[cfg(test)] + pub fn selected_index(&self) -> usize { + self.selected_index + } + + pub fn cycle_selection(&mut self, cx: &mut Context) { + if self.entries.is_empty() { + return; + } + self.selected_index = (self.selected_index + 1) % self.entries.len(); + self.emit_preview(cx); + } + + pub fn select_last(&mut self, cx: &mut Context) { + if self.entries.is_empty() { + return; + } + if self.selected_index == 0 { + self.selected_index = self.entries.len() - 1; + } else { + self.selected_index -= 1; + } + self.emit_preview(cx); + } + + fn emit_preview(&mut self, cx: &mut Context) { + if let Some(entry) = self.entries.get(self.selected_index) { + cx.emit(ThreadSwitcherEvent::Preview { + agent: entry.agent.clone(), + session_info: entry.session_info.clone(), + workspace: entry.workspace.clone(), + }); + } + } + + fn confirm(&mut self, _: &menu::Confirm, _window: &mut gpui::Window, cx: &mut Context) { + if let Some(entry) = self.entries.get(self.selected_index) { + cx.emit(ThreadSwitcherEvent::Confirmed { + agent: entry.agent.clone(), + session_info: entry.session_info.clone(), + workspace: entry.workspace.clone(), + }); + } + cx.emit(DismissEvent); + } + + fn cancel(&mut self, _: &menu::Cancel, _window: &mut gpui::Window, cx: &mut Context) { + cx.emit(ThreadSwitcherEvent::Dismissed); + cx.emit(DismissEvent); + } + + fn toggle( + &mut self, + action: &ToggleThreadSwitcher, + _window: &mut gpui::Window, + cx: &mut Context, + ) { + if action.select_last { + self.select_last(cx); + } else { + self.cycle_selection(cx); + } + } + + fn handle_modifiers_changed( + &mut self, + event: &ModifiersChangedEvent, + window: &mut gpui::Window, + cx: &mut Context, + ) { + let Some(init_modifiers) = self.init_modifiers else { + return; + }; + if !event.modified() || !init_modifiers.is_subset_of(event) { + self.init_modifiers = None; + if self.entries.is_empty() { + cx.emit(DismissEvent); + } else { + window.dispatch_action(menu::Confirm.boxed_clone(), cx); + } + } + } +} + +impl ModalView for ThreadSwitcher {} + +impl EventEmitter for ThreadSwitcher {} +impl EventEmitter for ThreadSwitcher {} + +impl Focusable for ThreadSwitcher { + fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ThreadSwitcher { + fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { + let selected_index = self.selected_index; + let color = cx.theme().colors(); + let panel_bg = color + .title_bar_background + .blend(color.panel_background.opacity(0.2)); + + v_flex() + .key_context("ThreadSwitcher") + .track_focus(&self.focus_handle) + .w(gpui::rems(PANEL_WIDTH_REMS)) + .elevation_3(cx) + .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::toggle)) + .children(self.entries.iter().enumerate().map(|(ix, entry)| { + let is_first = ix == 0; + let is_last = ix == self.entries.len() - 1; + let selected = ix == selected_index; + let base_bg = if selected { + color.element_active + } else { + panel_bg + }; + + let dot_separator = || { + Label::new("\u{2022}") + .size(LabelSize::Small) + .color(Color::Muted) + .alpha(0.5) + }; + + let icon_container = || h_flex().size_4().flex_none().justify_center(); + + let agent_icon = || { + if let Some(ref svg) = entry.icon_from_external_svg { + Icon::from_external_svg(svg.clone()) + .color(Color::Muted) + .size(IconSize::Small) + } else { + Icon::new(entry.icon) + .color(Color::Muted) + .size(IconSize::Small) + } + }; + + let decoration = |kind: IconDecorationKind, deco_color: Hsla| { + IconDecoration::new(kind, base_bg, cx) + .color(deco_color) + .position(gpui::Point { + x: px(-2.), + y: px(-2.), + }) + }; + + let icon_element: AnyElement = if entry.status == AgentThreadStatus::Running { + icon_container() + .child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2), + ) + .into_any_element() + } else if entry.status == AgentThreadStatus::Error { + icon_container() + .child(DecoratedIcon::new( + agent_icon(), + Some(decoration(IconDecorationKind::X, cx.theme().status().error)), + )) + .into_any_element() + } else if entry.status == AgentThreadStatus::WaitingForConfirmation { + icon_container() + .child(DecoratedIcon::new( + agent_icon(), + Some(decoration( + IconDecorationKind::Triangle, + cx.theme().status().warning, + )), + )) + .into_any_element() + } else if entry.notified { + icon_container() + .child(DecoratedIcon::new( + agent_icon(), + Some(decoration(IconDecorationKind::Dot, color.text_accent)), + )) + .into_any_element() + } else { + icon_container().child(agent_icon()).into_any_element() + }; + + let title_label: AnyElement = if entry.is_title_generating { + Label::new(entry.title.clone()) + .color(Color::Muted) + .with_animation( + "generating-title", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.alpha(delta), + ) + .into_any_element() + } else { + Label::new(entry.title.clone()).into_any_element() + }; + + let has_diff_stats = + entry.diff_stats.lines_added > 0 || entry.diff_stats.lines_removed > 0; + let has_worktree = entry.worktree_name.is_some(); + let has_timestamp = !entry.timestamp.is_empty(); + + v_flex() + .id(ix) + .w_full() + .py_1() + .px_1p5() + .border_1() + .border_color(gpui::transparent_black()) + .when(selected, |s| s.bg(color.element_active)) + .when(is_first, |s| s.rounded_t_lg()) + .when(is_last, |s| s.rounded_b_lg()) + .child( + h_flex() + .min_w_0() + .w_full() + .gap_1p5() + .child(icon_element) + .child(title_label), + ) + .when(has_worktree || has_diff_stats || has_timestamp, |this| { + this.child( + h_flex() + .min_w_0() + .gap_1p5() + .child(icon_container()) + .when_some(entry.worktree_name.clone(), |this, worktree| { + this.child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::GitWorktree) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child( + Label::new(worktree) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + }) + .when(has_worktree && (has_diff_stats || has_timestamp), |this| { + this.child(dot_separator()) + }) + .when(has_diff_stats, |this| { + this.child(DiffStat::new( + ix, + entry.diff_stats.lines_added as usize, + entry.diff_stats.lines_removed as usize, + )) + }) + .when(has_diff_stats && has_timestamp, |this| { + this.child(dot_separator()) + }) + .when(has_timestamp, |this| { + this.child( + Label::new(entry.timestamp.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ) + }) + })) + } +} diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 86e15dd7284881961cbc2c43f17e603ea3d39bc3..42c348bacb680e2a09586d0dc0279fc8c95d1604 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -49,6 +49,7 @@ use util::ResultExt; use workspace::{ MultiWorkspace, ToggleWorktreeSecurity, Workspace, WorkspaceId, notifications::NotifyResultExt, }; + use zed_actions::OpenRemote; pub use onboarding_banner::restore_banner; diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 924471d4dd08fa14f08723ffb990e9d8f555c048..adfbd99866544fe1045d2edebd271111928cf6f4 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -15,7 +15,7 @@ use std::path::PathBuf; use std::sync::Arc; use ui::prelude::*; use util::ResultExt; -use zed_actions::agents_sidebar::MoveWorkspaceToNewWindow; +use zed_actions::agents_sidebar::{MoveWorkspaceToNewWindow, ToggleThreadSwitcher}; use agent_settings::AgentSettings; use settings::SidebarDockPosition; @@ -100,8 +100,16 @@ pub trait Sidebar: Focusable + Render + Sized { fn is_threads_list_view_active(&self) -> bool { true } - /// Makes focus reset bac to the search editor upon toggling the sidebar from outside + /// Makes focus reset back to the search editor upon toggling the sidebar from outside fn prepare_for_focus(&mut self, _window: &mut Window, _cx: &mut Context) {} + /// Opens or cycles the thread switcher popup. + fn toggle_thread_switcher( + &mut self, + _select_last: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + } } pub trait SidebarHandle: 'static + Send + Sync { @@ -113,6 +121,7 @@ pub trait SidebarHandle: 'static + Send + Sync { fn has_notifications(&self, cx: &App) -> bool; fn to_any(&self) -> AnyView; fn entity_id(&self) -> EntityId; + fn toggle_thread_switcher(&self, select_last: bool, window: &mut Window, cx: &mut App); fn is_threads_list_view_active(&self, cx: &App) -> bool; @@ -162,6 +171,15 @@ impl SidebarHandle for Entity { Entity::entity_id(self) } + fn toggle_thread_switcher(&self, select_last: bool, window: &mut Window, cx: &mut App) { + let entity = self.clone(); + window.defer(cx, move |window, cx| { + entity.update(cx, |this, cx| { + this.toggle_thread_switcher(select_last, window, cx); + }); + }); + } + fn is_threads_list_view_active(&self, cx: &App) -> bool { self.read(cx).is_threads_list_view_active() } @@ -177,6 +195,7 @@ pub struct MultiWorkspace { active_workspace_index: usize, sidebar: Option>, sidebar_open: bool, + sidebar_overlay: Option, pending_removal_tasks: Vec>, _serialize_task: Option>, _subscriptions: Vec, @@ -225,6 +244,7 @@ impl MultiWorkspace { active_workspace_index: 0, sidebar: None, sidebar_open: false, + sidebar_overlay: None, pending_removal_tasks: Vec::new(), _serialize_task: None, _subscriptions: vec![ @@ -247,6 +267,11 @@ impl MultiWorkspace { self.sidebar.as_deref() } + pub fn set_sidebar_overlay(&mut self, overlay: Option, cx: &mut Context) { + self.sidebar_overlay = overlay; + cx.notify(); + } + pub fn sidebar_open(&self) -> bool { self.sidebar_open } @@ -916,6 +941,13 @@ impl Render for MultiWorkspace { .on_action(cx.listener(Self::next_workspace)) .on_action(cx.listener(Self::previous_workspace)) .on_action(cx.listener(Self::move_active_workspace_to_new_window)) + .on_action(cx.listener( + |this: &mut Self, action: &ToggleThreadSwitcher, window, cx| { + if let Some(sidebar) = &this.sidebar { + sidebar.toggle_thread_switcher(action.select_last, window, cx); + } + }, + )) }) .when( self.sidebar_open() && self.multi_workspace_enabled(cx), @@ -947,7 +979,20 @@ impl Render for MultiWorkspace { .child(self.workspace().clone()), ) .children(right_sidebar) - .child(self.workspace().read(cx).modal_layer.clone()), + .child(self.workspace().read(cx).modal_layer.clone()) + .children(self.sidebar_overlay.as_ref().map(|view| { + deferred(div().absolute().size_full().inset_0().occlude().child( + v_flex().h(px(0.0)).top_20().items_center().child( + h_flex().occlude().child(view.clone()).on_mouse_down( + MouseButton::Left, + |_, _, cx| { + cx.stop_propagation(); + }, + ), + ), + )) + .with_priority(2) + })), window, cx, Tiling { diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index f64df42e050023f9f8615dc468e7c3459a4af063..7d8811de705713df7ac8e3a161f14c9f9138ebfc 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -779,7 +779,18 @@ pub mod preview { } pub mod agents_sidebar { - use gpui::actions; + use gpui::{Action, actions}; + use schemars::JsonSchema; + use serde::Deserialize; + + /// Toggles the thread switcher popup when the sidebar is focused. + #[derive(PartialEq, Clone, Deserialize, JsonSchema, Default, Action)] + #[action(namespace = agents_sidebar)] + #[serde(deny_unknown_fields)] + pub struct ToggleThreadSwitcher { + #[serde(default)] + pub select_last: bool, + } actions!( agents_sidebar, From 6694a3bd14bba5d52c375cc4c3ce9681445cbc22 Mon Sep 17 00:00:00 2001 From: MostlyK <135974627+MostlyKIGuess@users.noreply.github.com> Date: Sun, 29 Mar 2026 04:11:33 +0530 Subject: [PATCH 2/2] gpui: Implement pinch event support for X11 and Windows (#51354) Closes #51312 - Remove platform-specific #[cfg] gates from PinchEvent, event listeners, and dispatch logic in GPUI - Windows: Intercept Ctrl+ScrollWheel (emitted by precision trackpads for pinch gestures) and convert them to GPUI PinchEvents - Image Viewer: remove redundant platform-specific blocks - X11: Bump XInput version to 2.4 and implement handlers for XinputGesturePinch events - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Pinching gestures now available on all devices. --------- Co-authored-by: John Tur --- Cargo.toml | 2 + crates/gpui/src/elements/div.rs | 26 -- crates/gpui/src/interactive.rs | 11 - crates/gpui/src/window.rs | 1 - crates/gpui_linux/src/linux/x11/client.rs | 67 +++- crates/gpui_linux/src/linux/x11/window.rs | 8 +- .../gpui_windows/src/direct_manipulation.rs | 359 ++++++++++++++++++ crates/gpui_windows/src/events.rs | 22 ++ crates/gpui_windows/src/gpui_windows.rs | 1 + crates/gpui_windows/src/window.rs | 6 + crates/image_viewer/src/image_viewer.rs | 26 +- 11 files changed, 463 insertions(+), 66 deletions(-) create mode 100644 crates/gpui_windows/src/direct_manipulation.rs diff --git a/Cargo.toml b/Cargo.toml index 998a4705f28c82160b7124a98c1eb23c22360125..29b4494503a8e05017b2badee31416849a89c634 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -812,6 +812,7 @@ features = [ "Win32_Graphics_Direct3D_Fxc", "Win32_Graphics_DirectComposition", "Win32_Graphics_DirectWrite", + "Win32_Graphics_DirectManipulation", "Win32_Graphics_Dwm", "Win32_Graphics_Dxgi", "Win32_Graphics_Dxgi_Common", @@ -843,6 +844,7 @@ features = [ "Win32_UI_HiDpi", "Win32_UI_Input_Ime", "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Input_Pointer", "Win32_UI_Shell", "Win32_UI_Shell_Common", "Win32_UI_Shell_PropertiesSystem", diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index c1bb2011d0bdff432fc5bd0da12b63a79cb9ef5a..cc4f586a3dce937c310e177eefaff1c81c6a4b89 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -15,7 +15,6 @@ //! and Tailwind-like styling that you can use to build your own custom elements. Div is //! constructed by combining these two systems into an all-in-one element. -#[cfg(any(target_os = "linux", target_os = "macos"))] use crate::PinchEvent; use crate::{ AbsoluteLength, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, @@ -357,11 +356,7 @@ impl Interactivity { /// Bind the given callback to pinch gesture events during the bubble phase. /// - /// Note: This event is only available on macOS and Wayland (Linux). - /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. - /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. - #[cfg(any(target_os = "linux", target_os = "macos"))] pub fn on_pinch(&mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) { self.pinch_listeners .push(Box::new(move |event, phase, hitbox, window, cx| { @@ -373,11 +368,7 @@ impl Interactivity { /// Bind the given callback to pinch gesture events during the capture phase. /// - /// Note: This event is only available on macOS and Wayland (Linux). - /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. - /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. - #[cfg(any(target_os = "linux", target_os = "macos"))] pub fn capture_pinch( &mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static, @@ -675,15 +666,9 @@ impl Interactivity { self.hitbox_behavior = HitboxBehavior::BlockMouseExceptScroll; } - #[cfg(any(target_os = "linux", target_os = "macos"))] fn has_pinch_listeners(&self) -> bool { !self.pinch_listeners.is_empty() } - - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - fn has_pinch_listeners(&self) -> bool { - false - } } /// A trait for elements that want to use the standard GPUI event handlers that don't @@ -957,11 +942,7 @@ pub trait InteractiveElement: Sized { /// Bind the given callback to pinch gesture events during the bubble phase. /// The fluent API equivalent to [`Interactivity::on_pinch`]. /// - /// Note: This event is only available on macOS and Wayland (Linux). - /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. - /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. - #[cfg(any(target_os = "linux", target_os = "macos"))] fn on_pinch(mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) -> Self { self.interactivity().on_pinch(listener); self @@ -970,11 +951,7 @@ pub trait InteractiveElement: Sized { /// Bind the given callback to pinch gesture events during the capture phase. /// The fluent API equivalent to [`Interactivity::capture_pinch`]. /// - /// Note: This event is only available on macOS and Wayland (Linux). - /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. - /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. - #[cfg(any(target_os = "linux", target_os = "macos"))] fn capture_pinch( mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static, @@ -1367,7 +1344,6 @@ pub(crate) type MouseMoveListener = pub(crate) type ScrollWheelListener = Box; -#[cfg(any(target_os = "linux", target_os = "macos"))] pub(crate) type PinchListener = Box; @@ -1725,7 +1701,6 @@ pub struct Interactivity { pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, - #[cfg(any(target_os = "linux", target_os = "macos"))] pub(crate) pinch_listeners: Vec, pub(crate) key_down_listeners: Vec, pub(crate) key_up_listeners: Vec, @@ -2297,7 +2272,6 @@ impl Interactivity { }) } - #[cfg(any(target_os = "linux", target_os = "macos"))] for listener in self.pinch_listeners.drain(..) { let hitbox = hitbox.clone(); window.on_mouse_event(move |event: &PinchEvent, phase, window, cx| { diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 3d3ddb49f70b2f96772627d085c93ce31b6dc0b5..0c7f2f9c97c59f90f8e037f069357dcc3c60c9cd 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -473,10 +473,7 @@ impl Default for ScrollDelta { /// A pinch gesture event from the platform, generated when the user performs /// a pinch-to-zoom gesture (typically on a trackpad). /// -/// Note: This event is only available on macOS and Wayland (Linux). -/// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. #[derive(Clone, Debug, Default)] -#[cfg(any(target_os = "linux", target_os = "macos"))] pub struct PinchEvent { /// The position of the pinch center on the window. pub position: Point, @@ -493,20 +490,15 @@ pub struct PinchEvent { pub phase: TouchPhase, } -#[cfg(any(target_os = "linux", target_os = "macos"))] impl Sealed for PinchEvent {} -#[cfg(any(target_os = "linux", target_os = "macos"))] impl InputEvent for PinchEvent { fn to_platform_input(self) -> PlatformInput { PlatformInput::Pinch(self) } } -#[cfg(any(target_os = "linux", target_os = "macos"))] impl GestureEvent for PinchEvent {} -#[cfg(any(target_os = "linux", target_os = "macos"))] impl MouseEvent for PinchEvent {} -#[cfg(any(target_os = "linux", target_os = "macos"))] impl Deref for PinchEvent { type Target = Modifiers; @@ -675,7 +667,6 @@ pub enum PlatformInput { /// The scroll wheel was used. ScrollWheel(ScrollWheelEvent), /// A pinch gesture was performed. - #[cfg(any(target_os = "linux", target_os = "macos"))] Pinch(PinchEvent), /// Files were dragged and dropped onto the window. FileDrop(FileDropEvent), @@ -693,7 +684,6 @@ impl PlatformInput { PlatformInput::MousePressure(event) => Some(event), PlatformInput::MouseExited(event) => Some(event), PlatformInput::ScrollWheel(event) => Some(event), - #[cfg(any(target_os = "linux", target_os = "macos"))] PlatformInput::Pinch(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), } @@ -710,7 +700,6 @@ impl PlatformInput { PlatformInput::MousePressure(_) => None, PlatformInput::MouseExited(_) => None, PlatformInput::ScrollWheel(_) => None, - #[cfg(any(target_os = "linux", target_os = "macos"))] PlatformInput::Pinch(_) => None, PlatformInput::FileDrop(_) => None, } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 088dabb3c0cefa2fc6b25c984e46e8e2f6a6b081..48c381e5275e950bd6754541fedbab03ae3d64c2 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -4146,7 +4146,6 @@ impl Window { self.modifiers = scroll_wheel.modifiers; PlatformInput::ScrollWheel(scroll_wheel) } - #[cfg(any(target_os = "linux", target_os = "macos"))] PlatformInput::Pinch(pinch) => { self.mouse_position = pinch.position; self.modifiers = pinch.modifiers; diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 05d4ebd1bacce7e955b48a218e93b432182b75d4..57871e6ef32b937a7a47662f8022293a57bc3fe2 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -176,6 +176,7 @@ pub struct X11ClientState { pub(crate) last_mouse_button: Option, pub(crate) last_location: Point, pub(crate) current_count: usize, + pub(crate) pinch_scale: f32, pub(crate) gpu_context: GpuContext, pub(crate) compositor_gpu: Option, @@ -342,11 +343,12 @@ impl X11Client { xcb_connection.prefetch_extension_information(render::X11_EXTENSION_NAME)?; xcb_connection.prefetch_extension_information(xinput::X11_EXTENSION_NAME)?; - // Announce to X server that XInput up to 2.1 is supported. To increase this to 2.2 and - // beyond, support for touch events would need to be added. + // Announce to X server that XInput up to 2.4 is supported. + // Version 2.4 is needed for gesture events (GesturePinchBegin/Update/End). + // If the server only supports an older version, gesture events simply won't be delivered. let xinput_version = get_reply( || "XInput XiQueryVersion failed", - xcb_connection.xinput_xi_query_version(2, 1), + xcb_connection.xinput_xi_query_version(2, 4), )?; assert!( xinput_version.major_version >= 2, @@ -502,6 +504,7 @@ impl X11Client { last_mouse_button: None, last_location: Point::new(px(0.0), px(0.0)), current_count: 0, + pinch_scale: 1.0, gpu_context: Rc::new(RefCell::new(None)), compositor_gpu, scale_factor, @@ -1324,6 +1327,64 @@ impl X11Client { reset_pointer_device_scroll_positions(pointer); } } + Event::XinputGesturePinchBegin(event) => { + let window = self.get_window(event.event)?; + let mut state = self.0.borrow_mut(); + state.pinch_scale = 1.0; + let modifiers = modifiers_from_xinput_info(event.mods); + state.modifiers = modifiers; + let position = point( + px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor), + px(event.event_y as f32 / u16::MAX as f32 / state.scale_factor), + ); + drop(state); + window.handle_input(PlatformInput::Pinch(gpui::PinchEvent { + position, + delta: 0.0, + modifiers, + phase: gpui::TouchPhase::Started, + })); + } + Event::XinputGesturePinchUpdate(event) => { + let window = self.get_window(event.event)?; + let mut state = self.0.borrow_mut(); + let modifiers = modifiers_from_xinput_info(event.mods); + state.modifiers = modifiers; + let position = point( + px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor), + px(event.event_y as f32 / u16::MAX as f32 / state.scale_factor), + ); + // scale is in FP16.16 format: divide by 65536 to get the float value + let new_absolute_scale = event.scale as f32 / 65536.0; + let previous_scale = state.pinch_scale; + let zoom_delta = new_absolute_scale - previous_scale; + state.pinch_scale = new_absolute_scale; + drop(state); + window.handle_input(PlatformInput::Pinch(gpui::PinchEvent { + position, + delta: zoom_delta, + modifiers, + phase: gpui::TouchPhase::Moved, + })); + } + Event::XinputGesturePinchEnd(event) => { + let window = self.get_window(event.event)?; + let mut state = self.0.borrow_mut(); + state.pinch_scale = 1.0; + let modifiers = modifiers_from_xinput_info(event.mods); + state.modifiers = modifiers; + let position = point( + px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor), + px(event.event_y as f32 / u16::MAX as f32 / state.scale_factor), + ); + drop(state); + window.handle_input(PlatformInput::Pinch(gpui::PinchEvent { + position, + delta: 0.0, + modifiers, + phase: gpui::TouchPhase::Ended, + })); + } _ => {} }; diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 31d1dbacb114f2a9f760f94a898ba37e582cf12e..79bd7666e0eca36459c925be1628f542a30162f5 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -671,7 +671,13 @@ impl X11WindowState { | xinput::XIEventMask::BUTTON_PRESS | xinput::XIEventMask::BUTTON_RELEASE | xinput::XIEventMask::ENTER - | xinput::XIEventMask::LEAVE, + | xinput::XIEventMask::LEAVE + // x11rb 0.13 doesn't define XIEventMask constants for gesture + // events, so we construct them from the event opcodes (each + // XInput event type N maps to mask bit N). + | xinput::XIEventMask::from(1u32 << xinput::GESTURE_PINCH_BEGIN_EVENT) + | xinput::XIEventMask::from(1u32 << xinput::GESTURE_PINCH_UPDATE_EVENT) + | xinput::XIEventMask::from(1u32 << xinput::GESTURE_PINCH_END_EVENT), ], }], ), diff --git a/crates/gpui_windows/src/direct_manipulation.rs b/crates/gpui_windows/src/direct_manipulation.rs new file mode 100644 index 0000000000000000000000000000000000000000..08a1e5243e19e1ea6464ceb224754bee93573ea2 --- /dev/null +++ b/crates/gpui_windows/src/direct_manipulation.rs @@ -0,0 +1,359 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use ::util::ResultExt; +use anyhow::Result; +use gpui::*; +use windows::Win32::{ + Foundation::*, + Graphics::{DirectManipulation::*, Gdi::*}, + System::Com::*, + UI::{Input::Pointer::*, WindowsAndMessaging::*}, +}; + +use crate::*; + +/// Default viewport size in pixels. The actual content size doesn't matter +/// because we're using the viewport only for gesture recognition, not for +/// visual output. +const DEFAULT_VIEWPORT_SIZE: i32 = 1000; + +pub(crate) struct DirectManipulationHandler { + manager: IDirectManipulationManager, + update_manager: IDirectManipulationUpdateManager, + viewport: IDirectManipulationViewport, + _handler_cookie: u32, + window: HWND, + scale_factor: Rc>, + pending_events: Rc>>, +} + +impl DirectManipulationHandler { + pub fn new(window: HWND, scale_factor: f32) -> Result { + unsafe { + let manager: IDirectManipulationManager = + CoCreateInstance(&DirectManipulationManager, None, CLSCTX_INPROC_SERVER)?; + + let update_manager: IDirectManipulationUpdateManager = manager.GetUpdateManager()?; + + let viewport: IDirectManipulationViewport = manager.CreateViewport(None, window)?; + + let configuration = DIRECTMANIPULATION_CONFIGURATION_INTERACTION + | DIRECTMANIPULATION_CONFIGURATION_TRANSLATION_X + | DIRECTMANIPULATION_CONFIGURATION_TRANSLATION_Y + | DIRECTMANIPULATION_CONFIGURATION_TRANSLATION_INERTIA + | DIRECTMANIPULATION_CONFIGURATION_RAILS_X + | DIRECTMANIPULATION_CONFIGURATION_RAILS_Y + | DIRECTMANIPULATION_CONFIGURATION_SCALING; + viewport.ActivateConfiguration(configuration)?; + + viewport.SetViewportOptions( + DIRECTMANIPULATION_VIEWPORT_OPTIONS_MANUALUPDATE + | DIRECTMANIPULATION_VIEWPORT_OPTIONS_DISABLEPIXELSNAPPING, + )?; + + let mut rect = RECT { + left: 0, + top: 0, + right: DEFAULT_VIEWPORT_SIZE, + bottom: DEFAULT_VIEWPORT_SIZE, + }; + viewport.SetViewportRect(&mut rect)?; + + manager.Activate(window)?; + viewport.Enable()?; + + let scale_factor = Rc::new(Cell::new(scale_factor)); + let pending_events = Rc::new(RefCell::new(Vec::new())); + + let event_handler: IDirectManipulationViewportEventHandler = + DirectManipulationEventHandler::new( + window, + Rc::clone(&scale_factor), + Rc::clone(&pending_events), + ) + .into(); + + let handler_cookie = viewport.AddEventHandler(Some(window), &event_handler)?; + + update_manager.Update(None)?; + + Ok(Self { + manager, + update_manager, + viewport, + _handler_cookie: handler_cookie, + window, + scale_factor, + pending_events, + }) + } + } + + pub fn set_scale_factor(&self, scale_factor: f32) { + self.scale_factor.set(scale_factor); + } + + pub fn on_pointer_hit_test(&self, wparam: WPARAM) { + unsafe { + let pointer_id = wparam.loword() as u32; + let mut pointer_type = POINTER_INPUT_TYPE::default(); + if GetPointerType(pointer_id, &mut pointer_type).is_ok() && pointer_type == PT_TOUCHPAD + { + self.viewport.SetContact(pointer_id).log_err(); + } + } + } + + pub fn update(&self) { + unsafe { + self.update_manager.Update(None).log_err(); + } + } + + pub fn drain_events(&self) -> Vec { + std::mem::take(&mut *self.pending_events.borrow_mut()) + } +} + +impl Drop for DirectManipulationHandler { + fn drop(&mut self) { + unsafe { + self.viewport.Stop().log_err(); + self.viewport.Abandon().log_err(); + self.manager.Deactivate(self.window).log_err(); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GestureKind { + None, + Scroll, + Pinch, +} + +#[windows_core::implement(IDirectManipulationViewportEventHandler)] +struct DirectManipulationEventHandler { + window: HWND, + scale_factor: Rc>, + gesture_kind: Cell, + last_scale: Cell, + last_x_offset: Cell, + last_y_offset: Cell, + scroll_phase: Cell, + pending_events: Rc>>, +} + +impl DirectManipulationEventHandler { + fn new( + window: HWND, + scale_factor: Rc>, + pending_events: Rc>>, + ) -> Self { + Self { + window, + scale_factor, + gesture_kind: Cell::new(GestureKind::None), + last_scale: Cell::new(1.0), + last_x_offset: Cell::new(0.0), + last_y_offset: Cell::new(0.0), + scroll_phase: Cell::new(TouchPhase::Started), + pending_events, + } + } + + fn end_gesture(&self) { + let position = self.mouse_position(); + let modifiers = current_modifiers(); + match self.gesture_kind.get() { + GestureKind::Scroll => { + self.pending_events + .borrow_mut() + .push(PlatformInput::ScrollWheel(ScrollWheelEvent { + position, + delta: ScrollDelta::Pixels(point(px(0.0), px(0.0))), + modifiers, + touch_phase: TouchPhase::Ended, + })); + } + GestureKind::Pinch => { + self.pending_events + .borrow_mut() + .push(PlatformInput::Pinch(PinchEvent { + position, + delta: 0.0, + modifiers, + phase: TouchPhase::Ended, + })); + } + GestureKind::None => {} + } + self.gesture_kind.set(GestureKind::None); + } + + fn mouse_position(&self) -> Point { + let scale_factor = self.scale_factor.get(); + unsafe { + let mut point: POINT = std::mem::zeroed(); + let _ = GetCursorPos(&mut point); + let _ = ScreenToClient(self.window, &mut point); + logical_point(point.x as f32, point.y as f32, scale_factor) + } + } +} + +impl IDirectManipulationViewportEventHandler_Impl for DirectManipulationEventHandler_Impl { + fn OnViewportStatusChanged( + &self, + viewport: windows_core::Ref<'_, IDirectManipulationViewport>, + current: DIRECTMANIPULATION_STATUS, + previous: DIRECTMANIPULATION_STATUS, + ) -> windows_core::Result<()> { + if current == previous { + return Ok(()); + } + + // A new gesture interrupted inertia, so end the old sequence. + if current == DIRECTMANIPULATION_RUNNING && previous == DIRECTMANIPULATION_INERTIA { + self.end_gesture(); + } + + if current == DIRECTMANIPULATION_READY { + self.end_gesture(); + + // Reset the content transform so the viewport is ready for the next gesture. + // ZoomToRect triggers a second RUNNING -> READY cycle, so prevent an infinite loop here. + if self.last_scale.get() != 1.0 + || self.last_x_offset.get() != 0.0 + || self.last_y_offset.get() != 0.0 + { + if let Some(viewport) = viewport.as_ref() { + unsafe { + viewport + .ZoomToRect( + 0.0, + 0.0, + DEFAULT_VIEWPORT_SIZE as f32, + DEFAULT_VIEWPORT_SIZE as f32, + false, + ) + .log_err(); + } + } + } + + self.last_scale.set(1.0); + self.last_x_offset.set(0.0); + self.last_y_offset.set(0.0); + } + + Ok(()) + } + + fn OnViewportUpdated( + &self, + _viewport: windows_core::Ref<'_, IDirectManipulationViewport>, + ) -> windows_core::Result<()> { + Ok(()) + } + + fn OnContentUpdated( + &self, + _viewport: windows_core::Ref<'_, IDirectManipulationViewport>, + content: windows_core::Ref<'_, IDirectManipulationContent>, + ) -> windows_core::Result<()> { + let content = content.as_ref().ok_or(E_POINTER)?; + + // Get the 6-element content transform: [scale, 0, 0, scale, tx, ty] + let mut xform = [0.0f32; 6]; + unsafe { + content.GetContentTransform(&mut xform)?; + } + + let scale = xform[0]; + let scale_factor = self.scale_factor.get(); + let x_offset = xform[4] / scale_factor; + let y_offset = xform[5] / scale_factor; + + if scale == 0.0 { + return Ok(()); + } + + let last_scale = self.last_scale.get(); + let last_x = self.last_x_offset.get(); + let last_y = self.last_y_offset.get(); + + if float_equals(scale, last_scale) + && float_equals(x_offset, last_x) + && float_equals(y_offset, last_y) + { + return Ok(()); + } + + let position = self.mouse_position(); + let modifiers = current_modifiers(); + + // Direct Manipulation reports both translation and scale in every content update. + // Translation values can shift during a pinch due to the zoom center shifting. + // We classify each gesture as either scroll or pinch and only emit one type of event. + // We allow Scroll -> Pinch (a pinch can start with a small pan) but not the reverse. + if !float_equals(scale, 1.0) { + if self.gesture_kind.get() != GestureKind::Pinch { + self.end_gesture(); + self.gesture_kind.set(GestureKind::Pinch); + self.pending_events + .borrow_mut() + .push(PlatformInput::Pinch(PinchEvent { + position, + delta: 0.0, + modifiers, + phase: TouchPhase::Started, + })); + } + } else if self.gesture_kind.get() == GestureKind::None { + self.gesture_kind.set(GestureKind::Scroll); + self.scroll_phase.set(TouchPhase::Started); + } + + match self.gesture_kind.get() { + GestureKind::Scroll => { + let dx = x_offset - last_x; + let dy = y_offset - last_y; + let touch_phase = self.scroll_phase.get(); + self.scroll_phase.set(TouchPhase::Moved); + self.pending_events + .borrow_mut() + .push(PlatformInput::ScrollWheel(ScrollWheelEvent { + position, + delta: ScrollDelta::Pixels(point(px(dx), px(dy))), + modifiers, + touch_phase, + })); + } + GestureKind::Pinch => { + let scale_delta = scale / last_scale; + self.pending_events + .borrow_mut() + .push(PlatformInput::Pinch(PinchEvent { + position, + delta: scale_delta - 1.0, + modifiers, + phase: TouchPhase::Moved, + })); + } + GestureKind::None => {} + } + + self.last_scale.set(scale); + self.last_x_offset.set(x_offset); + self.last_y_offset.set(y_offset); + + Ok(()) + } +} + +fn float_equals(f1: f32, f2: f32) -> bool { + const EPSILON_SCALE: f32 = 0.00001; + (f1 - f2).abs() < EPSILON_SCALE * f1.abs().max(f2.abs()).max(EPSILON_SCALE) +} diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 985989a4c98dcaafa35661b0a496dcadf42665d3..21eb6bed899687e1c639efdc40788c229fdc4728 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -111,6 +111,7 @@ impl WindowsWindowInner { WM_GPUI_CURSOR_STYLE_CHANGED => self.handle_cursor_changed(lparam), WM_GPUI_FORCE_UPDATE_WINDOW => self.draw_window(handle, true), WM_GPUI_GPU_DEVICE_LOST => self.handle_device_lost(lparam), + DM_POINTERHITTEST => self.handle_dm_pointer_hit_test(wparam), _ => None, }; if let Some(n) = handled { @@ -758,6 +759,10 @@ impl WindowsWindowInner { self.state.scale_factor.set(new_scale_factor); self.state.border_offset.update(handle).log_err(); + self.state + .direct_manipulation + .set_scale_factor(new_scale_factor); + if is_maximized { // Get the monitor and its work area at the new DPI let monitor = unsafe { MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST) }; @@ -1139,10 +1144,27 @@ impl WindowsWindowInner { Some(0) } + fn handle_dm_pointer_hit_test(&self, wparam: WPARAM) -> Option { + self.state.direct_manipulation.on_pointer_hit_test(wparam); + None + } + #[inline] fn draw_window(&self, handle: HWND, force_render: bool) -> Option { let mut request_frame = self.state.callbacks.request_frame.take()?; + self.state.direct_manipulation.update(); + + let events = self.state.direct_manipulation.drain_events(); + if !events.is_empty() { + if let Some(mut func) = self.state.callbacks.input.take() { + for event in events { + func(event); + } + self.state.callbacks.input.set(Some(func)); + } + } + if force_render { // Re-enable drawing after a device loss recovery. The forced render // will rebuild the scene with fresh atlas textures. diff --git a/crates/gpui_windows/src/gpui_windows.rs b/crates/gpui_windows/src/gpui_windows.rs index af7408569ab1c88fc5f433795da99354942d89f2..0af5411d20e4fbb9d326e833641a2d4e5369dcb2 100644 --- a/crates/gpui_windows/src/gpui_windows.rs +++ b/crates/gpui_windows/src/gpui_windows.rs @@ -2,6 +2,7 @@ mod clipboard; mod destination_list; +mod direct_manipulation; mod direct_write; mod directx_atlas; mod directx_devices; diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 62e88c47dfc10fedf6d636e2c6d6cbdcdc2e37c5..3a55100dfb75e961f57b977297bfcd2dc2ae2701 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -26,6 +26,7 @@ use windows::{ core::*, }; +use crate::direct_manipulation::DirectManipulationHandler; use crate::*; use gpui::*; @@ -57,6 +58,7 @@ pub struct WindowsWindowState { pub last_reported_modifiers: Cell>, pub last_reported_capslock: Cell>, pub hovered: Cell, + pub direct_manipulation: DirectManipulationHandler, pub renderer: RefCell, @@ -131,6 +133,9 @@ impl WindowsWindowState { let fullscreen = None; let initial_placement = None; + let direct_manipulation = DirectManipulationHandler::new(hwnd, scale_factor) + .context("initializing Direct Manipulation")?; + Ok(Self { origin: Cell::new(origin), logical_size: Cell::new(logical_size), @@ -157,6 +162,7 @@ impl WindowsWindowState { initial_placement: Cell::new(initial_placement), hwnd, invalidate_devices, + direct_manipulation, }) } diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 93729559035437f58f30abae0e5a22a7a514967a..dc8d22b67270a58155c05eaf25cb450166e8eb51 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -6,14 +6,12 @@ use std::path::Path; use anyhow::Context as _; use editor::{EditorSettings, items::entry_git_aware_label_color}; use file_icons::FileIcons; -#[cfg(any(target_os = "linux", target_os = "macos"))] -use gpui::PinchEvent; use gpui::{ AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter, FocusHandle, Focusable, Font, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - ParentElement, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task, - WeakEntity, Window, actions, checkerboard, div, img, point, px, size, + ParentElement, PinchEvent, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, + Task, WeakEntity, Window, actions, checkerboard, div, img, point, px, size, }; use language::File as _; use persistence::ImageViewerDb; @@ -263,7 +261,6 @@ impl ImageView { } } - #[cfg(any(target_os = "linux", target_os = "macos"))] fn handle_pinch(&mut self, event: &PinchEvent, _window: &mut Window, cx: &mut Context) { let zoom_factor = 1.0 + event.delta; self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx); @@ -685,7 +682,6 @@ impl Render for ImageView { .relative() .bg(cx.theme().colors().editor_background) .child({ - #[cfg(any(target_os = "linux", target_os = "macos"))] let container = div() .id("image-container") .size_full() @@ -704,24 +700,6 @@ impl Render for ImageView { .on_mouse_move(cx.listener(Self::handle_mouse_move)) .child(ImageContentElement::new(cx.entity())); - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - let container = div() - .id("image-container") - .size_full() - .overflow_hidden() - .cursor(if self.is_dragging() { - gpui::CursorStyle::ClosedHand - } else { - gpui::CursorStyle::OpenHand - }) - .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel)) - .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) - .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down)) - .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up)) - .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up)) - .on_mouse_move(cx.listener(Self::handle_mouse_move)) - .child(ImageContentElement::new(cx.entity())); - container }) }