use crate::{
model::{session::ChatSession, settings::Features, update},
view::{
article::TogglePlainText,
component::{
menu::{self, Menu},
transcript,
},
keymap::{self, Command},
root::{self, CommitName, Cydonia, DismissName, NewSession, OpenProject, Pane},
settings::Section,
},
};
use bezel::ui::scroll as scrollbars;
use bezel::{
agent::orbs::{OrbState, engine::Frame},
gpui::{
self, AnyElement, App, Bounds, Context, Div, Empty, Entity, Focusable as _, FontWeight,
Hsla, MouseButton, Pixels, Point, ScrollStrategy, SharedString, Stateful,
UniformListDecoration, Window, div, prelude::*, px, uniform_list,
},
theme::{TextStyle, Theme, Typeset},
ui::{
icons::{self, Icon},
menu::Item,
popover,
surface::Surfaced as _,
tooltip::Tooltip,
widgets::{Buttons, Content, Layout},
},
};
use std::{cell::RefCell, cmp::Reverse, ops::Range, rc::Rc, time::Duration};
struct SessionRow {
project: usize,
id: u64,
label: String,
icon: Option<Icon>,
working: Option<Working>,
archived: bool,
}
struct Working {
state: OrbState,
since: Duration,
frame: Rc<RefCell<Frame>>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Row {
Project(usize),
Archive(usize),
Session {
project: usize,
id: u64,
},
Board {
project: usize,
ix: usize,
},
Article {
project: usize,
ix: usize,
},
Table {
project: usize,
ix: usize,
},
}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum Filter {
#[default]
All,
Sessions,
Boards,
Articles,
Tables,
}
impl Filter {
fn every(features: &Features) -> Vec<Self> {
let mut every = vec![Self::All];
if features.sessions {
every.push(Self::Sessions);
}
if features.boards {
every.push(Self::Boards);
}
every.push(Self::Articles);
if features.tables {
every.push(Self::Tables);
}
every
}
fn resolved(self, features: &Features) -> Self {
match self {
Self::Sessions if !features.sessions => Self::All,
Self::Boards if !features.boards => Self::All,
Self::Tables if !features.tables => Self::All,
filter => filter,
}
}
fn label(self) -> &'static str {
match self {
Self::All => "All",
Self::Sessions => "Sessions",
Self::Boards => "Boards",
Self::Articles => "Articles",
Self::Tables => "Tables",
}
}
fn icon(self) -> &'static [u8] {
match self {
Self::All => icons::text::ListFilter,
Self::Sessions => icons::social::MessageCircle,
Self::Boards => icons::development::SquareKanban,
Self::Articles => icons::files::FileText,
Self::Tables => icons::files::Table2,
}
}
fn keeps(self, row: Row) -> bool {
matches!(
(self, row),
(Self::All, _)
| (Self::Sessions, Row::Session { .. })
| (Self::Boards, Row::Board { .. })
| (Self::Articles, Row::Article { .. })
| (Self::Tables, Row::Table { .. })
)
}
}
fn shown(row: Row, features: &Features) -> bool {
match row {
Row::Session { .. } => features.sessions,
Row::Board { .. } => features.boards,
Row::Table { .. } => features.tables,
Row::Project(_) | Row::Archive(_) | Row::Article { .. } => true,
}
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) enum Renaming {
Session(u64),
Table(String),
Column(String),
}
pub(crate) fn tint(selected: bool, archived: bool, theme: &Theme) -> Hsla {
match (selected, archived) {
(true, _) => theme.text,
(false, true) => theme.text_faint,
(false, false) => theme.text_muted,
}
}
#[derive(Clone)]
pub(crate) struct ProjectDrag(usize);
struct Carried(SharedString);
impl Render for Carried {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::of(cx).clone();
popover::popover_card(&theme)
.px(px(10.))
.py(px(4.))
.text_style(TextStyle::Callout)
.text_color(theme.text)
.child(self.0.clone())
}
}
fn key_of(entry: Row) -> String {
match entry {
Row::Project(ix) => format!("project-{ix}"),
Row::Archive(ix) => format!("archive-{ix}"),
Row::Session { project, id } => format!("session-{project}-{id}"),
Row::Board { project, ix } => format!("board-{project}-{ix}"),
Row::Article { project, ix } => format!("article-{project}-{ix}"),
Row::Table { project, ix } => format!("table-{project}-{ix}"),
}
}
const ROW_PILL: f32 = 30.;
pub(crate) const ROW_HEIGHT: f32 = ROW_PILL + 2.;
const PINNED_BLEED: f32 = 20.;
pub(crate) fn row(
id: impl Into<gpui::ElementId>,
group: &'static str,
selected: bool,
indent: bool,
theme: &Theme,
) -> Stateful<Div> {
div()
.id(id)
.group(group)
.h(px(ROW_PILL))
.ml(px(root::SIDEBAR_GUTTER))
.mr(px(root::SIDEBAR_GUTTER))
.px(px(root::SIDEBAR_GUTTER))
.when(indent, |el| el.pl(px(root::SIDEBAR_GUTTER + 14.)))
.flex()
.flex_row()
.items_center()
.gap(px(8.))
.rounded(px(Theme::control_radius()))
.cursor_pointer()
.when(selected, |el| el.bg(theme.element_active))
.when(!selected, |el| el.hover(|el| el.bg(theme.element_hover)))
}
fn row_label(name: String, tint: Hsla) -> AnyElement {
div()
.flex_1()
.min_w_0()
.truncate()
.text_style(TextStyle::Body)
.line_height(px(18.))
.text_color(tint)
.child(name)
.into_any_element()
}
struct PinnedHead(Entity<Cydonia>);
impl UniformListDecoration for PinnedHead {
fn compute(
&self,
visible: Range<usize>,
_bounds: Bounds<Pixels>,
scroll: Point<Pixels>,
item_height: Pixels,
_count: usize,
_window: &mut Window,
cx: &mut App,
) -> AnyElement {
self.0.update(cx, |this, cx| {
this.pinned_head(visible.start, scroll.y, item_height, cx)
})
}
}
impl Cydonia {
fn sidebar_hover(&mut self, menu: Menu, hovered: bool, cx: &mut Context<Self>) {
if hovered {
if self.sidebar_hovered.as_ref() == Some(&menu) {
return;
}
self.sidebar_hovered = Some(menu);
} else if self.sidebar_hovered.as_ref() == Some(&menu) {
self.sidebar_hovered = None;
} else {
return;
}
cx.notify();
}
pub(crate) fn sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement + use<> {
let theme = Theme::of(cx).clone();
let shortcuts = &self.workspace.read(cx).settings.shortcuts;
let settings_chord = keymap::label(Command::OpenSettings, shortcuts);
let open_chord = keymap::label(Command::OpenProject, shortcuts);
let rows = self.rows(cx);
let count = rows.len();
div()
.flex_none()
.w(px(self.sidebar_width))
.h_full()
.bg(root::sidebar_bg(&theme))
.border_r_1()
.border_color(theme.border)
.flex()
.flex_col()
.child(
div()
.flex_none()
.h(px(root::HEADER_HEIGHT))
.pr(px(8.))
.flex()
.flex_row()
.items_center()
.justify_end()
.child(self.fold_toggle(theme.text_faint, cx)),
)
.child(
div()
.relative()
.flex_1()
.min_h_0()
.child(
uniform_list(
"project-list",
count,
cx.processor(move |this, range: Range<usize>, _, cx| {
range.map(|ix| this.sidebar_row(rows[ix], cx)).collect()
}),
)
.track_scroll(&self.rail)
.with_decoration(PinnedHead(cx.entity()))
.size_full(),
)
.child(
scrollbars::Overlay::new(
"sidebar-bar",
&self.rail.0.borrow().base_handle,
bezel::gpui::Axis::Vertical,
)
.visibility(
self.workspace
.read(cx)
.settings
.appearance
.sidebar_scrollbars
.into(),
),
),
)
.children(self.restart_notice(cx))
.child(
div()
.flex_none()
.mx(px(8.))
.mb(px(8.))
.flex()
.flex_row()
.items_center()
.justify_between()
.child(
theme
.ghost("settings")
.px(px(8.))
.py(px(6.))
.tooltip(move |window, cx| match settings_chord.clone() {
Some(chord) => {
Tooltip::with_keystroke("Settings", chord, window, cx)
}
None => Tooltip::text("Settings", window, cx),
})
.child(
icons::icon(icons::account::Settings)
.size(px(13.))
.text_color(theme.text_faint),
)
.on_click(cx.listener(|this, _, _, cx| {
this.open_settings(Section::General, cx)
})),
)
.child(
div()
.flex()
.flex_row()
.items_center()
.gap(px(2.))
.child(self.filter_button(cx))
.child(
theme
.ghost("open-project")
.px(px(8.))
.py(px(6.))
.tooltip(move |window, cx| match open_chord.clone() {
Some(chord) => Tooltip::with_keystroke(
"Open project",
chord,
window,
cx,
),
None => Tooltip::text("Open project", window, cx),
})
.child(
icons::icon(icons::files::FolderPlus)
.size(px(13.))
.text_color(theme.text_faint),
)
.on_click(cx.listener(|this, _, window, cx| {
this.open_project_action(&OpenProject, window, cx);
})),
),
),
)
}
fn restart_notice(&self, cx: &mut Context<Self>) -> Option<impl IntoElement + use<>> {
let updater = update::of(cx)?;
let version = updater.read(cx).ready()?;
let theme = Theme::of(cx).clone();
let ready = SharedString::from(format!("cydonia {version} is ready"));
Some(
theme
.ghost("restart-to-update")
.flex_none()
.mx(px(8.))
.mb(px(8.))
.px(px(8.))
.py(px(6.))
.gap(px(6.))
.tooltip(move |window, cx| Tooltip::text(ready.clone(), window, cx))
.child(
icons::icon(icons::development::CircleFadingArrowUp)
.size(px(13.))
.flex_none()
.text_color(theme.accent),
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_style(TextStyle::Footnote)
.child("Restart to update"),
)
.child(theme.badge(version))
.on_click(cx.listener(move |_, _, _, cx| {
updater.update(cx, |updater, cx| updater.restart(cx));
})),
)
}
pub(crate) fn fold_toggle(
&self,
tint: Hsla,
cx: &mut Context<Self>,
) -> impl IntoElement + use<> {
let theme = Theme::of(cx).clone();
let label = if self.sidebar_open {
"Hide sidebar"
} else {
"Show sidebar"
};
theme
.ghost("toggle-sidebar")
.p(px(4.))
.tooltip(move |window, cx| Tooltip::text(label, window, cx))
.child(
icons::icon(if self.sidebar_open {
icons::layout::PanelLeftClose
} else {
icons::layout::PanelLeftOpen
})
.size(px(14.))
.text_color(tint),
)
.on_click(cx.listener(|this, _, _, cx| this.toggle_sidebar(cx)))
}
fn pinned_head(
&self,
first: usize,
scroll: Pixels,
item_height: Pixels,
cx: &mut Context<Self>,
) -> AnyElement {
let rows = self.rows(cx);
let head = |row: &Row| matches!(row, Row::Project(_));
let Some(at) = rows
.get(..=first)
.and_then(|above| above.iter().rposition(head))
else {
return Empty.into_any_element();
};
let Row::Project(ix) = rows[at] else {
return Empty.into_any_element();
};
let next = rows[at + 1..]
.iter()
.position(head)
.map(|after| item_height * (at + 1 + after) - item_height);
let rest = item_height * at;
let y = next.map_or(-scroll, |limit| (-scroll).min(limit));
if y <= rest {
return Empty.into_any_element();
}
div()
.size_full()
.relative()
.child(
div()
.id("pinned-head")
.absolute()
.top(y)
.left_0()
.w_full()
.h(px(ROW_HEIGHT))
.overflow_hidden()
.child(self.project_head(ix, true, cx)),
)
.into_any_element()
}
fn project_head(&self, ix: usize, pinned: bool, cx: &mut Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
let (name, expanded) = match self.workspace.read(cx).projects.get(ix) {
Some(project) => (project.name(), project.expanded),
None => return Empty.into_any_element(),
};
let carried = SharedString::from(name.clone());
let head = div()
.id(("project", ix))
.group("project-head")
.on_hover(cx.listener(move |this, hovered: &bool, _, cx| {
this.sidebar_hover(Menu::Add(ix), *hovered, cx);
}))
.when(pinned, |el| {
el.absolute()
.top(px(-PINNED_BLEED))
.left_0()
.right_0()
.h(px(ROW_HEIGHT + 2. * PINNED_BLEED))
.px(px(8. + 6.))
})
.when(!pinned, |el| {
el.relative()
.mx(px(8.))
.px(px(6.))
.h(px(ROW_PILL))
.rounded(px(Theme::control_radius()))
})
.flex()
.flex_row()
.items_center()
.gap(px(6.))
.cursor_pointer()
.text_color(theme.text_faint)
.hover(|el| el.text_color(theme.text))
.child(
theme
.ghost(("project-fold", ix))
.flex_none()
.p(px(2.))
.child(
icons::icon(match expanded {
true => icons::files::FolderOpen,
false => icons::files::Folder,
})
.size(px(14.))
.text_color(theme.text_faint)
.group_hover("project-head", |el| el.text_color(theme.text)),
)
.on_click(cx.listener(move |this, _, _, cx| {
cx.stop_propagation();
this.toggle_project(ix, cx);
})),
)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_style(TextStyle::Callout)
.font_weight(FontWeight::MEDIUM)
.child(name),
)
.child(
self.menu_button(
("project-add", ix),
Some("project-head"),
icons::icon(icons::math::Plus)
.size(px(12.))
.text_color(theme.text_faint)
.group_hover("project-head", |el| el.text_color(theme.text)),
Menu::Add(ix),
cx,
)
.children(self.add_menu(ix, cx)),
)
.children(self.project_menu(ix, cx))
.on_mouse_down(
MouseButton::Right,
cx.listener(move |this, _, _, cx| this.toggle_menu(Menu::Project(ix), cx)),
)
.on_click(cx.listener(move |this, _, _, cx| match pinned {
true => this.scroll_to_project(ix, cx),
false => this.toggle_project(ix, cx),
}))
.on_drag(ProjectDrag(ix), move |_, _, _, cx| {
let carried = carried.clone();
cx.new(|_| Carried(carried))
})
.drag_over::<ProjectDrag>(move |style, _, _, cx| style.bg(Theme::of(cx).element_active))
.on_drop(cx.listener(move |this, drag: &ProjectDrag, _, cx| {
this.move_project(drag.0, ix, cx);
}));
let head = self.menu_press(head, Menu::Project(ix), cx);
match pinned {
true => head
.surface(&theme, theme.popover_surface)
.into_any_element(),
false => head.into_any_element(),
}
}
pub(crate) fn entries(&self, project: usize, cx: &App) -> Vec<Row> {
let workspace = self.workspace.read(cx);
let Some(open) = workspace.projects.get(project) else {
return Vec::new();
};
let features = &workspace.settings.features;
let sessions = open.sessions.iter().map(|chat| {
(
chat.closed,
chat.touched(),
Row::Session {
project,
id: chat.id,
},
)
});
let boards = open
.boards
.iter()
.enumerate()
.map(|(ix, board)| (board.archived, board.touched, Row::Board { project, ix }));
let articles = open.articles.iter().enumerate().map(|(ix, article)| {
(
article.archived,
article.touched,
Row::Article { project, ix },
)
});
let tables = open.tables.iter().enumerate().map(|(ix, table)| {
let at = table.updated_at.unwrap_or(table.created_at).max(0) as u128;
(table.archived, at * 1000, Row::Table { project, ix })
});
let mut entries: Vec<(bool, u128, Row)> = sessions
.chain(boards)
.chain(articles)
.chain(tables)
.collect();
let filter = self.filter.resolved(features);
entries.retain(|(_, _, row)| shown(*row, features) && filter.keeps(*row));
entries.sort_by_key(|(archived, touched, _)| (*archived, Reverse(*touched)));
let split = entries.iter().position(|(archived, ..)| *archived);
let mut rows: Vec<Row> = entries
.iter()
.take(split.unwrap_or(entries.len()))
.map(|(_, _, row)| *row)
.collect();
if let Some(split) = split {
rows.push(Row::Archive(project));
if open.archive_open {
rows.extend(entries[split..].iter().map(|(_, _, row)| *row));
}
}
rows
}
pub(crate) fn rows(&self, cx: &Context<Self>) -> Vec<Row> {
let mut rows = Vec::new();
for p in 0..self.workspace.read(cx).projects.len() {
rows.push(Row::Project(p));
if self.workspace.read(cx).projects[p].expanded {
rows.extend(self.entries(p, cx));
}
}
rows
}
pub(crate) fn open_row(&mut self, row: Row, window: &mut Window, cx: &mut Context<Self>) {
match row {
Row::Project(ix) => self.select_project(ix, cx),
Row::Archive(ix) => self.toggle_archive(ix, cx),
Row::Session { id, .. } => self.select_session(id, cx),
Row::Board { project, ix } => self.open_board(project, ix, cx),
Row::Article { project, ix } => self.open_article(project, ix, window, cx),
Row::Table { project, ix } => self.open_table(project, ix, cx),
}
}
fn scroll_to_project(&mut self, ix: usize, cx: &mut Context<Self>) {
let Some(at) = self
.rows(cx)
.iter()
.position(|row| *row == Row::Project(ix))
else {
return;
};
self.rail.scroll_to_item(at, ScrollStrategy::Top);
cx.notify();
}
pub(crate) fn reveal(&mut self, row: Row, cx: &Context<Self>) {
if let Some(ix) = self.rows(cx).iter().position(|at| *at == row) {
self.rail.scroll_to_item(ix, ScrollStrategy::Nearest);
}
}
fn sidebar_row(&self, row: Row, cx: &mut Context<Self>) -> AnyElement {
let workspace = self.workspace.read(cx);
let inner = match row {
Row::Project(ix) => self.project_head(ix, false, cx),
Row::Archive(ix) => self.archive_divider(ix, cx),
Row::Session { project, id } => match self.session_of(project, id, cx) {
Some(session) => self.session_row(session, cx),
None => Empty.into_any_element(),
},
Row::Board { project, ix } => {
match workspace
.projects
.get(project)
.and_then(|open| open.boards.get(ix).map(|board| board.label().to_owned()))
{
Some(name) => self.board_row(project, ix, name, cx),
None => Empty.into_any_element(),
}
}
Row::Article { project, ix } => {
match workspace.projects.get(project).and_then(|open| {
open.articles
.get(ix)
.map(|article| article.label().to_owned())
}) {
Some(title) => self.article_row(project, ix, title, cx).into_any_element(),
None => Empty.into_any_element(),
}
}
Row::Table { project, ix } => {
match workspace
.projects
.get(project)
.and_then(|open| open.tables.get(ix).map(|table| table.name.clone()))
{
Some(name) => self.table_row(project, ix, name, cx).into_any_element(),
None => Empty.into_any_element(),
}
}
};
div()
.id(SharedString::from(format!("sidebar-hover-{}", key_of(row))))
.when(!matches!(row, Row::Project(_) | Row::Archive(_)), |el| {
el.on_hover(cx.listener(move |this, hovered: &bool, _, cx| {
this.sidebar_hover(Menu::Entry(row), *hovered, cx);
}))
})
.h(px(ROW_HEIGHT))
.py(px(1.))
.child(inner)
.into_any_element()
}
fn session_of(&self, project: usize, id: u64, cx: &Context<Self>) -> Option<SessionRow> {
let workspace = self.workspace.read(cx);
let chat = workspace.projects.get(project)?.session(id)?;
Some(SessionRow {
project,
id: chat.id,
label: chat.label(),
icon: workspace.agent_icon(&chat.entry.name),
working: chat.streaming.then(|| Working {
state: transcript::orb_of(chat),
since: chat.elapsed().unwrap_or_default(),
frame: chat.transcript.mark.clone(),
}),
archived: chat.closed,
})
}
fn archive_divider(&self, project: usize, cx: &mut Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
let open = self
.workspace
.read(cx)
.projects
.get(project)
.is_some_and(|open| open.archive_open);
row(
("archive", project),
"archive-row",
false,
self.workspace.read(cx).indent_project_rows,
&theme,
)
.child(theme.disclosure(open).text_color(theme.text_faint))
.child(
div()
.flex_none()
.text_style(TextStyle::Callout)
.text_color(theme.text_faint)
.child("Archived"),
)
.child(div().flex_1().h(px(1.)).bg(theme.border))
.on_click(cx.listener(move |this, _, _, cx| this.toggle_archive(project, cx)))
.into_any_element()
}
fn toggle_archive(&mut self, project: usize, cx: &mut Context<Self>) {
self.commit(cx);
self.workspace.update(cx, |workspace, cx| {
if let Some(open) = workspace.projects.get_mut(project) {
open.archive_open = !open.archive_open;
}
cx.notify();
});
}
fn move_project(&mut self, from: usize, to: usize, cx: &mut Context<Self>) {
self.menu = None;
self.workspace
.update(cx, |workspace, cx| workspace.move_project(from, to, cx));
}
fn toggle_project(&mut self, ix: usize, cx: &mut Context<Self>) {
self.commit(cx);
self.workspace.update(cx, |workspace, cx| {
if let Some(project) = workspace.projects.get_mut(ix) {
project.expanded = !project.expanded;
}
cx.notify();
});
}
fn filter_button(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
let label = self.filter.label();
let trigger = theme
.ghost("filter")
.relative()
.px(px(8.))
.py(px(6.))
.tooltip(move |window, cx| Tooltip::text(format!("Showing {label}"), window, cx))
.child(
icons::icon(icons::text::ListFilter)
.size(px(13.))
.text_color(if self.filter == Filter::All {
theme.text_faint
} else {
theme.accent
}),
)
.on_click(cx.listener(|this, _, _, cx| {
cx.stop_propagation();
this.toggle_menu(Menu::Filter, cx);
}))
.children(self.filter_menu(cx));
self.menu_press(trigger, Menu::Filter, cx)
.into_any_element()
}
fn filter_menu(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
if self.menu != Some(Menu::Filter) {
return None;
}
let features = &self.workspace.read(cx).settings.features;
let rows = Filter::every(features)
.into_iter()
.map(|filter| {
menu::row(
Item::action(filter.label())
.with_icon(filter.icon())
.checked(self.filter == filter),
move |this, _, cx| {
this.filter = filter;
this.menu = None;
cx.notify();
},
)
})
.collect();
Some(popover::anchored_menu_above(
"filter-menu",
self.menu_card("filter-menu", rows, cx),
None,
))
}
fn add_menu(&self, ix: usize, cx: &mut Context<Self>) -> Option<AnyElement> {
if self.menu != Some(Menu::Add(ix)) {
return None;
}
let workspace = self.workspace.read(cx);
let features = &workspace.settings.features;
let (sessions, boards, tables) = (features.sessions, features.boards, features.tables);
let agents: Vec<(String, Option<Icon>)> = workspace
.settings
.agents
.iter()
.map(|entry| (entry.name.clone(), workspace.agent_icon(&entry.name)))
.collect();
let mut rows = Vec::new();
if sessions && agents.len() > 1 {
let picks = agents
.into_iter()
.enumerate()
.map(|(at, (name, icon))| {
let icon = icon.unwrap_or_else(|| icons::social::MessageCircle.into());
menu::row(Item::action(name).with_icon(icon), move |this, _, cx| {
this.select_project(ix, cx);
this.pick_agent(at, cx);
})
})
.collect();
rows.push(menu::submenu(
"New session",
icons::social::MessageCirclePlus,
picks,
));
} else if sessions {
rows.push(menu::row(
Item::action("New session").with_icon(icons::social::MessageCirclePlus),
move |this, window, cx| {
this.select_project(ix, cx);
this.new_session_action(&NewSession, window, cx);
},
));
}
if boards {
rows.push(menu::row(
Item::action("New board").with_icon(icons::development::SquareKanban),
move |this, window, cx| this.ask_new_board(ix, window, cx),
));
}
rows.push(menu::row(
Item::action("New article").with_icon(icons::files::FilePlus),
move |this, window, cx| this.new_article(ix, window, cx),
));
if tables {
rows.push(menu::row(
Item::action("New table").with_icon(icons::files::Table2),
move |this, _, cx| this.new_table(ix, cx),
));
}
let id = SharedString::from(format!("add-menu-{ix}"));
Some(popover::anchored_menu_below(
id.clone(),
self.menu_card(id, rows, cx),
None,
))
}
fn project_menu(&self, ix: usize, cx: &mut Context<Self>) -> Option<AnyElement> {
if self.menu != Some(Menu::Project(ix)) {
return None;
}
let rows = vec![menu::row(
Item::action("Remove project").with_icon(icons::files::FolderMinus),
move |this, _, cx| this.close_project(ix, cx),
)];
let id = SharedString::from(format!("project-menu-{ix}"));
Some(popover::anchored_menu_below(
id.clone(),
self.menu_card(id, rows, cx),
None,
))
}
fn session_row(&self, session: SessionRow, cx: &mut Context<Self>) -> AnyElement {
let entry = Row::Session {
project: session.project,
id: session.id,
};
let theme = Theme::of(cx).clone();
let id = session.id;
let selected =
self.showing(cx) == Some(Pane::Chat) && self.workspace.read(cx).active_id() == Some(id);
let tint = tint(selected, session.archived, &theme);
let mark = if let Some(working) = session.working {
transcript::orb(working.state, working.since, &working.frame, cx)
} else {
match session.icon {
Some(icon) => icons::icon(icon)
.size(px(14.))
.text_color(tint)
.into_any_element(),
None => Empty.into_any_element(),
}
};
let label = match self.renaming == Some(Renaming::Session(id))
&& self.header_renaming(cx).is_none()
{
true => self.name_field(cx),
false => row_label(session.label, tint),
};
row(
("session", id),
"session-row",
selected,
self.workspace.read(cx).indent_project_rows,
&theme,
)
.child(
div()
.flex_none()
.size(px(14.))
.flex()
.items_center()
.justify_center()
.child(mark),
)
.child(label)
.child(
self.menu_button(
("session-menu", id),
Some("session-row"),
icons::icon(icons::layout::Ellipsis)
.size(px(14.))
.text_color(theme.text_faint),
Menu::Entry(entry),
cx,
)
.children(self.entry_menu(Menu::Entry(entry), entry, session.archived, cx)),
)
.on_click(cx.listener(move |this, _, _, cx| {
this.select_session(id, cx);
}))
.into_any_element()
}
fn board_row(
&self,
project: usize,
ix: usize,
name: String,
cx: &mut Context<Self>,
) -> AnyElement {
let theme = Theme::of(cx).clone();
let workspace = self.workspace.read(cx);
let selected = self.showing(cx) == Some(Pane::Board)
&& workspace.active == Some(project)
&& workspace
.projects
.get(project)
.is_some_and(|open| open.board == Some(ix));
let entry = Row::Board { project, ix };
let board = workspace
.projects
.get(project)
.and_then(|open| open.boards.get(ix));
let archived = board.is_some_and(|board| board.archived);
let tint = tint(selected, archived, &theme);
let label = row_label(name, tint);
row(
SharedString::from(format!("board-{project}-{ix}")),
"board-row",
selected,
workspace.indent_project_rows,
&theme,
)
.child(
icons::icon(icons::development::SquareKanban)
.size(px(14.))
.flex_none()
.text_color(tint),
)
.child(label)
.child(
self.menu_button(
SharedString::from(format!("board-menu-{project}-{ix}")),
Some("board-row"),
icons::icon(icons::layout::Ellipsis)
.size(px(14.))
.text_color(theme.text_faint),
Menu::Entry(entry),
cx,
)
.children(self.entry_menu(Menu::Entry(entry), entry, archived, cx)),
)
.on_click(cx.listener(move |this, _, _, cx| this.open_board(project, ix, cx)))
.into_any_element()
}
pub(crate) fn entry_menu(
&self,
at: Menu,
entry: Row,
archived: bool,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
if self.menu.as_ref() != Some(&at) {
return None;
}
let put = match archived {
true => Item::action("Unarchive").with_icon(icons::files::ArchiveRestore),
false => Item::action("Archive").with_icon(icons::files::Archive),
};
let header = at == Menu::Header;
let named = match entry {
Row::Article { .. } => false,
Row::Board { .. } => !header,
_ => true,
};
let mut rows = vec![menu::row(put, move |this, _, cx| {
this.archive_entry(entry, !archived, cx)
})];
if named {
rows.insert(
0,
menu::row(
Item::action("Rename").with_icon(icons::text::SquarePen),
move |this, window, cx| this.rename_entry(entry, window, cx),
),
);
}
if header && matches!(entry, Row::Article { .. }) {
let workspace = self.workspace.read(cx);
let plain_chord = keymap::label(Command::PlainText, &workspace.settings.shortcuts)
.unwrap_or_default();
let held = workspace
.active_article()
.and_then(|article| article.full_width);
let wide = held.unwrap_or(workspace.wide_pages);
if held.is_some() {
rows.insert(
0,
menu::row(
Item::action("Use default width").with_icon(icons::layout::Columns2),
move |this, _, cx| this.set_full_width(None, cx),
),
);
}
rows.insert(
0,
menu::row(
Item::action("Full width")
.with_icon(icons::layout::UnfoldHorizontal)
.checked(wide),
move |this, _, cx| this.set_full_width(Some(!wide), cx),
),
);
rows.insert(
0,
menu::row(
Item::action("Plain text")
.with_icon(icons::text::Code)
.with_keystroke(plain_chord)
.checked(self.plain_text(cx).unwrap_or_default()),
move |this, window, cx| this.toggle_plain_text(&TogglePlainText, window, cx),
),
);
}
if header {
rows.push(menu::row(
Item::action("Delete").with_icon(icons::files::Trash),
move |this, _, cx| this.ask_delete(entry, cx),
));
}
let id = match header {
true => SharedString::from("header-menu-card"),
false => SharedString::from(format!("entry-menu-{}", key_of(entry))),
};
Some(popover::anchored_menu_below(
id.clone(),
self.menu_card(id, rows, cx),
None,
))
}
pub(crate) fn delete_entry(&mut self, entry: Row, window: &mut Window, cx: &mut Context<Self>) {
let landing_project = match entry {
Row::Session { project, id }
if self.showing(cx) == Some(Pane::Chat)
&& self.workspace.read(cx).active == Some(project)
&& self.workspace.read(cx).active_id() == Some(id) =>
{
Some(project)
}
_ => None,
};
self.commit(cx);
self.workspace.update(cx, |workspace, cx| match entry {
Row::Session { id, .. } => workspace.close_session(id, cx),
Row::Board { project, ix } => workspace.delete_board(project, ix, cx),
Row::Article { project, ix } => workspace.delete_article(project, ix, cx),
Row::Table { project, ix } => workspace.delete_table(project, ix, cx),
Row::Project(_) | Row::Archive(_) => {}
});
if let Some(project) = landing_project {
if let Some(landing) = self
.entries(project, cx)
.into_iter()
.find(|row| !matches!(row, Row::Archive(_)))
{
self.open_row(landing, window, cx);
self.reveal(landing, cx);
}
}
cx.notify();
}
fn rename_entry(&mut self, entry: Row, window: &mut Window, cx: &mut Context<Self>) {
if let Row::Board { project, ix } = entry {
let id = self
.workspace
.read(cx)
.projects
.get(project)
.and_then(|open| open.boards.get(ix))
.map(|board| board.id.clone());
if let Some(id) = id {
self.open_board(project, ix, cx);
self.open_info(&id, window, cx);
}
return;
}
let workspace = self.workspace.read(cx);
let what = match entry {
Row::Session { id, .. } => Some(Renaming::Session(id)),
Row::Table { project, ix } => workspace
.projects
.get(project)
.and_then(|open| open.tables.get(ix))
.map(|table| Renaming::Table(table.key.clone())),
Row::Article { .. } | Row::Board { .. } | Row::Project(_) | Row::Archive(_) => None,
};
if let Some(what) = what {
self.start_rename(what, window, cx);
}
}
fn archive_entry(&mut self, entry: Row, archived: bool, cx: &mut Context<Self>) {
self.workspace.update(cx, |workspace, cx| match entry {
Row::Session { id, .. } => workspace.archive_session(id, archived, cx),
Row::Board { project, ix } => {
if let Some(id) = workspace
.projects
.get(project)
.and_then(|open| open.boards.get(ix))
.map(|board| board.id.clone())
{
workspace.archive_board(&id, archived, cx);
}
}
Row::Article { project, ix } => {
if let Some(path) = workspace
.projects
.get(project)
.and_then(|open| open.articles.get(ix))
.map(|article| article.path.clone())
{
workspace.archive_article(&path, archived, cx);
}
}
Row::Table { project, ix } => {
if let Some(key) = workspace
.projects
.get(project)
.and_then(|open| open.tables.get(ix))
.map(|table| table.key.clone())
{
workspace.archive_table(&key, archived, cx);
}
}
Row::Project(_) | Row::Archive(_) => {}
});
}
pub(crate) fn name_field(&self, cx: &mut Context<Self>) -> AnyElement {
div()
.flex_1()
.min_w_0()
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _, window, cx| {
cx.stop_propagation();
window.focus(&this.name_field.read(cx).focus_handle(cx), cx);
}),
)
.on_mouse_down_out(cx.listener(|this, _, window, cx| {
this.commit_name(&CommitName, window, cx);
}))
.child(self.name_field.clone())
.into_any_element()
}
pub(crate) fn start_rename(
&mut self,
what: Renaming,
window: &mut Window,
cx: &mut Context<Self>,
) {
let workspace = self.workspace.read(cx);
let label = match &what {
Renaming::Session(id) => workspace
.session(*id)
.map(ChatSession::label)
.unwrap_or_default(),
Renaming::Table(key) => workspace
.projects
.iter()
.flat_map(|open| open.tables.iter())
.find(|table| table.key == *key)
.map(|table| table.name.clone())
.unwrap_or_default(),
Renaming::Column(id) => workspace
.active_board()
.and_then(|board| board.column(id))
.map(|column| column.name.clone())
.unwrap_or_default(),
};
self.name_field
.update(cx, |field, cx| field.set_content(label, cx));
self.info = None;
self.renaming = Some(what);
window.focus(&self.name_field.read(cx).focus_handle(cx), cx);
cx.notify();
}
pub(crate) fn commit_name(&mut self, _: &CommitName, _: &mut Window, cx: &mut Context<Self>) {
let Some(what) = self.renaming.take() else {
return;
};
let name = self.name_field.read(cx).content().to_string();
self.workspace.update(cx, |workspace, cx| match what {
Renaming::Session(id) => workspace.rename_session(id, name, cx),
Renaming::Table(key) => workspace.rename_table(&key, name, cx),
Renaming::Column(id) => workspace.rename_column(&id, name, cx),
});
cx.notify();
}
pub(crate) fn dismiss_name(&mut self, _: &DismissName, _: &mut Window, cx: &mut Context<Self>) {
self.renaming = None;
cx.notify();
}
}