use crate::{
model::{session::ChatSession, workspace::Showing},
view::{
component::{
menu::{self, Menu},
transcript,
},
leaf::Pane,
root::{Cydonia, NewBoard},
sidebar::Renaming,
},
};
use artifact::{
board::{Card, Status, View},
space::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::{
floating, icons,
input::{self, Shape, TextField},
menu::Item,
popover,
scroll::{self, Axes, DriftState, FollowState},
tooltip::Tooltip,
widgets::Buttons,
},
};
use markdown::Typography;
use std::{
cell::{Cell, RefCell},
collections::HashMap,
rc::Rc,
};
actions!(
cydonia_board,
[
CommitCard,
DismissCard,
FindCard,
DismissFind,
CloseCardPreview,
SaveCardDraft
]
);
const KEY_CONTEXT: &str = "CydoniaCard";
const FIND_CONTEXT: &str = "CydoniaBoardFind";
const DRAWER_CONTEXT: &str = "CydoniaCardPreview";
const DRAFT_CONTEXT: &str = "CydoniaCardDraft";
const COLUMN_WIDTH: f32 = 272.;
fn visible_lanes(offset: Pixels, width: Pixels, count: usize) -> std::ops::Range<usize> {
let left = (-f32::from(offset) - BOARD_INSET).max(0.);
let right = (left + f32::from(width)).max(0.);
let start = (left / COLUMN_WIDTH).floor() as usize;
let end = (right / COLUMN_WIDTH).ceil() as usize;
start.saturating_sub(1).min(count)..end.saturating_add(1).min(count)
}
const BOARD_INSET: f32 = 16.;
const LANE_CHANNEL: Pixels = px(18.);
const LIST_HEADING_HEIGHT: f32 = 36.;
const LIST_ROW_HEIGHT: f32 = 36.;
fn visible_list_rows(top: Pixels, height: Pixels, count: usize) -> std::ops::Range<usize> {
if top + height < px(-2. * LIST_ROW_HEIGHT) {
return 0..0;
}
let start = (f32::from(top).max(0.) / LIST_ROW_HEIGHT).floor() as usize;
let end = (f32::from(top + height).max(0.) / LIST_ROW_HEIGHT).ceil() as usize;
start.saturating_sub(2).min(count)..end.saturating_add(2).min(count)
}
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)),
KeyBinding::new("escape", CloseCardPreview, Some(DRAWER_CONTEXT)),
KeyBinding::new("cmd-enter", SaveCardDraft, Some(DRAWER_CONTEXT)),
KeyBinding::new("cmd-enter", SaveCardDraft, Some(DRAFT_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_frame(false)
.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(doc: &markdown::Doc, window: &mut Window, cx: &mut App) -> AnyElement {
markdown::render_with(
doc,
markdown::Editing {
caption: markdown::Caption::Hidden,
copy: markdown::CopyButton::Hidden,
typography: Some(Typography::of(cx).scaled(CARD_TEXT_SCALE)),
..Default::default()
},
window,
cx,
)
}
fn card_preview(
doc: &markdown::Doc,
shortened: bool,
overflow: Entity<bool>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
div()
.max_h(px(CARD_MAX_HEIGHT))
.overflow_hidden()
.child(
div().relative().child(card_body(doc, window, cx)).child(
gpui::canvas(
move |bounds, _, cx| {
let clipped = shortened || bounds.size.height > px(CARD_MAX_HEIGHT);
overflow.update(cx, |value, cx| {
if *value != clipped {
*value = clipped;
cx.notify();
}
});
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
),
)
.into_any_element()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Editing {
New(Place, String),
Card(String),
}
pub struct OpenCard {
board: Member,
card: String,
scroll: ScrollHandle,
focus: gpui::FocusHandle,
reveal: Rc<Cell<bool>>,
bounds: Rc<Cell<gpui::Bounds<Pixels>>>,
adjustments: Rc<RefCell<HashMap<String, LaneAdjustment>>>,
size: DrawerSize,
resize_grab: Option<Pixels>,
pane_bounds: Rc<Cell<gpui::Bounds<Pixels>>>,
editing: bool,
}
#[derive(Clone, Copy)]
struct DrawerSize {
fraction: f32,
expanded: bool,
}
impl Default for DrawerSize {
fn default() -> Self {
Self {
fraction: 0.5,
expanded: false,
}
}
}
impl DrawerSize {
fn fraction(&self, pane_height: Pixels) -> f32 {
if self.expanded {
return 1.;
}
let height = f32::from(pane_height);
if height <= 0. {
return self.fraction;
}
self.fraction.max((160. / height).min(1.)).min(1.)
}
fn resize(&mut self, pointer_y: Pixels, pane: gpui::Bounds<Pixels>) {
let height = f32::from(pane.size.height);
if height <= 0. {
return;
}
self.fraction =
(f32::from(pane.bottom() - pointer_y) / height).clamp((160. / height).min(1.), 1.);
self.expanded = false;
}
}
pub struct CardDraft {
board: Member,
card: String,
base: String,
field: Entity<TextField>,
error: Option<String>,
reveal_caret: Rc<Cell<bool>>,
_subscription: gpui::Subscription,
}
struct LaneAdjustment {
scroll: ScrollHandle,
before: gpui::Point<Pixels>,
after: gpui::Point<Pixels>,
}
impl LaneAdjustment {
fn restore(&self) {
if self.scroll.offset() == self.after {
self.scroll.set_offset(self.before);
}
}
}
impl Drop for OpenCard {
fn drop(&mut self) {
for adjustment in self.adjustments.borrow().values() {
adjustment.restore();
}
}
}
fn reveal_delta(
top: Pixels,
bottom: Pixels,
visible_top: Pixels,
visible_bottom: Pixels,
) -> Pixels {
if top < visible_top || bottom - top > visible_bottom - visible_top {
visible_top - top
} else if bottom > visible_bottom {
visible_bottom - bottom
} else {
px(0.)
}
}
#[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,
wheel: Rc<RefCell<gpui::OngoingScroll>>,
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>>>>);
#[derive(Default)]
pub struct Docs(RefCell<HashMap<String, CachedDoc>>);
struct CachedDoc {
full: Rc<markdown::Doc>,
preview: Option<(Rc<markdown::Doc>, bool)>,
title: Option<SharedString>,
}
impl Docs {
fn of(&self, text: &str) -> Rc<markdown::Doc> {
if let Some(cached) = self.0.borrow().get(text) {
return cached.full.clone();
}
self.0
.borrow_mut()
.entry(text.to_owned())
.or_insert_with(|| CachedDoc {
full: Rc::new(markdown::parse(text)),
preview: None,
title: None,
})
.full
.clone()
}
fn title(&self, text: &str) -> SharedString {
self.of(text);
let mut docs = self.0.borrow_mut();
let cached = docs.get_mut(text).unwrap();
cached
.title
.get_or_insert_with(|| list_title(&cached.full).into())
.clone()
}
fn preview(&self, text: &str) -> (Rc<markdown::Doc>, bool) {
if let Some(cached) = self.0.borrow_mut().get_mut(text) {
return cached
.preview
.get_or_insert_with(|| {
let (doc, shortened) = preview_doc(&cached.full);
(Rc::new(doc), shortened)
})
.clone();
}
self.of(text);
self.preview(text)
}
}
fn list_title(doc: &markdown::Doc) -> String {
use markdown::BlockKind;
for block in &doc.blocks {
let candidate = match &block.kind {
BlockKind::Image { alt, .. } => {
if alt.text.trim().is_empty() {
"Image".into()
} else {
alt.text.clone()
}
}
BlockKind::Bookmark { url, .. } => url.clone(),
BlockKind::Table { header, rows, .. } => header
.iter()
.chain(rows.iter().flatten())
.find(|cell| !cell.text.trim().is_empty())
.map(|cell| cell.text.clone())
.unwrap_or_else(|| "Table".into()),
BlockKind::Rule => continue,
BlockKind::Code { code, .. } => code
.text
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("Code")
.to_owned(),
_ => block
.text_at(markdown::Part::Body)
.map(|text| text.text.clone())
.unwrap_or_default(),
};
let mut title = String::new();
let mut length = 0;
for word in candidate.split_whitespace() {
if !title.is_empty() {
title.push(' ');
length += 1;
}
for ch in word.chars() {
if length >= 512 {
title.push('…');
return title;
}
title.push(ch);
length += 1;
}
}
if !title.is_empty() {
return title;
}
}
"Untitled card".into()
}
fn preview_doc(full: &markdown::Doc) -> (markdown::Doc, bool) {
let mut shortened = full.blocks.len() > 16;
let mut doc = markdown::Doc {
blocks: full.blocks.iter().take(16).cloned().collect(),
};
for block in &mut doc.blocks {
if let markdown::BlockKind::Table { rows, .. } = &mut block.kind {
shortened |= rows.len() > 12;
rows.truncate(12);
}
let code = matches!(block.kind, markdown::BlockKind::Code { .. });
for part in block.parts() {
let Some(text) = block.text_at_mut(part) else {
continue;
};
let mut end = text
.text
.char_indices()
.nth(4096)
.map(|(at, _)| at)
.unwrap_or(text.text.len());
if code {
end = end.min(
text.text
.match_indices('\n')
.nth(16)
.map(|(at, _)| at)
.unwrap_or(end),
);
}
if end < text.text.len() {
shortened = true;
text.text.truncate(end);
text.marks.retain_mut(|span| {
span.range.end = span.range.end.min(end);
span.range.start < span.range.end
});
}
}
}
(doc, shortened)
}
impl Marks {
fn of(&self, card: &str) -> Rc<RefCell<Frame>> {
self.0
.borrow_mut()
.entry(card.to_owned())
.or_default()
.clone()
}
}
struct Working {
session: Option<(u64, String)>,
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(&markdown::parse(&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,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.commit(cx);
let member = self
.workspace
.read(cx)
.member_of(project, Showing::Board(ix));
if self.enter_member(member, window, cx) {
return;
}
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();
}
pub(crate) fn pane_board(&self, on: Option<&Member>, cx: &App) -> Option<String> {
self.workspace
.read(cx)
.board_of(on)
.map(|board| board.id.clone())
}
fn edit(
&mut self,
on: Option<&Member>,
at: Editing,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.commit(cx);
if let Some(on) = on {
self.focus_pane(&on.clone(), window, cx);
}
self.leaf_of_mut(on).open_card = None;
let board = self.workspace.read(cx).board_of(on);
let text = match &at {
Editing::New(..) => String::new(),
Editing::Card(id) => board
.and_then(|board| board.card(id))
.map(|card| card.text.clone())
.unwrap_or_default(),
};
let id = board.map(|board| board.id.clone());
let leaf = self.leaf_of(on);
leaf.card_field
.update(cx, |field, cx| field.set_content(text, cx));
if let (Editing::New(Place::End, column), Some(id)) = (&at, &id) {
self.boards.of(id).lanes.follow(column);
}
let field = self.leaf_of(on).card_field.clone();
self.leaf_of_mut(on).editing = Some(at);
window.focus(&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);
for at in 0..self.leaves.len() {
self.commit_leaf(at, cx);
}
}
fn commit_leaf(&mut self, leaf: usize, cx: &mut Context<Self>) {
let Some(at) = self
.leaves
.get_mut(leaf)
.and_then(|leaf| leaf.editing.take())
else {
return;
};
let (field, on) = {
let leaf = &self.leaves[leaf];
(leaf.card_field.clone(), leaf.entry.clone())
};
let text = field.read(cx).content().trim().to_owned();
field.update(cx, |field, cx| field.clear(cx));
let Some(id) = self.pane_board(on.as_ref(), cx) else {
return;
};
self.workspace.update(cx, |workspace, cx| {
workspace.write_board(&id, |board| 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);
}
}
});
cx.notify();
});
}
pub(crate) fn drop_stale_edit(&mut self, cx: &mut Context<Self>) {
for leaf in 0..self.leaves.len() {
let Some(at) = self.leaves[leaf].editing.clone() else {
continue;
};
let on = self.leaves[leaf].entry.clone();
let board = self.workspace.read(cx).board_of(on.as_ref());
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.leaves[leaf].editing = None;
self.leaves[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, board: &str, card: &str, cx: &mut Context<Self>) {
self.commit(cx);
let (board, card) = (board.to_owned(), card.to_owned());
self.workspace.update(cx, |workspace, cx| {
workspace.write_board(&board, |board| board.remove_card(&card));
cx.notify();
});
cx.notify();
}
fn new_column(&mut self, board: &str, window: &mut Window, cx: &mut Context<Self>) {
self.commit(cx);
let board = board.to_owned();
let id = self
.workspace
.update(cx, |workspace, cx| workspace.new_column(&board, cx));
if let Some(id) = id {
self.start_rename(Renaming::Column(board, id), window, cx);
}
}
fn new_column_beside(
&mut self,
board: &str,
id: &str,
after: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.commit(cx);
let (board, id) = (board.to_owned(), id.to_owned());
let minted = self.workspace.update(cx, |workspace, cx| {
workspace.new_column_beside(&board, &id, after, cx)
});
if let Some(minted) = minted {
self.start_rename(Renaming::Column(board, minted), window, cx);
}
}
pub(crate) fn drop_column(&mut self, board: &str, id: &str, cx: &mut Context<Self>) {
self.commit(cx);
let (board, id) = (board.to_owned(), id.to_owned());
self.workspace
.update(cx, |workspace, cx| workspace.remove_column(&board, &id, cx));
cx.notify();
}
fn copy_card(&mut self, board: &str, card: &str, cx: &mut Context<Self>) {
let Some(text) = self
.workspace
.read(cx)
.board_at(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, board: &str, card: &str, cx: &mut Context<Self>) {
self.commit(cx);
let (board, card) = (board.to_owned(), card.to_owned());
self.workspace.update(cx, |workspace, cx| {
let text = workspace
.board_at(&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;
};
workspace.write_board(&board, |held| held.dispatch_card(&card, record));
});
cx.notify();
}
fn card_menu(&self, on: &str, 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 (from, held) = (on.to_owned(), on.to_owned());
let rows = vec![
menu::row(
Item::action("Copy text").with_icon(icons::text::Copy),
move |this, _, cx| this.copy_card(&from, &copied, cx),
),
menu::row(
Item::action("Delete").with_icon(icons::files::Trash),
move |this, _, cx| this.ask_delete_card(&held, &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> {
let session = chat.map(|chat| (chat.id, chat.label()));
match chat.filter(|chat| chat.streaming) {
Some(chat) => Some(Working {
session,
state: transcript::orb_of(chat),
since: chat.elapsed().unwrap_or_default(),
frame: chat.transcript.mark.clone(),
}),
None => (card.status == Some(Status::Busy)).then(|| Working {
session,
state: transcript::orb_for(&card.text),
since: SINCE.elapsed(),
frame: self.card_marks.of(&card.id),
}),
}
}
fn card_orb(&self, at: Working, cx: &mut Context<Self>) -> AnyElement {
let orb = transcript::orb(at.state, at.since, &at.frame, cx);
let session = at
.session
.filter(|_| self.workspace.read(cx).settings.features.sessions);
div()
.id("card-busy-orb")
.debug_selector(|| "card-busy-orb".into())
.flex_none()
.child(orb)
.when_some(session, |el, (session, title)| {
el.cursor_pointer()
.tooltip(move |window, cx| Tooltip::text(title.clone(), window, cx))
.on_click(cx.listener(move |this, _, window, cx| {
cx.stop_propagation();
this.select_session(session, window, cx);
this.show_pane(Pane::Chat, cx);
}))
})
.into_any_element()
}
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| {
query.trim().is_empty()
|| 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(),
)
}
fn open_card(
&mut self,
on: Option<&Member>,
board: Member,
card: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.commit(cx);
if let Some(on) = on {
self.focus_pane(on, window, cx);
}
let leaf = self.leaf_of_mut(on);
if let Some(opened) = &mut leaf.open_card
&& opened.board == board
{
if opened.card != card {
opened.card = card;
opened.scroll = ScrollHandle::new();
opened.editing = false;
}
opened.reveal.set(true);
} else {
leaf.open_card = Some(OpenCard {
board,
card,
scroll: ScrollHandle::new(),
focus: cx.focus_handle(),
reveal: Rc::new(Cell::new(true)),
bounds: Default::default(),
adjustments: Default::default(),
size: DrawerSize::default(),
resize_grab: None,
pane_bounds: Default::default(),
editing: false,
});
}
window.focus(&leaf.open_card.as_ref().unwrap().focus, cx);
cx.notify();
}
fn close_card_preview(
&mut self,
on: Option<&Member>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.leaf_of_mut(on).open_card = None;
window.focus(&self.leaf_of(on).focus, cx);
cx.notify();
}
fn drawer_for(
&self,
project: usize,
board_at: usize,
on: Option<&Member>,
cx: &App,
) -> Option<&OpenCard> {
let opened = self.leaf_of(on).open_card.as_ref()?;
let workspace = self.workspace.read(cx);
if workspace
.member_of(project, Showing::Board(board_at))
.as_ref()
!= Some(&opened.board)
{
return None;
}
if workspace
.board_in(project, board_at)?
.card(&opened.card)
.is_none()
&& self.draft_for(on).is_none()
{
return None;
}
Some(opened)
}
fn draft_for(&self, on: Option<&Member>) -> Option<&CardDraft> {
let leaf = self.leaf_of(on);
let opened = leaf.open_card.as_ref()?;
leaf.card_drafts
.iter()
.find(|draft| draft.board == opened.board && draft.card == opened.card)
}
fn toggle_card_edit(
&mut self,
on: Option<&Member>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(opened) = self.leaf_of(on).open_card.as_ref() else {
return;
};
let (board, card, editing) = (opened.board.clone(), opened.card.clone(), opened.editing);
if !editing && self.draft_for(on).is_none() {
let Some(text) = self
.workspace
.read(cx)
.board_of(Some(&board))
.and_then(|board| board.card(&card))
.map(|card| card.text.clone())
else {
return;
};
let field = cx.new(|cx| {
let mut field = TextField::new(cx)
.with_frame(false)
.with_shape(Shape::Grow {
min: 2,
max: usize::MAX,
})
.with_key_context(DRAFT_CONTEXT);
field.set_content(text.clone(), cx);
field
});
let reveal_caret = Rc::new(Cell::new(true));
let pending = reveal_caret.clone();
let subscription = cx.subscribe(&field, move |_, _, _: &input::FieldEvent, cx| {
pending.set(true);
cx.notify();
});
self.leaf_of_mut(on).card_drafts.push(CardDraft {
board,
card,
base: text,
field,
error: None,
reveal_caret,
_subscription: subscription,
});
}
self.leaf_of_mut(on).open_card.as_mut().unwrap().editing = !editing;
let focus = if !editing {
let draft = self.draft_for(on).unwrap();
draft.reveal_caret.set(true);
draft.field.read(cx).focus_handle(cx)
} else {
self.leaf_of(on).open_card.as_ref().unwrap().focus.clone()
};
window.focus(&focus, cx);
cx.notify();
}
fn cancel_card_draft(
&mut self,
on: Option<&Member>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let leaf = self.leaf_of_mut(on);
let Some(opened) = &mut leaf.open_card else {
return;
};
leaf.card_drafts
.retain(|draft| draft.board != opened.board || draft.card != opened.card);
opened.editing = false;
opened.scroll = ScrollHandle::new();
window.focus(&opened.focus, cx);
cx.notify();
}
fn save_card_draft(
&mut self,
on: Option<&Member>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(draft) = self.draft_for(on) else {
return;
};
let (board, card, base, text) = (
draft.board.clone(),
draft.card.clone(),
draft.base.clone(),
draft.field.read(cx).content().to_string(),
);
let result = if text == base {
Ok(())
} else {
self.workspace.update(cx, |workspace, cx| {
workspace.save_card_draft(&board, &card, &base, &text, cx)
})
};
match result {
Ok(()) => self.cancel_card_draft(on, window, cx),
Err(error) => {
if let Some(draft) = self
.leaf_of_mut(on)
.card_drafts
.iter_mut()
.find(|draft| draft.board == board && draft.card == card)
{
draft.error = Some(error);
}
cx.notify();
}
}
}
fn drawer_action(
&self,
id: &'static str,
glyph: &'static [u8],
label: &'static str,
cx: &Context<Self>,
) -> Stateful<Div> {
let theme = Theme::of(cx);
theme
.ghost(id)
.debug_selector(move || id.to_owned())
.flex_none()
.size(px(24.))
.flex()
.items_center()
.justify_center()
.rounded(px(Theme::control_radius()))
.child(
icons::icon(glyph)
.size(px(14.))
.text_color(theme.text_muted),
)
.tooltip(move |window, cx| Tooltip::text(label, window, cx))
}
fn card_drawer(
&self,
project: usize,
board_at: usize,
on: Option<&Member>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
let opened = self.drawer_for(project, board_at, on, cx)?;
let board = self.workspace.read(cx).board_in(project, board_at)?;
let card = board.card(&opened.card);
let handle = card
.and_then(|card| board.handle_of(card))
.unwrap_or_else(|| "Missing card".into());
let status = card.and_then(|card| card.status);
let draft = self.draft_for(on);
let text = draft
.map(|draft| draft.field.read(cx).content().to_string())
.unwrap_or_else(|| card.map(|card| card.text.clone()).unwrap_or_default());
let dirty = draft.is_some_and(|draft| draft.base != text);
let error = draft.and_then(|draft| draft.error.clone()).or_else(|| {
card.is_none().then(|| {
"This card was removed or moved. Copy your draft before cancelling.".into()
})
});
let editing = opened.editing;
let expanded = opened.size.expanded;
let scroll = opened.scroll.clone();
let focus = if editing {
draft
.map(|draft| draft.field.read(cx).focus_handle(cx))
.unwrap_or_else(|| opened.focus.clone())
} else {
opened.focus.clone()
};
let working = card.and_then(|card| self.card_working(card, self.card_session(card, cx)));
let theme = Theme::of(cx).clone();
let bounds = opened.bounds.clone();
let reveal = opened.reveal.clone();
let resize_on = on.cloned();
let (edit_on, close_on, escape_on, focus_on, save_on, key_save_on, cancel_on, expand_on) = (
on.cloned(),
on.cloned(),
on.cloned(),
on.cloned(),
on.cloned(),
on.cloned(),
on.cloned(),
on.cloned(),
);
let body = if editing {
let draft = draft?;
let field = draft.field.clone();
let pending = draft.reveal_caret.clone();
let caret_scroll = scroll.clone();
div()
.relative()
.child(field.clone())
.child(
gpui::canvas(
|_, _, _| {},
move |_, _, window, cx| {
if !pending.get() {
return;
}
let input = field.read(cx);
let Some(caret) = input.offset_bounds(input.cursor()) else {
return;
};
pending.set(false);
let viewport = caret_scroll.bounds();
let delta = reveal_delta(
caret.top(),
caret.bottom(),
viewport.top() + px(16.),
viewport.bottom() - px(16.),
);
if delta != px(0.) {
let offset = caret_scroll.offset();
caret_scroll.set_offset(gpui::point(
offset.x,
(offset.y + delta).min(px(0.)),
));
window.on_next_frame(|window, _| window.refresh());
}
},
)
.absolute()
.size_full(),
)
.into_any_element()
} else {
markdown::render_with(
&self.card_docs.of(&text),
markdown::Editing::default(),
window,
cx,
)
.into_any_element()
};
Some(
scroll::contain_wheel(floating::layer("card-drawer"), Axes::Both)
.debug_selector(|| "card-drawer".into())
.key_context(DRAWER_CONTEXT)
.track_focus(&opened.focus)
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(move |this, _, window, cx| {
if let Some(on) = &focus_on {
this.focus_pane(on, window, cx);
}
window.focus(&focus, cx);
cx.stop_propagation();
}),
)
.on_action(cx.listener(move |this, _: &CloseCardPreview, window, cx| {
this.close_card_preview(escape_on.as_ref(), window, cx);
}))
.on_action(cx.listener(move |this, _: &SaveCardDraft, window, cx| {
this.save_card_draft(key_save_on.as_ref(), window, cx);
}))
.bottom_0()
.left_0()
.right_0()
.h(gpui::relative(
opened.size.fraction(opened.pane_bounds.get().size.height),
))
.bg(theme.surface_raised)
.rounded_t(px(if expanded {
0.
} else {
Theme::control_radius()
}))
.border_t_1()
.border_l_1()
.border_r_1()
.border_color(theme.border)
.shadow(vec![gpui::BoxShadow {
color: gpui::hsla(0., 0., 0., 0.12),
offset: gpui::point(px(0.), px(-4.)),
blur_radius: px(16.),
spread_radius: px(-4.),
inset: false,
}])
.text_color(theme.text)
.flex()
.flex_col()
.overflow_hidden()
.child(
div()
.id("card-drawer-resize")
.debug_selector(|| "card-drawer-resize".into())
.group("drawer-resize")
.flex_none()
.h(px(9.))
.w_full()
.cursor_row_resize()
.flex()
.items_center()
.justify_center()
.child(
div()
.w(px(32.))
.h(px(2.))
.rounded_full()
.bg(theme.text_faint.opacity(0.4))
.group_hover("drawer-resize", |el| el.bg(theme.text_muted)),
)
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(move |this, event: &gpui::MouseDownEvent, _, cx| {
if let Some(opened) =
&mut this.leaf_of_mut(resize_on.as_ref()).open_card
{
opened.resize_grab =
Some(event.position.y - opened.bounds.get().top());
cx.notify();
}
cx.stop_propagation();
}),
),
)
.child(
div()
.flex_none()
.flex()
.items_center()
.gap(px(6.))
.px(px(16.))
.pb(px(6.))
.flex_wrap()
.text_style(TextStyle::Caption)
.text_color(theme.text_muted)
.child(div().font_family(theme.font_mono.clone()).child(handle))
.children(resting(status).map(|status| status_chip(status, &theme)))
.children(working.map(|at| self.card_orb(at, cx)))
.when(dirty, |el| el.child(div().child("Draft")))
.child(div().flex_1())
.when(draft.is_some(), |el| {
el.child(
self.drawer_action(
"card-draft-save",
icons::notifications::Check,
"Save card (⌘Enter)",
cx,
)
.on_click(cx.listener(
move |this, _, window, cx| {
cx.stop_propagation();
this.save_card_draft(save_on.as_ref(), window, cx);
},
)),
)
.child(
self.drawer_action(
"card-draft-cancel",
icons::glyph::Undo2,
"Cancel edits",
cx,
)
.on_click(cx.listener(
move |this, _, window, cx| {
cx.stop_propagation();
this.cancel_card_draft(cancel_on.as_ref(), window, cx);
},
)),
)
})
.child(
self.drawer_action(
"card-drawer-edit",
if editing {
icons::glyph::Eye
} else {
icons::text::Pencil
},
if editing {
"Preview draft"
} else {
"Edit card"
},
cx,
)
.on_click(cx.listener(
move |this, _, window, cx| {
cx.stop_propagation();
this.toggle_card_edit(edit_on.as_ref(), window, cx);
},
)),
)
.child(
self.drawer_action(
"card-drawer-expand",
if expanded {
icons::arrows::Shrink
} else {
icons::arrows::Expand
},
if expanded {
"Restore drawer"
} else {
"Expand drawer"
},
cx,
)
.on_click(cx.listener(
move |this, _, _, cx| {
cx.stop_propagation();
if let Some(opened) =
&mut this.leaf_of_mut(expand_on.as_ref()).open_card
{
opened.size.expanded = !opened.size.expanded;
opened.reveal.set(!opened.size.expanded);
}
cx.notify();
},
)),
)
.child(
self.drawer_action(
"card-drawer-close",
icons::notifications::X,
"Close preview (keeps draft)",
cx,
)
.on_click(cx.listener(
move |this, _, window, cx| {
cx.stop_propagation();
this.close_card_preview(close_on.as_ref(), window, cx);
},
)),
),
)
.children(error.map(|error| {
div()
.flex_none()
.px(px(16.))
.pb(px(8.))
.text_style(TextStyle::Caption)
.text_color(theme.danger)
.child(error)
}))
.child(
div()
.flex_1()
.min_h_0()
.relative()
.child(
scroll::pane("card-drawer-body", Axes::Vertical)
.size_full()
.track_scroll(&scroll)
.p(px(16.))
.child(body),
)
.child(scrollbars::Overlay::new(
"card-drawer-scroll",
&scroll,
gpui::Axis::Vertical,
)),
)
.child(
gpui::canvas(
move |measured, window, _| {
let old = bounds.replace(measured);
if old != measured {
if !expanded {
reveal.set(true);
}
window.on_next_frame(|window, _| window.refresh());
}
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
)
.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)))
.children(self.drawer_for(project, board_at, on, cx).map(|opened| {
let bounds = opened.pane_bounds.clone();
gpui::canvas(
move |measured, window, _| {
if bounds.replace(measured) != measured {
window.on_next_frame(|window, _| window.refresh());
}
},
|_, _, _, _| {},
)
.absolute()
.size_full()
}))
.child(body)
.children(self.view_pill(&id, view, cx))
.children(self.find_bar(on, cx))
.children(self.card_drawer(project, board_at, on, window, cx))
.children(
self.drawer_for(project, board_at, on, cx)
.and_then(|opened| {
opened.resize_grab.map(|grab| {
let move_on = on.cloned();
let release_on = on.cloned();
let release =
move |this: &mut Self,
_: &gpui::MouseUpEvent,
_: &mut Window,
cx: &mut Context<Self>| {
if let Some(opened) =
&mut this.leaf_of_mut(release_on.as_ref()).open_card
{
opened.resize_grab = None;
cx.notify();
}
cx.stop_propagation();
};
floating::layer("card-drawer-resizing")
.inset_0()
.cursor_row_resize()
.on_mouse_move(cx.listener(
move |this, event: &gpui::MouseMoveEvent, _, cx| {
if let Some(opened) =
&mut this.leaf_of_mut(move_on.as_ref()).open_card
{
if event.pressed_button == Some(gpui::MouseButton::Left)
{
opened.size.resize(
event.position.y - grab,
opened.pane_bounds.get(),
);
} else {
opened.resize_grab = None;
}
cx.notify();
}
cx.stop_propagation();
},
))
.on_mouse_up(gpui::MouseButton::Left, cx.listener(release.clone()))
.on_mouse_up_out(gpui::MouseButton::Left, cx.listener(release))
})
}),
)
.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 held = board.id.clone();
let ids: Vec<String> = board
.columns
.iter()
.map(|column| column.id.clone())
.collect();
let lanes = ids.len();
let editing_lane = match &self.leaf_of(on).editing {
Some(Editing::New(_, column)) => Some(column.clone()),
Some(Editing::Card(card)) => board
.columns
.iter()
.find(|column| column.cards.iter().any(|held| &held.id == card))
.map(|column| column.id.clone()),
None => None,
};
let measured_width = window
.use_keyed_state(
SharedString::from(format!("board-lane-width-{held}-{on:?}")),
cx,
|_, _| Rc::new(Cell::new(px(0.))),
)
.read(cx)
.clone();
let width = measured_width.get();
let width = if width > px(0.) {
width
} else {
window.viewport_size().width
};
let visible = visible_lanes(scroll.across.offset().x, width, lanes);
let columns: Vec<AnyElement> = ids
.iter()
.enumerate()
.map(|(at, id)| {
if !visible.contains(&at) && editing_lane.as_deref() != Some(id.as_str()) {
return div()
.flex_none()
.w(px(COLUMN_WIDTH))
.h_full()
.into_any_element();
}
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(&held, cx)),
)
.child(
scrollbars::Overlay::new(
"board-bar",
&scroll.across,
bezel::gpui::Axis::Horizontal,
)
.when(
self.drawer_for(project, board_at, on, cx)
.is_some_and(|drawer| drawer.resize_grab.is_some()),
|bar| bar.visibility(scrollbars::Visibility::Never),
),
)
.child(scroll::drift(
&scroll.across,
&scroll.drift,
Axes::Horizontal,
scroll::Beyond::Nothing,
))
.child(
gpui::canvas(
move |bounds, window, _| {
if measured_width.replace(bounds.size.width) != bounds.size.width {
let view = window.current_view();
window.on_next_frame(move |_, cx| cx.notify(view));
}
window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal)
},
move |_, hitbox, window, _| {
let handle = scroll.across.clone();
let gesture = scroll.wheel.clone();
let view = window.current_view();
window.on_mouse_event(
move |event: &gpui::ScrollWheelEvent, phase, window, cx| {
if phase != gpui::DispatchPhase::Capture
|| !hitbox.should_handle_scroll(window)
{
return;
}
let raw = event.delta.pixel_delta(window.line_height());
let mut delta = raw;
if event.delta.precise() {
gesture.borrow_mut().filter(&mut delta, event.touch_phase);
} else if delta.x.abs() <= delta.y.abs() {
return;
}
let sideways = delta.x.abs() > delta.y.abs();
let filtered_out = delta.x == px(0.)
&& delta.y == px(0.)
&& (raw.x != px(0.) || raw.y != px(0.));
if sideways || filtered_out {
let offset = handle.offset();
let next = gpui::point(
(offset.x + delta.x).clamp(-handle.max_offset().x, px(0.)),
offset.y,
);
if next != offset {
handle.set_offset(next);
cx.notify(view);
}
cx.stop_propagation();
}
},
);
},
)
.absolute()
.inset_0(),
)
.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 held = board.id.clone();
let ids: Vec<String> = board
.columns
.iter()
.map(|column| column.id.clone())
.collect();
let lanes = ids.len();
let measured_height = window
.use_keyed_state(
SharedString::from(format!("board-list-height-{held}-{on:?}")),
cx,
|_, _| Rc::new(Cell::new(px(0.))),
)
.read(cx)
.clone();
let height = measured_height.get();
let height = if height > px(0.) {
height
} else {
window.viewport_size().height
};
let viewport = (self.leaf_of(on).editing.is_none() && !cx.has_active_drag())
.then_some((-scroll.down.offset().y, height));
let mut group_top = px(0.);
let groups: Vec<AnyElement> = ids
.iter()
.enumerate()
.map(|(at, id)| {
self.list_group(
Lane {
project,
board: board_at,
id,
at,
lanes,
on,
},
&mut group_top,
viewport,
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)
+ self
.drawer_for(project, board_at, on, cx)
.map(|drawer| drawer.bounds.get().size.height)
.unwrap_or_default())
.track_scroll(&scroll.down)
.children(groups)
.child(self.new_column_row(&held, cx)),
)
.child(
scrollbars::Overlay::new(
"board-list-bar",
&scroll.down,
bezel::gpui::Axis::Vertical,
)
.when(
self.drawer_for(project, board_at, on, cx)
.is_some_and(|drawer| drawer.resize_grab.is_some()),
|bar| bar.visibility(scrollbars::Visibility::Never),
),
)
.child(scroll::drift(
&scroll.down,
&scroll.drift,
Axes::Vertical,
scroll::Beyond::Nothing,
))
.child(
gpui::canvas(
move |bounds, window, _| {
if measured_height.replace(bounds.size.height) != bounds.size.height {
let view = window.current_view();
window.on_next_frame(move |_, cx| cx.notify(view));
}
},
|_, _, _, _| {},
)
.absolute()
.inset_0(),
)
.into_any_element()
}
fn list_group(
&self,
lane: Lane<'_>,
group_top: &mut Pixels,
viewport: Option<(Pixels, Pixels)>,
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 pane = on.cloned();
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 Some((board_id, folded)) = self
.workspace
.read(cx)
.board_in(project, board_at)
.and_then(|board| {
let column = board.column(&id)?;
Some((board.id.clone(), column.collapsed))
})
else {
return div().into_any_element();
};
let writing = matches!(&self.leaf_of(on).editing, Some(Editing::New(_, at)) if *at == id);
let folded = folded && query.trim().is_empty() && !writing;
let count = if folded { 0 } else { cards.len() };
let visible = viewport
.map(|(top, height)| {
visible_list_rows(top - *group_top - px(LIST_HEADING_HEIGHT), height, count)
})
.unwrap_or(0..count);
*group_top += px(LIST_HEADING_HEIGHT
+ count as f32 * LIST_ROW_HEIGHT
+ if !folded && cards.is_empty() && query.trim().is_empty() {
LIST_ROW_HEIGHT
} else {
0.
});
let mut rows: Vec<AnyElement> = cards
.iter()
.enumerate()
.skip(visible.start)
.take(visible.len())
.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 visible.start > 0 {
rows.insert(
0,
div()
.flex_none()
.h(px(visible.start as f32 * LIST_ROW_HEIGHT))
.into_any_element(),
);
}
if visible.end < count {
rows.push(
div()
.flex_none()
.h(px((count - visible.end) as f32 * LIST_ROW_HEIGHT))
.into_any_element(),
);
}
if !folded && 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,
folded,
&board_id,
on,
cx,
))
.children(rows)
.children(
(!folded && cards.is_empty() && query.trim().is_empty()).then(|| {
theme
.ghost(SharedString::from(format!("list-add-card-{id}")))
.flex_none()
.h(px(LIST_ROW_HEIGHT))
.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(
pane.as_ref(),
Editing::New(Place::End, written.clone()),
window,
cx,
);
}))
}),
)
.into_any_element()
}
#[allow(clippy::too_many_arguments)]
fn list_group_header(
&self,
id: &str,
name: String,
tally: Tally,
at: usize,
lanes: usize,
folded: bool,
board: &str,
on: Option<&Member>,
cx: &mut Context<Self>,
) -> AnyElement {
let Tally { held, shown } = tally;
let theme = Theme::of(cx).clone();
let row = div()
.id(SharedString::from(format!("list-group-header-{id}")))
.relative()
.flex_none()
.h(px(LIST_HEADING_HEIGHT))
.px(px(BOARD_INSET))
.flex()
.flex_row()
.items_center()
.gap(px(6.))
.text_style(TextStyle::Subheadline)
.when(folded && self.aimed_at(id, on, cx), |row| {
row.bg(theme.accent.opacity(0.08)).child(
div()
.debug_selector(|| "list-collapsed-drop-target".into())
.absolute()
.inset_0()
.child(self.landing_mark(Mark::Foot, cx)),
)
});
if matches!(&self.renaming, Some(Renaming::Column(_, at)) if at == id) {
return row.child(self.name_field(cx)).into_any_element();
}
let folding = (board.to_owned(), id.to_owned());
let add_on = on.cloned();
let add_column = id.to_owned();
let can_add = self.board_query(on, cx).trim().is_empty();
row.group("list-group")
.child(
div()
.id(SharedString::from(format!("list-group-fold-{id}")))
.debug_selector(|| "list-group-toggle".into())
.flex_1()
.min_w_0()
.h_full()
.flex()
.items_center()
.gap(px(6.))
.cursor_pointer()
.child(
icons::icon(if folded {
icons::arrows::ChevronRight
} else {
icons::arrows::ChevronDown
})
.size(px(12.))
.text_color(theme.text_faint),
)
.child(
div()
.min_w_0()
.text_ellipsis()
.font_weight(FontWeight::MEDIUM)
.text_color(theme.text_muted)
.child(name),
)
.child(
div()
.flex_none()
.text_color(theme.text_faint)
.child(if shown == held {
held.to_string()
} else {
format!("{shown}/{held}")
}),
)
.on_click(cx.listener(move |this, _, _, cx| {
this.workspace.update(cx, |workspace, cx| {
workspace.toggle_column_collapsed(&folding.0, &folding.1, cx)
});
cx.notify();
})),
)
.children(can_add.then(|| {
self.drawer_action("list-group-add", icons::math::Plus, "Add card", cx)
.on_click(cx.listener(move |this, _, window, cx| {
cx.stop_propagation();
this.edit(
add_on.as_ref(),
Editing::New(Place::Top, add_column.clone()),
window,
cx,
);
}))
}))
.child(
self.menu_button(
SharedString::from(format!("list-group-menu-{id}")),
Some("list-group"),
icons::layout::Ellipsis,
Menu::Lane(id.to_owned()),
cx,
)
.children(self.lane_menu(
id,
held,
at,
lanes,
View::List,
board,
on,
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 on_board = self
.workspace
.read(cx)
.board_in(project, board_at)
.map(|board| board.id.clone())
.unwrap_or_default();
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 sent = on_board.clone();
let pane = on.cloned();
let member = self
.workspace
.read(cx)
.member_of(project, Showing::Board(board_at));
let selected = self
.drawer_for(project, board_at, on, cx)
.is_some_and(|opened| opened.card == id);
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;
let reveal = self
.drawer_for(project, board_at, on, cx)
.filter(|drawer| {
selected
&& !drawer.size.expanded
&& drawer.reveal.get()
&& drawer.bounds.get().size.height > px(0.)
})
.map(|drawer| {
let pending = drawer.reveal.clone();
let drawer_top = drawer.bounds.get().top();
let adjustments = drawer.adjustments.clone();
let scroll = viewport.clone();
let column = "list".to_owned();
gpui::canvas(
move |bounds, window, _| {
if !pending.replace(false) {
return;
}
let delta = reveal_delta(
bounds.top(),
bounds.bottom(),
scroll.bounds().top(),
drawer_top - px(8.),
);
if delta == px(0.) {
return;
}
let before = scroll.offset();
let after = gpui::point(before.x, (before.y + delta).min(px(0.)));
let mut adjustments = adjustments.borrow_mut();
let adjustment =
adjustments
.entry(column.clone())
.or_insert_with(|| LaneAdjustment {
scroll: scroll.clone(),
before,
after: before,
});
if adjustment.after != before {
adjustment.before = before;
}
adjustment.after = after;
scroll.set_offset(after);
window.on_next_frame(|window, _| window.refresh());
},
|_, _, _, _| {},
)
.absolute()
.size_full()
});
div()
.id(SharedString::from(format!("list-row-{id}")))
.debug_selector(|| "board-list-row".into())
.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.opacity(0.3))
.when(selected, |el| el.bg(theme.accent.opacity(0.08)))
.cursor_pointer()
.hover(|el| {
el.bg(if selected {
theme.accent.opacity(0.12)
} else {
theme.element_hover
})
})
.children(reveal)
.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()
.text_style(TextStyle::Callout)
.text_color(theme.text)
.text_ellipsis()
.child(self.card_docs.title(&text)),
)
.children(resting(status).map(|status| status_chip(status, &theme)))
.children(working.map(|at| self.card_orb(at, cx)))
.child(
div()
.invisible()
.group_hover("list-row", |el| el.visible())
.when(selected, |el| el.visible())
.flex()
.flex_row()
.items_center()
.gap(px(2.))
.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(&sent, &run, cx);
}))
})),
)
.child(
self.menu_button(
SharedString::from(format!("list-card-menu-{id}")),
Some("list-row"),
icons::layout::Ellipsis,
Menu::Card(id.to_owned()),
cx,
)
.children(self.card_menu(&on_board, 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| {
if let Some(member) = &member {
this.open_card(pane.as_ref(), member.clone(), 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, on: &str, cx: &mut Context<Self>) -> AnyElement {
let on = on.to_owned();
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(&on, 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 pane = on.cloned();
let on_board = self
.workspace
.read(cx)
.board_in(project, board_at)
.map(|board| board.id.clone())
.unwrap_or_default();
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}");
let clearance = self
.drawer_for(project, board_at, on, cx)
.map(|drawer| drawer.bounds.get().size.height)
.unwrap_or_default();
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,
&on_board,
on,
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.) + clearance)
.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(
pane.as_ref(),
Editing::New(Place::End, id.clone()),
window,
cx,
);
}))
})),
)
.child(scroll::drift(
&scroll,
&drift,
Axes::Vertical,
scroll::Beyond::Neighbour,
))
.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)
.when(
self.drawer_for(project, board_at, on, cx)
.is_some_and(|drawer| drawer.resize_grab.is_some()),
|bar| bar.visibility(scrollbars::Visibility::Never),
),
),
)
.into_any_element()
}
#[allow(clippy::too_many_arguments)]
fn column_header(
&self,
id: &str,
name: String,
tally: Tally,
at: usize,
lanes: usize,
board: &str,
on: Option<&Member>,
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();
let on_board = board.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(on_board.clone(), 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::layout::Ellipsis,
Menu::Lane(id.to_owned()),
cx,
)
.children(self.lane_menu(
id,
held,
at,
lanes,
View::Lanes,
board,
on,
cx,
)),
)
.into_any_element()
}
#[allow(clippy::too_many_arguments)]
fn lane_menu(
&self,
id: &str,
count: usize,
at: usize,
lanes: usize,
view: View,
board: &str,
pane: Option<&Member>,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
if self.menu != Some(Menu::Lane(id.to_owned())) {
return None;
}
let (back, on, both) = match view {
View::Lanes => (
("Left", icons::arrows::ArrowLeft),
("Right", icons::arrows::ArrowRight),
icons::arrows::ArrowLeftRight,
),
View::List => (
("Above", icons::arrows::ArrowUp),
("Below", icons::arrows::ArrowDown),
icons::arrows::ArrowUpDown,
),
};
let written = id.to_owned();
let pane = pane.cloned();
let rename_pane = pane.clone();
let on_board = board.to_owned();
let mut rows = vec![menu::row(
Item::action("Add card").with_icon(icons::math::Plus),
move |this, window, cx| {
this.edit(
pane.as_ref(),
Editing::New(Place::Top, written.clone()),
window,
cx,
)
},
)];
let renamed = id.to_owned();
let rename_board = board.to_owned();
rows.push(menu::row(
Item::action("Rename").with_icon(icons::design::Pencil),
move |this, window, cx| {
if let Some(pane) = &rename_pane {
this.focus_pane(pane, window, cx);
}
this.start_rename(
Renaming::Column(rename_board.clone(), renamed.clone()),
window,
cx,
);
},
));
let beside = [(false, back), (true, on)]
.map(|(after, (label, icon))| {
let (beside, made_on) = (id.to_owned(), on_board.clone());
menu::row(
Item::action(label).with_icon(icon),
move |this, window, cx| {
this.new_column_beside(&made_on, &beside, after, window, cx)
},
)
})
.into_iter()
.collect();
rows.push(menu::submenu("Add column", icons::math::Plus, beside));
let steps: Vec<_> = [(at > 0, -1, back), (at + 1 < lanes, 1, on)]
.into_iter()
.filter(|(can, ..)| *can)
.map(|(_, step, (label, icon))| {
let (moved, moved_on) = (id.to_owned(), on_board.clone());
menu::row(Item::action(label).with_icon(icon), move |this, _, cx| {
this.shift_column(&moved_on, &moved, step, cx)
})
})
.collect();
if !steps.is_empty() {
rows.push(menu::submenu("Move column", both, steps));
}
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.ask_delete_column(&on_board, &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, board: &str, id: &str, step: isize, cx: &mut Context<Self>) {
self.workspace.update(cx, |workspace, cx| {
workspace.move_column(board, id, step, cx)
});
cx.notify();
}
fn new_column_lane(&self, on: &str, cx: &mut Context<Self>) -> AnyElement {
let on = on.to_owned();
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(&on, 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 on_board = self
.workspace
.read(cx)
.board_in(project, board_at)
.map(|board| board.id.clone())
.unwrap_or_default();
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 sent = on_board.clone();
let pane = on.cloned();
let member = self
.workspace
.read(cx)
.member_of(project, Showing::Board(board_at));
let selected = self
.leaf_of(on)
.open_card
.as_ref()
.is_some_and(|opened| Some(&opened.board) == member.as_ref() && opened.card == id);
let overflow = window.use_keyed_state(
SharedString::from(format!("card-overflow-{on:?}-{id}")),
cx,
|_, _| false,
);
let truncated = *overflow.read(cx);
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);
let reveal = self
.drawer_for(project, board_at, on, cx)
.filter(|drawer| {
selected
&& !drawer.size.expanded
&& drawer.reveal.get()
&& drawer.bounds.get().size.height > px(0.)
})
.map(|drawer| {
let pending = drawer.reveal.clone();
let drawer_top = drawer.bounds.get().top();
let adjustments = drawer.adjustments.clone();
let scroll = viewport.clone();
let column = column.to_owned();
gpui::canvas(
move |bounds, window, _| {
if !pending.replace(false) {
return;
}
let delta = reveal_delta(
bounds.top(),
bounds.bottom(),
scroll.bounds().top(),
drawer_top - px(8.),
);
if delta == px(0.) {
return;
}
let before = scroll.offset();
let after = gpui::point(before.x, (before.y + delta).min(px(0.)));
let mut adjustments = adjustments.borrow_mut();
let adjustment =
adjustments
.entry(column.clone())
.or_insert_with(|| LaneAdjustment {
scroll: scroll.clone(),
before,
after: before,
});
if adjustment.after != before {
adjustment.before = before;
}
adjustment.after = after;
scroll.set_offset(after);
window.on_next_frame(|window, _| window.refresh());
},
|_, _, _, _| {},
)
.absolute()
.size_full()
});
div()
.id(SharedString::from(format!("card-{id}")))
.group("card")
.flex_none()
.relative()
.p(px(10.))
.rounded(px(Theme::control_radius()))
.border_1()
.border_color(if selected {
theme.accent
} else {
gpui::hsla(0., 0., 0., 0.)
})
.bg(theme.surface_raised)
.cursor_pointer()
.hover(|el| el.bg(theme.surface_raised_hover))
.flex()
.flex_col()
.gap(px(6.))
.child(
div()
.flex()
.flex_row()
.items_start()
.gap(px(6.))
.child(div().flex_1().min_w_0().child({
let (doc, shortened) = self.card_docs.preview(&text);
card_preview(&doc, shortened, overflow, window, cx)
}))
.child(
self.menu_button(
SharedString::from(format!("card-menu-{id}")),
Some("card"),
icons::layout::Ellipsis,
Menu::Card(id.to_owned()),
cx,
)
.children(self.card_menu(&on_board, id, cx)),
),
)
.when(truncated, |el| {
el.child(
div()
.text_style(TextStyle::Caption)
.text_color(theme.text_faint)
.child("…"),
)
})
.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| self.card_orb(at, cx)))
.child(
div()
.invisible()
.group_hover("card", |el| el.visible())
.flex()
.flex_row()
.items_center()
.gap(px(2.))
.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(&sent, &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| {
if let Some(member) = &member {
this.open_card(pane.as_ref(), member.clone(), opened.clone(), window, cx);
}
}))
.children(reveal)
.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;
#[cfg(test)]
#[path = "../../tests/unit/board_docs.rs"]
mod doc_tests;
#[cfg(test)]
#[path = "../../tests/unit/board_preview.rs"]
mod preview_tests;
#[cfg(test)]
#[path = "../../tests/unit/board_drawer.rs"]
mod drawer_tests;