From 5feaa1549a79a2b03626e2eb5d50e6f345ea3345 Mon Sep 17 00:00:00 2001 From: Scott Chacon Date: Tue, 4 Aug 2026 16:53:19 +0200 Subject: [PATCH] ti writeups - very professional --- crates/ticgit/src/commands/serve/mod.rs | 34 +++++++++++++++-- crates/ticgit/src/commands/serve/tickets.rs | 41 +++++++++++++++++---- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/crates/ticgit/src/commands/serve/mod.rs b/crates/ticgit/src/commands/serve/mod.rs index 1be58901..0388812a 100644 --- a/crates/ticgit/src/commands/serve/mod.rs +++ b/crates/ticgit/src/commands/serve/mod.rs @@ -4,10 +4,12 @@ //! title, tags) plus a per-ticket detail page, served over plain HTTP //! from a hand-rolled `std::net` listener so we pull in no web stack. //! -//! The ticket pages live in [`tickets`]; this module owns the listener, -//! the request/response plumbing, and the shared page chrome. +//! The ticket pages live in [`tickets`] and the writeup pages in +//! [`writeups`]; this module owns the listener, the request/response +//! plumbing, and the shared page chrome both use. mod tickets; +mod writeups; use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; @@ -213,11 +215,16 @@ fn route(request: &Request) -> Result { match request.path.as_str() { "/" => tickets::list_response(request), "/tickets.json" => tickets::json_response(request), + "/writeups" => writeups::list_response(request), + "/writeups.json" => writeups::json_response(request), "/favicon.ico" => Ok(Response::empty(204)), path => { if let Some(reference) = path.strip_prefix("/t/").filter(|r| !r.is_empty()) { return tickets::detail_response(reference); } + if let Some(reference) = path.strip_prefix("/w/").filter(|r| !r.is_empty()) { + return writeups::detail_response(request, reference); + } Ok(Response::html( 404, error_page("404 - not found", "No page at that address."), @@ -226,7 +233,7 @@ fn route(request: &Request) -> Result { } } -/// Per-request context shared by the pages. +/// Per-request context shared by both pages. struct Page { repo: String, current_user: String, @@ -257,6 +264,16 @@ fn repo_name() -> String { // -- shared chrome --------------------------------------------------------- +/// A jump to the other half of the site (tickets <-> writeups), set off +/// from the view tabs it sits next to. +fn section_link(href: &str, label: &str) -> String { + format!( + "{}", + escape(href), + escape(label) + ) +} + /// Carries the active narrowing through the search form, which would /// otherwise drop it on submit. fn hidden_input(name: &str, value: &str) -> String { @@ -353,7 +370,16 @@ dd{margin:2px 0 0}\ h2{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--dim);margin:24px 0 8px}\ .prose{white-space:pre-wrap;word-wrap:break-word;font:inherit;margin:0;\ background:var(--chip);border-radius:6px;padding:12px}\ -.comment{margin-bottom:12px}.byline{color:var(--dim);font-size:12px;margin:0 0 4px}"; +.comment{margin-bottom:12px}.byline{color:var(--dim);font-size:12px;margin:0 0 4px}\ +nav .view.section{color:var(--accent)}\ +.links{list-style:none;margin:0;padding:0}\ +.links li{padding:5px 0;border-bottom:1px solid var(--line)}\ +.links code{color:var(--dim);margin-right:8px}\ +.versions{display:flex;gap:4px;flex-wrap:wrap;margin:0 0 10px}\ +.vtab{background:var(--chip);color:var(--dim);border-radius:6px;padding:2px 9px;font-size:12px}\ +.vtab.active{background:var(--accent);color:#fff}\ +td.vers,td.who2{color:var(--dim);white-space:nowrap}\ +.state-open{color:#16a34a}.state-closed{color:var(--dim)}"; fn escape(value: &str) -> String { let mut out = String::with_capacity(value.len()); diff --git a/crates/ticgit/src/commands/serve/tickets.rs b/crates/ticgit/src/commands/serve/tickets.rs index 8a4708e8..46edfb4a 100644 --- a/crates/ticgit/src/commands/serve/tickets.rs +++ b/crates/ticgit/src/commands/serve/tickets.rs @@ -2,15 +2,15 @@ //! //! The list at `/`, a detail page at `/t/`, and `/tickets.json` for //! scripting. Page chrome, escaping and the HTTP types all come from the -//! parent module. +//! parent module so both halves of the site look and behave the same. use anyhow::Result; -use ticgit_lib::{Filter, SearchFilter, SortOrder, Ticket, TicketLifecycle, TicketStatus}; +use ticgit_lib::{Filter, SearchFilter, SortOrder, Ticket, TicketLifecycle, TicketStatus, Writeup}; use time::format_description::well_known::Rfc3339; use super::{ - document, error_page, escape, filter_chip, flatten, hidden_input, percent_encode, tag_hue, - Page, Request, Response, + document, error_page, escape, filter_chip, flatten, hidden_input, percent_encode, section_link, + tag_hue, Page, Request, Response, }; use crate::commands::open_store; use crate::render; @@ -50,7 +50,15 @@ pub(super) fn detail_response(reference: &str) -> Result { }; let ticket = store.load(&id)?; let page = Page::new(&store)?; - Ok(Response::html(200, detail_page(&page, &ticket))) + // Writeups point at tickets, not the other way round, so the back + // link has to come from a scan of the writeup list. + let linked: Vec = store + .list_writeups() + .unwrap_or_default() + .into_iter() + .filter(|writeup| writeup.tickets.contains(&id)) + .collect(); + Ok(Response::html(200, detail_page(&page, &ticket, &linked))) } // -- query ----------------------------------------------------------------- @@ -358,7 +366,7 @@ fn header(page: &Page, query: &ListQuery) -> String { ), ]; let current = query.href(None); - let nav = views + let mut nav = views .iter() .map(|(label, href)| { format!( @@ -369,6 +377,7 @@ fn header(page: &Page, query: &ListQuery) -> String { }) .collect::>() .join(""); + nav.push_str(§ion_link("/writeups", "Writeups")); let mut hidden = String::new(); if let Some(status) = &query.status { @@ -430,7 +439,7 @@ fn active_filters(query: &ListQuery) -> String { format!("
{}
", chips.join("")) } -fn detail_page(page: &Page, ticket: &Ticket) -> String { +fn detail_page(page: &Page, ticket: &Ticket, linked_writeups: &[Writeup]) -> String { let mut body = String::new(); body.push_str(&format!( "
\u{2190} all tickets\ @@ -519,6 +528,22 @@ fn detail_page(page: &Page, ticket: &Ticket) -> String { escape(spec) )); } + if !linked_writeups.is_empty() { + body.push_str(&format!( + "

Writeups ({})

    ", + linked_writeups.len() + )); + for writeup in linked_writeups { + body.push_str(&format!( + "
  • {} {}
  • ", + escape(&writeup.short_id()), + escape(&writeup.short_id()), + escape(&flatten(&writeup.title)), + )); + } + body.push_str("
"); + } + if !ticket.comments.is_empty() { body.push_str(&format!( "

Comments ({})

", @@ -707,7 +732,7 @@ mod tests { at: OffsetDateTime::UNIX_EPOCH, body: "on it".to_string(), }); - let html = detail_page(&page(), &t); + let html = detail_page(&page(), &t, &[]); assert!(html.contains("fix parser")); assert!(html.contains("in-progress")); assert!(html.contains("a longer\nexplanation"));