Files
Ely-GPUI-Components/src/project/panel.rs
T
ZacharyZhang-NYandClaude Opus 5.5 62382f7e68 Project · Issues and boards: a board, issue cards, a task's panel, labels
KanbanBoard lays columns of cards side by side. A card drags to a
place in any column: it rides raised at the pointer, a slot opens where
it would land, the other cards glide aside on motion::Flip, and the
drop asks the owner through on_move(card, column, index); a drop in its
own place asks nothing. Option with an arrow moves the focused card,
whose focus the board keys by card so it follows it across columns. A
press, Enter or Space opens it. Sprint Board is this board.

IssueCard shows an issue's status, name, assignee, title, priority,
labels and due day. TaskDetailPanel lays a task out in full: its title
edits in place, its status, priority, assignee and due day change where
they stand, and its notes and steps follow; each edit hands the whole
task on, and a blank title is not taken. LabelManager tends a team's
labels: a hue picked from a popover, a name edited in place, a delete,
and a field that makes a new one; names stay unique in any case, and a
refused name says why.

The fill rule changes: min_w_full, taken up in T30d, resolves against
a block's outer width in taffy 0.9, so a field in a padded card ran
past its padding. A component that fills now takes no width on its
root, which blocks and flex columns stretch; FormField and the new
roots do. The chart's hue names move to theme::HUE_NAMES, which the
event editor and the label manager share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 05:12:20 -04:00

280 lines
9.6 KiB
Rust

