//! T3 Code's thread actions, shared by the sidebar and the chat header: the //! thread action menu, the multi-select and draft menus, snooze presets and //! the custom snooze dialog, confirmations with T3's wording, copying, and //! the "Settled 1 thread, Ctrl+Z to undo" notice. use chrono::{Datelike as _, Local, NaiveDate, NaiveTime, TimeZone as _}; use gpui_kit::component::{ Icon, IconName, WindowExt as _, button::ButtonVariant, h_flex, input::{Input, InputState}, menu::{DropdownMenu as _, PopupMenu, PopupMenuItem}, notification::Notification, calendar::{Calendar, CalendarEvent, CalendarState, Matcher}, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use super::{ProjectKey, Workspace}; use crate::clock; use crate::session::Session; use crate::settings; use crate::ui::chat::ChatView; use crate::ui::icons::Lucide; use crate::ui::metal::{self, MetalButton, MetalStyled as _}; const HOUR: Duration = Duration::from_secs(3_600); const DAY: Duration = Duration::from_secs(86_400); /// How long the undo notice stays after the latest action. const UNDO_WINDOW: Duration = Duration::from_secs(5); use std::time::{Duration, SystemTime}; // ---- snoozing -------------------------------------------------------------- /// One of the snooze menu's presets: `In 1 hour (3:42 PM)`. #[derive(Clone)] pub(super) struct SnoozePreset { pub label: &'static str, /// The wake time as the menu shows it: `3:42 PM`, `Mon 9:00 AM`. pub when: String, pub until: SystemTime, } /// T3's snooze presets as of now: in 1 and 3 hours, this evening (while /// it's more than an hour off), tomorrow morning, and next Monday morning /// (unless that's tomorrow). pub(super) fn snooze_presets() -> Vec { let now = SystemTime::now(); let in_an_hour = now + HOUR; let in_three_hours = now + 3 * HOUR; let mut presets = vec![ SnoozePreset { label: "In 1 hour", when: clock::clock(in_an_hour), until: in_an_hour }, SnoozePreset { label: "In 3 hours", when: clock::clock(in_three_hours), until: in_three_hours }, ]; let evening = clock::at_hour(0, 18); if evening.duration_since(now).is_ok_and(|left| left > HOUR) { presets.push(SnoozePreset { label: "This evening", when: clock::clock(evening), until: evening }); } let tomorrow = clock::at_hour(1, 9); presets.push(SnoozePreset { label: "Tomorrow", when: clock::clock(tomorrow), until: tomorrow }); let weekday = Local::now().weekday().num_days_from_sunday() as i64; let days_until_monday = match (1 - weekday + 7) % 7 { 0 => 7, days => days, }; let next_week = clock::at_hour(days_until_monday, 9); if next_week != tomorrow { let day: chrono::DateTime = next_week.into(); presets.push(SnoozePreset { label: "Next week", when: format!("{} {}", day.format("%a"), clock::clock(next_week)), until: next_week, }); } presets } /// The countdown a snoozed row shows: `45m`, `2h`, `3d`, or `now` once due. pub(super) fn wake_countdown(until: SystemTime) -> String { let Ok(left) = until.duration_since(SystemTime::now()) else { return "now".into() }; let ms = left.as_millis(); if left.is_zero() { "now".into() } else if left < HOUR { format!("{}m", ms.div_ceil(60_000).max(1)) } else if left < DAY { format!("{}h", ms.div_ceil(HOUR.as_millis())) } else { format!("{}d", ms.div_ceil(DAY.as_millis())) } } /// The long relative time T3 uses in the command palette and the legacy /// sidebar: `just now`, `5m ago`, `3h ago`, `2d ago`. pub(super) fn relative_time_long(time: SystemTime) -> String { match super::relative_time(time).as_str() { "now" => "just now".into(), short => format!("{short} ago"), } } // ---- confirmations and toasts ---------------------------------------------- /// Splits a confirmation message as T3's dialog does: the first line ending /// in "?" is the title and the other lines the description. pub(super) fn confirm_copy(message: &str) -> (String, Option) { let message = message.trim(); let lines: Vec<&str> = message.lines().collect(); if let Some(ix) = lines.iter().position(|line| line.trim().ends_with('?')) { let rest: Vec<&str> = lines.iter().enumerate().filter(|(i, _)| *i != ix).map(|(_, line)| *line).collect(); let description = rest.join("\n").trim().to_string(); return (lines[ix].trim().to_string(), Some(description).filter(|text| !text.is_empty())); } if let Some(ix) = message.find('?') { let description = message[ix + 1..].trim().to_string(); return (message[..=ix].trim().to_string(), Some(description).filter(|text| !text.is_empty())); } let description = if message.is_empty() { "This action requires your confirmation." } else { message }; ("Confirm action".into(), Some(description.into())) } /// Asks before an action, with T3's Cancel and Confirm buttons; Confirm is /// red when the action destroys something. pub(super) fn confirm( message: &str, destructive: bool, window: &mut Window, cx: &mut App, on_confirm: impl Fn(&mut Window, &mut App) + 'static, ) { let (title, description) = confirm_copy(message); let on_confirm = std::rc::Rc::new(on_confirm); window.open_alert_dialog(cx, move |alert, _, _| { let on_confirm = on_confirm.clone(); alert .title(title.clone()) .when_some(description.clone(), |alert, description| alert.description(description)) .ok_text("Confirm") .cancel_text("Cancel") .when(destructive, |alert| alert.ok_variant(ButtonVariant::Danger)) .show_cancel(true) .on_ok(move |_, window, cx| { on_confirm(window, cx); true }) }); } /// Copies `text` and says so in a toast titled `title` ("Path copied"). pub(super) fn copy(text: String, title: &'static str, window: &mut Window, cx: &mut App) { cx.write_to_clipboard(ClipboardItem::new_string(text.clone())); window.push_notification(Notification::success(text).title(title), cx); } fn error_toast(title: &'static str, description: &str, window: &mut Window, cx: &mut App) { window.push_notification(Notification::error(description.to_string()).title(title), cx); } // ---- undo ---------------------------------------------------------------- /// What the undo notice says was done. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum UndoKind { Settled, Snoozed, Unpinned, Archived, Discarded, } impl UndoKind { fn label(self) -> &'static str { match self { UndoKind::Settled => "Settled", UndoKind::Snoozed => "Snoozed", UndoKind::Unpinned => "Unpinned", UndoKind::Archived => "Archived", UndoKind::Discarded => "Discarded", } } } /// One action the notice can take back. pub(super) struct UndoEntry { pub kind: UndoKind, pub session: Entity, /// For a discarded draft: where it was and its conversation view, kept /// alive until the notice expires. pub draft: Option<(ProjectKey, Entity)>, } impl Workspace { /// Records an action for the undo notice, which stays 5 seconds after /// the latest one. pub(super) fn push_undo(&mut self, entry: UndoEntry, cx: &mut Context) { self.undo.push(entry); self.undo_generation += 1; let generation = self.undo_generation; self.undo_timer = cx.spawn(async move |this, cx| { cx.background_executor().timer(UNDO_WINDOW).await; let _ = this.update(cx, |this, cx| { if this.undo_generation == generation { this.expire_undo(cx); } }); }); cx.notify(); } /// Lets go of what can no longer be undone: discarded drafts end for good. fn expire_undo(&mut self, cx: &mut Context) { for entry in std::mem::take(&mut self.undo) { if entry.draft.is_some() { entry.session.update(cx, |session, _| session.shutdown()); } } cx.notify(); } /// The notice's text: the latest action and how many like it came right /// before, as in "Settled 2 threads". pub(super) fn undo_notice(&self) -> Option { let latest = self.undo.last()?.kind; let count = self.undo.iter().rev().take_while(|entry| entry.kind == latest).count(); let noun = if latest == UndoKind::Discarded { "draft" } else { "thread" }; Some(format!("{} {count} {noun}{}", latest.label(), if count == 1 { "" } else { "s" })) } /// Takes back the latest group of like actions; false when there's none. pub(super) fn undo_latest(&mut self, window: &mut Window, cx: &mut Context) -> bool { let Some(latest) = self.undo.last().map(|entry| entry.kind) else { return false }; let keep = self.undo.iter().rposition(|entry| entry.kind != latest).map_or(0, |ix| ix + 1); let group = self.undo.split_off(keep); for entry in group { // Taking an action back keeps the thread where it was in the list. let updated = entry.session.read(cx).updated; match entry.kind { UndoKind::Settled => entry.session.update(cx, |session, cx| session.unsettle(cx)), UndoKind::Snoozed => entry.session.update(cx, |session, cx| session.snooze(None, cx)), UndoKind::Unpinned => entry.session.update(cx, |session, cx| session.set_pinned(true, cx)), UndoKind::Archived => entry.session.update(cx, |session, cx| session.set_archived(false, cx)), UndoKind::Discarded => { if let Some((key, chat)) = entry.draft { self.reattach_thread(&key, entry.session.clone(), chat, window, cx); } } } entry.session.update(cx, |session, _| session.updated = updated); } cx.notify(); true } // ---- what a thread is and where --------------------------------------- /// The branch a thread is on: its worktree's, or its project checkout's. pub(super) fn thread_branch(&self, session: &Entity, cx: &App) -> Option { let state = session.read(cx); if let Some(worktree) = &state.worktree { return Some(worktree.branch.clone()); } let project = self.projects.get(self.project_of(session)?)?; project.git.as_ref().and_then(|git| git.branch.clone()) } /// The folder a thread works in: its worktree, or its project. pub(super) fn thread_path(&self, session: &Entity, cx: &App) -> Option { let state = session.read(cx); if let Some(worktree) = &state.worktree { return Some(worktree.root.clone()); } self.projects.get(self.project_of(session)?).map(|project| project.path.clone()) } /// Whether a thread can be snoozed now: not while it waits on the user. pub(super) fn can_snooze(session: &Session) -> bool { !session.needs_attention() && !session.preparing } // ---- lifecycle --------------------------------------------------------- /// Settles threads with an undo notice, moving off the open one. pub(super) fn settle_threads(&mut self, sessions: Vec>, window: &mut Window, cx: &mut Context) { self.settle_armed = None; for session in sessions { if session.read(cx).settled { continue; } self.leave_thread(&session, window, cx); session.update(cx, |session, cx| session.settle(cx)); self.push_undo(UndoEntry { kind: UndoKind::Settled, session, draft: None }, cx); } cx.notify(); } pub(super) fn unsettle_thread(&mut self, session: Entity, cx: &mut Context) { session.update(cx, |session, cx| session.unsettle(cx)); cx.notify(); } /// Settles the thread, or un-settles it when it's settled (Ctrl+Shift+S). pub(super) fn toggle_settled(&mut self, session: Entity, window: &mut Window, cx: &mut Context) { if session.read(cx).settled { self.unsettle_thread(session, cx); } else { self.settle_threads(vec![session], window, cx); } } pub(super) fn snooze_threads( &mut self, sessions: Vec>, until: SystemTime, window: &mut Window, cx: &mut Context, ) { for session in sessions { if !Self::can_snooze(session.read(cx)) { continue; } self.leave_thread(&session, window, cx); session.update(cx, |session, cx| session.snooze(Some(until), cx)); self.push_undo(UndoEntry { kind: UndoKind::Snoozed, session, draft: None }, cx); } cx.notify(); } pub(super) fn wake_thread(&mut self, session: Entity, cx: &mut Context) { session.update(cx, |session, cx| session.snooze(None, cx)); cx.notify(); } /// Pins the thread, or unpins it (asking first when Unpin confirmation is on). pub(super) fn toggle_pin(&mut self, session: Entity, window: &mut Window, cx: &mut Context) { if session.read(cx).pinned { self.unpin_thread(session, window, cx); } else { session.update(cx, |session, cx| session.set_pinned(true, cx)); } } pub(super) fn unpin_thread(&mut self, session: Entity, window: &mut Window, cx: &mut Context) { let unpin = { let workspace = cx.weak_entity(); let session = session.clone(); move |_: &mut Window, cx: &mut App| { session.update(cx, |session, cx| session.set_pinned(false, cx)); let session = session.clone(); let _ = workspace.update(cx, |this, cx| { this.push_undo(UndoEntry { kind: UndoKind::Unpinned, session, draft: None }, cx) }); } }; if settings::get(|settings| settings.confirm_unpin) { let title = session.read(cx).title.clone(); confirm( &format!("Unpin thread \"{title}\"?\nThis will move the thread out of your pinned section."), false, window, cx, unpin, ); } else { unpin(window, cx); } } /// Archives a thread (asking first when Archive confirmation is on): it /// leaves the sidebar and waits under Settings → Archive. pub(super) fn archive_thread(&mut self, session: Entity, window: &mut Window, cx: &mut Context) { let archive = { let workspace = cx.weak_entity(); let session = session.clone(); move |window: &mut Window, cx: &mut App| { let session = session.clone(); let _ = workspace.update(cx, |this, cx| { this.leave_thread(&session, window, cx); session.update(cx, |session, cx| { session.settle(cx); session.set_archived(true, cx); }); this.push_undo(UndoEntry { kind: UndoKind::Archived, session, draft: None }, cx); }); } }; if settings::get(|settings| settings.confirm_archive) { let title = session.read(cx).title.clone(); confirm(&format!("Archive thread \"{title}\"?"), false, window, cx, archive); } else { archive(window, cx); } } /// Deletes threads for good, asking first when Delete confirmation is on. pub(super) fn delete_threads(&mut self, sessions: Vec>, window: &mut Window, cx: &mut Context) { let Some(first) = sessions.first() else { return }; let message = if sessions.len() == 1 { format!( "Delete thread \"{}\"?\nThis permanently clears conversation history for this thread.", first.read(cx).title ) } else { format!( "Delete {} threads?\nThis permanently clears conversation history for these threads.", sessions.len() ) }; let delete = { let workspace = cx.weak_entity(); move |window: &mut Window, cx: &mut App| { let sessions = sessions.clone(); let _ = workspace.update(cx, |this, cx| { for session in sessions { this.forget_thread(session, window, cx); } this.selection.clear(); }); } }; if settings::get(|settings| settings.confirm_delete) { confirm(&message, true, window, cx, delete); } else { delete(window, cx); } } /// Moves off a thread that's leaving the inbox: to the next active /// card, or a new thread in the same project when none is left. pub(super) fn leave_thread(&mut self, session: &Entity, window: &mut Window, cx: &mut Context) { if self.active.as_ref() != Some(session) { return; } let cards = self.inbox_cards(cx); let ix = cards.iter().position(|(_, candidate)| candidate == session); let next = match ix { Some(ix) => cards.get(ix + 1).or_else(|| ix.checked_sub(1).and_then(|ix| cards.get(ix))), None => cards.first(), } .cloned(); match next { Some((project, next)) => self.select(project, next, window, cx), None => { let project = self.project_of(session).unwrap_or(self.active_project); self.new_thread(project, window, cx); } } } // ---- drafts ----------------------------------------------------------- /// Discards an unsent draft with an undo notice. pub(super) fn discard_draft(&mut self, session: Entity, window: &mut Window, cx: &mut Context) { let Some(project_ix) = self.project_of(&session) else { return }; let was_active = self.active.as_ref() == Some(&session); let key = self.project_key(project_ix); let Some(chat) = self.chats.get(&session.entity_id()).cloned() else { return }; self.detach_thread(project_ix, &session, cx); if was_active { self.active = None; self.new_thread(project_ix, window, cx); } self.push_undo(UndoEntry { kind: UndoKind::Discarded, session, draft: Some((key, chat)) }, cx); } // ---- other thread actions ----------------------------------------------- pub(super) fn mark_unread(&mut self, sessions: Vec>, cx: &mut Context) { for session in sessions { session.update(cx, |session, cx| session.set_unread(true, cx)); } self.selection.clear(); cx.notify(); } pub(super) fn regenerate_titles(&mut self, sessions: Vec>, window: &mut Window, cx: &mut Context) { let off = |session: &Entity| settings::get_for(&session.read(cx).project, |settings| settings.text_model.is_empty()); if sessions.iter().all(off) { error_toast( if sessions.len() == 1 { "Failed to regenerate thread title" } else { "Failed to regenerate thread titles" }, "Pick a text generation model under Settings → General first.", window, cx, ); return; } for session in sessions { session.update(cx, |session, cx| session.regenerate_title(cx)); } self.selection.clear(); } /// Starts a thread on the same branch: in the thread's worktree when it /// has one, otherwise on the project checkout. pub(super) fn new_thread_on_branch(&mut self, session: Entity, window: &mut Window, cx: &mut Context) { let Some(project_ix) = self.project_of(&session) else { return }; let (worktree, cwd) = { let state = session.read(cx); (state.worktree.clone(), state.cwd.clone()) }; let settings = self.new_thread_settings(project_ix, cx); if self.projects[project_ix].environment.is_some() { self.new_thread_with(project_ix, settings, window, cx); return; } let new = self.spawn_thread(project_ix, settings, None, window, cx); if let Some(worktree) = worktree { new.update(cx, |new, cx| new.adopt_worktree(worktree, cwd, cx)); } self.select(project_ix, new, window, cx); } /// Scopes the sidebar to the thread's project, or back to all projects /// when it's already scoped there. pub(super) fn toggle_project_filter(&mut self, project_ix: usize, cx: &mut Context) { let key = self.project_key(project_ix); let scope = if self.project_scope.as_ref() == Some(&key) { None } else { Some(key) }; self.set_project_scope(scope, cx); } pub(super) fn set_project_scope(&mut self, scope: Option, cx: &mut Context) { self.project_scope = scope; self.selection.clear(); cx.notify(); } /// Opens the project's settings. They live in the settings view. pub(super) fn open_project_settings(&mut self, _project_ix: usize, window: &mut Window, cx: &mut Context) { self.open_settings_page("General", window, cx); } pub(super) fn copy_thread_id(session: &Entity, window: &mut Window, cx: &mut App) { copy(session.read(cx).id.clone(), "Thread ID copied", window, cx); } /// Copies the thread's pull request link, or its id when it has none /// (Ctrl+Shift+C). pub(super) fn copy_thread_reference(session: &Entity, window: &mut Window, cx: &mut App) { match session.read(cx).pull_request.as_ref().map(|pull_request| pull_request.url.clone()) { Some(url) => copy(url, "PR link copied", window, cx), None => Self::copy_thread_id(session, window, cx), } } // ---- the menus ----------------------------------------------------------- /// The thread action menu, item for item as T3 builds it. `sidebar` /// adds "Filter by {project}", which the chat header leaves out. pub(super) fn thread_action_menu( workspace: WeakEntity, session: Entity, sidebar: bool, mut menu: PopupMenu, window: &mut Window, cx: &mut Context, ) -> PopupMenu { let Some(this) = workspace.upgrade() else { return menu }; let (branch, project_ix, filter_label, filter_active, path) = { let this = this.read(cx); let project_ix = this.project_of(&session); let project = project_ix.and_then(|ix| this.projects.get(ix)); ( this.thread_branch(&session, cx), project_ix, project.map(|project| project.name.clone()), project_ix.is_some_and(|ix| this.project_scope.as_ref() == Some(&this.project_key(ix))), this.thread_path(&session, cx), ) }; let state = session.read(cx); let (pinned, settled, snoozed, can_snooze, regenerating, running, auto_settle, has_messages) = ( state.pinned, state.settled, state.is_snoozed(), Self::can_snooze(state), state.regenerating_title, state.is_working(), state.auto_settle, state.first_user_text().is_some(), ); let act = |run: fn(&mut Workspace, Entity, &mut Window, &mut Context)| { let workspace = workspace.clone(); let session = session.clone(); move |_: &ClickEvent, window: &mut Window, cx: &mut App| { let _ = workspace.update(cx, |this, cx| run(this, session.clone(), window, cx)); } }; let icon = |icon: Lucide| Icon::new(icon); menu = menu.min_w(px(208.)).max_w(px(384.)); if let Some(branch) = &branch { menu = menu.item( PopupMenuItem::new(format!("New thread on {branch}")) .icon(icon(Lucide::MessageSquarePlus)) .on_click(act(|this, session, window, cx| this.new_thread_on_branch(session, window, cx))), ); } menu = menu.item(if pinned { PopupMenuItem::new("Unpin thread") .icon(icon(Lucide::PinOff)) .on_click(act(|this, session, window, cx| this.unpin_thread(session, window, cx))) } else { PopupMenuItem::new("Pin thread") .icon(icon(Lucide::Pin)) .on_click(act(|_, session, _, cx| session.update(cx, |session, cx| session.set_pinned(true, cx)))) }); menu = menu.item(if settled { PopupMenuItem::new("Un-settle thread") .icon(Icon::new(IconName::CircleCheck)) .on_click(act(|this, session, _, cx| this.unsettle_thread(session, cx))) } else { PopupMenuItem::new("Settle thread") .icon(Icon::new(IconName::CircleCheck)) .on_click(act(|this, session, window, cx| this.settle_threads(vec![session], window, cx))) }); if snoozed { menu = menu.item( PopupMenuItem::new("Wake thread") .icon(icon(Lucide::Clock)) .on_click(act(|this, session, _, cx| this.wake_thread(session, cx))), ); } else { menu = Self::snooze_submenu(menu, workspace.clone(), vec![session.clone()], "Snooze".into(), !can_snooze, true, window, cx); } menu = menu.separator().item(PopupMenuItem::new("Rename thread").icon(icon(Lucide::Pencil)).on_click(if sidebar { act(|this, session, window, cx| this.start_rename(session, true, window, cx)) } else { act(|this, session, window, cx| this.start_rename(session, false, window, cx)) })); if has_messages { menu = menu.item( PopupMenuItem::new(if regenerating { "Regenerating…" } else { "Regenerate title" }) .icon(Icon::new(IconName::RefreshCw)) .disabled(regenerating) .on_click(act(|this, session, window, cx| this.regenerate_titles(vec![session], window, cx))), ); } menu = menu.item( PopupMenuItem::new("Mark unread") .icon(icon(Lucide::MailOpen)) .on_click(act(|this, session, _, cx| this.mark_unread(vec![session], cx))), ); if sidebar && let (Some(project_ix), Some(label)) = (project_ix, filter_label) { let workspace = workspace.clone(); menu = menu.item( PopupMenuItem::new(if filter_active { "Show all projects".to_string() } else { format!("Filter by {label}") }) .icon(icon(Lucide::FolderTree)) .on_click(move |_, _, cx| { let _ = workspace.update(cx, |this, cx| this.toggle_project_filter(project_ix, cx)); }), ); } { let session = session.clone(); menu = menu.submenu_with_icon(Some(icon(Lucide::Timer)), "Auto-settle behavior", window, cx, move |menu, _, _| { let set = |on: bool| { let session = session.clone(); move |_: &ClickEvent, _: &mut Window, cx: &mut App| { session.update(cx, |session, cx| session.set_auto_settle(on, cx)); } }; menu.item(PopupMenuItem::new("Enabled").checked(auto_settle).on_click(set(true))) .item(PopupMenuItem::new("Disabled").checked(!auto_settle).on_click(set(false))) }); } { let (branch, id) = (branch.clone(), session.read(cx).id.clone()); menu = menu.separator().submenu_with_icon(Some(Icon::new(IconName::Copy)), "Copy", window, cx, move |mut menu, _, _| { let path = path.clone(); menu = menu.item(PopupMenuItem::new("Path").icon(Icon::new(IconName::Folder)).on_click(move |_, window, cx| { match path.clone() { Some(path) => copy(path.display().to_string(), "Path copied", window, cx), None => error_toast("Path unavailable", "This thread does not have a workspace path to copy.", window, cx), } })); if let Some(branch) = branch.clone() { menu = menu.item( PopupMenuItem::new("Branch") .icon(Icon::new(Lucide::GitBranch)) .on_click(move |_, window, cx| copy(branch.clone(), "Branch copied", window, cx)), ); } let id = id.clone(); menu.item( PopupMenuItem::new("Thread ID") .icon(Icon::new(Lucide::Hash)) .on_click(move |_, window, cx| copy(id.clone(), "Thread ID copied", window, cx)), ) }); } if let Some(project_ix) = project_ix { let workspace = workspace.clone(); menu = menu.item(PopupMenuItem::new("Project settings").icon(Icon::new(IconName::Settings)).on_click( move |_, window, cx| { let _ = workspace.update(cx, |this, cx| this.open_project_settings(project_ix, window, cx)); }, )); } menu.separator() .item( PopupMenuItem::new("Archive thread") .icon(icon(Lucide::Archive)) .disabled(running) .on_click(act(|this, session, window, cx| this.archive_thread(session, window, cx))), ) .item(destructive_item("Delete", Lucide::Trash, cx).on_click(act(|this, session, window, cx| { this.delete_threads(vec![session], window, cx) }))) } /// The Snooze submenu: T3's presets with their wake times, then Custom…. #[allow(clippy::too_many_arguments)] fn snooze_submenu( menu: PopupMenu, workspace: WeakEntity, sessions: Vec>, label: SharedString, disabled: bool, with_icon: bool, window: &mut Window, cx: &mut Context, ) -> PopupMenu { let build = move |mut menu: PopupMenu, _: &mut Window, _: &mut Context| { for preset in snooze_presets() { let workspace = workspace.clone(); let sessions = sessions.clone(); let until = preset.until; menu = menu.item(PopupMenuItem::new(format!("{} ({})", preset.label, preset.when)).on_click( move |_, window, cx| { let sessions = sessions.clone(); let _ = workspace.update(cx, |this, cx| this.snooze_threads(sessions, until, window, cx)); }, )); } let workspace = workspace.clone(); let sessions = sessions.clone(); menu.separator().item(PopupMenuItem::new("Custom…").on_click(move |_, window, cx| { let sessions = sessions.clone(); let _ = workspace.update(cx, |this, cx| this.request_custom_snooze(sessions, window, cx)); })) }; let icon = with_icon.then(|| Icon::new(Lucide::Clock)); if disabled { let submenu = PopupMenu::build(window, cx, build); menu.item(PopupMenuItem::submenu(label, submenu).when_some(icon, |item, icon| item.icon(icon)).disabled(true)) } else { menu.submenu_with_icon(icon, label, window, cx, build) } } /// The menu for several selected rows. Counts only cover rows that show. pub(super) fn multi_select_menu( workspace: WeakEntity, sessions: Vec>, mut menu: PopupMenu, window: &mut Window, cx: &mut Context, ) -> PopupMenu { let count = sessions.len(); let pinned: Vec<_> = sessions.iter().filter(|session| session.read(cx).pinned).cloned().collect(); let settleable: Vec<_> = sessions.iter().filter(|session| !session.read(cx).settled).cloned().collect(); let all_snoozable = sessions.iter().all(|session| Self::can_snooze(session.read(cx))); let regenerating = sessions.iter().any(|session| session.read(cx).regenerating_title); let titled: Vec<_> = sessions.iter().filter(|session| session.read(cx).first_user_text().is_some()).cloned().collect(); let run = |sessions: Vec>, action: fn(&mut Workspace, Vec>, &mut Window, &mut Context)| { let workspace = workspace.clone(); move |_: &ClickEvent, window: &mut Window, cx: &mut App| { let sessions = sessions.clone(); let _ = workspace.update(cx, |this, cx| action(this, sessions, window, cx)); } }; menu = menu.min_w(px(160.)); if !pinned.is_empty() { menu = menu.item(PopupMenuItem::new(format!("Unpin ({})", pinned.len())).on_click(run( pinned, |this, sessions, _, cx| { for session in sessions { session.update(cx, |session, cx| session.set_pinned(false, cx)); this.push_undo(UndoEntry { kind: UndoKind::Unpinned, session, draft: None }, cx); } this.selection.clear(); }, ))); } menu = menu.item( PopupMenuItem::new(format!("Settle ({count})")) .disabled(settleable.is_empty()) .on_click(run(settleable, |this, sessions, window, cx| { this.settle_threads(sessions, window, cx); this.selection.clear(); })), ); if all_snoozable { menu = Self::snooze_submenu(menu, workspace.clone(), sessions.clone(), format!("Snooze ({count})").into(), false, false, window, cx); } menu = menu.item(if regenerating { PopupMenuItem::new(format!("Regenerating… ({count})")).disabled(true) } else { PopupMenuItem::new(format!("Regenerate titles ({})", titled.len())) .disabled(titled.is_empty()) .on_click(run(titled, |this, sessions, window, cx| this.regenerate_titles(sessions, window, cx))) }); menu.item( PopupMenuItem::new(format!("Mark unread ({count})")) .on_click(run(sessions.clone(), |this, sessions, _, cx| this.mark_unread(sessions, cx))), ) .item( destructive_item(format!("Delete ({count})"), None::, cx) .on_click(run(sessions, |this, sessions, window, cx| this.delete_threads(sessions, window, cx))), ) } /// The menu of an unsent draft row. pub(super) fn draft_menu( workspace: WeakEntity, session: Entity, mut menu: PopupMenu, window: &mut Window, cx: &mut Context, ) -> PopupMenu { let Some(this) = workspace.upgrade() else { return menu }; let (path, branch, project_ix) = { let this = this.read(cx); let project_ix = this.project_of(&session); let scratch = project_ix .and_then(|ix| this.projects.get(ix)) .is_some_and(|project| Some(&project.path) == super::scratch_dir().as_ref()); (this.thread_path(&session, cx), this.thread_branch(&session, cx), project_ix.filter(|_| !scratch)) }; let has_copy = path.is_some() || branch.is_some(); let copy_menu = { let (path, branch) = (path.clone(), branch.clone()); move |mut menu: PopupMenu, _: &mut Window, _: &mut Context| { if let Some(path) = path.clone() { menu = menu.item(PopupMenuItem::new("Path").icon(Icon::new(IconName::Folder)).on_click( move |_, window, cx| copy(path.display().to_string(), "Path copied", window, cx), )); } if let Some(branch) = branch.clone() { menu = menu.item(PopupMenuItem::new("Branch").icon(Icon::new(Lucide::GitBranch)).on_click( move |_, window, cx| copy(branch.clone(), "Branch copied", window, cx), )); } menu } }; menu = menu.min_w(px(160.)); menu = if has_copy { menu.submenu_with_icon(Some(Icon::new(IconName::Copy)), "Copy", window, cx, copy_menu) } else { let submenu = PopupMenu::build(window, cx, copy_menu); menu.item(PopupMenuItem::submenu("Copy", submenu).icon(Icon::new(IconName::Copy)).disabled(true)) }; if let Some(project_ix) = project_ix { let workspace = workspace.clone(); menu = menu.item(PopupMenuItem::new("Project settings").icon(Icon::new(IconName::Settings)).on_click( move |_, window, cx| { let _ = workspace.update(cx, |this, cx| this.open_project_settings(project_ix, window, cx)); }, )); } menu.separator().item(destructive_item("Discard draft", Lucide::Trash, cx).on_click(move |_, window, cx| { let session = session.clone(); let _ = workspace.update(cx, |this, cx| this.discard_draft(session, window, cx)); })) } // ---- custom snooze --------------------------------------------------- /// Opens T3's Custom snooze dialog for these threads. pub(super) fn request_custom_snooze(&mut self, sessions: Vec>, window: &mut Window, cx: &mut Context) { let workspace = cx.weak_entity(); let form = cx.new(|cx| CustomSnooze::new(window, cx)); let submit = { let form = form.clone(); move |window: &mut Window, cx: &mut App| { let Some(until) = form.update(cx, |form, cx| form.resolve(cx)) else { return }; window.close_dialog(cx); let sessions = sessions.clone(); let _ = workspace.update(cx, |this, cx| this.snooze_threads(sessions, until, window, cx)); } }; let submit = std::rc::Rc::new(submit); window.open_dialog(cx, move |dialog, _, cx| { let p = metal::palette(cx); let submit = submit.clone(); dialog .w(px(384.)) // The calendar pops out past the dialog; a click there isn't a click away. .overlay_closable(false) .title( v_flex() .gap(px(4.)) .child(div().text_size(px(16.)).font_weight(FontWeight::SEMIBOLD).text_color(p.ink).child("Custom snooze")) .child( div() .text_size(px(13.)) .font_weight(FontWeight::NORMAL) .text_color(p.ink3) .child("Choose when snoozed threads return to your inbox."), ), ) .child(form.clone()) .footer( h_flex() .w_full() .justify_end() .gap(px(8.)) .child( MetalButton::new("custom-snooze-cancel") .compact() .label("Cancel") .on_click(|_, window, cx| window.close_dialog(cx)), ) .child( MetalButton::new("custom-snooze-submit") .primary() .compact() .label("Snooze") .on_click(move |_, window, cx| submit(window, cx)), ), ) }); } } /// A red menu row for actions that destroy something. pub(super) fn destructive_item(label: impl Into, icon: impl Into>, cx: &App) -> PopupMenuItem { let red = metal::palette(cx).red; let label = label.into(); let item = PopupMenuItem::element(move |_, _| div().text_color(red).child(label.clone())); match icon.into() { Some(icon) => item.icon(Icon::new(icon).text_color(red)), None => item, } } /// Date and time, or a duration. #[derive(Clone, Copy, PartialEq, Eq)] enum SnoozeMode { Date, Duration, } #[derive(Clone, Copy, PartialEq, Eq)] enum SnoozeUnit { Minutes, Hours, Days, } impl SnoozeUnit { fn label(self) -> &'static str { match self { SnoozeUnit::Minutes => "Minutes", SnoozeUnit::Hours => "Hours", SnoozeUnit::Days => "Days", } } fn duration(self) -> Duration { match self { SnoozeUnit::Minutes => Duration::from_secs(60), SnoozeUnit::Hours => HOUR, SnoozeUnit::Days => DAY, } } } /// The body of the Custom snooze dialog. struct CustomSnooze { mode: SnoozeMode, date: NaiveDate, /// The date's calendar, past days disabled, and whether it's open. calendar: Entity, calendar_open: bool, _calendar_events: Subscription, time: Entity, amount: Entity, unit: SnoozeUnit, error: Option<&'static str>, } impl CustomSnooze { fn new(window: &mut Window, cx: &mut Context) -> Self { let initial: chrono::DateTime = (SystemTime::now() + HOUR).into(); let time = cx.new(|cx| InputState::new(window, cx).placeholder("HH:MM").default_value(initial.format("%H:%M").to_string())); let amount = cx.new(|cx| InputState::new(window, cx).default_value("2")); let today = Local::now().date_naive(); let calendar = cx.new(|cx| { let mut calendar = CalendarState::new(window, cx).disabled_matcher(Matcher::interval(Some(today), None)); calendar.set_date(initial.date_naive(), window, cx); calendar }); let calendar_events = cx.subscribe(&calendar, |this, _, event: &CalendarEvent, cx| { let CalendarEvent::Selected(date) = event; if let Some(date) = date.start() { this.date = date; this.calendar_open = false; this.error = None; cx.notify(); } }); Self { mode: SnoozeMode::Date, date: initial.date_naive(), calendar, calendar_open: false, _calendar_events: calendar_events, time, amount, unit: SnoozeUnit::Hours, error: None, } } /// The wake time the form describes, or `None` (showing why) when it /// isn't valid and in the future. fn resolve(&mut self, cx: &mut Context) -> Option { let now = SystemTime::now(); let wake = match self.mode { SnoozeMode::Duration => self .amount .read(cx) .value() .trim() .parse::() .ok() .filter(|amount| amount.is_finite() && *amount > 0.) .map(|amount| now + self.unit.duration().mul_f64(amount)), SnoozeMode::Date => NaiveTime::parse_from_str(self.time.read(cx).value().trim(), "%H:%M") .ok() .and_then(|time| Local.from_local_datetime(&self.date.and_time(time)).single()) .map(SystemTime::from), } .filter(|wake| *wake > now); if wake.is_none() { self.error = Some(match self.mode { SnoozeMode::Date => "Choose a valid date and time in the future.", SnoozeMode::Duration => "Enter a positive duration.", }); cx.notify(); } wake } fn step_amount(&mut self, delta: f64, window: &mut Window, cx: &mut Context) { let amount = self.amount.read(cx).value().trim().parse::().unwrap_or(0.); let next = (amount + delta).max(0.); let text = if next.fract() == 0. { format!("{next:.0}") } else { next.to_string() }; self.amount.update(cx, |input, cx| input.set_value(text, window, cx)); self.error = None; cx.notify(); } } impl Render for CustomSnooze { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let entity = cx.entity().downgrade(); let label = |text: &'static str| div().text_size(px(13.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(text); let field = |child: AnyElement| { h_flex().h(px(34.)).px(px(10.)).gap(px(6.)).rounded(px(12.)).well(p).text_size(px(13.)).child(child) }; let mode_switch = { let entity = entity.clone(); metal::segmented( "snooze-mode", &[(SnoozeMode::Date, "Date and time"), (SnoozeMode::Duration, "Duration")], self.mode, p, move |mode, _, cx| { let _ = entity.update(cx, |this, cx| { this.mode = mode; this.error = None; cx.notify(); }); }, ) }; let fields = match self.mode { SnoozeMode::Date => { let date = self.date; let toggle = entity.clone(); let close = entity.clone(); let date_button = div() .relative() .child( MetalButton::new("snooze-date") .compact() .w_full() .label(date.format("%b %-d, %Y").to_string()) .stretch() .icon(IconName::Calendar) .on_click(move |_, _, cx| { let _ = toggle.update(cx, |this, cx| { this.calendar_open = !this.calendar_open; cx.notify(); }); }), ) .when(self.calendar_open, |this| { this.child( div().absolute().top_full().left_0().child( deferred( anchored().snap_to_window_with_margin(px(8.)).child( crate::ui::pickers::panel(p) .mt(px(4.)) .p(px(8.)) .on_mouse_down_out(move |_, _, cx| { let _ = close.update(cx, |this, cx| { this.calendar_open = false; cx.notify(); }); }) .child(Calendar::new(&self.calendar)), ), ) .with_priority(100), ), ) }); h_flex() .items_start() .gap(px(12.)) .child(v_flex().flex_1().min_w_0().gap(px(6.)).child(label("Date")).child(date_button)) .child( v_flex() .flex_1() .min_w_0() .gap(px(6.)) .child(label("Time")) .child(field(Input::new(&self.time).appearance(false).into_any_element())), ) .into_any_element() } SnoozeMode::Duration => { let unit = self.unit; let entity_for_unit = entity.clone(); let dec = entity.clone(); let inc = entity.clone(); h_flex() .items_start() .gap(px(12.)) .child( v_flex().flex_1().min_w_0().gap(px(6.)).child(label("Snooze for")).child( h_flex() .gap(px(4.)) .child( MetalButton::new("snooze-decrease") .ghost() .mini() .icon(IconName::Minus) .tooltip("Decrease duration") .on_click(move |_, window, cx| { let _ = dec.update(cx, |this, cx| this.step_amount(-1., window, cx)); }), ) .child(div().flex_1().child(field(Input::new(&self.amount).appearance(false).into_any_element()))) .child( MetalButton::new("snooze-increase") .ghost() .mini() .icon(IconName::Plus) .tooltip("Increase duration") .on_click(move |_, window, cx| { let _ = inc.update(cx, |this, cx| this.step_amount(1., window, cx)); }), ), ), ) .child( v_flex().flex_1().min_w_0().gap(px(6.)).child(label("Unit")).child( MetalButton::new("snooze-unit") .compact() .w_full() .label(unit.label()) .stretch() .caret() .dropdown_menu(move |mut menu, _, _| { for option in [SnoozeUnit::Minutes, SnoozeUnit::Hours, SnoozeUnit::Days] { let entity = entity_for_unit.clone(); menu = menu.item(PopupMenuItem::new(option.label()).checked(option == unit).on_click( move |_, _, cx| { let _ = entity.update(cx, |this, cx| { this.unit = option; this.error = None; cx.notify(); }); }, )); } menu }), ), ) .into_any_element() } }; v_flex() .gap(px(16.)) .child(h_flex().justify_center().child(mode_switch)) .child(fields) .when_some(self.error, |this, error| this.child(div().text_size(px(13.)).text_color(p.red).child(error))) } } #[cfg(test)] mod tests { use super::{confirm_copy, wake_countdown}; use std::time::{Duration, SystemTime}; #[::core::prelude::v1::test] fn confirm_messages_split_like_t3() { let (title, body) = confirm_copy("Delete thread \"x\"?\nThis permanently clears conversation history for this thread."); assert_eq!(title, "Delete thread \"x\"?"); assert_eq!(body.as_deref(), Some("This permanently clears conversation history for this thread.")); assert_eq!(confirm_copy("Archive thread \"y\"?").1, None); assert_eq!(confirm_copy("Do it").0, "Confirm action"); } #[::core::prelude::v1::test] fn snooze_countdowns() { let now = SystemTime::now(); assert_eq!(wake_countdown(now - Duration::from_secs(5)), "now"); assert_eq!(wake_countdown(now + Duration::from_secs(45 * 60 - 5)), "45m"); assert_eq!(wake_countdown(now + Duration::from_secs(90 * 60)), "2h"); assert_eq!(wake_countdown(now + Duration::from_secs(50 * 3_600)), "3d"); } }