//! 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);
!state.archived && (!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)),
)
}