//! 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 = 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> { 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)> = 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 { let mut threads: Vec = 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, 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 { 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) -> 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, menu: PopupMenu, _: &mut Window, cx: &mut Context, ) -> 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| { 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) -> impl IntoElement { let p = metal::palette(cx); let groups = self.project_groups(cx); let rows = self.visible_rows(cx); let mut children: Vec = 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 { 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 = 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) -> 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 = 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, jump: Option, cx: &mut Context) -> 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, window: &mut Window, cx: &mut Context) { 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, session: Entity, mut menu: PopupMenu, _: &mut Window, cx: &mut Context, ) -> 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, &mut Window, &mut Context)| { let workspace = workspace.clone(); let session = session.clone(); move |_: &ClickEvent, window: &mut Window, cx: &mut App| { let _ = workspace.update(cx, |this, cx| run(this, session.clone(), window, cx)); } }; 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, sessions: Vec>, menu: PopupMenu, _: &mut Window, cx: &mut Context, ) -> PopupMenu { let count = sessions.len(); let running = sessions.iter().any(|session| session.read(cx).is_working()); let run = |action: fn(&mut Workspace, Vec>, &mut Window, &mut Context)| { 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::, 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>, window: &mut Window, cx: &mut Context) { 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, members: Vec, mut menu: PopupMenu, window: &mut Window, cx: &mut Context, ) -> 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) { 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) { 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| { 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) { 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) { 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); /// 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, /// 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 { 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)), ) }