use crate::{
model::session::ChatSession,
view::{
component::{
menu::{self, Menu},
transcript,
},
leaf::Pane,
root::{Cydonia, NewBoard},
sidebar::Renaming,
},
};
use artifact::{
board::{Card, Status, View},
layout::Member,
};
use bezel::agent::orbs::engine::Frame;
use bezel::ui::scroll as scrollbars;
use bezel::{
gpui::{
self, AnyElement, App, ClipboardItem, Context, Div, DragMoveEvent, Entity, Focusable as _,
FontWeight, KeyBinding, Pixels, Render, ScrollHandle, SharedString, Stateful, Window,
actions, div, prelude::*, px,
},
theme::{TextStyle, Theme, Typeset},
ui::{
icons,
input::{self, Shape, TextField},
menu::Item,
popover,
scroll::{self, Axes, DriftState, FollowState},
tooltip::Tooltip,
widgets::Buttons,
},
};
use markdown::Typography;
use std::{cell::RefCell, collections::HashMap, rc::Rc};
actions!(
cydonia_board,
[CommitCard, DismissCard, FindCard, DismissFind]
);
const KEY_CONTEXT: &str = "CydoniaCard";
const FIND_CONTEXT: &str = "CydoniaBoardFind";
const COLUMN_WIDTH: f32 = 272.;
const BOARD_INSET: f32 = 16.;
const LANE_CHANNEL: Pixels = px(18.);
const LIST_HEADING_HEIGHT: f32 = 30.;
const LIST_ROW_HEIGHT: f32 = 36.;
const LIST_LINE: f32 = 22.;
const LIST_HANDLE_WIDTH: f32 = 64.;
const PILL_CLEARANCE: f32 = 32.;
const CARD_MAX_HEIGHT: f32 = 140.;
const CARD_EDITOR_MAX_ROWS: usize = 512;
const CARD_TEXT_SCALE: f32 = TextStyle::Callout.size() / TextStyle::Body.size();
pub fn bindings() -> Vec<KeyBinding> {
let ctx = Some(KEY_CONTEXT);
vec![
KeyBinding::new("enter", CommitCard, ctx),
KeyBinding::new("shift-enter", input::InsertNewline, ctx),
KeyBinding::new("escape", DismissCard, ctx),
KeyBinding::new("escape", DismissFind, Some(FIND_CONTEXT)),
]
}
pub fn find_field(cx: &mut App) -> Entity<TextField> {
cx.new(|cx| {
TextField::new(cx)
.with_key_context(FIND_CONTEXT)
.with_placeholder("find a card…")
})
}
fn card_matches(card: &Card, handle: Option<&str>, query: &str) -> bool {
let query = query.trim().to_lowercase();
if query.is_empty() {
return true;
}
card.text.to_lowercase().contains(&query)
|| handle.is_some_and(|handle| handle.to_lowercase().contains(&query))
}
pub fn field(cx: &mut App) -> Entity<TextField> {
cx.new(|cx| {
TextField::new(cx)
.with_shape(Shape::Grow {
min: 2,
max: CARD_EDITOR_MAX_ROWS,
})
.with_key_context(KEY_CONTEXT)
.with_placeholder("what needs doing…")
})
}
fn resting(status: Option<Status>) -> Option<Status> {
status.filter(|status| *status != Status::Busy)
}
fn status_chip(status: Status, theme: &Theme) -> AnyElement {
let tint = match status {
Status::Busy => theme.accent,
Status::Blocked => theme.danger,
Status::Done => theme.text_faint,
};
div()
.flex_none()
.px(px(5.))
.rounded_full()
.border_1()
.border_color(tint)
.text_style(TextStyle::Caption)
.text_color(tint)
.child(status.key())
.into_any_element()
}
#[derive(Clone, Copy)]
struct Tally {
held: usize,
shown: usize,
}
fn card_body(text: &str, window: &mut Window, cx: &mut App) -> AnyElement {
markdown::render_with(
&markdown::parse(text),
markdown::Editing {
caption: markdown::Caption::Hidden,
typography: Some(Typography::of(cx).scaled(CARD_TEXT_SCALE)),
..Default::default()
},
window,
cx,
)
}
fn first_line(text: &str) -> &str {
text.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Editing {
New(Place, String),
Card(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Place {
Top,
End,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mark {
Above,
Top,
Below,
Foot,
Flow,
}
#[derive(Clone)]
pub struct CardDrag {
card: String,
project: usize,
board: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Landing {
pub column: String,
pub before: Option<String>,
}
#[derive(Default)]
pub struct Scrolls(RefCell<HashMap<String, Scroll>>);
#[derive(Clone, Default)]
pub struct Scroll {
pub across: ScrollHandle,
pub down: ScrollHandle,
pub drift: DriftState,
pub lanes: Rc<Lanes>,
}
impl Scrolls {
pub fn of(&self, board: &str) -> Scroll {
self.0
.borrow_mut()
.entry(board.to_owned())
.or_default()
.clone()
}
}
#[derive(Default)]
pub struct Marks(RefCell<HashMap<String, Rc<RefCell<Frame>>>>);
impl Marks {
fn of(&self, card: &str) -> Rc<RefCell<Frame>> {
self.0
.borrow_mut()
.entry(card.to_owned())
.or_default()
.clone()
}
}
struct Working {
state: bezel::agent::orbs::OrbState,
since: std::time::Duration,
frame: Rc<RefCell<Frame>>,
}
static SINCE: std::sync::LazyLock<std::time::Instant> =
std::sync::LazyLock::new(std::time::Instant::now);
#[derive(Default)]
pub struct Lanes(RefCell<HashMap<String, (ScrollHandle, DriftState, FollowState)>>);
impl Lanes {
fn of(&self, id: &str) -> (ScrollHandle, DriftState, FollowState) {
self.0
.borrow_mut()
.entry(id.to_owned())
.or_default()
.clone()
}
fn follow(&self, id: &str) {
self.of(id).2.follow();
}
}
pub struct HeldCard {
text: String,
}
impl Render for HeldCard {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::of(cx).clone();
div()
.w(px(COLUMN_WIDTH))
.max_h(px(CARD_MAX_HEIGHT))
.overflow_hidden()
.p(px(10.))
.rounded(px(Theme::control_radius()))
.border_1()
.border_color(theme.accent)
.bg(theme.surface_raised)
.child(card_body(&self.text, window, cx))
}
}
struct Lane<'a> {
project: usize,
board: usize,
id: &'a str,
at: usize,
lanes: usize,
on: Option<&'a Member>,
}
struct Slot<'a> {
project: usize,
board: usize,
id: &'a str,
column: &'a str,
first: bool,
next: Option<&'a str>,
}
impl Cydonia {
pub fn show_pane(&mut self, pane: Pane, cx: &mut Context<Self>) {
self.commit(cx);
self.leaf_mut().pane = pane;
cx.notify();
}
pub(crate) fn new_board_action(
&mut self,
_: &NewBoard,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(project) = self.workspace.read(cx).active else {
return;
};
self.ask_new_board(project, window, cx);
}
pub(crate) fn open_board(&mut self, project: usize, ix: usize, cx: &mut Context<Self>) {
self.commit(cx);
self.workspace
.update(cx, |workspace, cx| workspace.open_board(project, ix, cx));
self.leaf_mut().pane = Pane::Board;
cx.notify();
}
fn set_board_view(&mut self, id: &str, view: View, cx: &mut Context<Self>) {
self.workspace
.update(cx, |workspace, cx| workspace.set_board_view(id, view, cx));
cx.notify();
}
fn edit(&mut self, at: Editing, window: &mut Window, cx: &mut Context<Self>) {
self.commit(cx);
let text = match &at {
Editing::New(..) => String::new(),
Editing::Card(id) => self
.workspace
.read(cx)
.active_board()
.and_then(|board| board.card(id))
.map(|card| card.text.clone())
.unwrap_or_default(),
};
self.leaf()
.card_field
.update(cx, |field, cx| field.set_content(text, cx));
if let Editing::New(Place::End, column) = &at
&& let Some(id) = self
.workspace
.read(cx)
.active_board()
.map(|board| board.id.clone())
{
self.boards.of(&id).lanes.follow(column);
}
self.leaf_mut().editing = Some(at);
window.focus(&self.leaf().card_field.read(cx).focus_handle(cx), cx);
cx.notify();
}
pub(crate) fn commit(&mut self, cx: &mut Context<Self>) {
self.commit_cell(cx);
self.rest_ribbon(cx);
let Some(at) = self.leaf_mut().editing.take() else {
return;
};
let text = self.leaf().card_field.read(cx).content().trim().to_owned();
self.leaf()
.card_field
.update(cx, |field, cx| field.clear(cx));
self.workspace.update(cx, |workspace, cx| {
let Some(board) = workspace.active_board_mut() else {
return;
};
match at {
Editing::New(place, column) => {
if !text.is_empty() {
match place {
Place::Top => board.prepend_card(&column, text),
Place::End => board.add_card(&column, text),
};
}
}
Editing::Card(id) => {
if text.is_empty() {
board.remove_card(&id);
} else {
board.rewrite_card(&id, &text);
}
}
}
workspace.save_board();
cx.notify();
});
}
pub(crate) fn drop_stale_edit(&mut self, cx: &mut Context<Self>) {
let Some(at) = self.leaf().editing.clone() else {
return;
};
let board = self.workspace.read(cx).active_board();
let alive = match &at {
Editing::New(_, column) => board.is_some_and(|board| board.column(column).is_some()),
Editing::Card(card) => board.is_some_and(|board| board.card(card).is_some()),
};
if !alive {
self.leaf_mut().editing = None;
self.leaf()
.card_field
.update(cx, |field, cx| field.clear(cx));
}
}
fn commit_card(&mut self, _: &CommitCard, _: &mut Window, cx: &mut Context<Self>) {
self.commit(cx);
cx.notify();
}
fn dismiss_card(&mut self, _: &DismissCard, _: &mut Window, cx: &mut Context<Self>) {
self.leaf_mut().editing = None;
self.leaf()
.card_field
.update(cx, |field, cx| field.clear(cx));
cx.notify();
}
fn aim_card(&mut self, landing: Option<Landing>, cx: &mut Context<Self>) {
if self.leaf().landing != landing {
self.leaf_mut().landing = landing;
cx.notify();
}
}
fn drop_card(
&mut self,
drag: &CardDrag,
at: (usize, usize),
column: &str,
cx: &mut Context<Self>,
) {
let before = self
.leaf_mut()
.landing
.take()
.filter(|landing| landing.column == column)
.and_then(|landing| landing.before);
self.commit(cx);
let (card, column) = (drag.card.clone(), column.to_owned());
let from = (drag.project, drag.board);
self.workspace.update(cx, |workspace, cx| {
match from == at {
true => {
workspace.move_card_within(at, &card, &column, before.as_deref());
}
false => {
workspace.carry_card(from, at, &card, Some(&column));
}
}
cx.notify();
});
cx.notify();
}
pub(crate) fn delete_card(&mut self, card: &str, cx: &mut Context<Self>) {
self.commit(cx);
let card = card.to_owned();
self.workspace.update(cx, |workspace, cx| {
let gone = workspace
.active_board_mut()
.and_then(|board| board.remove_card(&card))
.is_some();
if gone {
workspace.save_board();
}
cx.notify();
});
cx.notify();
}
fn new_column(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.commit(cx);
let id = self
.workspace
.update(cx, |workspace, cx| workspace.new_column(cx));
if let Some(id) = id {
self.start_rename(Renaming::Column(id), window, cx);
}
}
fn new_column_beside(
&mut self,
id: &str,
after: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.commit(cx);
let id = id.to_owned();
let minted = self.workspace.update(cx, |workspace, cx| {
workspace.new_column_beside(&id, after, cx)
});
if let Some(minted) = minted {
self.start_rename(Renaming::Column(minted), window, cx);
}
}
fn drop_column(&mut self, id: &str, cx: &mut Context<Self>) {
self.commit(cx);
let id = id.to_owned();
self.workspace
.update(cx, |workspace, cx| workspace.remove_column(&id, cx));
cx.notify();
}
fn copy_card(&mut self, card: &str, cx: &mut Context<Self>) {
let Some(text) = self
.workspace
.read(cx)
.active_board()
.and_then(|board| board.card(card))
.map(|card| card.text.clone())
else {
return;
};
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
fn dispatch_card(&mut self, card: &str, cx: &mut Context<Self>) {
self.commit(cx);
let card = card.to_owned();
self.workspace.update(cx, |workspace, cx| {
let text = workspace
.active_board()
.and_then(|board| board.card(&card))
.map(|card| card.text.clone());
let (Some(text), Some(entry)) = (text, workspace.preferred_agent()) else {
return;
};
let Some(record) = workspace
.new_session(entry, Some(text), cx)
.and_then(|id| workspace.mint_record(id))
else {
return;
};
if workspace
.active_board_mut()
.is_some_and(|board| board.dispatch_card(&card, record))
{
workspace.save_board();
}
});
cx.notify();
}
fn card_menu(&self, card: &str, cx: &mut Context<Self>) -> Option<AnyElement> {
if self.menu.as_ref() != Some(&Menu::Card(card.to_owned())) {
return None;
}
let (copied, doomed) = (card.to_owned(), card.to_owned());
let rows = vec![
menu::row(
Item::action("Copy text").with_icon(icons::text::Copy),
move |this, _, cx| this.copy_card(&copied, cx),
),
menu::row(
Item::action("Delete").with_icon(icons::files::Trash),
move |this, _, cx| this.ask_delete_card(&doomed, cx),
),
];
let id = SharedString::from(format!("card-menu-card-{card}"));
Some(popover::anchored_menu_below(
id.clone(),
self.menu_card(id, rows, cx),
None,
))
}
fn card_session<'a>(&self, card: &Card, cx: &'a App) -> Option<&'a ChatSession> {
let record = card.session.as_deref()?;
self.workspace.read(cx).session_by_record(record)
}
fn card_working(&self, card: &Card, chat: Option<&ChatSession>) -> Option<Working> {
match chat.filter(|chat| chat.streaming) {
Some(chat) => Some(Working {
state: transcript::orb_of(chat),
since: chat.elapsed().unwrap_or_default(),
frame: chat.transcript.mark.clone(),
}),
None => (card.status == Some(Status::Busy)).then(|| Working {
state: transcript::orb_for(&card.text),
since: SINCE.elapsed(),
frame: self.card_marks.of(&card.id),
}),
}
}
fn scrolls(&self, project: usize, board_at: usize, cx: &App) -> Scroll {
let id = self
.workspace
.read(cx)
.board_in(project, board_at)
.map(|board| board.id.clone())
.unwrap_or_default();
self.boards.of(&id)
}
fn board_query(&self, on: Option<&Member>, cx: &App) -> String {
let leaf = self.leaf_of(on);
match leaf.finding {
true => leaf.find_field.read(cx).content().to_string(),
false => String::new(),
}
}
fn lane_cards(
&self,
project: usize,
board_at: usize,
id: &str,
query: &str,
cx: &App,
) -> Option<(String, usize, Vec<String>)> {
let board = self.workspace.read(cx).board_in(project, board_at)?;
let column = board.column(id)?;
let cards: Vec<String> = column
.cards
.iter()
.filter(|card| card_matches(card, board.handle_of(card).as_deref(), query))
.map(|card| card.id.clone())
.collect();
Some((column.name.clone(), column.cards.len(), cards))
}
pub(crate) fn find_card(&mut self, _: &FindCard, window: &mut Window, cx: &mut Context<Self>) {
let field = self.leaf().find_field.clone();
if self.leaf().finding && field.read(cx).focus_handle(cx).is_focused(window) {
self.dismiss_find(&DismissFind, window, cx);
return;
}
self.leaf_mut().finding = true;
window.focus(&field.read(cx).focus_handle(cx), cx);
cx.notify();
}
pub(crate) fn dismiss_find(&mut self, _: &DismissFind, _: &mut Window, cx: &mut Context<Self>) {
let field = self.leaf().find_field.clone();
self.leaf_mut().finding = false;
field.update(cx, |field, cx| field.clear(cx));
cx.notify();
}
fn find_bar(&self, on: Option<&Member>, cx: &mut Context<Self>) -> Option<AnyElement> {
if !self.leaf_of(on).finding || cx.has_active_drag() {
return None;
}
let theme = Theme::of(cx).clone();
Some(
div()
.absolute()
.top(px(BOARD_INSET))
.right(px(BOARD_INSET))
.w(px(COLUMN_WIDTH))
.flex()
.flex_row()
.items_center()
.gap(px(6.))
.px(px(8.))
.py(px(2.))
.rounded_full()
.border_1()
.border_color(theme.border)
.bg(theme.surface_raised)
.child(
icons::icon(icons::text::Search)
.size(px(14.))
.text_color(theme.text_faint),
)
.child(
div()
.flex_1()
.min_w_0()
.child(self.leaf_of(on).find_field.clone()),
)
.child(
theme
.ghost("board-find-close")
.p(px(4.))
.rounded_full()
.child(
icons::icon(icons::notifications::X)
.size(px(12.))
.text_color(theme.text_faint),
)
.tooltip(|window, cx| Tooltip::text("Stop finding", window, cx))
.on_click(cx.listener(|this, _, window, cx| {
cx.stop_propagation();
this.dismiss_find(&DismissFind, window, cx);
})),
)
.into_any_element(),
)
}
pub fn board(
&self,
project: usize,
board_at: usize,
on: Option<&Member>,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let Some((id, view)) = self
.workspace
.read(cx)
.board_in(project, board_at)
.map(|board| (board.id.clone(), board.view))
else {
return div().flex_1().into_any_element();
};
let body = match view {
View::Lanes => self.lanes(project, board_at, on, window, cx),
View::List => self.list(project, board_at, on, window, cx),
};
div()
.flex_1()
.min_h_0()
.relative()
.on_action(cx.listener(Self::commit_card))
.on_action(cx.listener(Self::dismiss_card))
.on_action(cx.listener(Self::dismiss_find))
.on_drag_move(cx.listener({
let drift = self.boards.of(&id).drift;
move |this, event: &DragMoveEvent<CardDrag>, _, cx| {
drift.aim(event.event.position);
this.aim_card(None, cx);
}
}))
.on_drop(cx.listener(|this, _: &CardDrag, _, cx| this.aim_card(None, cx)))
.child(body)
.children(self.view_pill(&id, view, cx))
.children(self.find_bar(on, cx))
.into_any_element()
}
fn view_pill(&self, id: &str, view: View, cx: &mut Context<Self>) -> Option<AnyElement> {
if cx.has_active_drag() {
return None;
}
let theme = Theme::of(cx).clone();
let mut pill = div()
.absolute()
.right(px(BOARD_INSET))
.bottom(px(BOARD_INSET))
.flex()
.flex_row()
.items_center()
.gap(px(2.))
.p(px(2.))
.rounded_full()
.border_1()
.border_color(theme.border)
.bg(theme.surface_raised);
for (at, glyph, label) in [
(View::Lanes, icons::development::SquareKanban, "Lanes"),
(View::List, icons::layout::LayoutList, "List"),
] {
let held = id.to_owned();
let on = at == view;
pill = pill.child(
theme
.ghost(SharedString::from(format!("board-view-{}", at.key())))
.p(px(5.))
.rounded_full()
.when(on, |el| el.bg(theme.element_active))
.child(icons::icon(glyph).size(px(14.)).text_color(match on {
true => theme.text,
false => theme.text_faint,
}))
.tooltip(move |window, cx| Tooltip::text(label, window, cx))
.on_click(cx.listener(move |this, _, _, cx| {
cx.stop_propagation();
this.set_board_view(&held, at, cx);
})),
);
}
Some(pill.into_any_element())
}
fn lanes(
&self,
project: usize,
board_at: usize,
on: Option<&Member>,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let scroll = self.scrolls(project, board_at, cx);
let Some(board) = self.workspace.read(cx).board_in(project, board_at) else {
return div().flex_1().into_any_element();
};
let ids: Vec<String> = board
.columns
.iter()
.map(|column| column.id.clone())
.collect();
let lanes = ids.len();
let columns: Vec<AnyElement> = ids
.iter()
.enumerate()
.map(|(at, id)| {
self.column(
Lane {
project,
board: board_at,
id,
at,
lanes,
on,
},
window,
cx,
)
})
.collect();
div()
.size_full()
.relative()
.child(
scroll::pane("board", Axes::Horizontal)
.size_full()
.flex()
.flex_row()
.px(px(BOARD_INSET))
.pt(px(BOARD_INSET))
.track_scroll(&scroll.across)
.children(columns)
.child(self.new_column_lane(cx)),
)
.child(scrollbars::Overlay::new(
"board-bar",
&scroll.across,
bezel::gpui::Axis::Horizontal,
))
.child(scroll::drift(
&scroll.across,
&scroll.drift,
Axes::Horizontal,
))
.into_any_element()
}
fn list(
&self,
project: usize,
board_at: usize,
on: Option<&Member>,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let scroll = self.scrolls(project, board_at, cx);
let Some(board) = self.workspace.read(cx).board_in(project, board_at) else {
return div().flex_1().into_any_element();
};
let ids: Vec<String> = board
.columns
.iter()
.map(|column| column.id.clone())
.collect();
let lanes = ids.len();
let groups: Vec<AnyElement> = ids
.iter()
.enumerate()
.map(|(at, id)| {
self.list_group(
Lane {
project,
board: board_at,
id,
at,
lanes,
on,
},
window,
cx,
)
})
.collect();
div()
.size_full()
.relative()
.child(
scroll::pane("board-list", Axes::Vertical)
.size_full()
.flex()
.flex_col()
.pb(px(BOARD_INSET + PILL_CLEARANCE))
.track_scroll(&scroll.down)
.children(groups)
.child(self.new_column_row(cx)),
)
.child(scrollbars::Overlay::new(
"board-list-bar",
&scroll.down,
bezel::gpui::Axis::Vertical,
))
.child(scroll::drift(&scroll.down, &scroll.drift, Axes::Vertical))
.into_any_element()
}
fn list_group(
&self,
lane: Lane<'_>,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let Lane {
project,
board: board_at,
id,
at,
lanes,
on,
} = lane;
let theme = Theme::of(cx).clone();
let id = id.to_owned();
let query = self.board_query(on, cx);
let Some((name, held, cards)) = self.lane_cards(project, board_at, &id, &query, cx) else {
return div().into_any_element();
};
let mut rows: Vec<AnyElement> = cards
.iter()
.enumerate()
.map(|(row, card)| {
let next = cards.get(row + 1).map(String::as_str);
self.list_row(
Slot {
project,
board: board_at,
id: card,
column: &id,
first: row == 0,
next,
},
on,
window,
cx,
)
})
.collect();
if cards.is_empty() && self.aimed_at(&id, on, cx) {
rows.push(self.landing_mark(Mark::Flow, cx));
}
if let Some(Editing::New(place, at)) = &self.leaf_of(on).editing
&& *at == id
{
let editor = div()
.px(px(BOARD_INSET))
.py(px(6.))
.child(self.card_editor(on, cx))
.into_any_element();
match place {
Place::Top => rows.insert(0, editor),
Place::End => rows.push(editor),
}
}
let lane = id.clone();
let taken = id.clone();
let written = id.clone();
div()
.flex_none()
.flex()
.flex_col()
.on_drag_move(cx.listener({
let drift = self.scrolls(project, board_at, cx).drift;
move |this, event: &DragMoveEvent<CardDrag>, _, cx| {
if !event.bounds.contains(&event.event.position) {
return;
}
drift.aim(event.event.position);
this.aim_card(
Some(Landing {
column: lane.clone(),
before: None,
}),
cx,
);
}
}))
.on_drop(cx.listener(move |this, drag: &CardDrag, _, cx| {
this.drop_card(drag, (project, board_at), &taken, cx);
}))
.child(self.list_group_header(
&id,
name,
Tally {
held,
shown: cards.len(),
},
at,
lanes,
cx,
))
.children(rows)
.children((query.trim().is_empty()).then(|| {
theme
.ghost(SharedString::from(format!("list-add-card-{id}")))
.flex_none()
.px(px(BOARD_INSET))
.py(px(6.))
.gap(px(6.))
.child(
icons::icon(icons::math::Plus)
.size(px(12.))
.text_color(theme.text_faint),
)
.child(
div()
.text_style(TextStyle::Callout)
.text_color(theme.text_faint)
.child("Add a card"),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.edit(Editing::New(Place::End, written.clone()), window, cx);
}))
}))
.into_any_element()
}
fn list_group_header(
&self,
id: &str,
name: String,
tally: Tally,
at: usize,
lanes: usize,
cx: &mut Context<Self>,
) -> AnyElement {
let Tally { held, shown } = tally;
let theme = Theme::of(cx).clone();
let row = div()
.flex_none()
.h(px(LIST_HEADING_HEIGHT))
.px(px(BOARD_INSET))
.flex()
.flex_row()
.items_center()
.gap(px(6.))
.bg(theme.surface_raised)
.border_b_1()
.border_color(theme.border)
.text_style(TextStyle::Subheadline);
if matches!(&self.renaming, Some(Renaming::Column(at)) if at == id) {
return row.child(self.name_field(cx)).into_any_element();
}
let named = id.to_owned();
row.group("list-group")
.child(
div()
.id(SharedString::from(format!("list-group-name-{id}")))
.font_weight(FontWeight::MEDIUM)
.text_color(theme.text_muted)
.cursor_pointer()
.child(name)
.on_click(cx.listener(move |this, _, window, cx| {
this.start_rename(Renaming::Column(named.clone()), window, cx);
})),
)
.child(
div()
.text_color(theme.text_faint)
.child(match shown == held {
true => held.to_string(),
false => format!("{shown}/{held}"),
}),
)
.child(div().flex_1())
.child(
self.menu_button(
SharedString::from(format!("list-group-menu-{id}")),
Some("list-group"),
icons::icon(icons::layout::Ellipsis)
.size(px(14.))
.text_color(theme.text_faint),
Menu::Lane(id.to_owned()),
cx,
)
.children(self.lane_menu(id, held, at, lanes, View::List, cx)),
)
.into_any_element()
}
fn list_row(
&self,
at: Slot<'_>,
on: Option<&Member>,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let Slot {
project,
board: board_at,
id,
column,
next,
..
} = at;
if matches!(&self.leaf_of(on).editing, Some(Editing::Card(at)) if at == id) {
return div()
.px(px(BOARD_INSET))
.py(px(6.))
.child(self.card_editor(on, cx))
.into_any_element();
}
let theme = Theme::of(cx).clone();
let Some((card, handle)) = self
.workspace
.read(cx)
.board_in(project, board_at)
.and_then(|board| board.card(id).map(|card| (card, board.handle_of(card))))
else {
return div().into_any_element();
};
let text = card.text.clone();
let status = card.status;
let chat = self.card_session(card, cx);
let live = chat.map(|chat| chat.id);
let sessions = self.workspace.read(cx).settings.features.sessions;
let working = self.card_working(card, chat);
let (opened, run) = (id.to_owned(), id.to_owned());
let ahead = cx.has_active_drag()
&& self
.leaf_of(on)
.landing
.as_ref()
.is_some_and(|at| at.before.as_deref() == Some(id));
let behind = next.is_none() && self.aimed_at(column, on, cx);
let viewport = self.scrolls(project, board_at, cx).down;
div()
.id(SharedString::from(format!("list-row-{id}")))
.group("list-row")
.flex_none()
.relative()
.h(px(LIST_ROW_HEIGHT))
.px(px(BOARD_INSET))
.flex()
.flex_row()
.items_center()
.gap(px(8.))
.border_b_1()
.border_color(theme.border)
.cursor_pointer()
.hover(|el| el.bg(theme.element_hover))
.child(
div()
.flex_none()
.w(px(LIST_HANDLE_WIDTH))
.text_style(TextStyle::Caption)
.font_family(theme.font_mono.clone())
.text_color(theme.text_faint)
.child(handle.unwrap_or_default()),
)
.child(
div()
.flex_1()
.min_w_0()
.h(px(LIST_LINE))
.overflow_hidden()
.child(card_body(first_line(&text), window, cx)),
)
.children(resting(status).map(|status| status_chip(status, &theme)))
.children(working.map(|at| transcript::orb(at.state, at.since, &at.frame, cx)))
.child(
div()
.invisible()
.group_hover("list-row", |el| el.visible())
.flex()
.flex_row()
.items_center()
.gap(px(2.))
.children(sessions.then_some(live).flatten().map(|session| {
self.card_action("list-open", id, icons::social::MessageCircle, cx)
.on_click(cx.listener(move |this, _, window, cx| {
cx.stop_propagation();
this.select_session(session, window, cx);
this.show_pane(Pane::Chat, cx);
}))
}))
.children((sessions && live.is_none() && status.is_none()).then(|| {
self.card_action("list-run", id, icons::multimedia::Play, cx)
.on_click(cx.listener(move |this, _, _, cx| {
cx.stop_propagation();
this.dispatch_card(&run, cx);
}))
})),
)
.child(
self.menu_button(
SharedString::from(format!("list-card-menu-{id}")),
Some("list-row"),
icons::icon(icons::layout::Ellipsis)
.size(px(14.))
.text_color(theme.text_faint),
Menu::Card(id.to_owned()),
cx,
)
.children(self.card_menu(id, cx)),
)
.on_drag(
CardDrag {
card: id.to_owned(),
project,
board: board_at,
},
move |_, _, _, cx| {
let text = text.clone();
cx.new(|_| HeldCard { text })
},
)
.on_drag_move(cx.listener({
let (column, card, next) =
(column.to_owned(), id.to_owned(), next.map(str::to_owned));
move |this, event: &DragMoveEvent<CardDrag>, _, cx| {
let at = event.event.position;
if !event.bounds.contains(&at) || !viewport.bounds().contains(&at) {
return;
}
let before = match at.y < event.bounds.center().y {
true => Some(card.clone()),
false => next.clone(),
};
this.aim_card(
Some(Landing {
column: column.clone(),
before,
}),
cx,
);
}
}))
.on_click(cx.listener(move |this, _, window, cx| {
this.edit(Editing::Card(opened.clone()), window, cx);
}))
.children(ahead.then(|| self.landing_mark(Mark::Top, cx)))
.children(behind.then(|| self.landing_mark(Mark::Foot, cx)))
.into_any_element()
}
fn new_column_row(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
theme
.ghost("list-add-column")
.flex_none()
.px(px(BOARD_INSET))
.py(px(8.))
.gap(px(6.))
.child(
icons::icon(icons::math::Plus)
.size(px(12.))
.text_color(theme.text_faint),
)
.child(
div()
.text_style(TextStyle::Callout)
.text_color(theme.text_faint)
.child("Add a column"),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.new_column(window, cx);
}))
.into_any_element()
}
fn column(&self, lane: Lane<'_>, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let Lane {
project,
board: board_at,
id,
at,
lanes,
on,
} = lane;
let theme = Theme::of(cx).clone();
let id = id.to_owned();
let query = self.board_query(on, cx);
let Some((name, held, cards)) = self.lane_cards(project, board_at, &id, &query, cx) else {
return div().into_any_element();
};
let mut rows: Vec<AnyElement> = cards
.iter()
.enumerate()
.map(|(at, card)| {
let next = cards.get(at + 1).map(String::as_str);
self.card(
Slot {
project,
board: board_at,
id: card,
column: &id,
first: at == 0,
next,
},
on,
window,
cx,
)
})
.collect();
if cards.is_empty() && self.aimed_at(&id, on, cx) {
rows.push(self.landing_mark(Mark::Flow, cx));
}
if let Some(Editing::New(place, at)) = &self.leaf_of(on).editing
&& *at == id
{
let editor = self.card_editor(on, cx);
match place {
Place::Top => rows.insert(0, editor),
Place::End => rows.push(editor),
}
}
let lane = id.clone();
let taken = id.clone();
let composing =
matches!(&self.leaf_of(on).editing, Some(Editing::New(Place::End, at)) if *at == id);
let (scroll, drift, follow) = self.scrolls(project, board_at, cx).lanes.of(&id);
let bar_id = format!("lane-bar-{id}");
div()
.flex_none()
.w(px(COLUMN_WIDTH))
.h_full()
.flex()
.flex_col()
.gap(px(8.))
.on_drag_move(cx.listener({
let drift = drift.clone();
move |this, event: &DragMoveEvent<CardDrag>, _, cx| {
if !event.bounds.contains(&event.event.position) {
return;
}
drift.aim(event.event.position);
this.aim_card(
Some(Landing {
column: lane.clone(),
before: None,
}),
cx,
);
}
}))
.on_drop(cx.listener(move |this, drag: &CardDrag, _, cx| {
this.drop_card(drag, (project, board_at), &taken, cx);
}))
.child(self.column_header(
&id,
name,
Tally {
held,
shown: cards.len(),
},
at,
lanes,
cx,
))
.child(
div()
.relative()
.flex_1()
.min_h_0()
.child(
scroll::pane(SharedString::from(format!("column-{id}")), Axes::Vertical)
.size_full()
.pr(LANE_CHANNEL)
.pb(px(8.))
.track_scroll(&scroll)
.flex()
.flex_col()
.gap(px(8.))
.children(rows)
.children(query.trim().is_empty().then(|| {
theme
.ghost(SharedString::from(format!("add-card-{id}")))
.flex_none()
.px(px(8.))
.py(px(6.))
.gap(px(6.))
.child(
icons::icon(icons::math::Plus)
.size(px(12.))
.text_color(theme.text_faint),
)
.child(
div()
.text_style(TextStyle::Callout)
.text_color(theme.text_faint)
.child("Add a card"),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.edit(Editing::New(Place::End, id.clone()), window, cx);
}))
})),
)
.child(scroll::drift(&scroll, &drift, Axes::Vertical))
.children(composing.then(|| scroll::follow(&scroll, &follow)))
.child(
scrollbars::Overlay::new(bar_id, &scroll, bezel::gpui::Axis::Vertical)
.end_inset(px(BOARD_INSET))
.channel(LANE_CHANNEL),
),
)
.into_any_element()
}
fn column_header(
&self,
id: &str,
name: String,
tally: Tally,
at: usize,
lanes: usize,
cx: &mut Context<Self>,
) -> AnyElement {
let Tally { held, shown } = tally;
let theme = Theme::of(cx).clone();
let row = div()
.flex_none()
.pl(px(4.))
.pr(LANE_CHANNEL)
.flex()
.flex_row()
.items_center()
.gap(px(6.))
.text_style(TextStyle::Subheadline);
if matches!(&self.renaming, Some(Renaming::Column(at)) if at == id) {
return row.child(self.name_field(cx)).into_any_element();
}
let named = id.to_owned();
row.group("column")
.child(
div()
.id(SharedString::from(format!("column-name-{id}")))
.font_weight(FontWeight::MEDIUM)
.text_color(theme.text_muted)
.cursor_pointer()
.child(name)
.on_click(cx.listener(move |this, _, window, cx| {
this.start_rename(Renaming::Column(named.clone()), window, cx);
})),
)
.child(
div()
.text_color(theme.text_faint)
.child(match shown == held {
true => held.to_string(),
false => format!("{shown}/{held}"),
}),
)
.child(div().flex_1())
.child(
self.menu_button(
SharedString::from(format!("column-menu-{id}")),
Some("column"),
icons::icon(icons::layout::Ellipsis)
.size(px(14.))
.text_color(theme.text_faint),
Menu::Lane(id.to_owned()),
cx,
)
.children(self.lane_menu(id, held, at, lanes, View::Lanes, cx)),
)
.into_any_element()
}
fn lane_menu(
&self,
id: &str,
count: usize,
at: usize,
lanes: usize,
view: View,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
if self.menu != Some(Menu::Lane(id.to_owned())) {
return None;
}
let before = match view {
View::Lanes => ("Add column left", "Add column right"),
View::List => ("Add column above", "Add column below"),
};
let (back, on) = match view {
View::Lanes => (
("Move left", icons::arrows::ArrowLeft),
("Move right", icons::arrows::ArrowRight),
),
View::List => (
("Move up", icons::arrows::ArrowUp),
("Move down", icons::arrows::ArrowDown),
),
};
let written = id.to_owned();
let mut rows = vec![menu::row(
Item::action("Add card").with_icon(icons::math::Plus),
move |this, window, cx| {
this.edit(Editing::New(Place::Top, written.clone()), window, cx)
},
)];
for (after, label) in [(false, before.0), (true, before.1)] {
let beside = id.to_owned();
rows.push(menu::row(
Item::action(label).with_icon(icons::math::Plus),
move |this, window, cx| this.new_column_beside(&beside, after, window, cx),
));
}
if at > 0 {
let moved = id.to_owned();
rows.push(menu::row(
Item::action(back.0).with_icon(back.1),
move |this, _, cx| this.shift_column(&moved, -1, cx),
));
}
if at + 1 < lanes {
let moved = id.to_owned();
rows.push(menu::row(
Item::action(on.0).with_icon(on.1),
move |this, _, cx| this.shift_column(&moved, 1, cx),
));
}
let drop = Item::action("Delete column").with_icon(icons::files::Trash);
let drop = match count {
0 => drop,
_ => drop
.disabled()
.with_tooltip("A column is only where work sits — move the cards out first."),
};
let dropped = id.to_owned();
rows.push(menu::row(drop, move |this, _, cx| {
this.drop_column(&dropped, cx)
}));
let card = SharedString::from(format!("lane-menu-{id}"));
Some(popover::anchored_menu_below(
card.clone(),
self.menu_card(card, rows, cx),
None,
))
}
fn shift_column(&mut self, id: &str, step: isize, cx: &mut Context<Self>) {
self.workspace
.update(cx, |workspace, cx| workspace.move_column(id, step, cx));
cx.notify();
}
fn new_column_lane(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
div()
.flex_none()
.w(px(COLUMN_WIDTH))
.h_full()
.child(
theme
.ghost("add-column")
.flex_none()
.px(px(8.))
.py(px(6.))
.gap(px(6.))
.child(
icons::icon(icons::math::Plus)
.size(px(12.))
.text_color(theme.text_faint),
)
.child(
div()
.text_style(TextStyle::Callout)
.text_color(theme.text_faint)
.child("Add a column"),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.new_column(window, cx);
})),
)
.into_any_element()
}
fn card(
&self,
at: Slot<'_>,
on: Option<&Member>,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let Slot {
project,
board: board_at,
id,
column,
first,
next,
} = at;
if matches!(&self.leaf_of(on).editing, Some(Editing::Card(at)) if at == id) {
return self.card_editor(on, cx);
}
let theme = Theme::of(cx).clone();
let Some((card, handle)) = self
.workspace
.read(cx)
.board_in(project, board_at)
.and_then(|board| board.card(id).map(|card| (card, board.handle_of(card))))
else {
return div().into_any_element();
};
let text = card.text.clone();
let status = card.status;
let chat = self.card_session(card, cx);
let live = chat.map(|chat| chat.id);
let sessions = self.workspace.read(cx).settings.features.sessions;
let working = self.card_working(card, chat);
let (opened, run) = (id.to_owned(), id.to_owned());
let ahead = cx.has_active_drag()
&& self
.leaf_of(on)
.landing
.as_ref()
.is_some_and(|at| at.before.as_deref() == Some(id));
let behind = next.is_none() && self.aimed_at(column, on, cx);
let (viewport, ..) = self.scrolls(project, board_at, cx).lanes.of(column);
div()
.id(SharedString::from(format!("card-{id}")))
.group("card")
.flex_none()
.relative()
.p(px(10.))
.rounded(px(Theme::control_radius()))
.border_1()
.border_color(theme.border)
.bg(theme.surface_raised)
.cursor_pointer()
.hover(|el| el.border_color(theme.text_faint))
.flex()
.flex_col()
.gap(px(6.))
.child(
div()
.flex()
.flex_row()
.items_start()
.gap(px(6.))
.child(
div()
.flex_1()
.min_w_0()
.max_h(px(CARD_MAX_HEIGHT))
.overflow_hidden()
.child(card_body(&text, window, cx)),
)
.child(
self.menu_button(
SharedString::from(format!("card-menu-{id}")),
Some("card"),
icons::icon(icons::layout::Ellipsis)
.size(px(14.))
.text_color(theme.text_faint),
Menu::Card(id.to_owned()),
cx,
)
.children(self.card_menu(id, cx)),
),
)
.child(
div()
.flex()
.flex_row()
.items_center()
.gap(px(2.))
.children(handle.map(|handle| {
div()
.flex_none()
.text_style(TextStyle::Caption)
.font_family(theme.font_mono.clone())
.text_color(theme.text_faint)
.child(handle)
}))
.children(resting(status).map(|status| status_chip(status, &theme)))
.child(div().flex_1())
.children(working.map(|at| transcript::orb(at.state, at.since, &at.frame, cx)))
.child(
div()
.invisible()
.group_hover("card", |el| el.visible())
.flex()
.flex_row()
.items_center()
.gap(px(2.))
.children(sessions.then_some(live).flatten().map(|session| {
self.card_action("open", id, icons::social::MessageCircle, cx)
.on_click(cx.listener(move |this, _, window, cx| {
cx.stop_propagation();
this.select_session(session, window, cx);
this.show_pane(Pane::Chat, cx);
}))
}))
.children((sessions && live.is_none() && status.is_none()).then(
|| {
self.card_action("run", id, icons::multimedia::Play, cx)
.on_click(cx.listener(move |this, _, _, cx| {
cx.stop_propagation();
this.dispatch_card(&run, cx);
}))
},
)),
),
)
.on_drag(
CardDrag {
card: id.to_owned(),
project,
board: board_at,
},
move |_, _, _, cx| {
let text = text.clone();
cx.new(|_| HeldCard { text })
},
)
.on_drag_move(cx.listener({
let (column, card, next) =
(column.to_owned(), id.to_owned(), next.map(str::to_owned));
move |this, event: &DragMoveEvent<CardDrag>, _, cx| {
let at = event.event.position;
if !event.bounds.contains(&at) || !viewport.bounds().contains(&at) {
return;
}
let before = match at.y < event.bounds.center().y {
true => Some(card.clone()),
false => next.clone(),
};
this.aim_card(
Some(Landing {
column: column.clone(),
before,
}),
cx,
);
}
}))
.on_click(cx.listener(move |this, _, window, cx| {
this.edit(Editing::Card(opened.clone()), window, cx);
}))
.children(
ahead.then(|| self.landing_mark(if first { Mark::Top } else { Mark::Above }, cx)),
)
.children(behind.then(|| self.landing_mark(Mark::Below, cx)))
.into_any_element()
}
fn aimed_at(&self, column: &str, on: Option<&Member>, cx: &App) -> bool {
cx.has_active_drag()
&& self
.leaf_of(on)
.landing
.as_ref()
.is_some_and(|at| at.column == column && at.before.is_none())
}
fn landing_mark(&self, at: Mark, cx: &Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
let mark = div().h(px(2.)).rounded_full().bg(theme.accent);
if at == Mark::Flow {
return mark.flex_none().into_any_element();
}
let mark = mark.absolute().left_0().right_0();
match at {
Mark::Above => mark.top(px(-5.)),
Mark::Top => mark.top_0(),
Mark::Foot => mark.bottom_0(),
_ => mark.bottom(px(-5.)),
}
.into_any_element()
}
fn card_action(
&self,
name: &'static str,
id: &str,
glyph: &'static [u8],
cx: &Context<Self>,
) -> Stateful<Div> {
let theme = Theme::of(cx).clone();
theme
.ghost(SharedString::from(format!("card-{name}-{id}")))
.p(px(3.))
.child(
icons::icon(glyph)
.size(px(12.))
.text_color(theme.text_faint),
)
}
fn card_editor(&self, on: Option<&Member>, cx: &Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
div()
.flex_none()
.p(px(8.))
.rounded(px(Theme::control_radius()))
.border_1()
.border_color(theme.accent)
.bg(theme.surface_raised)
.flex()
.flex_col()
.gap(px(4.))
.child(self.leaf_of(on).card_field.clone())
.child(
div()
.text_style(TextStyle::Subheadline)
.font_family(theme.font_mono.clone())
.text_color(theme.text_faint)
.child("enter file · esc cancel"),
)
.on_mouse_down_out(cx.listener(|this, _, _, cx| this.commit(cx)))
.into_any_element()
}
}
#[cfg(test)]
#[path = "../../tests/unit/board_find.rs"]
mod find_tests;