diff --git a/apps/maple-agent/app/assets/icons/eye.svg b/apps/maple-agent/app/assets/icons/eye.svg new file mode 100644 index 000000000..8944504b6 --- /dev/null +++ b/apps/maple-agent/app/assets/icons/eye.svg @@ -0,0 +1 @@ + diff --git a/apps/maple-agent/app/src/assets.rs b/apps/maple-agent/app/src/assets.rs index 0885906a8..dc1000137 100644 --- a/apps/maple-agent/app/src/assets.rs +++ b/apps/maple-agent/app/src/assets.rs @@ -29,6 +29,7 @@ assets!( "icons/cua-mark-black.svg", "icons/cua-mark-white.svg", "icons/ellipsis.svg", + "icons/eye.svg", "icons/folder-open.svg", "icons/folder-plus.svg", "icons/folder.svg", diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index a5331a4e9..7483e1120 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -887,6 +887,9 @@ impl ChatScreen { self.sessions = self.sidebar.read(cx).navigation_sessions(); self.request_delete_task(&id, cx); } + SidebarEvent::SetUnread { session_id, unread } => { + self.set_unread(&session_id, unread, cx) + } SidebarEvent::RemoveRoot(root) => self.request_remove_root(&root, cx), SidebarEvent::SetTrust { path, trusted } => self.set_project_trust(path, trusted, cx), SidebarEvent::ChooseProject => self.choose_root_dialog(cx), @@ -916,6 +919,19 @@ impl ChatScreen { cx.emit(NavigationChanged); } + /// The task menu reads or sets the marker a completed run leaves. + fn set_unread(&mut self, session_id: &str, unread: bool, cx: &mut Context) { + let changed = if unread { + self.completed_unread_sessions + .insert(session_id.to_string()) + } else { + self.completed_unread_sessions.remove(session_id) + }; + if changed { + self.sync_sidebar(cx); + } + } + fn sync_sidebar_selection(&mut self, cx: &mut Context) { let selected = self.selected_session.clone(); self.sidebar @@ -4017,7 +4033,8 @@ impl ChatScreen { let Some((row, request)) = self.task_step_target(delta, cx) else { return; }; - self.sidebar.read(cx).reveal_row(row); + self.sidebar + .update(cx, |sidebar, cx| sidebar.reveal_row(row, cx)); if request.computer.as_deref() == self.backend.remote_host() { if let Destination::Task(id) = request.destination { self.select_session(&id, cx); @@ -6220,7 +6237,8 @@ impl ChatScreen { self.active_runs.remove(session_id); // The marker follows the latest terminal: only a // successful completion off screen sets it. Opening the - // task (`set_active_session`) is the only other writer. + // task (`set_active_session`) and the task menu (`set_unread`) + // are the only other writers. self.completed_unread_sessions.remove(session_id); if matches!(terminal, maple_agent::agent::AgentRunTerminal::Completed) && !(self.visible && self.is_selected(session_id)) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index c1c4b7da0..f2799eaa1 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -128,6 +128,12 @@ pub(super) enum SidebarEvent { }, /// Delete a task for good; the screen confirms first. DeleteTask(String), + /// Set or clear a task's unread marker, as if a run had just ended + /// unseen or the task had been opened. + SetUnread { + session_id: String, + unread: bool, + }, /// Remove a project; the screen confirms first. RemoveRoot(String), /// Trust or untrust a project. @@ -160,12 +166,8 @@ pub(super) enum RenameTarget { pub(super) struct SidebarRow { pub(super) id: Arc, pub(super) element_id: SharedString, - /// Hover group that reveals the row's action buttons. + /// Hover group that reveals the row's menu button. pub(super) group: SharedString, - pub(super) rename_id: SharedString, - /// The hover button that moves the task one rung along - /// active, settled, archived, deleted. - pub(super) step_id: SharedString, /// Animation id of the running indicator. pub(super) spinner_id: SharedString, pub(super) pin_id: SharedString, @@ -174,10 +176,14 @@ pub(super) struct SidebarRow { pub(super) menu_panel_id: SharedString, pub(super) menu_rename_id: SharedString, pub(super) menu_pin_id: SharedString, + pub(super) menu_unread_id: SharedString, + pub(super) menu_folder_id: SharedString, pub(super) menu_settle_id: SharedString, pub(super) menu_archive_id: SharedString, pub(super) menu_delete_id: SharedString, pub(super) menu_reopen_id: SharedString, + menu_actions: Vec, + project_root: Arc, pub(super) title: SharedString, /// Display name of the task's project, shown on every row. pub(super) project_name: SharedString, @@ -192,18 +198,20 @@ impl SidebarRow { id: Arc::from(id.as_str()), element_id: SharedString::from(format!("session-{id}")), group: SharedString::from(format!("task-row-{id}")), - rename_id: SharedString::from(format!("rename-session-{id}")), - step_id: SharedString::from(format!("step-session-{id}")), spinner_id: SharedString::from(format!("spinner-session-{id}")), pin_id: SharedString::from(format!("pin-session-{id}")), menu_id: SharedString::from(format!("menu-session-{id}")), menu_panel_id: SharedString::from(format!("task-menu-{id}")), menu_rename_id: SharedString::from(format!("rename-task-{id}")), menu_pin_id: SharedString::from(format!("pin-task-{id}")), + menu_unread_id: SharedString::from(format!("unread-task-{id}")), + menu_folder_id: SharedString::from(format!("open-folder-task-{id}")), menu_settle_id: SharedString::from(format!("settle-task-{id}")), menu_archive_id: SharedString::from(format!("archive-task-{id}")), menu_delete_id: SharedString::from(format!("delete-task-{id}")), menu_reopen_id: SharedString::from(format!("reopen-task-{id}")), + menu_actions: Vec::new(), + project_root: Arc::from(session.project_root.as_str()), title: SharedString::from(session.title.clone()), project_name: SharedString::from(project_name.to_string()), search: session.title.to_lowercase(), @@ -211,6 +219,16 @@ impl SidebarRow { } } +#[derive(Clone, Copy)] +enum TaskMenuAction { + Rename, + Pin, + Unread, + Folder, + Move(TaskMove), + Separator, +} + /// The switcher's "All projects" row. const ALL_PROJECTS_ID: &str = "switcher-all-projects"; @@ -219,7 +237,8 @@ const ALL_PROJECTS_ID: &str = "switcher-all-projects"; #[derive(Clone, Debug, PartialEq, Eq)] enum SidebarPopup { Switcher, - /// A task row's overflow menu, by the row's shared id. + /// A task row's menu, by the row's shared id. It opens under the row's + /// "…" button, or at the pointer on a right-click. Task(Arc), } @@ -255,9 +274,8 @@ pub(super) struct SidebarTaskEntry { } /// Where a task sits on the ladder active, settled, archived, deleted. -/// The hover button steps one rung forward; the overflow menu reaches -/// every later rung and comes back only to active. The pinned section -/// is a view of active tasks, not a rung of its own. +/// The task menu reaches every later rung and comes back only to active. +/// The pinned section is a view of active tasks, not a rung of its own. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum TaskRung { Active, @@ -265,7 +283,7 @@ pub(super) enum TaskRung { Archived, } -/// Where a menu item or the hover button sends a task. +/// Where a menu item sends a task. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum TaskMove { Active, @@ -274,13 +292,6 @@ pub(super) enum TaskMove { Deleted, } -/// The one-rung-forward move of a task: the hover button's icon and -/// short label. -pub(super) struct TaskStep { - pub(super) icon: &'static str, - pub(super) row_label: &'static str, -} - impl TaskRung { pub(super) fn of(task: SidebarTaskEntry) -> Self { if task.archived { @@ -291,32 +302,6 @@ impl TaskRung { Self::Active } } - - /// The rung after this one. - pub(super) fn next(self) -> TaskMove { - match self { - Self::Active => TaskMove::Settled, - Self::Settled => TaskMove::Archived, - Self::Archived => TaskMove::Deleted, - } - } - - pub(super) fn step(self) -> TaskStep { - match self { - Self::Active => TaskStep { - icon: "check", - row_label: "Settle", - }, - Self::Settled => TaskStep { - icon: "archive", - row_label: "Archive", - }, - Self::Archived => TaskStep { - icon: "trash-2", - row_label: "Delete", - }, - } - } } /// Why the task list has no rows to show. @@ -357,12 +342,16 @@ impl SidebarSection { type MenuAction = Box)>; -/// One action of a sidebar overflow menu, as the task and project menus -/// list them. +/// One action of a sidebar menu, as the task and project menus list +/// them. struct SidebarMenuItem { id: SharedString, icon: &'static str, label: &'static str, + /// The letter that picks the item while its menu is open. + shortcut: Option<&'static str>, + /// Shown in the error color (Delete). + destructive: bool, on_click: MenuAction, } @@ -418,6 +407,11 @@ pub(super) struct Sidebar { project_filter: Option, // Menus. popup: Popup, + /// The list position when a menu opened. + menu_scroll_top: gpui::ListOffset, + /// Where a right-click opened the task menu; `None` hangs it under the + /// row's "…" button. + task_menu_at: Option>, /// A project's overflow menu, keyed by execution computer and path. project_popup: Popup, /// Trust status of the project whose menu is open, once it loads. @@ -623,6 +617,28 @@ impl Sidebar { this.search_changed(&input, cx) }) .detach(); + let list = gpui::ListState::new(0, gpui::ListAlignment::Top, px(200.)); + // A menu hangs off its row: close it only when the list moves, + // not for a zero-distance wheel event accompanying a right-click. + let sidebar = cx.weak_entity(); + list.set_scroll_handler(move |_, _, cx| { + let sidebar = sidebar.clone(); + // ListState is still borrowed while its scroll handler runs. + cx.defer(move |cx| { + sidebar + .update(cx, |sidebar, cx| { + if sidebar.menu_open() { + let top = sidebar.list.logical_scroll_top(); + if top.item_ix != sidebar.menu_scroll_top.item_ix + || top.offset_in_item != sidebar.menu_scroll_top.offset_in_item + { + sidebar.close_popups(cx); + } + } + }) + .ok(); + }); + }); Self { pages: SidebarPages::default(), group_pages: ComputerPages::default(), @@ -640,7 +656,7 @@ impl Sidebar { recent_roots: Vec::new(), application_vim_enabled, vim_active: false, - list: gpui::ListState::new(0, gpui::ListAlignment::Top, px(200.)), + list, entries: Vec::new(), rows: Vec::new(), pinned_rows: Vec::new(), @@ -656,6 +672,8 @@ impl Sidebar { scope_label: "All projects".into(), project_filter: None, popup: Popup::new(|this| &mut this.popup, cx), + menu_scroll_top: gpui::ListOffset::default(), + task_menu_at: None, project_popup: Popup::new(|this| &mut this.project_popup, cx), menu_trust: None, filter: String::new(), @@ -983,7 +1001,10 @@ impl Sidebar { Some(rows[target].clone()) } - pub(super) fn reveal_row(&self, row: usize) { + /// Scroll `row` into view. A menu hangs off its row, so moving the + /// list closes one that is open, as a wheel scroll does. + pub(super) fn reveal_row(&mut self, row: usize, cx: &mut Context) { + self.close_popups(cx); self.list.scroll_to_reveal_item(row); } @@ -1122,7 +1143,7 @@ impl Sidebar { #[cfg(test)] pub(super) fn open_task_menu_for_test(&mut self, session_id: &str, cx: &mut Context) { - self.popup.open(SidebarPopup::Task(session_id.into()), cx); + self.open_task_menu(session_id.into(), None, cx); } #[cfg(test)] @@ -1376,6 +1397,34 @@ impl Sidebar { if self.computers.len() > 1 && self.project_filter.is_none() { entries = self.computer_entries(); } + for entry in &entries { + if let SidebarEntry::Task(task) = entry { + let rung = TaskRung::of(*task); + let mut actions = vec![TaskMenuAction::Rename]; + if rung == TaskRung::Active { + actions.push(TaskMenuAction::Pin); + } + if rung != TaskRung::Archived { + actions.push(TaskMenuAction::Unread); + } + if self.backend.remote_host().is_none() { + actions.extend([TaskMenuAction::Separator, TaskMenuAction::Folder]); + } + actions.extend([ + TaskMenuAction::Separator, + TaskMenuAction::Move(if rung == TaskRung::Active { + TaskMove::Settled + } else { + TaskMove::Active + }), + ]); + if rung != TaskRung::Archived { + actions.push(TaskMenuAction::Move(TaskMove::Archived)); + } + actions.push(TaskMenuAction::Move(TaskMove::Deleted)); + self.rows[task.session].menu_actions = actions; + } + } let old = std::mem::replace(&mut self.entries, entries); if self.application_vim_enabled { self.rebuild_vim_targets(); @@ -1435,6 +1484,7 @@ impl Sidebar { let fresh = self.rows.len() == self.sessions.len() && self.rows.iter().zip(&self.sessions).all(|(row, session)| { *row.id == *session.id + && *row.project_root == *session.project_root && row.title.as_ref() == session.title && self.root_name_matches(&session.project_root, &row.project_name) }); @@ -1479,6 +1529,11 @@ impl Sidebar { true } + /// Whether any of the sidebar's menus is open. + fn menu_open(&self) -> bool { + self.popup.open_key().is_some() || self.project_popup.open_key().is_some() + } + /// Close whichever popup menu is open. Reports whether one was. pub(super) fn close_popups(&mut self, cx: &mut Context) -> bool { let project = self.project_popup.close(cx); @@ -1576,6 +1631,7 @@ impl Sidebar { /// Open or close the project switcher menu from its header. pub(super) fn toggle_switcher_menu(&mut self, cx: &mut Context) { self.project_popup.close(cx); + self.menu_scroll_top = self.list.logical_scroll_top(); self.popup.toggle(SidebarPopup::Switcher, cx); } @@ -1587,6 +1643,7 @@ impl Sidebar { } self.project_popup.close(cx); if open { + self.menu_scroll_top = self.list.logical_scroll_top(); let current = self .project_filter .as_deref() @@ -1829,6 +1886,7 @@ impl Sidebar { /// Open or close a project's overflow menu. Its trust row appears once /// the project's trust status loads. fn toggle_project_menu(&mut self, root: &ProjectTarget, cx: &mut Context) { + self.menu_scroll_top = self.list.logical_scroll_top(); if !self.project_popup.toggle(root.clone(), cx) { return; } @@ -1854,104 +1912,93 @@ impl Sidebar { ); } - /// Open or close the overflow menu of one task row. + /// Open or close a task row's menu from its "…" button. fn toggle_task_menu(&mut self, session_id: Arc, cx: &mut Context) { + self.task_menu_at = None; + self.menu_scroll_top = self.list.logical_scroll_top(); self.popup.toggle(SidebarPopup::Task(session_id), cx); } - /// The overflow menu of a task row: rename, then where the task can - /// go. Active tasks pin, settle, archive, or delete; settled tasks - /// reopen, archive, or delete; archived tasks reopen or delete. - fn task_menu_items(&self, task: SidebarTaskEntry) -> Vec { - let Some(row) = self.rows.get(task.session) else { - return Vec::new(); + /// Open a task row's menu, at `at` where the row was right-clicked or + /// under its "…" button. + fn open_task_menu( + &mut self, + session_id: Arc, + at: Option>, + cx: &mut Context, + ) { + self.task_menu_at = at; + self.menu_scroll_top = self.list.logical_scroll_top(); + self.popup.open(SidebarPopup::Task(session_id), cx); + } + + /// Build an item from the cached action order, keeping stateful labels live. + fn task_menu_item( + &self, + task: SidebarTaskEntry, + action: TaskMenuAction, + ) -> Option { + let row = self.rows.get(task.session)?; + let mark_unread = !self.unread.contains(&*row.id); + let (id, icon, label, key) = match action { + TaskMenuAction::Rename => (&row.menu_rename_id, "pencil", "Rename", "R"), + TaskMenuAction::Pin => ( + &row.menu_pin_id, + "pin", + if task.pinned { "Unpin" } else { "Pin" }, + "P", + ), + TaskMenuAction::Unread => ( + &row.menu_unread_id, + "eye", + if mark_unread { + "Mark as unread" + } else { + "Mark as read" + }, + "U", + ), + TaskMenuAction::Folder => (&row.menu_folder_id, "folder-open", "Open folder", "O"), + TaskMenuAction::Move(to) => match to { + TaskMove::Settled => (&row.menu_settle_id, "check", "Settle", "S"), + TaskMove::Active if TaskRung::of(task) == TaskRung::Archived => { + (&row.menu_reopen_id, "archive-restore", "Unarchive", "A") + } + TaskMove::Active => (&row.menu_reopen_id, "undo-2", "Reopen", "S"), + TaskMove::Archived => (&row.menu_archive_id, "archive", "Archive", "A"), + TaskMove::Deleted => (&row.menu_delete_id, "trash-2", "Delete", "D"), + }, + TaskMenuAction::Separator => return None, }; - let rung = TaskRung::of(task); - let rename_id = row.id.to_string(); - let mut items = vec![SidebarMenuItem { - id: row.menu_rename_id.clone(), - icon: "pencil", - label: "Rename task", - on_click: Box::new(move |this, cx| { - this.begin_rename(RenameTarget::Task(rename_id.clone()), cx) + let session_id = row.id.clone(); + let on_click: MenuAction = match action { + TaskMenuAction::Rename => Box::new(move |this, cx| { + this.begin_rename(RenameTarget::Task(session_id.to_string()), cx) }), - }]; - if rung == TaskRung::Active { - let pinned_id = row.id.to_string(); - let pinned = task.pinned; - items.push(SidebarMenuItem { - id: row.menu_pin_id.clone(), - icon: "pin", - label: if pinned { "Unpin task" } else { "Pin task" }, - on_click: Box::new(move |this, cx| this.toggle_task_pin(&pinned_id, cx)), - }); - } - let moves: &[(TaskMove, &SharedString, &'static str, &'static str)] = match rung { - TaskRung::Active => &[ - ( - TaskMove::Settled, - &row.menu_settle_id, - "check", - "Settle task", - ), - ( - TaskMove::Archived, - &row.menu_archive_id, - "archive", - "Archive task", - ), - ( - TaskMove::Deleted, - &row.menu_delete_id, - "trash-2", - "Delete task", - ), - ], - TaskRung::Settled => &[ - ( - TaskMove::Active, - &row.menu_reopen_id, - "undo-2", - "Reopen task", - ), - ( - TaskMove::Archived, - &row.menu_archive_id, - "archive", - "Archive task", - ), - ( - TaskMove::Deleted, - &row.menu_delete_id, - "trash-2", - "Delete task", - ), - ], - TaskRung::Archived => &[ - ( - TaskMove::Active, - &row.menu_reopen_id, - "undo-2", - "Reopen task", - ), - ( - TaskMove::Deleted, - &row.menu_delete_id, - "trash-2", - "Delete task", - ), - ], + TaskMenuAction::Pin => Box::new(move |this, cx| this.toggle_task_pin(&session_id, cx)), + TaskMenuAction::Unread => Box::new(move |_, cx| { + cx.emit(SidebarEvent::SetUnread { + session_id: session_id.to_string(), + unread: mark_unread, + }); + }), + TaskMenuAction::Folder => { + let root = row.project_root.clone(); + Box::new(move |this, cx| this.open_folder(&root, cx)) + } + TaskMenuAction::Move(to) => { + Box::new(move |this, cx| this.move_task(&session_id, to, cx)) + } + TaskMenuAction::Separator => return None, }; - for &(to, id, icon, label) in moves { - let session_id = row.id.to_string(); - items.push(SidebarMenuItem { - id: id.clone(), - icon, - label, - on_click: Box::new(move |this, cx| this.move_task(&session_id, to, cx)), - }); - } - items + Some(SidebarMenuItem { + id: id.clone(), + icon, + label, + shortcut: Some(key), + destructive: matches!(action, TaskMenuAction::Move(TaskMove::Deleted)), + on_click, + }) } /// Send a task to a rung. The runtime owns the state, so every move @@ -1992,20 +2039,16 @@ impl Sidebar { pub(super) fn task_menu_labels_for_test(&self, session_id: &str) -> Vec<&'static str> { self.task_menu_entry(session_id) .map(|task| { - self.task_menu_items(task) - .into_iter() + self.rows[task.session] + .menu_actions + .iter() + .filter_map(|&action| self.task_menu_item(task, action)) .map(|item| item.label) .collect() }) .unwrap_or_default() } - #[cfg(test)] - pub(super) fn row_step_label_for_test(&self, session_id: &str) -> Option<&'static str> { - self.task_menu_entry(session_id) - .map(|task| TaskRung::of(task).step().row_label) - } - /// The overflow menu of one switcher project row. fn project_menu_items(&self, target: &ProjectTarget) -> Vec { let rename_target = target.clone(); @@ -2014,6 +2057,8 @@ impl Sidebar { id: target.element_id("rename-project"), icon: "pencil", label: "Rename project", + shortcut: None, + destructive: false, on_click: Box::new(move |this, cx| { this.begin_rename(RenameTarget::Project(rename_target.clone()), cx) }), @@ -2029,6 +2074,8 @@ impl Sidebar { id: target.element_id("open-project"), icon: "folder-open", label: "Open folder", + shortcut: None, + destructive: false, on_click: Box::new(move |this, cx| this.open_folder(&open_root, cx)), }); if let Some(status) = self @@ -2046,6 +2093,8 @@ impl Sidebar { } else { "Trust project" }, + shortcut: None, + destructive: false, on_click: Box::new(move |_, cx| { cx.emit(SidebarEvent::SetTrust { path: path.clone(), @@ -2058,6 +2107,8 @@ impl Sidebar { id: SharedString::from(format!("remove-project-{root}")), icon: "trash-2", label: "Remove project", + shortcut: None, + destructive: false, on_click: Box::new(move |_, cx| { cx.emit(SidebarEvent::RemoveRoot(remove_root.clone())); }), @@ -2644,44 +2695,54 @@ impl Sidebar { let toggle = root.target.clone(); div() .relative() - .child(self.project_popup.trigger( - root.target.clone(), - row_menu_button( - root.menu_id.clone(), - &root.row_group, - "Project options", - open, + .child( + self.project_popup.trigger( + root.target.clone(), + row_menu_button( + root.menu_id.clone(), + &root.row_group, + "Project options", + open, + ) + .when(!open, |button| { + button.tooltip(widgets::tooltip("Project options", None)) + }), + cx, + move |this, _, cx| this.toggle_project_menu(&toggle, cx), ), - cx, - move |this, _, cx| this.toggle_project_menu(&toggle, cx), - )) + ) .children(menu) } - /// A task row's overflow button, with the task's menu under it while - /// open: rename, then where the task can go. + /// A task row's "…" button, with the task's menu while open: under + /// the button, or where a right-click on the row opened it. fn task_menu_trigger( &self, row: &SidebarRow, task: SidebarTaskEntry, + open: bool, window: &mut Window, cx: &mut Context, ) -> Div { - let key = SidebarPopup::Task(row.id.clone()); - let open = self.popup.is_open(&key); let menu = open.then(|| { - let menu = Menu::new(row.menu_panel_id.clone(), px(180.)) + let mut menu = Menu::new(row.menu_panel_id.clone(), px(200.)) .label("Task options") - .application_vim(self.application_vim_enabled) - .items(self.task_menu_items(task).into_iter().map(menu_item)); - self.popup.render(menu, Placement::BelowEnd, window, cx) + .application_vim(self.application_vim_enabled); + for &action in &row.menu_actions { + menu = match self.task_menu_item(task, action) { + Some(item) => menu.item(menu_item(item)), + None => menu.separator(), + }; + } + let placement = self.task_menu_at.map_or(Placement::BelowEnd, Placement::At); + self.popup.render(menu, placement, window, cx) }); let session = row.id.clone(); div() .relative() .child(self.popup.trigger( - key, - row_menu_button(row.menu_id.clone(), &row.group, "More", open), + SidebarPopup::Task(row.id.clone()), + row_menu_button(row.menu_id.clone(), &row.group, "Task options", open), cx, move |this, _, cx| this.toggle_task_menu(session.clone(), cx), )) @@ -2732,9 +2793,7 @@ impl Sidebar { ) } - /// One task row: title, its project, and hover actions. The last - /// hover button moves the task one rung forward; the overflow menu - /// reaches every later rung and the way back to active. + /// A task row with its menu and inline rename field. fn render_task_row( &self, task: SidebarTaskEntry, @@ -2755,16 +2814,18 @@ impl Sidebar { .then(|| self.session_activity(&row.id)) .flatten(); let session_id = Arc::clone(&row.id); - let step_id = Arc::clone(&row.id); - let rename_id = Arc::clone(&row.id); - let rung = TaskRung::of(task); - let step = rung.step(); - let pin_id = row.pin_id.clone(); + let menu_id = Arc::clone(&row.id); + let unpin_id = Arc::clone(&row.id); let rename_field = self.task_rename_field(&row.id, cx); let renaming = rename_field.is_some(); + let menu_open = + matches!(self.popup.open_key(), Some(SidebarPopup::Task(open)) if *open == row.id); + // The menu owns hover until it closes. + let hover = !renaming && !self.menu_open(); div() .relative() .w_full() + .debug_selector(|| row.element_id.to_string()) .id(row.element_id.clone()) .role(gpui::Role::ListBoxOption) .accessibility_id(row.id.to_string()) @@ -2786,10 +2847,13 @@ impl Sidebar { row.bg(gpui::rgb(theme::bg_sidebar_row_selected())) .font_weight(gpui::FontWeight::MEDIUM) }) + .when(menu_open, |row| { + row.bg(gpui::rgb(theme::bg_sidebar_row_hover())) + }) .text_color(gpui::rgb(theme::text_primary())) // A row being renamed is a text field: no hover highlight and // no hover actions over it, so the edit is plain to see. - .when(!renaming, |el| { + .when(hover, |el| { el.hover(|style| { style .bg(gpui::rgb(theme::bg_sidebar_row_hover())) @@ -2799,6 +2863,15 @@ impl Sidebar { .on_click(cx.listener(move |_this, _event, _window, cx| { cx.emit(SidebarEvent::Select(session_id.to_string())); })) + // A field's right-click is its own (Paste, Cut). + .when(!renaming, |el| { + el.on_mouse_down( + gpui::MouseButton::Right, + cx.listener(move |this, event: &gpui::MouseDownEvent, _window, cx| { + this.open_task_menu(menu_id.clone(), Some(event.position), cx); + }), + ) + }) // The rename field takes the title's line; the project line // stays under it. .child( @@ -2830,67 +2903,38 @@ impl Sidebar { .when_some(activity, |row_element, activity| { row_element.child(activity_indicator(&row.spinner_id, activity)) }) - .when(is_pinned, |row| { - row.child( - div() - .id(pin_id.clone()) - .size_5() - .flex() - .items_center() - .justify_center() - .rounded(theme::RADIUS_SM) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_selected())) - .cursor_pointer() - }) - .tooltip(widgets::tooltip("Unpin", None)) - .on_click(cx.listener(move |this, _event, _window, cx| { - cx.stop_propagation(); - this.toggle_task_pin(&pin_id, cx); - })) - .child(icon("pin", px(13.), theme::accent())), - ) - }) - .when(!renaming, |el| { + .when(hover || menu_open, |el| { el.child( div() .absolute() .top_0() .bottom_0() - .when(is_pinned, |overlay| overlay.right(px(26.))) - .when(!is_pinned, |overlay| { - overlay.right_0().rounded_r(theme::RADIUS_MD) - }) + .right_0() .flex() .items_center() .gap_1p5() .pl_2() .pr_6() + .rounded_r(theme::RADIUS_MD) .group_hover(row.group.clone(), |style| { style.bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) - .child(self.task_menu_trigger(row, task, window, cx)) - .child(row_action( - row.rename_id.clone(), - &row.group, - "pencil", - "Rename", - cx.listener(move |this, _event, _window, cx| { - cx.stop_propagation(); - this.begin_rename(RenameTarget::Task(rename_id.to_string()), cx); - }), - )) - .child(row_action( - row.step_id.clone(), - &row.group, - step.icon, - step.row_label, - cx.listener(move |this, _event, _window, cx| { - cx.stop_propagation(); - this.move_task(&step_id, rung.next(), cx); - }), - )), + .when(menu_open, |overlay| { + overlay.bg(gpui::rgb(theme::bg_sidebar_row_hover())) + }) + .child(self.task_menu_trigger(row, task, menu_open, window, cx)) + // The Pinned section names a pinned row at rest; on + // hover the pin unpins it. + .when(is_pinned, |overlay| { + overlay.child( + row_button(row.pin_id.clone(), &row.group, "pin", "Unpin") + .tooltip(widgets::tooltip("Unpin", None)) + .on_click(cx.listener(move |this, _event, _window, cx| { + cx.stop_propagation(); + this.toggle_task_pin(&unpin_id, cx); + })), + ) + }), ) }) } @@ -3108,36 +3152,19 @@ fn activity_indicator(spinner_id: &SharedString, activity: SessionActivity) -> A } } -/// Small icon button that shows only while the pointer is over its row. -pub(super) fn row_action( - id: SharedString, - group: &SharedString, - icon_name: &'static str, - label: &'static str, - on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static, -) -> gpui::Stateful
{ - row_button(id, group, icon_name, label) - .tooltip(widgets::tooltip(label, None)) - .on_click(on_click) -} - -/// A row's overflow button: [`row_action`]'s look, but it stays visible -/// and pressed while its menu is open, and shows no tooltip over the menu. +/// A row's overflow button: [`row_button`]'s look, but it stays visible +/// and pressed while its menu is open. fn row_menu_button( id: SharedString, group: &SharedString, label: &'static str, open: bool, ) -> gpui::Stateful
{ - row_button(id, group, "ellipsis", label) - .when(open, |button| { - button - .opacity(1.) - .bg(gpui::rgb(theme::bg_sidebar_row_selected())) - }) - .when(!open, |button| { - button.tooltip(widgets::tooltip(label, None)) - }) + row_button(id, group, "ellipsis", label).when(open, |button| { + button + .opacity(1.) + .bg(gpui::rgb(theme::bg_sidebar_row_selected())) + }) } /// The small icon button a sidebar row shows while it is hovered. @@ -3175,14 +3202,25 @@ fn menu_item(item: SidebarMenuItem) -> MenuItem { id, icon, label, + shortcut, + destructive, on_click, } = item; - MenuItem::new( + let item = MenuItem::new( id, label, move |this: &mut Sidebar, _: &mut Window, cx: &mut Context| on_click(this, cx), ) - .icon(icon) + .icon(icon); + let item = match shortcut { + Some(key) => item.shortcut(key), + None => item, + }; + if destructive { + item.destructive() + } else { + item + } } /// Field-wise equality for session rows; the summary type has no diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs index eeaead3f9..b14e0979f 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -1271,9 +1271,11 @@ impl Sidebar { row.aria_active_descendant() .bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) - .hover(|s| { - s.bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + .when(!self.menu_open(), |row| { + row.hover(|s| { + s.bg(gpui::rgb(theme::bg_sidebar_row_hover())) + .cursor_pointer() + }) }) .on_click(cx.listener(move |_, _, _, cx| { cx.emit(SidebarEvent::Navigate(request.clone())) diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 1dd1eaf02..c2c134729 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -5315,10 +5315,7 @@ mod state_tests { }); } - /// The hover button steps a task one rung forward along active, - /// settled, archived, deleted. The overflow menu reaches every later - /// rung and comes back only to active: no settle or unsettle on an - /// archived task, no pin on a settled one. + /// Each rung offers only its available actions. #[gpui::test] fn test_task_ladder_actions_follow_the_rung(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -5336,28 +5333,32 @@ mod state_tests { .update(cx, |sidebar, cx| sidebar.set_archived_expanded(true, cx)); let sidebar = this.sidebar.read(cx); - assert_eq!(sidebar.row_step_label_for_test("s1"), Some("Settle")); assert_eq!( sidebar.task_menu_labels_for_test("s1"), - vec![ - "Rename task", - "Pin task", - "Settle task", - "Archive task", - "Delete task" + [ + "Rename", + "Pin", + "Mark as unread", + "Open folder", + "Settle", + "Archive", + "Delete", ] ); - - assert_eq!(sidebar.row_step_label_for_test("s2"), Some("Archive")); assert_eq!( sidebar.task_menu_labels_for_test("s2"), - vec!["Rename task", "Reopen task", "Archive task", "Delete task"] + [ + "Rename", + "Mark as unread", + "Open folder", + "Reopen", + "Archive", + "Delete", + ] ); - - assert_eq!(sidebar.row_step_label_for_test("s3"), Some("Delete")); assert_eq!( sidebar.task_menu_labels_for_test("s3"), - vec!["Rename task", "Reopen task", "Delete task"] + ["Rename", "Open folder", "Unarchive", "Delete"] ); }); } @@ -5543,10 +5544,6 @@ mod state_tests { this.sidebar .update(cx, |sidebar, cx| sidebar.toggle_task_pin("s1", cx)); assert_eq!(this.sidebar_pinned(cx), vec![0]); - assert_eq!( - this.sidebar.read(cx).row_step_label_for_test("s1"), - Some("Settle") - ); // The pin drops at once; the row moves when the runtime's // record lands. @@ -5558,10 +5555,6 @@ mod state_tests { this.settle_task("s1", cx); assert_eq!(this.sidebar_settled(cx), vec![0]); assert_eq!(this.sidebar_active(cx), vec![1]); - assert_eq!( - this.sidebar.read(cx).row_step_label_for_test("s1"), - Some("Archive") - ); this.unsettle_task("s1", cx); assert!(this.sidebar_pinned(cx).is_empty()); @@ -5712,16 +5705,269 @@ mod state_tests { cx.simulate_keystrokes(keys); assert!(sidebar.update(cx, |sidebar, _| sidebar.task_menu().is_none())); }; - // Rows: Rename, Pin, Settle, Archive, Delete. - pick(cx, "down down down enter"); - // Rows: Rename, Reopen, Archive, Delete. + pick(cx, "down down down down down enter"); chat.update(cx, |this, cx| this.settle_task("s1", cx)); - pick(cx, "down down enter"); + pick(cx, "down down down down enter"); chat.update(cx, |this, cx| this.unsettle_task("s1", cx)); pick(cx, "down down enter"); assert_eq!(chat.update(cx, |this, cx| this.sidebar_pinned(cx)), vec![0]); } + /// Right-click opens at the pointer, survives release, and switches tasks. + #[gpui::test] + fn test_right_clicking_a_task_row_opens_its_menu(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One"), summary("s2", "Two")]; + this.sync_sidebar(cx); + }); + for (row, menu, open) in [ + ("session-s2", "task-menu-s2", "Task(s2)"), + ("session-s1", "task-menu-s1", "Task(s1)"), + ] { + let bounds = on_screen(cx, row); + let at = gpui::point(bounds.left() + px(20.), bounds.center().y); + cx.simulate_mouse_down(at, gpui::MouseButton::Right, gpui::Modifiers::default()); + cx.executor() + .advance_clock(std::time::Duration::from_millis(16)); + cx.run_until_parked(); + let panel = on_screen(cx, menu); + assert!( + (panel.origin.x - at.x).abs() <= px(1.) && (panel.origin.y - at.y).abs() <= px(1.) + ); + let released = at + gpui::point(px(1.), px(-1.)); + cx.simulate_mouse_move( + released, + Some(gpui::MouseButton::Right), + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up( + released, + gpui::MouseButton::Right, + gpui::Modifiers::default(), + ); + cx.executor() + .advance_clock(std::time::Duration::from_millis(160)); + cx.run_until_parked(); + assert_eq!(open_menu(&chat, cx), open); + } + cx.simulate_keystrokes("escape"); + assert_eq!(open_menu(&chat, cx), ""); + press(cx, "menu-session-s1"); + assert_eq!(open_menu(&chat, cx), "Task(s1)"); + cx.simulate_click(gpui::point(px(1100.), px(700.)), gpui::Modifiers::default()); + cx.run_until_parked(); + assert_eq!(open_menu(&chat, cx), ""); + right_click(cx, "session-s1"); + assert_eq!(open_menu(&chat, cx), "Task(s1)"); + } + + /// Menu letters dispatch the corresponding task actions. + #[gpui::test] + fn test_task_menu_letters_pick_their_items(cx: &mut TestAppContext) { + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let sink = events.clone(); + let (chat, cx) = chat_window(cx, move |this, cx| { + cx.subscribe(&this.sidebar, move |_, _, event: &SidebarEvent, _| { + let event = match event { + SidebarEvent::SetState { session_id, state } => { + format!("{session_id} {state:?}") + } + SidebarEvent::DeleteTask(session_id) => format!("{session_id} deleted"), + SidebarEvent::SetUnread { session_id, unread } => { + format!("{session_id} unread {unread}") + } + _ => return, + }; + sink.borrow_mut().push(event); + }) + .detach(); + this.sessions = vec![summary("s1", "One"), summary("s2", "Archived")]; + this.sessions[1].state = AgentTaskState::Archived; + this.sync_sidebar(cx); + this.sidebar + .update(cx, |sidebar, cx| sidebar.set_archived_expanded(true, cx)); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let pick = |cx: &mut gpui::VisualTestContext, id: &str, key: &str| { + sidebar.update(cx, |sidebar, cx| sidebar.open_task_menu_for_test(id, cx)); + cx.run_until_parked(); + cx.simulate_keystrokes(key); + assert!( + sidebar.update(cx, |sidebar, _| sidebar.task_menu().is_none()), + "{key} closes the menu" + ); + cx.run_until_parked(); + std::mem::take(&mut *events.borrow_mut()) + }; + + // R hands the keyboard to the rename field: menu letters typed + // into it are text, not actions. + assert!(pick(cx, "s1", "r").is_empty()); + assert!(matches!( + sidebar.update(cx, |sidebar, _| sidebar.rename_target()), + Some(RenameTarget::Task(ref target)) if target == "s1" + )); + cx.simulate_input("Pause and drop"); + cx.run_until_parked(); + let field = sidebar.update(cx, |sidebar, _| sidebar.inputs().last().cloned()); + assert_eq!( + field.map(|field| field.read_with(cx, |field, _| field.text())), + Some("Pause and drop".to_string()) + ); + assert!(events.borrow().is_empty(), "{:?}", events.borrow()); + assert!( + chat.update(cx, |this, cx| this.sidebar_pinned(cx)) + .is_empty() + ); + chat.update(cx, |this, cx| this.cancel_rename(cx)); + + assert!(pick(cx, "s1", "p").is_empty()); + assert_eq!(chat.update(cx, |this, cx| this.sidebar_pinned(cx)), vec![0]); + assert!(pick(cx, "s1", "p").is_empty()); + assert!( + chat.update(cx, |this, cx| this.sidebar_pinned(cx)) + .is_empty() + ); + for (unread, label, event) in [ + (true, "Mark as read", "s1 unread true"), + (false, "Mark as unread", "s1 unread false"), + ] { + assert_eq!(pick(cx, "s1", "u"), [event]); + assert_eq!( + chat.update(cx, |this, _| this.completed_unread_sessions.contains("s1")), + unread + ); + assert!(sidebar.update(cx, |sidebar, _| { + sidebar.task_menu_labels_for_test("s1").contains(&label) + })); + } + assert_eq!(pick(cx, "s1", "s"), ["s1 Settled"]); + chat.update(cx, |this, cx| this.settle_task("s1", cx)); + assert_eq!(pick(cx, "s1", "s"), ["s1 Active"]); + assert_eq!(pick(cx, "s1", "a"), ["s1 Archived"]); + assert_eq!(pick(cx, "s1", "d"), ["s1 deleted"]); + chat.update(cx, |this, _| { + assert_eq!( + this.confirm, + Some(dialogs::ConfirmDialog::DeleteTask("s1".into())) + ); + assert_eq!(this.sessions.len(), 2); + this.confirm = None; + }); + assert_eq!(pick(cx, "s2", "a"), ["s2 Active"]); + + // A press picks the same items. + sidebar.update(cx, |sidebar, cx| sidebar.open_task_menu_for_test("s2", cx)); + press(cx, "delete-task-s2"); + cx.run_until_parked(); + assert_eq!(*events.borrow(), ["s2 deleted"]); + } + + /// Pressing a pinned row's Unpin button removes its pin without selecting it. + #[gpui::test] + fn test_a_pinned_rows_strip_unpins_it(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One")]; + this.sync_sidebar(cx); + this.sidebar + .update(cx, |sidebar, cx| sidebar.toggle_task_pin("s1", cx)); + }); + assert_eq!(chat.update(cx, |this, cx| this.sidebar_pinned(cx)), vec![0]); + let selected = chat.update(cx, |this, _| this.selected_session.clone()); + let row = on_screen(cx, "session-s1"); + cx.simulate_mouse_move(row.center(), None, gpui::Modifiers::default()); + press(cx, "pin-session-s1"); + cx.run_until_parked(); + assert!( + chat.update(cx, |this, cx| this.sidebar_pinned(cx)) + .is_empty() + ); + assert_eq!( + chat.update(cx, |this, _| this.selected_session.clone()), + selected + ); + assert_eq!(open_menu(&chat, cx), ""); + } + + /// Reordering keeps the same task's menu; removing the task closes it. + #[gpui::test] + fn test_task_menu_follows_its_task(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One"), summary("s2", "Two")]; + this.sync_sidebar(cx); + }); + press(cx, "menu-session-s2"); + let row_before = on_screen(cx, "session-s2"); + let panel_before = on_screen(cx, "task-menu-s2"); + chat.update(cx, |this, cx| { + this.sessions[1].updated_ms = 10; + this.sync_sidebar(cx); + assert_eq!(this.sidebar_active(cx), vec![1, 0]); + }); + let row_after = on_screen(cx, "session-s2"); + let panel_after = on_screen(cx, "task-menu-s2"); + let row_movement = row_after.top() - row_before.top(); + let panel_movement = panel_after.top() - panel_before.top(); + assert!(row_movement < px(0.)); + assert!((panel_movement - row_movement).abs() <= px(1.)); + assert_eq!(open_menu(&chat, cx), "Task(s2)"); + chat.update(cx, |this, cx| this.remove_session("s2", cx)); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + assert_eq!(open_menu(&chat, cx), ""); + } + + /// A wheel event can accompany a secondary click without moving the + /// list. Only a change in position dismisses the task menu. + #[gpui::test] + fn test_task_menu_stays_open_when_a_scroll_does_not_move_the_list(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = (1..=40) + .map(|ix| summary(&format!("s{ix}"), &format!("Task {ix}"))) + .collect(); + this.sync_sidebar(cx); + }); + let list = chat.update(cx, |this, cx| this.sidebar.read(cx).list().clone()); + right_click(cx, "session-s2"); + let row = on_screen(cx, "session-s2"); + let beside = gpui::point(row.left() + px(8.), row.center().y + px(120.)); + let scroll = |cx: &mut gpui::VisualTestContext, x, y| { + cx.simulate_event(gpui::ScrollWheelEvent { + position: beside, + delta: gpui::ScrollDelta::Pixels(gpui::point(px(x), px(y))), + ..Default::default() + }); + cx.run_until_parked(); + }; + let offset = || { + let top = list.logical_scroll_top(); + (top.item_ix, top.offset_in_item) + }; + let before = offset(); + for (x, y) in [(0., 0.), (0., 20.), (20., 0.)] { + scroll(cx, x, y); + assert_eq!(offset(), before, "the list did not move"); + assert_eq!(open_menu(&chat, cx), "Task(s2)"); + on_screen(cx, "task-menu-s2"); + } + + // A single pixel within the same first item still counts as a scroll. + scroll(cx, 0., -1.); + assert_eq!(offset().0, before.0); + assert_ne!(offset().1, before.1); + assert_eq!(open_menu(&chat, cx), ""); + + right_click(cx, "session-s2"); + let before = offset(); + scroll(cx, 0., 0.); + assert_eq!(offset(), before); + assert_eq!(open_menu(&chat, cx), "Task(s2)"); + on_screen(cx, "task-menu-s2"); + chat.update(cx, |this, cx| this.step_task(1, cx)); + cx.run_until_parked(); + assert_eq!(open_menu(&chat, cx), "", "revealing a row closes the menu"); + } + /// Opened from the keyboard, the switcher starts on the current scope; /// Application Vim's j reaches a project and Enter scopes the sidebar /// to it. @@ -5776,14 +6022,34 @@ mod state_tests { /// those bounds back; one extra frame covers a park that finished /// before that paint. fn press(cx: &mut gpui::VisualTestContext, selector: &'static str) { - let bounds = (0..2) + let at = on_screen(cx, selector).center(); + cx.simulate_click(at, gpui::Modifiers::default()); + } + + /// Right-click the middle of the element with `selector`. + fn right_click(cx: &mut gpui::VisualTestContext, selector: &'static str) { + let at = on_screen(cx, selector).center(); + right_click_at(cx, at); + } + + fn right_click_at(cx: &mut gpui::VisualTestContext, at: gpui::Point) { + cx.simulate_mouse_down(at, gpui::MouseButton::Right, gpui::Modifiers::default()); + cx.simulate_mouse_up(at, gpui::MouseButton::Right, gpui::Modifiers::default()); + } + + /// The bounds of the element with `selector`, once a refreshed frame + /// has drawn it (see [`press`]). + fn on_screen( + cx: &mut gpui::VisualTestContext, + selector: &'static str, + ) -> gpui::Bounds { + (0..2) .find_map(|_| { cx.update(|window, _| window.refresh()); cx.run_until_parked(); cx.debug_bounds(selector) }) - .unwrap_or_else(|| panic!("{selector} is not on screen")); - cx.simulate_click(bounds.center(), gpui::Modifiers::default()); + .unwrap_or_else(|| panic!("{selector} is not on screen")) } #[gpui::test] @@ -5928,8 +6194,7 @@ mod state_tests { } /// A task menu reaches over the rows below it. A press on one of its - /// items lands on the item, not on the overflow button of the row - /// underneath. + /// items lands on the item, not on the row underneath. #[gpui::test] fn test_a_press_on_a_menu_item_reaches_the_item(cx: &mut TestAppContext) { let (chat, cx) = chat_window(cx, |this, cx| { @@ -5941,14 +6206,19 @@ mod state_tests { this.sync_sidebar(cx); }); press(cx, "menu-session-s1"); - let beneath = cx.debug_bounds("menu-session-s2").expect("s2's button"); + let beneath = cx.debug_bounds("session-s2").expect("s2's row"); let rename = cx.debug_bounds("rename-task-s1").expect("s1's Rename item"); assert!( - rename.contains(&beneath.center()), - "the fixture puts s2's button under s1's Rename item" + beneath.contains(&rename.center()), + "the fixture puts s1's Rename item over s2's row" + ); + cx.simulate_click(rename.center(), gpui::Modifiers::default()); + assert_eq!(open_menu(&chat, cx), ""); + assert_ne!( + chat.update(cx, |this, _| this.selected_session.clone()), + Some("s2".to_string()), + "the press did not select s2" ); - cx.simulate_click(beneath.center(), gpui::Modifiers::default()); - assert_eq!(open_menu(&chat, cx), "", "s2's menu did not open"); let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); assert!( matches!( @@ -6483,8 +6753,8 @@ mod state_tests { rename(cx, "Pressed"); cx.update(|window, _| window.refresh()); cx.run_until_parked(); - let other_row = cx.debug_bounds("menu-session-s2").expect("s2's row"); - let on_row = other_row.center() - gpui::point(gpui::px(80.), gpui::px(0.)); + let other_row = cx.debug_bounds("session-s2").expect("s2's row"); + let on_row = other_row.center(); cx.simulate_mouse_down(on_row, gpui::MouseButton::Left, gpui::Modifiers::default()); cx.simulate_mouse_up(on_row, gpui::MouseButton::Left, gpui::Modifiers::default()); cx.run_until_parked(); @@ -6566,13 +6836,13 @@ mod state_tests { this.sync_sidebar(cx); }); let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); - let rename_button = |cx: &mut gpui::VisualTestContext| { + let menu_button = |cx: &mut gpui::VisualTestContext| { cx.update(|window, _| window.refresh()); cx.run_until_parked(); - cx.debug_bounds("rename-session-s1") + cx.debug_bounds("menu-session-s1") }; assert!( - rename_button(cx).is_some(), + menu_button(cx).is_some(), "a listed row has its hover actions" ); @@ -6580,7 +6850,7 @@ mod state_tests { sidebar.begin_rename(RenameTarget::Task("s1".into()), cx) }); cx.run_until_parked(); - assert!(rename_button(cx).is_none(), "a row being renamed has none"); + assert!(menu_button(cx).is_none(), "a row being renamed has none"); cx.simulate_input("Fresh"); let field = sidebar.update(cx, |sidebar, _| sidebar.inputs().last().cloned()); assert_eq!( @@ -6590,7 +6860,7 @@ mod state_tests { ); chat.update(cx, |this, cx| this.cancel_rename(cx)); - assert!(rename_button(cx).is_some(), "the actions come back after"); + assert!(menu_button(cx).is_some(), "the actions come back after"); } /// A project's rename field sits in the switcher. Escape ends the diff --git a/apps/maple-agent/app/src/ui/popup.rs b/apps/maple-agent/app/src/ui/popup.rs index fb69e291f..87114874c 100644 --- a/apps/maple-agent/app/src/ui/popup.rs +++ b/apps/maple-agent/app/src/ui/popup.rs @@ -16,9 +16,10 @@ //! right-click menu) counts as inside. //! - The open popup holds keyboard focus. Up and Down (`j`/`k` under //! Application Vim) move the highlight, Home and End (`g g`/`G`) jump, -//! Enter or Space picks, Escape closes, and a typed letter jumps to the -//! next row that starts with it. The pointer moves the same highlight. A -//! text field inside the menu keeps its own keys. +//! Enter or Space picks, Escape closes, and a typed letter picks the row +//! with that shortcut, or else jumps to the next row that starts with +//! it. The pointer moves the same highlight. A text field inside the +//! menu keeps its own keys. //! - One popup per window. Whatever takes focus next (another popup, a //! dialog, a text field) closes the open one. When a popup that holds //! focus closes, focus returns to where it was before it opened, also @@ -462,6 +463,7 @@ impl Popup { enabled: item.enabled, keep_open: item.keep_open, initial: initial(&item.label), + shortcut: item.shortcut.and_then(initial), child: children.len(), }); children.push(item_row(item, highlighted, get, cx)); @@ -890,6 +892,10 @@ pub(crate) struct MenuItem { state: Option, enabled: bool, keep_open: bool, + /// The key that picks the row while the menu is open, as shown. + shortcut: Option<&'static str>, + /// Show enabled labels and icons in the error color. + destructive: bool, trailing: Option, style: Option, on_select: OnSelect, @@ -917,6 +923,8 @@ impl MenuItem { state: None, enabled: true, keep_open: false, + shortcut: None, + destructive: false, trailing: None, style: None, on_select: Rc::new(on_select), @@ -965,6 +973,18 @@ impl MenuItem { self } + /// Show a letter shortcut that takes precedence over type-ahead. + pub(crate) fn shortcut(mut self, key: &'static str) -> Self { + self.shortcut = Some(key); + self + } + + /// Show the row as destructive, in the error color. + pub(crate) fn destructive(mut self) -> Self { + self.destructive = true; + self + } + /// A control of the row's own at its end, such as a "…" button. A /// press on it is the control's alone. pub(crate) fn trailing(mut self, element: impl IntoElement) -> Self { @@ -988,6 +1008,7 @@ struct Pick { enabled: bool, keep_open: bool, initial: Option, + shortcut: Option, child: usize, } @@ -1012,6 +1033,8 @@ fn item_row( state, enabled, keep_open, + shortcut, + destructive, trailing, style, on_select, @@ -1021,10 +1044,10 @@ fn item_row( Some(ItemState::Current(_)) => Role::MenuItemRadio, Some(ItemState::Switch(_)) => Role::MenuItemCheckBox, }; - let text = if enabled { - theme::text_primary() - } else { - theme::text_muted() + let (text, icon_color) = match (enabled, destructive) { + (true, true) => (theme::status_error(), theme::status_error()), + (true, false) => (theme::text_primary(), theme::text_secondary()), + (false, _) => (theme::text_muted(), theme::text_secondary()), }; let hovered_id = id.clone(); let row_element = div() @@ -1072,7 +1095,7 @@ fn item_row( on_select(view, window, cx); })) }) - .children(icon_name.map(|name| icon(name, widgets::ROW_ICON, theme::text_secondary()))) + .children(icon_name.map(|name| icon(name, widgets::ROW_ICON, icon_color))) .map(|row| match content { Some(content) => row.child(div().flex_1().min_w_0().child(content)), None => row.child( @@ -1102,6 +1125,12 @@ fn item_row( })), ), }) + .children(shortcut.map(|key| { + div() + .flex_none() + .text_color(gpui::rgb(theme::text_muted())) + .child(key) + })) .children(trailing.map(|trailing| { // The row's own control, such as a "…" button. A press on it is // the control's alone: the row neither shows as pressed nor @@ -1190,10 +1219,11 @@ fn pick( (item.on_select)(view, window, cx); } -/// A letter typed into the menu highlights the next item that starts with -/// it. Other typing stops here too: it must not reach a text field under -/// the menu. Only while the menu itself has focus, so a field inside the -/// menu still takes its typing. +/// A letter typed into the menu picks the item with that shortcut, or +/// else highlights the next item that starts with it. Other typing stops +/// here too: it must not reach a text field under the menu. Only while +/// the menu itself has focus, so a field inside the menu still takes its +/// typing. fn type_ahead( view: &mut V, get: fn(&mut V) -> &mut Popup, @@ -1215,6 +1245,13 @@ fn type_ahead( }; cx.stop_propagation(); let typed = initial(typed); + if let Some(row) = picks + .iter() + .position(|item| item.shortcut.is_some() && item.shortcut == typed) + { + pick(view, get, picks, row, window, cx); + return; + } let popup = get(view); let rows = picks.len(); let start = popup.highlighted_row(picks).map_or(0, |row| row + 1);