crates/cli/ents-web/src/pages/dashboard.rs
dashboard.rshistorycomment on this file
| 1 | //! `GET /`: the workbench dashboard -- `git status` for review and |
| 2 | //! issue tracking (`docs/web-workbench-plan.adoc`'s Phase C home page). Four |
| 3 | //! sections on a `.desk` grid: the working tree's changed files (a live |
| 4 | //! `gix` status of the repository at `state.path`), a needs-attention |
| 5 | //! feed of open comment threads, the open issues, and a full-width |
| 6 | //! History card of recent commits with their Scoped-Commits scope chips. |
| 7 | //! The `README` this page used to render moved to `crate::pages::files`'s |
| 8 | //! root listing -- the dashboard is a work surface, not a document viewer. |
| 9 | //! |
| 10 | //! The status and history reads browse the repository through `gix`'s |
| 11 | //! high-level `Repository` types, opened fresh per request from |
| 12 | //! `state.path`, exactly as [`crate::pages::files`]/[`crate::pages::commits`] |
| 13 | //! do (and for the same reason: browsing arbitrary repository content is |
| 14 | //! not the `facet-git-tree` meta-ref convention the generic pages use). |
| 15 | //! Every repository read here is best-effort: an unopenable repository or |
| 16 | //! a failed status walk degrades to an in-card note, never an error. |
| 17 | |
| 18 | use std::sync::Arc; |
| 19 | |
| 20 | use axum::extract::State; |
| 21 | use gix::bstr::ByteSlice as _; |
| 22 | use gix_object::{Find, Write}; |
| 23 | use maud::{Markup, html}; |
| 24 | |
| 25 | use crate::error::Result; |
| 26 | use crate::pages::{scope_class, split_scope}; |
| 27 | use crate::state::AppState; |
| 28 | |
| 29 | /// How many commits the History card shows -- a dashboard lane, not the |
| 30 | /// full pager `crate::pages::commits::list` already is. |
| 31 | const HISTORY_LIMIT: usize = 8; |
| 32 | |
| 33 | /// How many characters of a comment's or issue's first line a `.what` |
| 34 | /// row shows before ellipsizing. |
| 35 | const WHAT_LIMIT: usize = 90; |
| 36 | |
| 37 | /// `GET /`. |
| 38 | /// |
| 39 | /// # Errors |
| 40 | /// |
| 41 | /// Propagates a ref-store or object read failure on the comment and issue |
| 42 | /// listings; every repository read degrades in-card instead (see this |
| 43 | /// module's own doc). |
| 44 | pub async fn show<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup> |
| 45 | where |
| 46 | O: Find + Write + Send + 'static, |
| 47 | { |
| 48 | let changes = worktree_changes(&state); |
| 49 | let (comments, _unreadable) = |
| 50 | ents_forge::comment::list_all(state.refs.as_ref(), &*state.objects())?; |
| 51 | let open_comments: Vec<(String, ents_forge::comment::Comment)> = comments |
| 52 | .into_iter() |
| 53 | .filter(|(_, comment)| comment.state == "open") |
| 54 | .collect(); |
| 55 | let (issues, _unreadable) = |
| 56 | ents_forge::issue::list_all(state.refs.as_ref(), &*state.objects())?; |
| 57 | let open_issues: Vec<(String, ents_forge::Issue)> = issues |
| 58 | .into_iter() |
| 59 | .filter(|(_, issue)| issue.state == "open") |
| 60 | .collect(); |
| 61 | let (history, _older) = super::commits::commit_rows(&state, None, HISTORY_LIMIT); |
| 62 | |
| 63 | let repo = super::RepoHeader::from_state(&state); |
| 64 | let history_title = repo.branch.as_ref().map_or_else( |
| 65 | || "History".to_owned(), |
| 66 | |branch| format!("History \u{2014} {branch}"), |
| 67 | ); |
| 68 | |
| 69 | let attention = attention_card(&state, &open_comments, open_issues.len()); |
| 70 | // A bespoke `.page-header` rather than [`super::layout`]'s plain |
| 71 | // title-only one: the desk's header carries a subtitle and a |
| 72 | // right-aligned "local · trusted deployment" line beside the title, |
| 73 | // which the shared helper has no slot for. [`super::layout_shell`] is |
| 74 | // the same chrome one layer down, so this still shares every other |
| 75 | // page's `.wb`/`.wb-bar` shell -- only the `.content` wrapper below is |
| 76 | // grown by hand to match. |
| 77 | Ok(super::layout_shell( |
| 78 | &repo, |
| 79 | &super::identity_label(&state), |
| 80 | super::Tab::Overview, |
| 81 | "Dashboard", |
| 82 | html! { |
| 83 | main.content { |
| 84 | div.page-header { |
| 85 | div { |
| 86 | h1.page-title { "Dashboard" } |
| 87 | p.page-sub { |
| 88 | "Your morning desk \u{2014} " span.mono { "git status" } " for review and ticketing." |
| 89 | } |
| 90 | } |
| 91 | div.desk-status { |
| 92 | span.dot {} |
| 93 | "local \u{b7} trusted deployment" |
| 94 | } |
| 95 | } |
| 96 | div.desk { |
| 97 | (working_tree_card(&state, changes.as_deref())) |
| 98 | (attention) |
| 99 | (issues_card(&open_issues)) |
| 100 | } |
| 101 | div.desk-wide { |
| 102 | (history_card(&history_title, &history)) |
| 103 | } |
| 104 | } |
| 105 | }, |
| 106 | )) |
| 107 | } |
| 108 | |
| 109 | /// The "Working tree" card: every changed file [`worktree_changes`] found, |
| 110 | /// each linking into the Files browser with its open-in-editor affordance |
| 111 | /// ([`super::editor_open`]) beside it and its change kind chip |
| 112 | /// ([`kind_chip_class`]) right-aligned. The path sits in a |
| 113 | /// `.desk-path` container so a long path ellipsizes from the front |
| 114 | /// (`direction: rtl`) rather than colliding with the editor pill and kind |
| 115 | /// chip -- both of the latter render inside a plain wrapping `span` so |
| 116 | /// `ents.css`'s blanket `.card-row a { flex: 1 }` rule (aimed at the path |
| 117 | /// link) never reaches into them through the descendant selector. `None` |
| 118 | /// (the status walk itself failed) renders a note row; an empty list |
| 119 | /// renders a "clean" row -- either way the card itself always renders, so |
| 120 | /// the desk's shape is stable. |
| 121 | fn working_tree_card<O: Find>( |
| 122 | state: &AppState<O>, |
| 123 | changes: Option<&[(String, &'static str)]>, |
| 124 | ) -> Markup { |
| 125 | html! { |
| 126 | section.card { |
| 127 | div.card-header { |
| 128 | "Working tree" |
| 129 | @if let Some(changes) = changes { |
| 130 | span.entry-size { (changes.len()) " changed" } |
| 131 | } |
| 132 | } |
| 133 | @match changes { |
| 134 | None => { div.card-row.muted { "Working-tree status unavailable." } }, |
| 135 | Some([]) => { div.card-row.muted { "Clean \u{2014} no uncommitted changes." } }, |
| 136 | Some(changes) => { |
| 137 | @for (path, kind) in changes { |
| 138 | div.card-row { |
| 139 | a.desk-path href={ "/files/" (path) } { (path) } |
| 140 | span { (super::editor_open(state, path, None)) } |
| 141 | span class={ "chip " (kind_chip_class(kind)) } { (kind) } |
| 142 | } |
| 143 | } |
| 144 | }, |
| 145 | } |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | /// The `.chip-*` color class for a working-tree row's change-kind label |
| 151 | /// (README's ChangeKindLabel component): green for a new addition, amber |
| 152 | /// for an ordinary modification, red for a deletion or an unresolved |
| 153 | /// conflict, indigo for a rename, and the neutral gray fallback for |
| 154 | /// anything else ([`change_kind`]'s "untracked" and "type changed" both |
| 155 | /// land here, matching the prototype's own `kindStyle` fallback). `.chip` |
| 156 | /// itself (`ents.css`) supplies the pill's shape; this only picks its |
| 157 | /// color. |
| 158 | fn kind_chip_class(kind: &str) -> &'static str { |
| 159 | match kind { |
| 160 | "added" => "chip-added", |
| 161 | "modified" => "chip-modified", |
| 162 | "deleted" | "conflict" => "chip-deleted", |
| 163 | "renamed" => "chip-renamed", |
| 164 | _ => "chip-untracked", |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | /// The "Needs attention" card: every open comment thread, each linking to |
| 169 | /// its own page and naming where its anchor lands ([`comment_where`]), |
| 170 | /// closed by an open-issues count line when any issues are open. |
| 171 | fn attention_card<O: Find>( |
| 172 | state: &AppState<O>, |
| 173 | open_comments: &[(String, ents_forge::comment::Comment)], |
| 174 | open_issue_count: usize, |
| 175 | ) -> Markup { |
| 176 | html! { |
| 177 | section.card { |
| 178 | div.card-header { "Needs attention" } |
| 179 | @if open_comments.is_empty() && open_issue_count == 0 { |
| 180 | div.card-row.muted { "Nothing waiting on you." } |
| 181 | } |
| 182 | @for (id, comment) in open_comments { |
| 183 | a.attention-row href={ "/comments/" (id) } { |
| 184 | span.what { |
| 185 | span.lead { "open thread" } |
| 186 | " \u{2014} \u{201c}" (what_line(&comment.body)) "\u{201d}" |
| 187 | } |
| 188 | span class="where" { (comment_where(state, comment)) } |
| 189 | } |
| 190 | } |
| 191 | @if open_issue_count > 0 { |
| 192 | a.attention-row href="/issues" { |
| 193 | span.what { |
| 194 | (open_issue_count) |
| 195 | @if open_issue_count == 1 { " open issue" } @else { " open issues" } |
| 196 | } |
| 197 | } |
| 198 | } |
| 199 | } |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | /// The "Issues" card: every open issue linking to its own page, with a |
| 204 | /// ghost "New" button into the Issues page's own composer. |
| 205 | fn issues_card(open_issues: &[(String, ents_forge::Issue)]) -> Markup { |
| 206 | html! { |
| 207 | section.card { |
| 208 | div.card-header { |
| 209 | "Issues" |
| 210 | a.btn.btn-ghost.btn-sm href="/issues" { "+ New" } |
| 211 | } |
| 212 | @if open_issues.is_empty() { |
| 213 | div.card-row.muted { "No open issues." } |
| 214 | } |
| 215 | @for (id, issue) in open_issues { |
| 216 | a.attention-row href={ "/issues/" (id) } { |
| 217 | span.what { (what_line(&issue.title)) } |
| 218 | span class="where" { "#" (ents_forge::abbreviate_id(id)) " \u{b7} " (issue.state) } |
| 219 | } |
| 220 | } |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | /// The full-width "History" card: the most recent commits, each with its |
| 226 | /// Scoped-Commits scope chip ([`split_scope`], [`scope_class`]) when its |
| 227 | /// subject carries one. |
| 228 | fn history_card(title: &str, rows: &[super::commits::CommitRow]) -> Markup { |
| 229 | html! { |
| 230 | section.card.history { |
| 231 | div.card-header { (title) } |
| 232 | @if rows.is_empty() { |
| 233 | div.card-row.muted { "No commits yet." } |
| 234 | } |
| 235 | @for row in rows { |
| 236 | div.card-row { |
| 237 | a href={ "/commit/" (row.oid) } { code { (row.short) } } |
| 238 | @match split_scope(&row.subject) { |
| 239 | Some((scope, rest)) => { |
| 240 | span class={ "scope " (scope_class(scope)) } { (scope) } |
| 241 | span.desk-subject { (rest) } |
| 242 | }, |
| 243 | None => { span.desk-subject { (row.subject) } }, |
| 244 | } |
| 245 | span.desk-when { (row.ago) } |
| 246 | } |
| 247 | } |
| 248 | } |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | /// A body's first line, ellipsized past [`WHAT_LIMIT`] characters -- what |
| 253 | /// a `.what` row shows of a comment or issue. |
| 254 | fn what_line(text: &str) -> String { |
| 255 | let line = text.lines().next().unwrap_or(""); |
| 256 | let mut shown: String = line.chars().take(WHAT_LIMIT).collect(); |
| 257 | if shown.len() < line.len() { |
| 258 | shown.push('\u{2026}'); |
| 259 | } |
| 260 | shown |
| 261 | } |
| 262 | |
| 263 | /// Where an open comment lives, for its `.where` line: its anchor's |
| 264 | /// `path:line` when it carries one this build can read back, else the |
| 265 | /// context entity it names, else a bare "unanchored". |
| 266 | fn comment_where<O: Find>(state: &AppState<O>, comment: &ents_forge::comment::Comment) -> String { |
| 267 | if let Some(raw) = &comment.anchor { |
| 268 | let objects = state.objects(); |
| 269 | if let Ok(anchor) = |
| 270 | facet_git_tree::deserialize::<ents_anchor::Anchor>(&raw.oid(), &*objects) |
| 271 | { |
| 272 | return match anchor.lines { |
| 273 | Some(range) => format!("{}:{}", anchor.path, range.start), |
| 274 | None => anchor.path, |
| 275 | }; |
| 276 | } |
| 277 | } |
| 278 | comment |
| 279 | .context |
| 280 | .clone() |
| 281 | .unwrap_or_else(|| "unanchored".to_owned()) |
| 282 | } |
| 283 | |
| 284 | /// Every changed path in the working tree against `HEAD` and the index -- |
| 285 | /// `gix`'s own status walk (`gix::Repository::status`), deduplicated by |
| 286 | /// path (a file both staged and modified appears in the head-to-index and |
| 287 | /// index-to-worktree halves; the first classification wins) and sorted for |
| 288 | /// a stable render. `None` when the repository cannot be opened or the |
| 289 | /// walk cannot start at all -- [`working_tree_card`] renders a note row |
| 290 | /// then, never an error. |
| 291 | fn worktree_changes<O>(state: &AppState<O>) -> Option<Vec<(String, &'static str)>> { |
| 292 | let repo = gix::open(&state.path).ok()?; |
| 293 | let iter = repo |
| 294 | .status(gix::progress::Discard) |
| 295 | .ok()? |
| 296 | .into_iter(None) |
| 297 | .ok()?; |
| 298 | let mut by_path: std::collections::BTreeMap<String, &'static str> = |
| 299 | std::collections::BTreeMap::new(); |
| 300 | for item in iter.flatten() { |
| 301 | let Some(kind) = change_kind(&item) else { |
| 302 | continue; |
| 303 | }; |
| 304 | by_path |
| 305 | .entry(item.location().to_str_lossy().into_owned()) |
| 306 | .or_insert(kind); |
| 307 | } |
| 308 | Some(by_path.into_iter().collect()) |
| 309 | } |
| 310 | |
| 311 | /// A status item's display kind, or `None` for one that is not a change a |
| 312 | /// reader acts on (a stat-only refresh, an ignored entry). |
| 313 | fn change_kind(item: &gix::status::Item) -> Option<&'static str> { |
| 314 | use gix::status::plumbing::index_as_worktree::{Change, EntryStatus}; |
| 315 | match item { |
| 316 | gix::status::Item::TreeIndex(change) => Some(match change { |
| 317 | gix::diff::index::ChangeRef::Addition { .. } => "added", |
| 318 | gix::diff::index::ChangeRef::Deletion { .. } => "deleted", |
| 319 | gix::diff::index::ChangeRef::Modification { .. } => "modified", |
| 320 | gix::diff::index::ChangeRef::Rewrite { .. } => "renamed", |
| 321 | }), |
| 322 | gix::status::Item::IndexWorktree(change) => match change { |
| 323 | gix::status::index_worktree::Item::Modification { status, .. } => match status { |
| 324 | EntryStatus::Conflict { .. } => Some("conflict"), |
| 325 | EntryStatus::Change(change) => Some(match change { |
| 326 | Change::Removed => "deleted", |
| 327 | Change::Type { .. } => "type changed", |
| 328 | Change::Modification { .. } | Change::SubmoduleModification(_) => "modified", |
| 329 | }), |
| 330 | EntryStatus::NeedsUpdate(_) => None, |
| 331 | EntryStatus::IntentToAdd => Some("added"), |
| 332 | }, |
| 333 | gix::status::index_worktree::Item::DirectoryContents { entry, .. } => { |
| 334 | matches!(entry.status, gix::dir::entry::Status::Untracked).then_some("untracked") |
| 335 | } |
| 336 | gix::status::index_worktree::Item::Rewrite { .. } => Some("renamed"), |
| 337 | }, |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | #[cfg(test)] |
| 342 | mod tests { |
| 343 | #![allow(clippy::expect_used, reason = "unit test")] |
| 344 | |
| 345 | use rstest::rstest; |
| 346 | |
| 347 | use super::*; |
| 348 | |
| 349 | #[rstest] |
| 350 | #[case::scoped("model: fix stale rustdoc", Some(("model", "fix stale rustdoc")))] |
| 351 | #[case::hyphenated("web-ui: polish", Some(("web-ui", "polish")))] |
| 352 | #[case::unscoped("Fix stale rustdoc", None)] |
| 353 | #[case::uppercase_prefix("Model: fix", None)] |
| 354 | #[case::no_colon("just a subject", None)] |
| 355 | #[case::empty_scope(": odd", None)] |
| 356 | fn split_scope_takes_only_a_lowercase_scope_prefix( |
| 357 | #[case] subject: &str, |
| 358 | #[case] expected: Option<(&str, &str)>, |
| 359 | ) { |
| 360 | assert_eq!(split_scope(subject), expected); |
| 361 | } |
| 362 | |
| 363 | #[test] |
| 364 | fn scope_class_is_stable_and_within_the_token_palette() { |
| 365 | let class = scope_class("model"); |
| 366 | assert_eq!(class, scope_class("model"), "same scope, same color"); |
| 367 | let index: usize = class |
| 368 | .strip_prefix("scope-c") |
| 369 | .expect("prefixed class") |
| 370 | .parse() |
| 371 | .expect("numeric suffix"); |
| 372 | assert!(index < 6, "always one of the six --s-* token colors"); |
| 373 | } |
| 374 | |
| 375 | #[test] |
| 376 | fn what_line_takes_the_first_line_and_ellipsizes_long_ones() { |
| 377 | assert_eq!(what_line("short\nrest"), "short"); |
| 378 | let long = "x".repeat(200); |
| 379 | let shown = what_line(&long); |
| 380 | assert!(shown.chars().count() <= WHAT_LIMIT.saturating_add(1)); |
| 381 | assert!(shown.ends_with('\u{2026}')); |
| 382 | } |
| 383 | } |