Vectrum
Sign In
1018 lines · 46.1 KB · Rust
//! T3 Code's legacy sidebar, on with Settings → Legacy sidebar: a Search
//! button, then threads in per-project trees with the Sidebar options sort
//! menu, the project menu (Rename, Group into..., Copy Path, Project
//! settings, Remove) and T3's dialogs behind it.

use std::rc::Rc;

use gpui_kit::component::{
    Icon, IconName, WindowExt as _,
    button::{Button, ButtonVariants as _},
    h_flex,
    input::{Input, InputEvent, InputState},
    menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem},
    notification::Notification,
    tooltip::Tooltip,
    v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use super::super::actions::{confirm, copy, relative_time_long};
use super::super::{ThreadEntry, Workspace, display_path};
use super::{JUMP_MODIFIER, TopStatus, top_status};
use crate::session::Session;
use crate::settings::{self, ProjectGrouping, ProjectOrder, ThreadOrder};
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _};

/// The visible thread count's bounds in the Sidebar options menu.
const VISIBLE_THREADS: std::ops::RangeInclusive<u32> = 1..=15;

impl Workspace {
    /// The grouping rule for a project: its own, or the default.
    fn grouping_of(&self, ix: usize) -> ProjectGrouping {
        let path = self.projects[ix].path.display().to_string();
        settings::get(|settings| settings.project_grouping_overrides.get(&path).copied().unwrap_or(settings.project_grouping))
    }

    /// The legacy sidebar's rows: projects that share a repository (as
    /// their grouping rules say) together, in project order.
    pub(in super::super) fn project_groups(&self, cx: &App) -> Vec<Vec<usize>> {
        let key = |ix: usize| -> String {
            let project = &self.projects[ix];
            match (&project.repo, project.environment.is_some(), self.grouping_of(ix)) {
                (Some((repo, _)), false, ProjectGrouping::Repository) => format!("repo:{}", repo.display()),
                (Some((repo, inside)), false, ProjectGrouping::RepositoryPath) => {
                    format!("repo:{}:{}", repo.display(), inside.display())
                }
                _ => format!("path:{}:{}", project.environment.as_deref().unwrap_or(""), project.path.display()),
            }
        };
        let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
        for ix in self.ordered_projects(cx) {
            let key = key(ix);
            match groups.iter_mut().find(|(candidate, _)| *candidate == key) {
                Some((_, members)) => members.push(ix),
                None => groups.push((key, vec![ix])),
            }
        }
        groups.into_iter().map(|(_, members)| members).collect()
    }

