git-ents.gitmain
⌘K
foforge
commit c03d515
roots: give code, review, and tickets master-detail split panes

layout_split renders a sticky .tree sidebar beside a padded .pane, so file and diff content no longer sits on the viewport edge. The Code pages list the current directory’s entries with parent crumbs and the viewed entry active; a commit page gets the compact recent-history sidebar with the viewed commit active; the tickets list moves into the sidebar (state, assignees, and labels on its locator line) beside the new-issue composer, and a ticket’s page keeps that list beside it.

Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/ents-web/tests/router.rs @@ -895,6 +895,64 @@ assert!(listing < readme, "the README card sits below the listing"); } +/// The master-detail splits (`crate::pages::layout_split`): a blob view +/// renders a `.tree` sidebar with its own entry active and its siblings +/// listed; a commit page renders the compact history sidebar with the +/// viewed commit active; the tickets page renders its list beside the +/// composer. +#[tokio::test] +async fn split_pages_render_a_sidebar_with_the_current_selection_active() { + let dir = seed_repo(&[ + ("src/main.rs", "fn main() {}\n"), + ("src/lib.rs", "pub fn f() {}\n"), + ("README.md", "# hi\n"), + ]); + let oid = head_oid(dir.path()); + let state = build_state_at( + FixtureIdentity { + name: "local-user", + key: Keypair::from_seed(1), + }, + dir.path().to_owned(), + ); + let router = ents_web::router(state.clone()); + let issue_id = seed_issue(&router, &state, "Split the panes", "open", "", "").await; + + let blob = get_body(&router, "/files/src/main.rs").await; + assert!(blob.contains("class=\"tree\""), "the blob page splits"); + assert!( + blob.contains(">main.rs</a>") && blob.contains("active"), + "the viewed blob's own entry renders in the sidebar" + ); + assert!( + blob.contains("href=\"/files/src/lib.rs\""), + "its sibling entries render beside it" + ); + assert!( + blob.contains("class=\"pane\""), + "the content sits in a pane" + ); + + let commit = get_body(&router, &format!("/commit/{oid}")).await; + assert!(commit.contains("class=\"tree\""), "the commit page splits"); + assert!( + commit.contains(&format!("class=\"active\" href=\"/commit/{oid}\"")), + "the viewed commit highlights in the history sidebar" + ); + + let issues = get_body(&router, "/issues").await; + assert!(issues.contains("class=\"tree\""), "the tickets page splits"); + assert!( + issues.contains("Split the panes") && issues.contains("Open an Issue"), + "the list and the composer render side by side" + ); + let detail = get_body(&router, &format!("/issues/{issue_id}")).await; + assert!( + detail.contains(&format!("class=\"active\" href=\"/issues/{issue_id}\"")), + "the viewed ticket highlights in the sidebar" + ); +} + /// `GET /files/<path>` on a plain-text blob with no recognized grammar /// renders a line-numbered, escaped `pre.blob-code` source view -- no /// syntax highlighting, and no unescaped source.
crates/cli/ents-web/src/assets/ents.css @@ -144,6 +144,27 @@ } .content { max-width: var(--max-width); width: 100%; margin: 0 auto; padding: 2.25rem 1.5rem 3rem; flex: 1; } +/* Master-detail split (`crate::pages::layout_split`): a sticky `.tree` + * sidebar beside a padded `.pane` -- the pane, not the shell, owns the + * content padding here, so blob and diff content never sits on the + * viewport edge. */ +.split { display: grid; grid-template-columns: 16rem minmax(0, 1fr); flex: 1; align-items: start; } +.tree { border-right: 1px solid var(--color-border); background: var(--color-surface); padding: .9rem .6rem; font-family: var(--font-mono); font-size: .8rem; position: sticky; top: 52px; height: calc(100vh - 52px); overflow-y: auto; } +.tree a { display: block; padding: .16rem .5rem; border-radius: 6px; color: var(--color-text); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tree a:hover { background: var(--color-code-bg); } +.tree a.active { background: var(--color-accent-subtle); color: var(--color-accent); font-weight: 600; } +.tree a span { display: block; overflow: hidden; text-overflow: ellipsis; } +.tree .dir { color: var(--color-text-muted); font-weight: 600; } +.tree .i1 { padding-left: 1.3rem; } +.tree .i2 { padding-left: 2.5rem; } +.tree .i3 { padding-left: 3.7rem; } +.tree-note { display: block; padding: .16rem .5rem; color: var(--color-text-muted); } +.tree .where { font-size: .68rem; font-weight: 400; color: var(--color-text-muted); } +.pane { padding: 1.4rem 1.75rem 2.5rem; min-width: 0; } +@media (max-width: 900px) { + .split { grid-template-columns: minmax(0, 1fr); } + .tree { position: static; height: auto; max-height: 14rem; border-right: none; border-bottom: 1px solid var(--color-border); } +} /* Single-column reading content (an entity's cards, a discussion thread, * a form) caps at `--content-narrow` so it never smears across a wide * viewport; wide surfaces (the commits table, file lists, diffs) stay at
crates/cli/ents-web/src/pages/commits.rs @@ -259,12 +259,14 @@ let (diff, truncated) = diff_sections(&repo, old_tree_ref, &new_tree); let comments = super::comments::for_commit(&state, object_id); let reviews = reviews_section(&state, &session, object_id, &oid); + let (sidebar_rows, _older) = commit_rows(&state, None, PAGE_SIZE); - Ok(super::layout( + Ok(super::layout_split( &super::RepoHeader::from_state(&state), &super::identity_label(&state), super::Tab::Commits, &subject, + commits_sidebar(&sidebar_rows, object_id), html! { (super::child_crumbs("commits", "/commits", &super::short_oid(&object_id))) // The commit card, its reviews, and the conversation are @@ -317,6 +319,25 @@ )) } +/// The Review split's `.tree` sidebar (`crate::pages::layout_split`): the +/// most recent commits, the viewed one active, each row its short oid and +/// subject on one ellipsized line, closed by a link into the full pager. +/// A commit older than the newest [`PAGE_SIZE`] simply highlights nothing +/// -- the sidebar is a recency lane, not a second pager. +fn commits_sidebar(rows: &[CommitRow], current: ObjectId) -> Markup { + html! { + @if rows.is_empty() { + span.tree-note { "No history to show." } + } + @for row in rows { + a.active[row.oid == current] href={ "/commit/" (row.oid) } { + (row.short) " " (row.subject) + } + } + a href="/commits" { "all commits \u{2192}" } + } +} + /// Every review targeting `commit_id` (`ents_forge::review::list` filtered /// to this commit, `model.review`), each rendering its verdict prominently, /// its body as AsciiDoc, and its reviewer (from the review ref's own tip
crates/cli/ents-web/src/pages/files.rs @@ -138,11 +138,12 @@ if path.is_empty() { let entries = tree_entries(&head_tree)?; - return Ok(super::layout( + return Ok(super::layout_split( &super::RepoHeader::from_state(state), &super::identity_label(state), super::Tab::Files, "Files", + tree_sidebar(&head_tree, "", ""), html! { (dir_listing(path, entries)) (readme_card(&head_tree)) @@ -164,11 +165,12 @@ .try_into_tree() .map_err(|source| Error::Repo(source.to_string()))?; let entries = tree_entries(&subtree)?; - Ok(super::layout( + Ok(super::layout_split( &super::RepoHeader::from_state(state), &super::identity_label(state), super::Tab::Files, path, + tree_sidebar(&head_tree, path, path), html! { (crumbs(path)) (dir_listing(path, entries)) @@ -187,11 +189,13 @@ .map_err(|source| Error::Repo(source.to_string()))? .to_string(); let (body, below) = blob_view(path, name, &head_oid, session, &blob.data, &comments)?; - Ok(super::layout( + let parent = path.rsplit_once('/').map_or("", |(dir, _)| dir); + Ok(super::layout_split( &super::RepoHeader::from_state(state), &super::identity_label(state), super::Tab::Files, path, + tree_sidebar(&head_tree, parent, path), html! { (crumbs(path)) (body) @@ -238,6 +242,80 @@ .collect() } +/// The Code split's `.tree` sidebar (`crate::pages::layout_split`): a +/// crumb trail back to the repository root, then the entries of the +/// directory at `dir` (the viewed directory itself, or a viewed blob's +/// parent), directories first -- not a full recursive tree, just enough +/// context to move one level in any direction. `active` names the full +/// path of the entry (or trailing crumb) being viewed. Best-effort: a +/// subtree that fails to read renders an empty entry list rather than +/// failing the page around it. +fn tree_sidebar(head_tree: &gix::Tree<'_>, dir: &str, active: &str) -> Markup { + let mut entries = if dir.is_empty() { + tree_entries(head_tree).unwrap_or_default() + } else { + head_tree + .lookup_entry_by_path(dir) + .ok() + .flatten() + .and_then(|entry| entry.object().ok()) + .and_then(|object| object.try_into_tree().ok()) + .map(|subtree| tree_entries(&subtree).unwrap_or_default()) + .unwrap_or_default() + }; + entries.sort_by(|(a_name, a_is_dir, _), (b_name, b_is_dir, _)| { + b_is_dir.cmp(a_is_dir).then_with(|| a_name.cmp(b_name)) + }); + + let crumb_parts: Vec<&str> = dir.split('/').filter(|s| !s.is_empty()).collect(); + let mut crumb_trail: Vec<(String, String)> = Vec::new(); + let mut acc = String::new(); + for part in &crumb_parts { + if !acc.is_empty() { + acc.push('/'); + } + acc.push_str(part); + crumb_trail.push(((*part).to_owned(), acc.clone())); + } + let entry_depth = crumb_parts.len().saturating_add(1); + + html! { + a class=(tree_class(true, 0, active.is_empty())) href="/files" { "/" } + @for (index, (label, crumb_path)) in crumb_trail.iter().enumerate() { + a class=(tree_class(true, index.saturating_add(1), crumb_path == active)) + href={ "/files/" (crumb_path) } { (label) "/" } + } + @for (name, is_dir, _) in &entries { + @let full = if dir.is_empty() { name.clone() } else { format!("{dir}/{name}") }; + a class=(tree_class(*is_dir, entry_depth, full == active)) + href=(child_href(dir, name)) { + (name) @if *is_dir { "/" } + } + } + } +} + +/// The class list for one [`tree_sidebar`] link: `.dir` for a directory, +/// an `.i{1..3}` indent per crumb depth (capped -- the sidebar shows one +/// directory's entries, not an unbounded tree), `.active` for the viewed +/// entry. +fn tree_class(is_dir: bool, depth: usize, active: bool) -> String { + let mut classes = Vec::new(); + if is_dir { + classes.push("dir"); + } + match depth { + 0 => {} + 1 => classes.push("i1"), + 2 => classes.push("i2"), + _ => classes.push("i3"), + } + if active { + classes.push("active"); + } + classes.join(" ") +} + /// The link to a child of the directory at `dir` (empty at the root). fn child_href(dir: &str, name: &str) -> String { if dir.is_empty() {
crates/cli/ents-web/src/pages/issues.rs @@ -29,9 +29,10 @@ use crate::session::Session; use crate::state::AppState; -/// `GET /issues`: every issue recorded in this repository -/// (`ents_forge::issue::list`) -- title, state, assignees, and labels -- -/// plus the new-issue form. +/// `GET /issues`: the Tickets split (`crate::pages::layout_split`) -- +/// every issue recorded in this repository (`ents_forge::issue::list_all`) +/// as the sidebar, its state/assignees/labels on each row's own locator +/// line, beside the new-issue composer in the pane. /// /// # Errors /// @@ -49,35 +50,20 @@ .into_iter() .map(|entry| (entry.refname, entry.error)) .collect(); - Ok(super::layout( + Ok(super::layout_split( &super::RepoHeader::from_state(&state), &super::identity_label(&state), super::Tab::Issues, - "Issues", + "Tickets", + issues_sidebar(&rows, None), html! { div.readable { (crate::render::unreadable_disclosure(&failures)) @if rows.is_empty() { (super::blankslate( - "No issues yet", + "No tickets yet", html! { "Open one with the form below." }, )) - } @else { - table.entity-list { - thead { - tr { th { "issue" } th { "state" } th { "assignees" } th { "labels" } } - } - tbody { - @for (id, issue) in &rows { - tr { - td { a href=(format!("/issues/{id}")) { (issue.title) } } - td { span.comment-state { (issue.state) } } - td { (join_members(&issue.assignees)) } - td { (issue.labels.join(", ")) } - } - } - } - } } h2 { "Open an Issue" } (new_form(&session)) @@ -87,6 +73,27 @@ )) } +/// The Tickets split's `.tree` sidebar: every issue as a two-line row -- +/// its title, then a muted locator of its state, assignees, and labels -- +/// linking to its own page, `active` naming the viewed issue's id. +fn issues_sidebar(rows: &[(String, ents_forge::Issue)], active: Option<&str>) -> Markup { + html! { + @if rows.is_empty() { + span.tree-note { "No tickets yet." } + } + @for (id, issue) in rows { + a.active[active == Some(id.as_str())] href={ "/issues/" (id) } { + span { (issue.title) } + span class="where" { + (issue.state) + @if !issue.assignees.is_empty() { " \u{b7} " (join_members(&issue.assignees)) } + @if !issue.labels.is_empty() { " \u{b7} " (issue.labels.join(", ")) } + } + } + } + } +} + /// `GET /issues/{id}`: one issue (`ents_forge::issue::show`), an edit form /// for its state/assignees/labels, and its discussion thread -- the /// comments naming `issues/<id>` as their context @@ -133,11 +140,16 @@ let body = crate::asciidoc::to_html(&issue.body).unwrap_or_else(|_| html! { p { (issue.body) } }); let return_to = format!("/issues/{id}"); - Ok(super::layout( + // Best-effort: the sidebar listing every ticket beside this one is + // navigation chrome, never a reason to fail the issue's own page. + let (rows, _unreadable) = + issue::list_all(state.refs.as_ref(), &*state.objects()).unwrap_or_default(); + Ok(super::layout_split( &super::RepoHeader::from_state(&state), &super::identity_label(&state), super::Tab::Issues, &issue.title, + issues_sidebar(&rows, Some(&id)), html! { (super::child_crumbs("issues", "/issues", ents_forge::abbreviate_id(&id))) div.readable {
crates/cli/ents-web/src/pages/mod.rs @@ -354,6 +354,40 @@ ) } +/// Wrap `title`, `sidebar`, and `pane` in the master-detail split every +/// selection-heavy page family renders through ([`super::files`]'s tree +/// beside a blob, [`super::commits`]'s compact history beside a diff, +/// [`super::issues`]'s ticket list beside a ticket): the workbench chrome +/// ([`layout_shell`]) around a full-bleed `.split` grid -- a sticky +/// `nav.tree` sidebar on the left, a padded `main.pane` (carrying the +/// page's own `.page-header` title and `pane` body) on the right. Every +/// selection in the sidebar is a real URL and the sidebar always renders, +/// so the split stays SSR-friendly (`docs/web-workbench-plan.adoc`). +pub(crate) fn layout_split( + repo: &RepoHeader, + identity: &str, + active: Tab, + title: &str, + sidebar: Markup, + pane: Markup, +) -> Markup { + layout_shell( + repo, + identity, + active, + title, + html! { + div.split { + nav.tree { (sidebar) } + main.pane { + div.page-header { h1.page-title { (title) } } + (pane) + } + } + }, + ) +} + /// The signing identity's display label for [`layout`]'s `.id-chip` /// (`roots.web-signing`) -- [`crate::identity::SigningIdentity::label`]. /// Every page reads this off `state` itself rather than `layout` reaching