roots: give code, review, and tickets master-detail split panes
commit
c03d515roots: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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