From e561146c2723498109797c1566d3e7b147e22574 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 11 Nov 2026 22:32:01 +0000 Subject: [PATCH] feat(zbugs-gpui): three-column layout as zbugs-swift's A collapsible sidebar with the filters as labeled rows (Status, Sort, Order, Created by Me and Assigned to Me, then Project, Creator, Assignee and Labels), the list with its title, count, connection and new-issue, select and account buttons in its toolbar, and the issue with close, edit and delete icons and the search in its toolbar. The toolbars take the title bar's place. The theme follows the system's light or dark appearance. Amp-Thread-ID: https://ampcode.com/threads/T-cbacad36-05e5-4611-baca-5ce275bfe755 Co-authored-by: tmm --- apps/zbugs-gpui/src/app.rs | 1155 ++++++++++++++++++++++------------- apps/zbugs-gpui/src/main.rs | 31 +- 2 files changed, 761 insertions(+), 425 deletions(-) diff --git a/apps/zbugs-gpui/src/app.rs b/apps/zbugs-gpui/src/app.rs index cfd076b841..87c24f8fb3 100644 --- a/apps/zbugs-gpui/src/app.rs +++ b/apps/zbugs-gpui/src/app.rs @@ -10,12 +10,14 @@ use crate::picker::{self, Choice}; use crate::session::{Session, app_dir, now_ms, save_setting}; use futures::StreamExt; use futures::channel::mpsc; +use gpui_kit::assets::IconName as Lucide; use gpui_kit::base::actions::Confirm; use gpui_kit::component::button::*; use gpui_kit::component::dialog::DialogFooter; use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; use gpui_kit::component::menu::{DropdownMenu as _, PopupMenu, PopupMenuItem}; use gpui_kit::component::notification::Notification; +use gpui_kit::component::switch::Switch; use gpui_kit::component::text::TextView; use gpui_kit::component::tooltip::Tooltip; use gpui_kit::component::*; @@ -43,7 +45,13 @@ use zero_client::ttl::Ttl; /// Rows the list asks for at a time, as zbugs' list pages. const PAGE: i64 = 100; -const ROW_HEIGHT: f32 = 52.; +const ROW_HEIGHT: f32 = 56.; +/// The columns' toolbars, together the window's toolbar. +const TOOLBAR_HEIGHT: f32 = 52.; +/// The room the window's traffic lights take at the toolbar's start. +const TRAFFIC_LIGHTS: f32 = 84.; +const SIDEBAR_WIDTH: f32 = 250.; +const LIST_WIDTH: f32 = 380.; /// Comments `issueDetail` loads, and how many more "Show older comments" /// adds. const COMMENT_PAGE: i64 = 50; @@ -216,6 +224,41 @@ struct Seen { emoji: BTreeSet, } +/// An issue's state, as zbugs-swift shows it: a green circle when open, a +/// purple check when closed. +fn state_icon(open: bool, cx: &App) -> Icon { + let (icon, color) = if open { + (Lucide::Circle, cx.theme().green) + } else { + (Lucide::CircleCheck, closed_color()) + }; + Icon::new(icon).small().text_color(color) +} + +/// zbugs-swift's purple for a closed issue. +fn closed_color() -> Hsla { + hsla(271. / 360., 0.6, 0.6, 1.) +} + +fn label_chip(name: &str, cx: &App) -> impl IntoElement { + div() + .flex_none() + .px_1p5() + .rounded_full() + .bg(cx.theme().secondary) + .text_color(cx.theme().secondary_foreground) + .child(name.to_owned()) +} + +/// Controls in a toolbar: pressing them doesn't move the window. +fn controls() -> Div { + h_flex() + .flex_none() + .gap_0p5() + .items_center() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) +} + /// `user`'s avatar, round, as zbugs' `AvatarImage`; GitHub's at 64 /// pixels, as `avatarURLWithSize` asks for. fn avatar(user: &User, size: Pixels) -> impl IntoElement { @@ -331,6 +374,10 @@ pub struct ZbugsApp { /// The window's title, as zbugs' page titles (`window_title`). title: String, seen: Option, + /// Whether the filters' sidebar shows. + sidebar: bool, + /// Where a press on a toolbar began, until it moves the window. + pressed: Option>, _unsubscribe: Box, _tasks: Vec>, _subscriptions: Vec, @@ -391,7 +438,12 @@ impl ZbugsApp { // On every change, drawn or not: a window that isn't drawing // still marks the issue it shows viewed. cx.observe_self(|this, cx| this.mark_viewed(cx)), + // Light or dark, as the system is. + cx.observe_window_appearance(window, |_, window, cx| { + Theme::sync_system_appearance(Some(window), cx) + }), ]; + Theme::sync_system_appearance(Some(window), cx); // The connection badge. let connection = Rc::new(RefCell::new(session.zero.connection().state())); @@ -452,6 +504,8 @@ impl ZbugsApp { view_timer: None, title: String::new(), seen: None, + sidebar: true, + pressed: None, _unsubscribe: unsubscribe, _tasks: vec![watch], _subscriptions: subscriptions, @@ -476,7 +530,9 @@ impl ZbugsApp { /// Starts over on another login or other servers. fn replace_session(&mut self, session: Session, window: &mut Window, cx: &mut Context) { + let sidebar = self.sidebar; *self = ZbugsApp::new(session, window, cx); + self.sidebar = sidebar; cx.notify(); } @@ -720,15 +776,17 @@ impl ZbugsApp { Button::new("account") .small() .ghost() - .label(match (&login, logged_in) { - (Some(login), _) => login.clone(), - (None, true) => "Account".into(), - (None, false) => "Log in".into(), - }) + .icon(Lucide::CircleUser) + .tooltip("Account") .dropdown_menu(move |menu: PopupMenu, _, _| { let (a, b, c) = (app.clone(), app.clone(), app.clone()); let menu = if logged_in { - menu.item( + menu.label(match &login { + Some(login) => format!("Logged in as {login}"), + None => "Logged in".into(), + }) + .separator() + .item( PopupMenuItem::new("Log out").on_click(move |_, window, cx| { let _ = a.update(cx, |this, cx| this.log_in(None, window, cx)); }), @@ -1654,14 +1712,65 @@ impl ZbugsApp { true } - fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { - let current = self.filters.project.to_lowercase(); + /// A column's toolbar, together the window's toolbar as in a macOS app + /// with a unified one: dragging it moves the window, and a double + /// click zooms it, past its `controls`. + fn toolbar(&self, id: &'static str, cx: &mut Context) -> Stateful
{ + h_flex() + .id(id) + .flex_none() + .h(px(TOOLBAR_HEIGHT)) + .px_3() + .gap_2() + .items_center() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, e: &MouseDownEvent, _, _| this.pressed = Some(e.position)), + ) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, _| this.pressed = None), + ) + .on_mouse_move(cx.listener(|this, e: &MouseMoveEvent, window, _| { + let Some(at) = this.pressed else { return }; + let moved = e.position - at; + if e.pressed_button != Some(MouseButton::Left) { + this.pressed = None; + } else if moved.x.abs() > px(3.) || moved.y.abs() > px(3.) { + this.pressed = None; + window.start_window_move(); + } + })) + .on_double_click(|_, window, _| window.titlebar_double_click()) + } + + fn sidebar_toggle(&self, cx: &mut Context) -> impl IntoElement { + Button::new("toggle-sidebar") + .small() + .ghost() + .icon(Lucide::PanelLeft) + .tooltip(if self.sidebar { + "Hide sidebar" + } else { + "Show sidebar" + }) + .on_click(cx.listener(|this, _, _, cx| { + this.sidebar = !this.sidebar; + cx.notify(); + })) + } + + /// The filters, as zbugs-swift's sidebar: its rows (status, sort, + /// order, and the user's own), then the web app's others. + fn render_sidebar(&self, lights: Pixels, cx: &mut Context) -> impl IntoElement { + let f = &self.filters; + let current = f.project.to_lowercase(); let mut projects = self.projects.snapshot().value.clone().unwrap_or_default(); projects.sort_by(|a, b| a.lower_case_name.cmp(&b.lower_case_name)); let project = self.picker( Button::new("project") - .label(self.filters.project.clone()) - .tooltip("Project"), + .label(f.project.clone()) + .dropdown_caret(true), projects .into_iter() .map(|p| Choice { @@ -1678,16 +1787,217 @@ impl ZbugsApp { }), cx, ); - let status_button = |id: &'static str, label: &'static str, status: Status| { - Button::new(id) - .label(label) - .small() - .ghost() - .selected(self.filters.status == status) - .on_click( - cx.listener(move |this, _, _, cx| this.refilter(|f| f.status = status, cx)), - ) + let choice = |label: &str, value: &str, checked: bool| Choice { + avatar: None, + label: label.into(), + value: Some(value.into()), + checked, + }; + let status = self.picker( + Button::new("status") + .label(match f.status { + Status::Open => "Open", + Status::Closed => "Closed", + Status::All => "All", + }) + .dropdown_caret(true), + vec![ + choice("Open", "open", f.status == Status::Open), + choice("Closed", "closed", f.status == Status::Closed), + choice("All", "all", f.status == Status::All), + ], + Rc::new(|this, value, _, cx| { + let status = match value.as_deref() { + Some("closed") => Status::Closed, + Some("all") => Status::All, + _ => Status::Open, + }; + this.refilter(|f| f.status = status, cx) + }), + cx, + ); + let sort = self.picker( + Button::new("sort") + .label(if f.by_created { "Created" } else { "Modified" }) + .dropdown_caret(true), + vec![ + choice("Modified", "modified", !f.by_created), + choice("Created", "created", f.by_created), + ], + Rc::new(|this, value, _, cx| { + let by_created = value.as_deref() == Some("created"); + this.refilter(|f| f.by_created = by_created, cx) + }), + cx, + ); + let order = self.picker( + Button::new("order") + .label(if f.ascending { + "Oldest First" + } else { + "Newest First" + }) + .dropdown_caret(true), + vec![ + choice("Newest First", "newest", !f.ascending), + choice("Oldest First", "oldest", f.ascending), + ], + Rc::new(|this, value, _, cx| { + let ascending = value.as_deref() == Some("oldest"); + this.refilter(|f| f.ascending = ascending, cx) + }), + cx, + ); + // The user's own issues, with their login: the creator and + // assignee filters' values. + let login = self + .me + .as_ref() + .and_then(|me| me.snapshot().value.clone().flatten()) + .map(|u| u.login); + let mine = |id: &'static str, on: bool, assigned: bool, cx: &mut Context| { + let login = login.clone(); + Switch::new(id) + .checked(on) + .on_click(cx.listener(move |this, on: &bool, _, cx| { + let login = on.then(|| login.clone()).flatten(); + this.refilter( + |f| match assigned { + true => f.assignee = login, + false => f.creator = login, + }, + cx, + ) + })) + .into_any_element() + }; + let by_me = login.as_ref().map(|l| { + let on = f.creator.as_ref() == Some(l); + mine("created-by-me", on, false, cx) + }); + let to_me = login.as_ref().map(|l| { + let on = f.assignee.as_ref() == Some(l); + mine("assigned-to-me", on, true, cx) + }); + let creator = self.searchable( + "filter-creator", + false, + Button::new("filter-creator") + .label(f.creator.clone().unwrap_or_else(|| "Anyone".into())) + .dropdown_caret(true), + Self::user_choices(&self.creators, "Anyone", f.creator.as_deref()), + Rc::new(|this, login, _, cx| this.refilter(|f| f.creator = login, cx)), + cx, + ); + let assignee = self.searchable( + "filter-assignee", + false, + Button::new("filter-assignee") + .label(f.assignee.clone().unwrap_or_else(|| "Anyone".into())) + .dropdown_caret(true), + Self::user_choices(&self.assignees, "Anyone", f.assignee.as_deref()), + Rc::new(|this, login, _, cx| this.refilter(|f| f.assignee = login, cx)), + cx, + ); + let mut names = self + .labels + .snapshot() + .value + .iter() + .flatten() + .map(|l| l.name.clone()) + .collect::>(); + names.sort_by_key(|n| n.to_lowercase()); + let mut label_choices = vec![Choice { + avatar: None, + label: "Any label".into(), + value: None, + checked: f.labels.is_empty(), + }]; + label_choices.extend(names.into_iter().map(|name| Choice { + avatar: None, + checked: f.labels.contains(&name), + label: name.clone(), + value: Some(name), + })); + let labels = self.searchable( + "filter-labels", + true, + Button::new("filter-labels") + .label(match f.labels.as_slice() { + [] => "Any".into(), + [one] => one.clone(), + many => format!("{} labels", many.len()), + }) + .dropdown_caret(true), + label_choices, + Rc::new(|this, name, _, cx| this.toggle_label_filter(name, cx)), + cx, + ); + // Rows of a label and its control, in a rounded group, as a + // grouped form's. + let group = |rows: Vec<(&'static str, AnyElement)>, cx: &mut Context| { + v_flex() + .mx_3() + .rounded_lg() + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .children(rows.into_iter().enumerate().map(|(i, (label, control))| { + h_flex() + .h_9() + .pl_3() + .pr_1() + .gap_2() + .items_center() + .justify_between() + .when(i > 0, |d| d.border_t_1().border_color(cx.theme().border)) + .child(div().text_sm().truncate().child(label)) + .child(div().flex_none().pr_1().child(control)) + })) }; + let mut rows = vec![("Status", status), ("Sort", sort), ("Order", order)]; + rows.extend(by_me.map(|c| ("Created by Me", c))); + rows.extend(to_me.map(|c| ("Assigned to Me", c))); + let first = group(rows, cx); + let more = group( + vec![ + ("Project", project), + ("Creator", creator), + ("Assignee", assignee), + ("Labels", labels), + ], + cx, + ); + v_flex() + .flex_none() + .w(px(SIDEBAR_WIDTH)) + .h_full() + .bg(cx.theme().sidebar) + .border_r_1() + .border_color(cx.theme().sidebar_border) + .child( + self.toolbar("sidebar-toolbar", cx) + .pl(lights) + .child(div().flex_1()) + .child(controls().child(self.sidebar_toggle(cx))), + ) + .child( + v_flex() + .id("filters") + .flex_1() + .overflow_y_scroll() + .py_2() + .gap_4() + .child(first) + .child(more), + ) + } + + /// The list's toolbar, as zbugs-swift's: its title, the count, the + /// connection, and buttons to write an issue, pick issues to delete, + /// and log in or out. + fn render_list_toolbar(&self, lights: Pixels, cx: &mut Context) -> impl IntoElement { let count = { let s = self.count.snapshot(); match (s.value, self.list.snapshot().value.as_ref()) { @@ -1696,48 +2006,59 @@ impl ZbugsApp { _ => String::new(), } }; - v_flex() - .gap_2() - .p_2() + let logged_in = self.session.auth.is_some(); + self.toolbar("list-toolbar", cx) .border_b_1() .border_color(cx.theme().border) + .when(!self.sidebar, |d| { + d.pl(lights) + .child(controls().child(self.sidebar_toggle(cx))) + }) .child( - h_flex() - .gap_1() - .child(project) - .child(status_button("status-open", "Open", Status::Open)) - .child(status_button("status-closed", "Closed", Status::Closed)) - .child(status_button("status-all", "All", Status::All)) - .child(div().flex_1()) - .child( - div() - .id("issue-count") - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(count), - ) - .child(self.render_connection(cx)) - .child(self.render_account(cx)), + div() + .min_w_0() + .truncate() + .font_weight(FontWeight::SEMIBOLD) + .child(match self.filters.status { + Status::Open => "Open Issues", + Status::Closed => "Closed Issues", + Status::All => "All Issues", + }), ) - .child(self.render_filters(cx)) + .child(div().flex_1()) .child( - h_flex() - .gap_2() - .child( - div() - .flex_1() - .child(Input::new(&self.search).small().cleanable(true)), - ) + div() + .id("issue-count") + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(count), + ) + .child(self.render_connection(cx)) + .child( + controls() .child( Button::new("new-issue") .small() - .primary() - .label("New issue") - .disabled(self.session.auth.is_none()) + .ghost() + .icon(Lucide::SquarePen) + .tooltip("New issue") + .disabled(!logged_in) .on_click(cx.listener(|this, _, window, cx| { this.open_editor(None, window, cx) })), - ), + ) + .when(logged_in, |d| { + d.child( + Button::new("start-checking") + .small() + .ghost() + .icon(Lucide::ListChecks) + .selected(self.checked.is_some()) + .tooltip("Select issues to delete") + .on_click(cx.listener(|this, _, _, cx| this.toggle_checking(cx))), + ) + }) + .child(self.render_account(cx)), ) } @@ -1821,13 +2142,19 @@ impl ZbugsApp { choices } - fn render_filters(&self, cx: &mut Context) -> AnyElement { - if let Some(checked) = &self.checked { - let count = checked.len(); - return h_flex() + /// While picking issues to delete, the bar to delete them or stop. + fn render_bulk_bar(&self, cx: &mut Context) -> Option { + let count = self.checked.as_ref()?.len(); + Some( + h_flex() + .flex_none() .gap_1() + .px_2() + .py_1() .items_center() - .child(div().px_2().text_sm().child(format!("{count} selected"))) + .border_b_1() + .border_color(cx.theme().border) + .child(div().px_1().text_sm().child(format!("{count} selected"))) .child(div().flex_1()) .child( Button::new("delete-checked") @@ -1855,132 +2182,35 @@ impl ZbugsApp { .label("Cancel") .on_click(cx.listener(|this, _, _, cx| this.toggle_checking(cx))), ) - .into_any_element(); - } - let f = &self.filters; - let creator = self.searchable( - "filter-creator", - false, - Button::new("filter-creator") - .label( - f.creator - .as_ref() - .map_or("Creator".into(), |l| format!("Creator: {l}")), - ) - .selected(f.creator.is_some()), - Self::user_choices(&self.creators, "Anyone", f.creator.as_deref()), - Rc::new(|this, login, _, cx| this.refilter(|f| f.creator = login, cx)), - cx, - ); - let assignee = self.searchable( - "filter-assignee", - false, - Button::new("filter-assignee") - .label( - f.assignee - .as_ref() - .map_or("Assignee".into(), |l| format!("Assignee: {l}")), - ) - .selected(f.assignee.is_some()), - Self::user_choices(&self.assignees, "Anyone", f.assignee.as_deref()), - Rc::new(|this, login, _, cx| this.refilter(|f| f.assignee = login, cx)), - cx, - ); - let mut names = self - .labels - .snapshot() - .value - .iter() - .flatten() - .map(|l| l.name.clone()) - .collect::>(); - names.sort_by_key(|n| n.to_lowercase()); - let mut label_choices = vec![Choice { - avatar: None, - label: "Any label".into(), - value: None, - checked: f.labels.is_empty(), - }]; - label_choices.extend(names.into_iter().map(|name| Choice { - avatar: None, - checked: f.labels.contains(&name), - label: name.clone(), - value: Some(name), - })); - let labels = self.searchable( - "filter-labels", - true, - Button::new("filter-labels") - .label(match f.labels.as_slice() { - [] => "Labels".into(), - [one] => format!("Label: {one}"), - many => format!("Labels: {}", many.len()), - }) - .selected(!f.labels.is_empty()), - label_choices, - Rc::new(|this, name, _, cx| this.toggle_label_filter(name, cx)), - cx, - ); - let sort_field = Button::new("sort-field") - .small() - .ghost() - .label(if f.by_created { "Created" } else { "Modified" }) - .tooltip("Sort by modified or created") - .on_click( - cx.listener(|this, _, _, cx| this.refilter(|f| f.by_created = !f.by_created, cx)), - ); - let sort_direction = Button::new("sort-direction") - .small() - .ghost() - .label(if f.ascending { "↑" } else { "↓" }) - .tooltip(if f.ascending { - "Oldest first" - } else { - "Newest first" - }) - .on_click( - cx.listener(|this, _, _, cx| this.refilter(|f| f.ascending = !f.ascending, cx)), - ); - h_flex() - .gap_1() - .flex_wrap() - .child(creator) - .child(assignee) - .child(labels) - .child(div().flex_1()) - .child(sort_field) - .child(sort_direction) - .when(self.session.auth.is_some(), |d| { - d.child( - Button::new("start-checking") - .small() - .ghost() - .label("Select") - .tooltip("Select issues to delete") - .on_click(cx.listener(|this, _, _, cx| this.toggle_checking(cx))), - ) - }) - .into_any_element() + .into_any_element(), + ) } fn render_connection(&self, cx: &mut Context) -> impl IntoElement { let state = self.connection.borrow().clone(); - let (color, label) = match &state { - ConnectionState::Connected => (cx.theme().success, "Connected".to_owned()), + let (color, label, reason) = match &state { + ConnectionState::Connected => (cx.theme().success, "Connected", None), ConnectionState::Connecting { .. } | ConnectionState::Initializing => { - (cx.theme().warning, "Connecting".to_owned()) + (cx.theme().warning, "Connecting", None) + } + ConnectionState::NeedsAuth(_) => (cx.theme().danger, "Log In Again", None), + ConnectionState::Disconnected { reason } | ConnectionState::Closed { reason } => { + (cx.theme().danger, "Offline", Some(reason.clone())) } - ConnectionState::NeedsAuth(_) => (cx.theme().danger, "Needs login".to_owned()), - ConnectionState::Disconnected { reason } - | ConnectionState::Error { reason } - | ConnectionState::Closed { reason } => (cx.theme().danger, reason.clone()), + ConnectionState::Error { reason } => (cx.theme().danger, "Error", Some(reason.clone())), }; - div() + h_flex() .id("connection") - .size_2() - .rounded_full() - .bg(color) - .tooltip(move |window, cx| Tooltip::new(label.clone()).build(window, cx)) + .flex_none() + .gap_1() + .items_center() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(div().size_2().rounded_full().bg(color)) + .child(label) + .when_some(reason, |d, reason| { + d.tooltip(move |window, cx| Tooltip::new(reason.clone()).build(window, cx)) + }) } fn render_list(&self, cx: &mut Context) -> impl IntoElement { @@ -2036,17 +2266,6 @@ impl ZbugsApp { let selected = self.selected.as_deref() == Some(issue.id.as_str()); let unread = self.session.auth.is_some() && is_unread(issue); let id = issue.id.clone(); - let creator = issue - .creator - .as_ref() - .map_or(String::new(), |u| u.login.clone()); - let labels = issue - .labels - .iter() - .flatten() - .map(|l| l.name.clone()) - .collect::>() - .join(", "); // While picking issues to delete, a click checks the issue // instead, if the user can delete it. let check = self.checked.as_ref().map(|checked| { @@ -2056,33 +2275,40 @@ impl ZbugsApp { ) }); let row = issue.clone(); + let subtitle = [ + issue.short_id.map(|n| format!("#{n}")), + issue.creator.as_ref().map(|u| u.login.clone()), + Some(relative_time( + if self.filters.by_created { + issue.created + } else { + issue.modified + }, + now_ms(), + )), + ] + .into_iter() + .flatten() + .collect::>() + .join(" · "); let content = v_flex() .flex_1() .min_w_0() + .gap_0p5() .when(check.is_some_and(|(_, can)| !can), |d| d.opacity(0.5)) .child( h_flex() .gap_2() - .child( - div() - .text_color(cx.theme().muted_foreground) - .child(issue.short_id.map_or(String::new(), |n| format!("#{n}"))), - ) + .items_center() + .child(state_icon(issue.open, cx)) .child( div() .flex_1() .min_w_0() .truncate() + .font_weight(FontWeight::SEMIBOLD) .child(issue.title.clone()), ) - .when(!issue.open, |d| { - d.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("Closed"), - ) - }) .when(unread, |d| { d.child( div() @@ -2100,31 +2326,26 @@ impl ZbugsApp { ) .child( h_flex() - .gap_2() + .pl(px(22.)) + .gap_1p5() + .overflow_hidden() .text_xs() .text_color(cx.theme().muted_foreground) - .child(creator) - .child(div().flex_1().min_w_0().truncate().child(labels)) - .child(div().flex_none().child(relative_time( - if self.filters.by_created { - issue.created - } else { - issue.modified - }, - now_ms(), - ))), + .child(div().flex_none().child(subtitle)) + .children( + issue + .labels + .iter() + .flatten() + .map(|l| label_chip(&l.name, cx)), + ), ); - h_flex() + div() .id(SharedString::from(format!("issue-{}", issue.id))) .w_full() .h(px(ROW_HEIGHT)) - .px_3() - .gap_3() - .overflow_hidden() - .border_b_1() - .border_color(cx.theme().border) - .when(selected, |d| d.bg(cx.theme().accent)) - .hover(|d| d.bg(cx.theme().accent)) + .px_2() + .py_0p5() .on_click(cx.listener(move |this, _, window, cx| { if this.checked.is_some() { this.toggle_checked(&row, cx); @@ -2133,31 +2354,41 @@ impl ZbugsApp { } window.focus(&this.list_focus, cx); })) - .when_some(check, |d, (checked, can)| { - d.child( - div() - .flex_none() - .size_4() - .flex() - .items_center() - .justify_center() - .rounded_sm() - .border_1() - .text_xs() - .when(!can, |d| d.opacity(0.4)) - .border_color(if checked { - cx.theme().primary - } else { - cx.theme().border - }) - .when(checked, |d| { - d.bg(cx.theme().primary) - .text_color(cx.theme().primary_foreground) - .child("✓") - }), - ) - }) - .child(content) + .child( + h_flex() + .size_full() + .px_2() + .gap_2() + .rounded_md() + .overflow_hidden() + .when(selected, |d| d.bg(cx.theme().list_active)) + .when(!selected, |d| d.hover(|d| d.bg(cx.theme().list_hover))) + .when_some(check, |d, (checked, can)| { + d.child( + div() + .flex_none() + .size_4() + .flex() + .items_center() + .justify_center() + .rounded_sm() + .border_1() + .text_xs() + .when(!can, |d| d.opacity(0.4)) + .border_color(if checked { + cx.theme().primary + } else { + cx.theme().border + }) + .when(checked, |d| { + d.bg(cx.theme().primary) + .text_color(cx.theme().primary_foreground) + .child("✓") + }), + ) + }) + .child(content), + ) } /// The reactions on an issue or comment, one pill per emoji, and a @@ -2276,6 +2507,73 @@ impl ZbugsApp { .into_any_element() } + /// The detail's toolbar, as zbugs-swift's: the issue's actions, and the + /// search at the window's end. + fn render_detail_toolbar( + &self, + issue: Option<&Issue>, + cx: &mut Context, + ) -> impl IntoElement { + let actions = issue.filter(|_| self.session.auth.is_some()).map(|issue| { + let (toggle, edit, id) = (issue.clone(), issue.clone(), issue.id.clone()); + controls() + .child( + Button::new("toggle-open") + .small() + .ghost() + .icon(if issue.open { + Lucide::CircleCheck + } else { + Lucide::Undo2 + }) + .tooltip(if issue.open { "Close issue" } else { "Reopen" }) + .on_click(cx.listener(move |this, _, _, cx| this.toggle_open(&toggle, cx))), + ) + .child( + Button::new("edit-issue") + .small() + .ghost() + .icon(Lucide::Pencil) + .tooltip("Edit") + .on_click(cx.listener(move |this, _, window, cx| { + this.open_editor(Some(edit.clone()), window, cx) + })), + ) + .when(self.can_change(&issue.creator_id), |d| { + d.child( + Button::new("delete-issue") + .small() + .ghost() + .icon(Lucide::Trash) + .tooltip("Delete") + .on_click(cx.listener(move |this, _, window, cx| { + let id = id.clone(); + this.confirm_delete( + "Delete this issue?", + move |this, cx| this.delete_issue(&id, cx), + window, + cx, + ) + })), + ) + }) + }); + self.toolbar("detail-toolbar", cx) + .border_b_1() + .border_color(cx.theme().border) + .children(actions) + .child(div().flex_1()) + .child( + controls().w(px(240.)).child( + Input::new(&self.search).small().cleanable(true).prefix( + Icon::new(Lucide::Search) + .small() + .text_color(cx.theme().muted_foreground), + ), + ), + ) + } + fn render_detail(&self, window: &mut Window, cx: &mut Context) -> AnyElement { let empty = |text: &'static str, cx: &mut Context| { div() @@ -2317,8 +2615,6 @@ impl ZbugsApp { let who = |u: &Option>| { u.as_ref().map_or("nobody".to_owned(), |u| u.login.clone()) }; - let toggle = issue.clone(); - let edit = issue.clone(); let assignee_id = issue.assignee_id.clone(); let mut assignee_choices = vec![Choice { avatar: None, @@ -2461,197 +2757,191 @@ impl ZbugsApp { .on_action(cx.listener(|this, _: &SelectPrevious, _, cx| this.move_selection(-1, cx))) .size_full() .overflow_y_scroll() - .p_6() - .gap_4() .child( - h_flex() - .gap_3() - .items_start() + v_flex() + .w_full() + .max_w(px(760.)) + .mx_auto() + .p_6() + .gap_4() .child( div() - .flex_1() - .min_w_0() .text_xl() - .font_weight(FontWeight::SEMIBOLD) + .font_weight(FontWeight::BOLD) .child(issue.title.clone()), ) - .child( - Button::new("edit-issue") - .small() - .outline() - .label("Edit") - .disabled(!logged_in) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_editor(Some(edit.clone()), window, cx) - })), - ) - .child( - Button::new("toggle-open") - .small() - .label(if issue.open { "Close issue" } else { "Reopen" }) - .disabled(!logged_in) - .on_click( - cx.listener(move |this, _, _, cx| this.toggle_open(&toggle, cx)), - ), - ) - .when(self.can_change(&issue.creator_id), |row| { - let id = issue.id.clone(); - row.child( - Button::new("delete-issue") - .small() - .outline() - .label("Delete") - .on_click(cx.listener(move |this, _, window, cx| { - let id = id.clone(); - this.confirm_delete( - "Delete this issue?", - move |this, cx| this.delete_issue(&id, cx), - window, - cx, - ) - })), - ) - }), - ) - .child( - h_flex() - .flex_wrap() - .gap_3() - .text_sm() - .text_color(cx.theme().muted_foreground) - .children(issue.short_id.map(|n| format!("#{n}"))) - .child(if issue.open { "Open" } else { "Closed" }) .child( h_flex() - .gap_1() - .child("by") - .children(issue.creator.as_deref().map(|u| avatar(u, px(18.)))) - .child(who(&issue.creator)), + .flex_wrap() + .gap_2() + .items_center() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child( + h_flex() + .gap_1() + .items_center() + .text_color(if issue.open { + cx.theme().green + } else { + closed_color() + }) + .child(state_icon(issue.open, cx)) + .child(if issue.open { "Open" } else { "Closed" }), + ) + .child( + h_flex() + .gap_1() + .items_center() + .child("by") + .children(issue.creator.as_deref().map(|u| avatar(u, px(18.)))) + .child(who(&issue.creator)), + ) + .children(issue.short_id.map(|n| format!("· #{n}"))) + .child(format!( + "· updated {}", + relative_time(issue.modified, now_ms()) + )), ) - .child(format!( - "updated {}", - relative_time(issue.modified, now_ms()) - )) - .child(assignee) - .child(labels) - .child(notifications) - .children(visibility) - .children(issue.labels.iter().flatten().map(|l| { - div() - .px_1() - .rounded_sm() - .bg(cx.theme().secondary) - .child(l.name.clone()) - })), - ) - .child(markdown( - format!("description-{}", issue.id), - &issue.description, - window, - cx, - )) - .child(self.render_reactions( - Subject::Issue, - &issue.id, - issue.emoji.as_deref().unwrap_or_default(), - cx, - )) - .child( - div() - .text_sm() - .font_weight(FontWeight::SEMIBOLD) - .child(format!( - "Comments ({}{})", - comments.len(), - if more { "+" } else { "" } - )), - ) - .when(more, |d| { - d.child( - Button::new("older-comments") - .small() - .ghost() - .label("Show older comments") - .on_click(cx.listener(|this, _, _, cx| this.show_older_comments(cx))), - ) - }) - .children(comments.into_iter().map(|c| { - let reactions = self.render_reactions( - Subject::Comment, - &c.id, - c.emoji.as_deref().unwrap_or_default(), - cx, - ); - let (edit, remove) = (c.clone(), c.id.clone()); - v_flex() - .gap_1() - .p_3() - .rounded_md() - .border_1() - .border_color(cx.theme().border) .child( h_flex() + .flex_wrap() .gap_1() - .text_xs() - .text_color(cx.theme().muted_foreground) - .children(c.creator.as_deref().map(|u| avatar(u, px(18.)))) - .child(who(&c.creator)) - .child(div().flex_1().child(relative_time(c.created, now_ms()))) - .when(self.can_change(&c.creator_id), |row| { - row.child( - Button::new(SharedString::from(format!("edit-{}", c.id))) - .xsmall() - .ghost() - .label("Edit") - .on_click(cx.listener(move |this, _, window, cx| { - this.open_comment_editor(edit.clone(), window, cx) - })), - ) - .child( - Button::new(SharedString::from(format!("delete-{}", c.id))) - .xsmall() - .ghost() - .label("Delete") - .on_click(cx.listener(move |this, _, window, cx| { - let id = remove.clone(); - this.confirm_delete( - "Delete this comment?", - move |this, cx| this.delete_comment(&id, cx), - window, - cx, - ) - })), - ) - }), + .items_center() + .text_sm() + .child(assignee) + .child(labels) + .child(notifications) + .children(visibility), ) - .child(markdown(format!("comment-{}", c.id), &c.body, window, cx)) - .child(reactions) - })) - .child( - h_flex() - .gap_2() - .items_end() + .when(issue.labels.iter().flatten().next().is_some(), |d| { + d.child( + h_flex().flex_wrap().gap_1().text_xs().children( + issue + .labels + .iter() + .flatten() + .map(|l| label_chip(&l.name, cx)), + ), + ) + }) + .child(markdown( + format!("description-{}", issue.id), + &issue.description, + window, + cx, + )) + .child(self.render_reactions( + Subject::Issue, + &issue.id, + issue.emoji.as_deref().unwrap_or_default(), + cx, + )) .child( div() - .flex_1() - .key_context("CommentBox") - .on_action(cx.listener(|this, _: &Confirm, window, cx| { - this.post_comment(window, cx) - })) - .child(Textarea::new(&self.comment)), + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .child(format!( + "Comments ({}{})", + comments.len(), + if more { "+" } else { "" } + )), ) + .when(more, |d| { + d.child( + Button::new("older-comments") + .small() + .ghost() + .label("Show older comments") + .on_click( + cx.listener(|this, _, _, cx| this.show_older_comments(cx)), + ), + ) + }) + .children(comments.into_iter().map(|c| { + let reactions = self.render_reactions( + Subject::Comment, + &c.id, + c.emoji.as_deref().unwrap_or_default(), + cx, + ); + let (edit, remove) = (c.clone(), c.id.clone()); + v_flex() + .gap_1() + .p_3() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .children(c.creator.as_deref().map(|u| avatar(u, px(18.)))) + .child(who(&c.creator)) + .child(div().flex_1().child(relative_time(c.created, now_ms()))) + .when(self.can_change(&c.creator_id), |row| { + row.child( + Button::new(SharedString::from(format!( + "edit-{}", + c.id + ))) + .xsmall() + .ghost() + .label("Edit") + .on_click(cx.listener(move |this, _, window, cx| { + this.open_comment_editor(edit.clone(), window, cx) + })), + ) + .child( + Button::new(SharedString::from(format!( + "delete-{}", + c.id + ))) + .xsmall() + .ghost() + .label("Delete") + .on_click(cx.listener(move |this, _, window, cx| { + let id = remove.clone(); + this.confirm_delete( + "Delete this comment?", + move |this, cx| this.delete_comment(&id, cx), + window, + cx, + ) + })), + ) + }), + ) + .child(markdown(format!("comment-{}", c.id), &c.body, window, cx)) + .child(reactions) + })) .child( - Button::new("post-comment") - .primary() - .label("Comment") - .tooltip(if cx.platform_info().is_mac() { - "⌘↩" - } else { - "Ctrl+Enter" - }) - .disabled(!logged_in) - .on_click( - cx.listener(|this, _, window, cx| this.post_comment(window, cx)), + h_flex() + .gap_2() + .items_end() + .child( + div() + .flex_1() + .key_context("CommentBox") + .on_action(cx.listener(|this, _: &Confirm, window, cx| { + this.post_comment(window, cx) + })) + .child(Textarea::new(&self.comment)), + ) + .child( + Button::new("post-comment") + .primary() + .label("Comment") + .tooltip(if cx.platform_info().is_mac() { + "⌘↩" + } else { + "Ctrl+Enter" + }) + .disabled(!logged_in) + .on_click(cx.listener(|this, _, window, cx| { + this.post_comment(window, cx) + })), ), ), ) @@ -2681,27 +2971,46 @@ impl Render for ZbugsApp { window.set_window_title(&title); self.title = title; } + // The traffic lights sit at the first column's start. + let lights = if window.is_fullscreen() { + px(12.) + } else { + px(TRAFFIC_LIGHTS) + }; + let issue = self + .detail + .as_ref() + .and_then(|d| d.snapshot().value.clone().flatten()); h_flex() .size_full() .bg(cx.theme().background) .text_color(cx.theme().foreground) + .when(self.sidebar, |d| d.child(self.render_sidebar(lights, cx))) .child( v_flex() .flex_none() - .w(px(420.)) + // Past the traffic lights, when they're its. + .w(px(LIST_WIDTH) + if self.sidebar { px(0.) } else { lights }) .h_full() .border_r_1() .border_color(cx.theme().border) - .child(self.render_toolbar(cx)) + .child(self.render_list_toolbar(lights, cx)) + .children(self.render_bulk_bar(cx)) .child(self.render_list(cx)), ) .child( - div() + v_flex() .flex_1() .min_w_0() .h_full() - .overflow_hidden() - .child(self.render_detail(window, cx)), + .child(self.render_detail_toolbar(issue.as_ref(), cx)) + .child( + div() + .flex_1() + .min_h_0() + .overflow_hidden() + .child(self.render_detail(window, cx)), + ), ) } } diff --git a/apps/zbugs-gpui/src/main.rs b/apps/zbugs-gpui/src/main.rs index 25fb919fff..6d4f5c813b 100644 --- a/apps/zbugs-gpui/src/main.rs +++ b/apps/zbugs-gpui/src/main.rs @@ -11,11 +11,33 @@ mod token; mod transport; use gpui_kit::*; +use std::borrow::Cow; use std::sync::Arc; +// The icons the app uses past GPUI Kit's default set. +gpui_kit::assets::icon_assets!(ExtraIcons, [Circle, SquarePen, Pencil, ListChecks, Trash]); + +/// GPUI Kit's icons and `ExtraIcons`. +struct Assets; + +impl AssetSource for Assets { + fn load(&self, path: &str) -> Result>> { + match ExtraIcons.load(path)? { + Some(svg) => Ok(Some(svg)), + None => gpui_kit::assets::Assets.load(path), + } + } + + fn list(&self, path: &str) -> Result> { + let mut paths = gpui_kit::assets::Assets.list(path)?; + paths.extend(ExtraIcons.list(path)?); + Ok(paths) + } +} + fn main() { gpui_kit::application() - .with_assets(gpui_kit::assets::Assets) + .with_assets(Assets) .with_http_client(Arc::new(http::UreqClient::new())) .run(|cx| { gpui_kit::init(cx); @@ -25,10 +47,15 @@ fn main() { let (_window, _app) = gpui_kit::open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), + // The columns' toolbars take the title bar's place, as + // a macOS app's unified toolbar does, and move the + // window themselves (`ZbugsApp::toolbar`). titlebar: Some(TitlebarOptions { title: Some("Zero Bugs".into()), - ..Default::default() + appears_transparent: true, + traffic_light_position: Some(point(px(18.), px(18.))), }), + app_owns_titlebar_drag: true, ..Default::default() }, cx,