git-ents.gitmain
⌘K
foforge
dashboard.rs383 lines · 15.1 KB · rusthistorycomment 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
18use std::sync::Arc;
19
20use axum::extract::State;
21use gix::bstr::ByteSlice as _;
22use gix_object::{Find, Write};
23use maud::{Markup, html};
24
25use crate::error::Result;
26use crate::pages::{scope_class, split_scope};
27use 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.
31const 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.
35const 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).
44pub async fn show<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup>
45where
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.
121fn 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.
158fn 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.
171fn 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.
205fn 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.
228fn 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.
254fn 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".
266fn 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.
291fn 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).
313fn 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)]
342mod 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}