    /// A group's threads, as the thread sort says.
    fn group_threads(&self, members: &[usize], cx: &App) -> Vec<ThreadEntry> {
        let mut threads: Vec<ThreadEntry> = members
            .iter()
            .flat_map(|&ix| self.projects[ix].sessions.iter().map(move |session| (ix, session.clone())))
            .filter(|(_, session)| {
                let state = session.read(cx);
                // Subagents' threads open from the subagents panel.
                !state.archived && !state.is_subagent() && (!state.is_blank() || self.draft_of(session, cx).is_some())
            })
            .collect();
        match settings::get(|settings| settings.thread_order) {
            ThreadOrder::LastMessage => {
                threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).last_message.max(session.read(cx).created)))
            }
            ThreadOrder::Created => threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).created)),
        }
        threads
    }

    /// The threads a group shows: the first few, all of them after "Show
    /// more", and always the open one.
    fn shown_threads(&self, members: &[usize], cx: &App) -> (Vec<ThreadEntry>, usize) {
        let threads = self.group_threads(members, cx);
        let total = threads.len();
        let limit = settings::get(|settings| settings.visible_threads.clamp(*VISIBLE_THREADS.start(), *VISIBLE_THREADS.end()))
            as usize;
        let expanded = self.expanded_projects.contains(&self.projects[members[0]].path);
        let shown = threads
            .into_iter()
            .enumerate()
            .filter(|(row, (_, session))| expanded || *row < limit || self.active.as_ref() == Some(session))
            .map(|(_, entry)| entry)
            .collect();
        (shown, total)
    }

    fn group_collapsed(&self, members: &[usize]) -> bool {
        self.collapsed_projects.contains(&self.projects[members[0]].path)
    }

    /// The thread rows showing in the legacy sidebar, top to bottom.
    pub(in super::super) fn legacy_rows(&self, cx: &App) -> Vec<ThreadEntry> {
        self.project_groups(cx)
            .iter()
            .flat_map(|members| {
                if self.group_collapsed(members) {
                    // The open thread still shows under a folded project.
                    self.group_threads(members, cx)
                        .into_iter()
                        .filter(|(_, session)| self.active.as_ref() == Some(session))
                        .collect()
                } else {
                    self.shown_threads(members, cx).0
                }
            })
            .collect()
    }

    // ---- the header -----------------------------------------------------------

    pub(in super::super) fn render_legacy_header(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let workspace = cx.weak_entity();
        v_flex()
            .flex_none()
            .px(px(8.))
            .gap(px(6.))
            .child(
                h_flex()
                    .id("legacy-search")
                    .h(px(32.))
                    .px(px(10.))
                    .gap(px(8.))
                    .rounded(px(12.))
                    .well(p)
                    .cursor_pointer()
                    .text_size(px(13.))
                    .text_color(p.ink3)
                    .on_click(cx.listener(|this, _, window, cx| this.open_palette(super::super::palette::PaletteView::Root, window, cx)))
                    .child(Icon::new(IconName::Search).size(px(14.)).text_color(p.field_ink))
                    .child(div().flex_1().child("Search"))
                    .child(metal::keycap("Ctrl+K", p)),
            )
            .child(
                h_flex()
                    .h(px(28.))
                    .pl(px(8.))
                    .gap(px(2.))
                    .child(div().flex_1().text_size(px(12.)).font_weight(FontWeight::MEDIUM).text_color(p.ink3).child("Projects"))
                    .child(
                        MetalButton::new("sidebar-options")
                            .ghost()
                            .mini()
                            .icon(Lucide::ArrowUpDown)
                            .tooltip("Sidebar options")
                            .dropdown_menu_with_anchor(Anchor::TopRight, move |menu, window, cx| {
                                Self::sidebar_options_menu(workspace.clone(), menu, window, cx)
                            }),
                    )
                    .child(
                        MetalButton::new("legacy-add-project")
                            .ghost()
                            .mini()
                            .icon(Lucide::FolderPlus)
                            .tooltip("Add project")
                            .on_click(cx.listener(|this, _, window, cx| this.prompt_for_project(window, cx))),
                    ),
            )
    }

    /// Sort projects, sort threads, and how many threads show per project.
    fn sidebar_options_menu(
        workspace: WeakEntity<Self>,
        menu: PopupMenu,
        _: &mut Window,
        cx: &mut Context<PopupMenu>,
    ) -> PopupMenu {
        let (project_order, thread_order, visible) =
            settings::get(|settings| (settings.project_order, settings.thread_order, settings.visible_threads));
        let set = |edit: fn(&mut settings::Settings)| {
            let workspace = workspace.clone();
            move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
                settings::update(edit);
                let _ = workspace.update(cx, |_, cx| cx.notify());
            }
        };
        let p = metal::palette(cx);
        let step = |delta: i32, workspace: WeakEntity<Self>| {
            move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
                cx.stop_propagation();
                settings::update(|settings| {
                    let next = (settings.visible_threads as i32 + delta)
                        .clamp(*VISIBLE_THREADS.start() as i32, *VISIBLE_THREADS.end() as i32);
                    settings.visible_threads = next as u32;
                });
                let _ = workspace.update(cx, |_, cx| cx.notify());
            }
        };
        let (minus, plus) = (step(-1, workspace.clone()), step(1, workspace.clone()));
        menu.min_w(px(220.))
            .label("Sort projects")
            .item(
                PopupMenuItem::new("Last user message")
                    .checked(project_order == ProjectOrder::LastMessage)
                    .on_click(set(|settings| settings.project_order = ProjectOrder::LastMessage)),
            )
            .item(
                PopupMenuItem::new("Created at")
                    .checked(project_order == ProjectOrder::Added)
                    .on_click(set(|settings| settings.project_order = ProjectOrder::Added)),
            )
            .item(
                PopupMenuItem::new("Manual")
                    .checked(project_order == ProjectOrder::Manual)
                    .on_click(set(|settings| settings.project_order = ProjectOrder::Manual)),
            )
            .separator()
            .label("Sort threads")
            .item(
                PopupMenuItem::new("Last user message")
                    .checked(thread_order == ThreadOrder::LastMessage)
                    .on_click(set(|settings| settings.thread_order = ThreadOrder::LastMessage)),
            )
            .item(
                PopupMenuItem::new("Created at")
                    .checked(thread_order == ThreadOrder::Created)
                    .on_click(set(|settings| settings.thread_order = ThreadOrder::Created)),
            )
            .separator()
            .label("Visible threads")
            .item(
                PopupMenuItem::element(move |_, _| {
                    let (minus, plus) = (minus.clone(), plus.clone());
                    h_flex()
                        .w_full()
                        .gap(px(6.))
                        .child(
                            MetalButton::new("visible-threads-decrease")
                                .ghost()
                                .mini()
                                .icon(IconName::Minus)
                                .tooltip("Decrease visible thread count")
                                .on_click(minus),
                        )
                        .child(
                            div()
                                .flex_1()
                                .h(px(24.))
                                .flex()
                                .items_center()
                                .justify_center()
                                .rounded(px(8.))
                                .well(p)
                                .font_family(metal::MONO)
                                .text_size(px(12.))
                                .text_color(p.ink)
                                .child(visible.to_string()),
                        )
                        .child(
                            MetalButton::new("visible-threads-increase")
                                .ghost()
                                .mini()
                                .icon(IconName::Plus)
                                .tooltip("Increase visible thread count")
                                .on_click(plus),
                        )
                })
                .disabled(true),
            )
    }

    // ---- the tree --------------------------------------------------------------

    pub(in super::super) fn render_legacy_list(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let groups = self.project_groups(cx);
        let rows = self.visible_rows(cx);
        let mut children: Vec<AnyElement> = Vec::new();
        for (group_ix, members) in groups.iter().enumerate() {
            children.push(self.render_group_header(group_ix, members, cx));
            if self.group_collapsed(members) {
                for (ix, session) in self.legacy_rows_in(members, true, cx) {
                    let jump = rows.iter().take(9).position(|(_, row)| row == &session);
                    children.push(self.render_legacy_row(ix, &session, jump, cx));
                }
                continue;
            }
            let (shown, total) = self.shown_threads(members, cx);
            if total == 0 {
                children.push(
                    div().pl(px(32.)).h(px(28.)).flex().items_center().text_size(px(12.)).text_color(p.ink3).child("No threads yet").into_any_element(),
                );
            }
            let shown_count = shown.len();
            for (ix, session) in shown {
                let jump = rows.iter().take(9).position(|(_, row)| row == &session);
                children.push(self.render_legacy_row(ix, &session, jump, cx));
            }
            let path = self.projects[members[0]].path.clone();
            let expanded = self.expanded_projects.contains(&path);
            if total > shown_count || expanded {
                children.push(
                    div()
                        .id(("legacy-show-more", group_ix))
                        .pl(px(32.))
                        .h(px(26.))
                        .flex()
                        .items_center()
                        .cursor_pointer()
                        .text_size(px(12.))
                        .text_color(p.ink3)
                        .hover(|style| style.text_color(p.ink))
                        .on_click(cx.listener(move |this, _, _, cx| {
                            if !this.expanded_projects.remove(&path) {
                                this.expanded_projects.insert(path.clone());
                            }
                            cx.notify();
                        }))
                        .child(if expanded { "Show less" } else { "Show more" })
                        .into_any_element(),
                );
            }
        }
        v_flex()
            .id("legacy-threads")
            .flex_1()
            .min_h_0()
            .overflow_y_scroll()
            .px(px(8.))
            .pb(px(8.))
            .gap(px(1.))
            .when(groups.is_empty(), |this| {
                this.child(div().px(px(8.)).py(px(24.)).text_size(px(12.)).text_color(p.ink3).child("No projects yet"))
            })
            .children(children)
    }

    fn legacy_rows_in(&self, members: &[usize], only_active: bool, cx: &App) -> Vec<ThreadEntry> {
        self.group_threads(members, cx)
            .into_iter()
            .filter(|(_, session)| !only_active || self.active.as_ref() == Some(session))
            .collect()
    }

    /// The strongest status among a group's threads, for the dot a folded
    /// project shows.
    fn group_status(&self, members: &[usize], cx: &App) -> Option<(Led, bool)> {
        let statuses: Vec<TopStatus> =
            self.group_threads(members, cx).iter().filter_map(|(_, session)| top_status(session.read(cx))).collect();
        [
            (TopStatus::Approval, Led::Waiting, false),
            (TopStatus::Input, Led::Waiting, true),
            (TopStatus::Working, Led::Live, true),
            (TopStatus::Waiting, Led::Off, false),
            (TopStatus::Done, Led::Live, false),
        ]
        .into_iter()
        .find(|(status, _, _)| statuses.contains(status))
        .map(|(_, led, pulse)| (led, pulse))
    }

    fn render_group_header(&self, group_ix: usize, members: &[usize], cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let first = &self.projects[members[0]];
        let collapsed = self.group_collapsed(members);
        let status = collapsed.then(|| self.group_status(members, cx)).flatten();
        let path = first.path.clone();
        let group = SharedString::from(format!("legacy-group-{group_ix}"));
        let remote = first.environment.as_deref().map(|env| self.environment_name(env));
        let name = first.name.clone();
        let members_list: Vec<usize> = members.to_vec();
        let workspace = cx.weak_entity();
        let menu_members = members_list.clone();
        let new_thread = if members.len() > 1 {
            let workspace = workspace.clone();
            let labels: Vec<(usize, String)> = members.iter().map(|&ix| (ix, self.member_label(ix))).collect();
            MetalButton::new(("group-new-thread", group_ix))
                .ghost()
                .mini()
                .icon(Lucide::SquarePen)
                .tooltip("New thread (Ctrl+N)")
                .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
                    for (ix, label) in labels.clone() {
                        let workspace = workspace.clone();
                        menu = menu.item(PopupMenuItem::new(label).on_click(move |_, window, cx| {
                            let _ = workspace.update(cx, |this, cx| this.new_thread(ix, window, cx));
                        }));
                    }
                    menu
                })
                .into_any_element()
        } else {
            let ix = members[0];
            MetalButton::new(("group-new-thread", group_ix))
                .ghost()
                .mini()
                .icon(Lucide::SquarePen)
                .tooltip("New thread (Ctrl+N)")
                .on_click(cx.listener(move |this, _, window, cx| {
                    cx.stop_propagation();
                    this.new_thread(ix, window, cx);
                }))
                .into_any_element()
        };
        h_flex()
            .id(("legacy-group", group_ix))
            .group(group.clone())
            .flex_none()
            .mt(px(4.))
            .h(px(32.))
            .pl(px(8.))
            .pr(px(4.))
            .gap(px(8.))
            .rounded(px(10.))
            .cursor_pointer()
            .hover(|style| style.bg(p.row_hover))
            .on_click(cx.listener(move |this, _, _, cx| {
                if !this.collapsed_projects.remove(&path) {
                    this.collapsed_projects.insert(path.clone());
                }
                cx.notify();
            }))
            .child(
                div().w(px(14.)).flex().justify_center().map(|this| match status {
                    Some((led, pulse)) => this.child(super::hover_swap(
                        group.clone(),
                        metal::led(("group-status", group_ix), led, pulse, p),
                        Icon::new(IconName::ChevronRight).size(px(14.)).text_color(p.ink3),
                        None,
                    )),
                    None => this.child(
                        Icon::new(if collapsed { IconName::ChevronRight } else { IconName::ChevronDown })
                            .size(px(14.))
                            .text_color(p.ink3),
                    ),
                }),
            )
            .child(metal::badge(first.initials.clone(), first.tint, p))
            .child(
                div()
                    .min_w_0()
                    .truncate()
                    .text_size(px(14.))
                    .font_weight(FontWeight::MEDIUM)
                    .text_color(p.ink.opacity(0.9))
                    .child(name.clone()),
            )
            .when(members.len() > 1, |this| {
                this.child(div().flex_none().text_size(px(10.)).text_color(p.ink3).child(format!("{} projects", members.len())))
            })
            .child(div().flex_1())
            .child(div().flex_none().child(super::hover_swap(
                group.clone(),
                match remote.clone() {
                    Some(remote) => div()
                        .id(("group-remote", group_ix))
                        .tooltip(move |window, cx| Tooltip::new(format!("Remote environment: {remote}")).build(window, cx))
                        .child(Icon::new(Lucide::Server).size(px(13.)).text_color(p.ink3))
                        .into_any_element(),
                    None => div().size(px(22.)).into_any_element(),
                },
                new_thread,
                None,
            )))
            .context_menu(move |menu, window, cx| Self::project_menu(workspace.clone(), menu_members.clone(), menu, window, cx))
            .into_any_element()
    }

    /// A project as the group menus list it: `{env} — {path}` or `{path}`.
    fn member_label(&self, ix: usize) -> String {
        let project = &self.projects[ix];
        match &project.environment {
            Some(env) => format!("{} — {}", self.environment_name(env), display_path(&project.path)),
            None => display_path(&project.path),
        }
    }

    fn render_legacy_row(&self, project_ix: usize, session: &Entity<Session>, jump: Option<usize>, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let state = session.read(cx);
        let id = session.entity_id();
        let key = id.as_u64() as usize;
        let active = self.active.as_ref() == Some(session);
        let selected = self.selection.contains(&id);
        let group = SharedString::from(format!("legacy-row-{id}"));
        let status = top_status(state).and_then(|status| match status {
            TopStatus::Approval => Some((Led::Waiting, false)),
            TopStatus::Input => Some((Led::Waiting, true)),
            TopStatus::Working => Some((Led::Live, true)),
            TopStatus::Done => Some((Led::Live, false)),
            TopStatus::Failed => Some((Led::Failed, false)),
            _ => None,
        });
        let in_worktree = state.worktree.is_some();
        let tasks = state.background_tasks.len();
        let pull_request = state.pull_request.as_ref().map(|pr| (pr.number, pr.state.clone()));
        let time = relative_time_long(state.last_message.max(state.created));
        let armed = self.archive_armed.is_some_and(|(armed, at)| armed == id && at.elapsed() < super::super::SETTLE_ARMED);
        let running = state.is_working();
        let archive_session = session.clone();
        let archive: AnyElement = if armed {
            MetalButton::new(("legacy-archive-confirm", key))
                .danger()
                .mini()
                .label("Confirm")
                .on_click(cx.listener(move |this, _, window, cx| {
                    cx.stop_propagation();
                    this.archive_armed = None;
                    this.archive_now(archive_session.clone(), window, cx);
                }))
                .into_any_element()
        } else {
            MetalButton::new(("legacy-archive", key))
                .ghost()
                .mini()
                .icon(Icon::new(Lucide::Archive).size(px(14.)))
                .tooltip("Archive")
                .disabled(running)
                .on_click(cx.listener(move |this, _, window, cx| {
                    cx.stop_propagation();
                    if settings::get(|settings| settings.confirm_archive) {
                        this.archive_armed = Some((archive_session.entity_id(), std::time::Instant::now()));
                        cx.notify();
                    } else {
                        this.archive_now(archive_session.clone(), window, cx);
                    }
                }))
                .into_any_element()
        };
        let show_hints = self.jump_hints;
        let surface = h_flex()
            .id(("legacy-thread", key))
            .group(group.clone())
            .relative()
            .flex_none()
            .h(px(32.))
            .pl(px(30.))
            .pr(px(8.))
            .gap(px(6.))
            .rounded(px(10.))
            .cursor_pointer()
            .map(|this| {
                if active {
                    this.raised(p)
                } else if selected {
                    this.bg(metal::vertical(p.pressed)).shadow(p.pressed_sh.clone())
                } else {
                    this.hover(|style| style.bg(p.row_hover))
                }
            })
            .tooltip(self.hover_card(project_ix, session, cx))
            .on_click({
                let session = session.clone();
                cx.listener(move |this, event: &ClickEvent, window, cx| this.click_row(project_ix, session.clone(), event, window, cx))
            })
            .when_some(pull_request, |this, (number, state)| this.child(super::pr_badge(number, &state, p)))
            .when_some(status, |this, (led, pulse)| this.child(metal::led(("legacy-status", key), led, pulse, p)))
            .child(self.row_title(session, 14., FontWeight::NORMAL, if active { p.ink } else { p.ink2 }, cx))
            .when(in_worktree, |this| this.child(Icon::new(Lucide::FolderGit2).size(px(12.)).text_color(p.ink3)))
            .when(tasks > 0, |this| this.child(super::terminal_icon(key, tasks, p)))
            .child({
                let rest = div()
                    .when(!(show_hints && jump.is_some()), |this| {
                        this.font_family(metal::MONO).text_size(px(10.)).text_color(p.ink3).child(time.clone())
                    })
                    .when_some(jump.filter(|_| show_hints), |this, ix| {
                        this.child(metal::keycap(format!("{JUMP_MODIFIER}+{}", ix + 1), p))
                    });
                h_flex().flex_none().justify_end().min_w(px(40.)).child(if armed {
                    archive
                } else {
                    super::hover_swap(group.clone(), rest, archive, None).into_any_element()
                })
            });
        surface.context_menu(self.row_menu(session, cx)).into_any_element()
    }

    /// Archives at once, past any confirmation.
    fn archive_now(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
        self.leave_thread(&session, window, cx);
        session.update(cx, |session, cx| {
            session.settle(cx);
            session.set_archived(true, cx);
        });
        self.push_undo(
            super::super::actions::UndoEntry { kind: super::super::actions::UndoKind::Archived, session, draft: None },
            cx,
        );
    }

    // ---- menus --------------------------------------------------------------

    /// The legacy sidebar's thread menu.
    pub(in super::super) fn legacy_thread_menu(
        workspace: WeakEntity<Self>,
        session: Entity<Session>,
        mut menu: PopupMenu,
        _: &mut Window,
        cx: &mut Context<PopupMenu>,
    ) -> PopupMenu {
        let Some(this) = workspace.upgrade() else { return menu };
        let (branch, project_ix) = {
            let this = this.read(cx);
            (this.thread_branch(&session, cx), this.project_of(&session))
        };
        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));
            }
        };
        menu = menu.min_w(px(200.));
        if let Some(branch) = branch {
            menu = menu.item(
                PopupMenuItem::new(format!("New thread on {branch}"))
                    .on_click(act(|this, session, window, cx| this.new_thread_on_branch(session, window, cx))),
            );
        }
        menu = menu
            .item(PopupMenuItem::new("Rename thread").on_click(act(|this, session, window, cx| this.start_rename(session, true, window, cx))))
            .item(PopupMenuItem::new("Mark unread").on_click(act(|this, session, _, cx| this.mark_unread(vec![session], cx))))
            .item(PopupMenuItem::new("Copy Path").on_click({
                let workspace = workspace.clone();
                let session = session.clone();
                move |_, window, cx| {
                    if let Some(this) = workspace.upgrade() {
                        let path = this.read(cx).thread_path(&session, cx);
                        if let Some(path) = path {
                            copy(path.display().to_string(), "Path copied", window, cx);
                        }
                    }
                }
            }))
            .item(PopupMenuItem::new("Copy Thread ID").on_click({
                let session = session.clone();
                move |_, window, cx| Workspace::copy_thread_id(&session, window, cx)
            }));
        if let Some(project_ix) = project_ix {
            let workspace = workspace.clone();
            menu = menu.item(PopupMenuItem::new("Project settings").on_click(move |_, window, cx| {
                let _ = workspace.update(cx, |this, cx| this.open_project_settings(project_ix, window, cx));
            }));
        }
        menu.item(
            super::super::actions::destructive_item("Delete", Lucide::Trash, cx)
                .on_click(act(|this, session, window, cx| this.delete_threads(vec![session], window, cx))),
        )
    }

    /// The legacy sidebar's menu for several picked threads.
    pub(in super::super) fn legacy_multi_select_menu(
        workspace: WeakEntity<Self>,
        sessions: Vec<Entity<Session>>,
        menu: PopupMenu,
        _: &mut Window,
        cx: &mut Context<PopupMenu>,
    ) -> PopupMenu {
        let count = sessions.len();
        let running = sessions.iter().any(|session| session.read(cx).is_working());
        let run = |action: fn(&mut Workspace, Vec<Entity<Session>>, &mut Window, &mut Context<Workspace>)| {
            let workspace = workspace.clone();
            let sessions = sessions.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.min_w(px(160.))
            .item(PopupMenuItem::new(format!("Mark unread ({count})")).on_click(run(|this, sessions, _, cx| this.mark_unread(sessions, cx))))
            .item(
                PopupMenuItem::new(format!("Archive ({count})"))
                    .disabled(running)
                    .on_click(run(|this, sessions, window, cx| this.archive_threads(sessions, window, cx))),
            )
            .item(
                super::super::actions::destructive_item(format!("Delete ({count})"), None::<Lucide>, cx)
                    .on_click(run(|this, sessions, window, cx| this.delete_threads(sessions, window, cx))),
            )
    }

    /// Archives several threads, asking first when Archive confirmation is on.
    fn archive_threads(&mut self, sessions: Vec<Entity<Session>>, window: &mut Window, cx: &mut Context<Self>) {
        let count = sessions.len();
        let workspace = cx.weak_entity();
        let archive = move |window: &mut Window, cx: &mut App| {
            let sessions = sessions.clone();
            let _ = workspace.update(cx, |this, cx| {
                for session in sessions {
                    this.archive_now(session, window, cx);
                }
                this.selection.clear();
            });
        };
        if settings::get(|settings| settings.confirm_archive) {
            confirm(&format!("Archive {count} thread{}?", if count == 1 { "" } else { "s" }), false, window, cx, archive);
        } else {
            archive(window, cx);
        }
    }

    /// The project menu: Rename, Group into..., Copy Path, Project settings
    /// and Remove, each a submenu of members when projects share the row.
    fn project_menu(
        workspace: WeakEntity<Self>,
        members: Vec<usize>,
        mut menu: PopupMenu,
        window: &mut Window,
        cx: &mut Context<PopupMenu>,
    ) -> PopupMenu {
        let Some(this) = workspace.upgrade() else { return menu };
        let labels: Vec<(usize, String, bool)> = {
            let this = this.read(cx);
            members.iter().map(|&ix| (ix, this.member_label(ix), this.projects[ix].environment.is_some())).collect()
        };
        let targeted = Targeted { workspace: workspace.clone(), labels };
        menu = menu.min_w(px(200.));
        menu = targeted.add(menu, "Rename", |this, ix, window, cx| this.rename_project_dialog(ix, window, cx), false, true, window, cx);
        menu = targeted.add(menu, "Group into...", |this, ix, window, cx| this.grouping_dialog(ix, window, cx), false, true, window, cx);
        menu = targeted.add(
            menu,
            "Copy Path",
            |this, ix, window, cx| copy(this.projects[ix].path.display().to_string(), "Path copied", window, cx),
            false,
            false,
            window,
            cx,
        );
        let first = members[0];
        let settings_workspace = workspace.clone();
        menu = menu.item(PopupMenuItem::new("Project settings").icon(Icon::new(IconName::Settings)).on_click(move |_, window, cx| {
            let _ = settings_workspace.update(cx, |this, cx| this.open_project_settings(first, window, cx));
        }));
        targeted.add(menu, "Remove", |this, ix, window, cx| this.request_remove_project(ix, window, cx), true, true, window, cx)
    }

    // ---- project dialogs -------------------------------------------------------

    /// T3's Rename project dialog.
    fn rename_project_dialog(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
        let project = &self.projects[ix];
        let key = self.project_key(ix);
        let path = display_path(&project.path);
        let environment = project.environment.as_deref().map(|env| self.environment_name(env));
        let input = cx.new(|cx| InputState::new(window, cx).default_value(project.name.to_string()));
        input.update(cx, |input, cx| input.focus(window, cx));
        let workspace = cx.weak_entity();
        let save = {
            let input = input.clone();
            move |window: &mut Window, cx: &mut App| {
                let title = input.read(cx).value().trim().to_string();
                if !title.is_empty() {
                    let key = key.clone();
                    let _ = workspace.update(cx, |this, cx| {
                        if let Some(ix) = this.project_by_key(&key) {
                            let project = &mut this.projects[ix];
                            project.initials = super::super::initials(&title).into();
                            project.name = title.into();
                            cx.notify();
                        }
                    });
                }
                window.close_dialog(cx);
            }
        };
        let on_enter = save.clone();
        let subscription = cx.subscribe_in(&input, window, move |_, _, event: &InputEvent, window, cx| {
            if matches!(event, InputEvent::PressEnter { .. }) {
                on_enter(window, cx);
            }
        });
        self._subscriptions.push(subscription);
        let save = Rc::new(save);
        window.open_dialog(cx, move |dialog, _, cx| {
            let p = metal::palette(cx);
            let save = save.clone();
            dialog
                .w(px(512.))
                .title(dialog_title("Rename project", format!("Update the title for {path}."), p))
                .child(
                    v_flex()
                        .gap(px(6.))
                        .child(div().text_size(px(12.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child("Project title"))
                        .child(
                            h_flex()
                                .h(px(34.))
                                .px(px(10.))
                                .rounded(px(12.))
                                .well(p)
                                .text_size(px(13.5))
                                .child(div().flex_1().child(Input::new(&input).appearance(false))),
                        )
                        .when_some(environment.clone(), |this, environment| {
                            this.child(div().text_size(px(12.)).text_color(p.ink3).child(format!("Environment: {environment}")))
                        }),
                )
                .footer(dialog_footer("rename-project", "Save", move |window, cx| save(window, cx)))
        });
    }

    /// T3's Project grouping dialog.
    fn grouping_dialog(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
        let path_key = self.projects[ix].path.display().to_string();
        let path = display_path(&self.projects[ix].path);
        let (default, current) = settings::get(|settings| {
            (settings.project_grouping, settings.project_grouping_overrides.get(&path_key).copied())
        });
        let choice = Rc::new(std::cell::Cell::new(current));
        let workspace = cx.weak_entity();
        window.open_dialog(cx, move |dialog, _, cx| {
            let p = metal::palette(cx);
            let selected = choice.get();
            let label = match selected {
                None => format!("Use global default ({})", default.label()),
                Some(mode) => mode.label().to_string(),
            };
            let description = selected.unwrap_or(default).description();
            let pick = choice.clone();
            let save_choice = choice.clone();
            let save_path = path_key.clone();
            let workspace = workspace.clone();
            dialog
                .w(px(512.))
                .title(dialog_title("Project grouping", format!("Choose how {path} should be grouped in the sidebar."), p))
                .child(
                    v_flex()
                        .gap(px(6.))
                        .child(div().text_size(px(12.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child("Grouping rule"))
                        .child(
                            MetalButton::new("grouping-rule")
                                .compact()
                                .w_full()
                                .label(label)
                                .stretch()
                                .caret()
                                .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
                                    let option = |text: String, value: Option<ProjectGrouping>| {
                                        let pick = pick.clone();
                                        PopupMenuItem::new(text).on_click(move |_, window, cx| {
                                            pick.set(value);
                                            window.refresh();
                                            let _ = cx;
                                        })
                                    };
                                    menu = menu.min_w(px(260.)).item(option("Use global default".into(), None));
                                    for mode in ProjectGrouping::ALL {
                                        menu = menu.item(option(mode.label().into(), Some(mode)));
                                    }
                                    menu
                                }),
                        )
                        .child(div().text_size(px(12.)).text_color(p.ink3).child(description)),
                )
                .footer(dialog_footer("project-grouping", "Save", move |window, cx| {
                    let value = save_choice.get();
                    let path = save_path.clone();
                    settings::update(|settings| match value {
                        Some(mode) => {
                            settings.project_grouping_overrides.insert(path, mode);
                        }
                        None => {
                            settings.project_grouping_overrides.remove(&path);
                        }
                    });
                    let _ = workspace.update(cx, |_, cx| cx.notify());
                    window.close_dialog(cx);
                }))
        });
    }

    /// Removes a project: at once (after confirming) when it has no threads,
    /// otherwise only after deleting them, as T3 asks.
    fn request_remove_project(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
        let project = &self.projects[ix];
        let title = project.name.clone();
        let key = self.project_key(ix);
        let threads = project.sessions.iter().filter(|session| !session.read(cx).is_blank()).count();
        let lines = format!(
            "Path: {}\nEnvironment: {}",
            display_path(&project.path),
            project.environment.as_deref().map(|env| self.environment_name(env)).unwrap_or_else(|| "Local".into())
        );
        let workspace = cx.weak_entity();
        if threads == 0 {
            let message = format!(
                "Remove project \"{title}\"?\n{lines}\nThis permanently clears any archived conversation history.\nThis removes only this project entry."
            );
            confirm(&message, true, window, cx, move |window, cx| {
                let key = key.clone();
                let _ = workspace.update(cx, |this, cx| this.remove_project(&key, window, cx));
            });
            return;
        }
        let message = format!(
            "Remove project \"{title}\" and delete its {threads} thread{}?\n{lines}\nThis permanently clears conversation history for those threads and any archived threads.\nThis removes only this project entry.\nThis action cannot be undone.",
            if threads == 1 { "" } else { "s" }
        );
        let message: SharedString = message.into();
        window.push_notification(
            Notification::warning("Delete all threads in this project before removing it.")
                .title("Project is not empty")
                .action(move |_, _, _| {
                    let workspace = workspace.clone();
                    let key = key.clone();
                    let message = message.clone();
                    Button::new("delete-anyway").label("Delete anyway").danger().on_click(move |_, window, cx| {
                        let workspace = workspace.clone();
                        let key = key.clone();
                        confirm(&message, true, window, cx, move |window, cx| {
                            let key = key.clone();
                            let _ = workspace.update(cx, |this, cx| this.remove_project(&key, window, cx));
                        });
                    })
                }),
            cx,
        );
    }

    /// Deletes a project's threads and takes it off the list.
    fn remove_project(&mut self, key: &super::super::ProjectKey, window: &mut Window, cx: &mut Context<Self>) {
        let Some(ix) = self.project_by_key(key) else { return };
        for session in self.projects[ix].sessions.clone() {
            self.forget_thread(session, window, cx);
        }
        let Some(ix) = self.project_by_key(key) else { return };
        self.projects.remove(ix);
        if self.project_scope.as_ref() == Some(key) {
            self.project_scope = None;
        }
        self.active_project = self
            .active
            .clone()
            .and_then(|active| self.project_of(&active))
            .unwrap_or(0)
            .min(self.projects.len().saturating_sub(1));
        cx.notify();
    }
}

type ProjectRun = fn(&mut Workspace, usize, &mut Window, &mut Context<Workspace>);

/// Builds the project menu's items that act on one project: a plain item
/// when the row is one project, a submenu of its projects otherwise.
struct Targeted {
    workspace: WeakEntity<Workspace>,
    /// Each project: its index, its `{env} — {path}` label, and whether it's remote.
    labels: Vec<(usize, String, bool)>,
}

impl Targeted {
    #[allow(clippy::too_many_arguments)]
    fn add(
        &self,
        menu: PopupMenu,
        label: &'static str,
        run: ProjectRun,
        destructive: bool,
        local_only: bool,
        window: &mut Window,
        cx: &mut Context<PopupMenu>,
    ) -> PopupMenu {
        let workspace = self.workspace.clone();
        let leaf = move |text: SharedString, ix: usize, disabled: bool, icon: bool, cx: &App| {
            let workspace = workspace.clone();
            let item = if destructive {
                super::super::actions::destructive_item(text, icon.then_some(Lucide::Trash), cx)
            } else {
                PopupMenuItem::new(text)
            };
            item.disabled(disabled).on_click(move |_, window, cx| {
                let _ = workspace.update(cx, |this, cx| run(this, ix, window, cx));
            })
        };
        if let [(ix, _, remote)] = self.labels.as_slice() {
            return menu.item(leaf(label.into(), *ix, local_only && *remote, true, cx));
        }
        let labels = self.labels.clone();
        let icon = destructive.then(|| Icon::new(Lucide::Trash));
        menu.submenu_with_icon(icon, label, window, cx, move |mut menu, _, cx| {
            for (ix, text, remote) in labels.clone() {
                menu = menu.item(leaf(text.into(), ix, local_only && remote, false, cx));
            }
            menu
        })
    }
}

/// A dialog's title with its description beneath, as T3's dialog headers.
fn dialog_title(title: &'static str, description: String, p: &metal::Palette) -> Div {
    v_flex()
        .gap(px(4.))
        .child(div().text_size(px(16.)).font_weight(FontWeight::SEMIBOLD).text_color(p.ink).child(title))
        .child(div().text_size(px(13.)).font_weight(FontWeight::NORMAL).text_color(p.ink3).child(description))
}

/// Cancel and the dialog's action, right-aligned.
fn dialog_footer(id: &'static str, action: &'static str, on_action: impl Fn(&mut Window, &mut App) + 'static) -> Div {
    h_flex()
        .w_full()
        .justify_end()
        .gap(px(8.))
        .child(
            MetalButton::new(SharedString::from(format!("{id}-cancel")))
                .compact()
                .label("Cancel")
                .on_click(|_, window, cx| window.close_dialog(cx)),
        )
        .child(
            MetalButton::new(SharedString::from(format!("{id}-save")))
                .primary()
                .compact()
                .label(action)
                .on_click(move |_, window, cx| on_action(window, cx)),
        )
}