Artifact 5fe3420b65fb8d9abf3d26624cef7c7ee3600b1bea2e3dcde96304f16b43258b:
- File
internal/web/timeline.go
— part of check-in
[6b6121e609]
at
2026-08-22 20:48:43
on branch trunk
— timeline: the mainline always holds the leftmost rail
A branch that committed most recently was taking rail 0, and trunk was inheriting a branch's column at the fork point because the branch line followed its parents there. The default branch (timeline.list now names it) reserves rail 0 whenever it is on the page; other lines converge into it. Absent from view, nothing is reserved and the rest close up. (user: dmestas, size: 14259) [annotate] [blame] [check-ins using] [more...]
// timeline.go — the fossil-shaped timeline view model: day dividers, // per-branch colors, and the DAG rail graph rendered as server-side // inline SVG (no JavaScript; 0001 Principle 3). Rail assignment follows // the shape of fossil's graph.c — computed here, drawn here — walking // rows newest-first: a node lands on the rail that expects its hash, // rails continue toward primary parents, merges curve out to the rail // carrying the secondary parent. // // Rows have content-dependent heights, so each row's rails render as a // stretchable SVG (preserveAspectRatio="none": verticals stay straight, // curves stretch) filling the cell, with the node drawn as a separate // fixed-size dot overlay that cannot distort — that is what keeps the // rail visually continuous through tall cards, like fossil's graph. package web import ( "fmt" "hash/fnv" "html/template" neturl "net/url" "strings" ) const ( railW = 14 // px between rails headH = 36 // fixed head band: node + curves live here nodeCY = 24 // node center inside the head: card padding + half line maxRail = 14 // clamp; beyond this rails overlap rather than widen the page ) type tlDay struct { Date string Carry template.HTML // rails passing through the date-divider row Width int Rows []tlRow } type tlRow struct { Kind string // ci | w | t Time string Hash string Short string URL string // the event's page User string Title string // comment first line Paras []string // remaining comment paragraphs, newlines collapsed Branch string Tint template.CSS // per-branch card tint (empty for trunk) IsLeaf bool IsMerge bool CI template.HTML // CI verdict badge, empty when none was recorded Head template.HTML // fixed-height top layer: curves + node Tail template.HTML // stretching lower layer: rails only Width int // graph cell width } // branchHue is the cleanroom analogue of fossil's hash_color: a stable // hue from the branch name, so every page renders a branch identically. func branchHue(branch string) int { h := fnv.New32a() h.Write([]byte(branch)) return int(h.Sum32() % 360) } func branchStroke(branch string) string { switch branch { case "trunk": return "#7086a8" case "": // A rail whose branch is unknowable (its commit is beyond this // page) renders neutral — never a hashed hue, so no arbitrary // intermediate colors appear mid-line. return "#8a8f98" } return fmt.Sprintf("hsl(%d, 55%%, 45%%)", branchHue(branch)) } func branchTint(branch string) template.CSS { if branch == "trunk" || branch == "" { return "" } // Same weight as --surface (10% alpha), so a branch card and a trunk // card read as one material that differs only in hue. At 14% and 55% // saturation the tint read as a different kind of card entirely. return template.CSS(fmt.Sprintf("background: hsla(%d, 45%%, 50%%, 0.10)", branchHue(branch))) } // rail tracks one vertical line of the graph: the hash it expects to // reach next (a parent of something above) and the branch coloring it. type rail struct { want string branch string active bool // reserved marks the mainline's column. A reserved rail is never // handed to another branch, even while it is inactive, so trunk // keeps the leftmost line for the whole page instead of losing it // to whichever branch happens to hold the newest commit. reserved bool } // stroke emits one vertical rail segment. A rail whose commits are all // off-page is otherwise unidentifiable (the jimtcl question: a branch // line running the whole page with nothing naming it), so every segment // carries its branch name as a native hover title. func railLine(b *strings.Builder, x1, y1, x2, y2 int, branch string) { if branch == "" { fmt.Fprintf(b, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2.5"/>`, x1, y1, x2, y2, branchStroke(branch)) return } fmt.Fprintf(b, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2.5"><title>%s</title></line>`, x1, y1, x2, y2, branchStroke(branch), template.HTMLEscapeString(branch)) } // buildTimeline groups entries under day dividers and assigns graph // rails. Entries must be newest-first (the timeline order). Non-checkin // events (wiki, ticket) take no rail: the active rails pass straight // through their rows, like the date dividers. func buildTimeline(repo, defaultBranch string, entries []timelineEntry) []tlDay { rails := []rail{} // The mainline owns rail 0 whenever it appears on the page: a // reader scans left for it, and a branch that forked yesterday must // not take that column just because it committed most recently. If // the default branch is not in view at all, nothing is reserved and // the remaining lines close up as before. mainline := false if defaultBranch != "" { for _, e := range entries { if e.Kind == "ci" && e.Branch == defaultBranch { mainline = true break } } } if mainline { rails = append(rails, rail{branch: defaultBranch, reserved: true}) } find := func(hash string) []int { var idxs []int for i := range rails { if rails[i].active && rails[i].want == hash { idxs = append(idxs, i) } } return idxs } alloc := func(want, branch string) int { if mainline && branch == defaultBranch && !rails[0].active { rails[0] = rail{want: want, branch: branch, active: true, reserved: true} return 0 } for i := range rails { if rails[i].active || rails[i].reserved { continue } rails[i] = rail{want: want, branch: branch, active: true} return i } rails = append(rails, rail{want: want, branch: branch, active: true}) return len(rails) - 1 } // carry renders the rails active at a row boundary — the stretchable // layer a date-divider row shows so rails run unbroken through it. carry := func() (template.HTML, int) { width := (min(len(rails), maxRail))*railW + railW var b strings.Builder any := false fmt.Fprintf(&b, `<svg class="tl-tail" width="%d" preserveAspectRatio="none" viewBox="0 0 %d 1" aria-hidden="true">`, width, width) for i := range rails { if rails[i].active { any = true x := min(i, maxRail-1)*railW + railW/2 railLine(&b, x, 0, x, 1, rails[i].branch) } } b.WriteString(`</svg>`) if !any { return "", width } return template.HTML(b.String()), width } // Look-ahead branch map: a rail heading toward a commit carries that // commit's branch color for its whole run — the branch line reads as // one color from fork to merge (fossil's rule). branchOf := map[string]string{} for _, e := range entries { branchOf[e.Hash] = e.Branch } var days []tlDay for _, e := range entries { date, clock, _ := strings.Cut(e.Date, "T") if len(clock) > 5 { clock = clock[:5] } if len(days) == 0 || days[len(days)-1].Date != date { c, w := carry() days = append(days, tlDay{Date: date, Carry: c, Width: w}) } if e.Kind == "w" || e.Kind == "t" || e.Kind == "e" || e.Kind == "f" { // A flat event row: rails pass through, no node. c, w := carry() var url string switch e.Kind { case "w": // a wiki row is one specific edit — link that revision url = "/r/" + repo + "/wiki/" + neturl.PathEscape(e.Ref) + "?rev=" + e.Hash[:min(12, len(e.Hash))] case "t": url = "/r/" + repo + "/ticket/" + e.Ref case "e": url = "/r/" + repo + "/technote/" + e.Ref[:min(16, len(e.Ref))] case "f": url = "/r/" + repo + "/forum/" + e.Ref[:min(16, len(e.Ref))] } day := &days[len(days)-1] day.Rows = append(day.Rows, tlRow{ Kind: e.Kind, Time: clock, Hash: e.Hash, URL: url, Short: e.Hash[:min(12, len(e.Hash))], User: e.User, Title: e.Comment, Tail: c, Width: w, }) continue } // Which rails were expecting this check-in? joins := find(e.Hash) var nodeRail int switch { case mainline && e.Branch == defaultBranch: // The mainline always draws on its own rail, even when a // branch line arrived here first by following its parents // into the fork point. Those lines converge INTO it — which // is the shape a reader expects: branches hang off trunk, // trunk does not hop into a branch's column. nodeRail = 0 rails[0].active = true rails[0].reserved = true case len(joins) > 0: nodeRail = joins[0] default: nodeRail = alloc(e.Hash, e.Branch) // a tip: nothing above points here } // The segment ABOVE this node belongs to the line that was // heading here (e.g. a branch line ending at its fork commit); // capture its color, and whether it existed at all, before the // rail is rebranded. hadAbove := rails[nodeRail].active && rails[nodeRail].want == e.Hash aboveBranch := rails[nodeRail].branch joinBranch := map[int]string{} for _, j := range joins { joinBranch[j] = rails[j].branch } rails[nodeRail].branch = e.Branch // Snapshot the rails passing through this row (before rewiring), // for the vertical strokes. type seg struct { idx int branch string } var through []seg for i := range rails { if rails[i].active && i != nodeRail && !contains(joins, i) { through = append(through, seg{idx: i, branch: rails[i].branch}) } } // Rewire: converging rails close; the node rail continues to the // primary parent; secondary parents curve out. // Every rail that was heading here other than the node's own // closes with a curve into it. extraJoins := []int{} for _, j := range joins { if j != nodeRail { extraJoins = append(extraJoins, j) } } for _, j := range extraJoins { rails[j].active = false } // A line above this node exists when the node's own rail was // already carrying one into this row. Reading it off `joins` // was equivalent only while nodeRail always came from joins. hasAbove := hadAbove hasBelow := false var mergeOut []seg if len(e.Parents) > 0 { rails[nodeRail].want = e.Parents[0] hasBelow = true for _, p := range e.Parents[1:] { var target int if t := find(p); len(t) > 0 { target = t[0] } else { // The merged branch's line: colored by the commit // it descends to (look-ahead), neutral if off-page. target = alloc(p, branchOf[p]) } mergeOut = append(mergeOut, seg{idx: target, branch: rails[target].branch}) } } else { rails[nodeRail].active = false // root: nothing below } // Render the two graph layers. The head is a fixed-height band // holding the node and every curve (top-anchored, like fossil — // the node sits beside the card's first line); the tail is a // stretching layer of plain verticals that fills whatever height // the card takes, keeping rails continuous. width := (min(len(rails), maxRail))*railW + railW x := func(i int) int { return min(i, maxRail-1)*railW + railW/2 } cy := nodeCY nx := x(nodeRail) stroke := branchStroke(e.Branch) var hd strings.Builder fmt.Fprintf(&hd, `<svg class="tl-head" width="%d" height="%d" viewBox="0 0 %d %d" aria-hidden="true">`, width, headH, width, headH) for _, s := range through { railLine(&hd, x(s.idx), 0, x(s.idx), headH, s.branch) } if hasAbove { fmt.Fprintf(&hd, `<line x1="%d" y1="0" x2="%d" y2="%d" stroke="%s" stroke-width="2.5"/>`, nx, nx, cy, branchStroke(aboveBranch)) } if hasBelow { fmt.Fprintf(&hd, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="2.5"/>`, nx, cy, nx, headH, stroke) } for _, j := range extraJoins { // A converging line ends here; it keeps ITS branch color to // the last pixel (a branch line meeting its fork point). fmt.Fprintf(&hd, `<path d="M %d 0 Q %d %d %d %d" fill="none" stroke="%s" stroke-width="2.5"/>`, x(j), x(j), cy, nx, cy, branchStroke(joinBranch[j])) } for _, m := range mergeOut { fmt.Fprintf(&hd, `<path d="M %d %d Q %d %d %d %d" fill="none" stroke="%s" stroke-width="2.5"/>`, nx, cy, x(m.idx), cy, x(m.idx), headH, branchStroke(m.branch)) } if e.IsLeaf { // A branch tip is drawn the way the mark is: an open node // with the check-in dot at its centre. The open ring alone // said "nothing follows"; the dot says "and this is where // the branch is now", which is what a tip actually means. fmt.Fprintf(&hd, `<circle cx="%d" cy="%d" r="4" style="fill:var(--bg)" stroke="%s" stroke-width="2.5"/>`, nx, cy, stroke) fmt.Fprintf(&hd, `<circle cx="%d" cy="%d" r="1.6" fill="%s"/>`, nx, cy, stroke) } else { fmt.Fprintf(&hd, `<circle cx="%d" cy="%d" r="4.5" fill="%s"/>`, nx, cy, stroke) } hd.WriteString(`</svg>`) var tl strings.Builder fmt.Fprintf(&tl, `<svg class="tl-tail" width="%d" preserveAspectRatio="none" viewBox="0 0 %d 1" aria-hidden="true">`, width, width) for _, s := range through { railLine(&tl, x(s.idx), 0, x(s.idx), 1, s.branch) } if hasBelow { fmt.Fprintf(&tl, `<line x1="%d" y1="0" x2="%d" y2="1" stroke="%s" stroke-width="2.5"/>`, nx, nx, stroke) } for _, m := range mergeOut { fmt.Fprintf(&tl, `<line x1="%d" y1="0" x2="%d" y2="1" stroke="%s" stroke-width="2.5"/>`, x(m.idx), x(m.idx), branchStroke(m.branch)) } tl.WriteString(`</svg>`) title, body, _ := strings.Cut(strings.TrimSpace(e.Comment), "\n") day := &days[len(days)-1] day.Rows = append(day.Rows, tlRow{ Kind: "ci", Time: clock, Hash: e.Hash, Short: e.Hash[:min(12, len(e.Hash))], URL: "/r/" + repo + "/checkin/" + e.Hash, User: e.User, Title: title, Paras: paragraphs(body), Branch: e.Branch, Tint: branchTint(e.Branch), IsLeaf: e.IsLeaf, IsMerge: e.IsMerge, CI: ciBadge(e.CI), Head: template.HTML(hd.String()), Tail: template.HTML(tl.String()), Width: width, }) } return days } // paragraphs splits a comment body on blank lines and collapses the // hard newlines within each paragraph — check-in comments carry 72-col // wraps that must reflow to the card width (fossil renders comments the // same way: newlines are not line breaks). func paragraphs(body string) []string { var out []string for _, p := range strings.Split(body, "\n\n") { p = strings.Join(strings.Fields(p), " ") if p != "" { out = append(out, p) } } return out } func contains(xs []int, v int) bool { for _, x := range xs { if x == v { return true } } return false }