use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div, prelude::*,
};
use super::{
card::label_chip,
marks::{AssigneePicker, IssueIdBadge, StatusSelect},
work::{Person, Priority, Task},
};
use crate::{
buttons::{ButtonVariant, IconButton},
documents::Checklist,
forms::{Choice, DatePicker, InlineEdit, OnValue, Run, Select},
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
};
type OnTask = Rc<dyn Fn(&Task, &mut Window, &mut App)>;
type OnStep = Rc<dyn Fn(usize, bool, &mut Window, &mut App)>;
type Edit = Rc<dyn Fn(&dyn Fn(&mut Task), &mut Window, &mut App)>;
/// A task laid out in full: its status and issue with a way to close, its title to edit in place, its status, priority, assignee, due day and labels, its notes, and its steps. Each edit hands the whole task on.
#[derive(IntoElement)]
pub struct TaskDetailPanel {
id: ElementId,
task: Task,
people: Vec<Person>,
notes: Option<SharedString>,
steps: Vec<(SharedString, bool)>,
on_change: Option<OnTask>,
on_close: Option<Run>,
on_step: Option<OnStep>,
on_add_step: Option<OnValue>,
}
impl TaskDetailPanel {
/// `people` are those it can go to.
pub fn new(
id: impl Into<ElementId>,
task: Task,
people: impl IntoIterator<Item = Person>,
) -> Self {
Self {
id: id.into(),
task,
people: people.into_iter().collect(),
notes: None,
steps: Vec::new(),
on_change: None,
on_close: None,
on_step: None,
on_add_step: None,
}
}
pub fn notes(mut self, notes: impl Into<SharedString>) -> Self {
self.notes = Some(notes.into());
self
}
/// Its steps, each done or not.
pub fn steps(
mut self,
steps: impl IntoIterator<Item = (impl Into<SharedString>, bool)>,
) -> Self {
self.steps = steps
.into_iter()
.map(|(step, done)| (step.into(), done))
.collect();
self
}
/// Gets the task after each edit.
pub fn on_change(mut self, handler: impl Fn(&Task, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_close = Some(Rc::new(handler));
self
}
/// Gets a step's place and whether it is now done.
pub fn on_step(
mut self,
handler: impl Fn(usize, bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_step = Some(Rc::new(handler));
self
}
/// A field under the steps; gets a new step's words.
pub fn on_add_step(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_add_step = Some(Rc::new(handler));
self
}
}
/// A property's name over a fixed column and its control, which drops below when narrow.
fn property(name: &'static str, control: AnyElement, cx: &App) -> impl IntoElement {
let theme = cx.theme();
div()
.flex()
.flex_wrap()
.items_center()
.gap_x_3()
.gap_y_1()
.child(
div()
.w(theme.label_width())
.flex_none()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(name),
)
.child(div().flex_1().min_w(theme.label_width()).child(control))
}
impl RenderOnce for TaskDetailPanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id.clone();
let task = self.task;
let edit: Edit = {
let (task, on_change, id) = (task.clone(), self.on_change, id.clone());
Rc::new(move |apply, window, cx| {
let mut next = task.clone();
apply(&mut next);
log::info!("task panel {id:?}: {} changed", next.key);
if let Some(on_change) = &on_change {
on_change(&next, window, cx);
}
})
};
let theme = cx.theme();
let colors = &theme.colors;
let head = div()
.flex()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
Icon::new(task.status.icon())
.size(IconSize::Sm)
.color(colors.fg_muted),
)
.children(task.issue.clone().map(IssueIdBadge::new)),
)
.children(self.on_close.map(|close| {
IconButton::new((id.clone(), "close"), IconName::X)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Close")
.on_click(move |_, window, cx| close(window, cx))
}));
let title = {
let edit = edit.clone();
InlineEdit::new((id.clone(), "title"), task.title.clone()).on_commit(
move |title, window, cx| {
let title = SharedString::from(title.trim().to_string());
if title.is_empty() {
log::info!("task panel: a blank title is not taken");
return;
}
edit(&move |task| task.title = title.clone(), window, cx)
},
)
};
let status = {
let edit = edit.clone();
StatusSelect::new((id.clone(), "status"), task.status)
.size(ControlSize::Sm)
.on_change(move |status, window, cx| {
edit(&move |task| task.status = status, window, cx)
})
};
let priority = {
let edit = edit.clone();
let choices =
Priority::ALL.map(|priority| Choice::new(priority.key(), priority.words()));
Select::new((id.clone(), "priority"), choices)
.selected(task.priority.key())
.size(ControlSize::Sm)
.on_change(move |key: &SharedString, window, cx| {
let priority = Priority::of_key(key);
edit(&move |task| task.priority = priority, window, cx)
})
};
let assignee = {
let edit = edit.clone();
let picker = AssigneePicker::new((id.clone(), "assignee"), self.people)
.size(ControlSize::Sm)
.on_change(move |person, window, cx| {
let person = person.cloned();
edit(&move |task| task.assignee = person.clone(), window, cx)
});
match &task.assignee {
Some(person) => picker.assignee(person.key.clone()),
None => picker,
}
};
let due = {
let edit = edit.clone();
DatePicker::new((id.clone(), "due"), task.due)
.size(ControlSize::Sm)
.on_change(move |day, window, cx| {
edit(&move |task| task.due = Some(day), window, cx)
})
};
let labels = match task.labels.is_empty() {
true => div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_subtle)
.child("None"),
false => div()
.flex()
.flex_wrap()
.gap_x_3()
.gap_y_1()
.children(task.labels.iter().map(|label| label_chip(label, cx))),
};
let steps = (!self.steps.is_empty() || self.on_add_step.is_some()).then(|| {
let steps = Checklist::new((id.clone(), "steps"), "Subtasks", self.steps);
let steps = match self.on_step {
Some(on_step) => {
steps.on_toggle(move |ix, done, window, cx| on_step(ix, done, window, cx))
}
None => steps,
};
match self.on_add_step {
Some(on_add) => steps.on_add("Add a subtask", move |words, window, cx| {
on_add(words, window, cx)
}),
None => steps,
}
});
div()
.debug_selector(|| "task-panel".into())
.flex()
.flex_col()
.gap_5()
.child(head)
.child(
div()
.text_size(theme.text_size(TextSize::Lg))
.font_weight(FontWeight::SEMIBOLD)
.child(title),
)
.child(
div()
.flex()
.flex_col()
.gap_2()
.child(property("Status", status.into_any_element(), cx))
.child(property("Priority", priority.into_any_element(), cx))
.child(property("Assignee", assignee.into_any_element(), cx))
.child(property("Due", due.into_any_element(), cx))
.child(property("Labels", labels.into_any_element(), cx)),
)
.children(self.notes.map(|notes| {
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg)
.child(notes)
}))
.children(steps)
}
}