Vectrum
Sign In
1233 lines · 53.1 KB · Rust
//! 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<SnoozePreset> {
    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<Local> = 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<String>) {
    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<Session>,
    /// For a discarded draft: where it was and its conversation view, kept
    /// alive until the notice expires.
    pub draft: Option<(ProjectKey, Entity<ChatView>)>,
}

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>) {
        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<Self>) {
        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<String> {
        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<Self>) -> 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<Session>, cx: &App) -> Option<String> {
        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<Session>, cx: &App) -> Option<std::path::PathBuf> {
        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<Entity<Session>>, window: &mut Window, cx: &mut Context<Self>) {
        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<Session>, cx: &mut Context<Self>) {
        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<Session>, window: &mut Window, cx: &mut Context<Self>) {
        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<Entity<Session>>,
        until: SystemTime,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        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<Session>, cx: &mut Context<Self>) {
        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<Session>, window: &mut Window, cx: &mut Context<Self>) {
        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<Session>, window: &mut Window, cx: &mut Context<Self>) {
        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<Session>, window: &mut Window, cx: &mut Context<Self>) {
        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<Entity<Session>>, window: &mut Window, cx: &mut Context<Self>) {
        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<Session>, window: &mut Window, cx: &mut Context<Self>) {
        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<Session>, window: &mut Window, cx: &mut Context<Self>) {
        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<Entity<Session>>, cx: &mut Context<Self>) {
        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<Entity<Session>>, window: &mut Window, cx: &mut Context<Self>) {
        let off = |session: &Entity<Session>| 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<Session>, window: &mut Window, cx: &mut Context<Self>) {
        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<Self>) {
        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<ProjectKey>, cx: &mut Context<Self>) {
        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>) {
        self.open_settings_page("General", window, cx);
    }

    pub(super) fn copy_thread_id(session: &Entity<Session>, 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<Session>, 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<Self>,
        session: Entity<Session>,
        sidebar: bool,
        mut menu: PopupMenu,
        window: &mut Window,
        cx: &mut Context<PopupMenu>,
    ) -> 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<Session>, &mut Window, &mut Context<Workspace>)| {
            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<Self>,
        sessions: Vec<Entity<Session>>,
        label: SharedString,
        disabled: bool,
        with_icon: bool,
        window: &mut Window,
        cx: &mut Context<PopupMenu>,
    ) -> PopupMenu {
        let build = move |mut menu: PopupMenu, _: &mut Window, _: &mut Context<PopupMenu>| {
            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<Self>,
        sessions: Vec<Entity<Session>>,
        mut menu: PopupMenu,
        window: &mut Window,
        cx: &mut Context<PopupMenu>,
    ) -> 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<Entity<Session>>,
                   action: fn(&mut Workspace, Vec<Entity<Session>>, &mut Window, &mut Context<Workspace>)| {
            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::<Lucide>, 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<Self>,
        session: Entity<Session>,
        mut menu: PopupMenu,
        window: &mut Window,
        cx: &mut Context<PopupMenu>,
    ) -> 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<PopupMenu>| {
                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<Entity<Session>>, window: &mut Window, cx: &mut Context<Self>) {
        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<SharedString>, icon: impl Into<Option<Lucide>>, 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<CalendarState>,
    calendar_open: bool,
    _calendar_events: Subscription,
    time: Entity<InputState>,
    amount: Entity<InputState>,
    unit: SnoozeUnit,
    error: Option<&'static str>,
}

impl CustomSnooze {
    fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        let initial: chrono::DateTime<Local> = (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<Self>) -> Option<SystemTime> {
        let now = SystemTime::now();
        let wake = match self.mode {
            SnoozeMode::Duration => self
                .amount
                .read(cx)
                .value()
                .trim()
                .parse::<f64>()
                .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<Self>) {
        let amount = self.amount.read(cx).value().trim().parse::<f64>().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<Self>) -> 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");
    }
}