Artifact b3d4e5f1fb5d583572cae38869ea0fcffc7b7744882eadc43c44c185cd9c09bf:
- File
internal/web/timeline.go
— part of check-in
[ac80214995]
at
2026-08-24 02:38:12
on branch notes
— notes: one row per technote, not one per edit
Fossil registers one timeline event per technote; quarry registered one per artifact. libfossil has 50 technote artifacts under 26 event ids, so its timeline showed a superseded title five rows above its own correction. The collapse is in the event trigger — insert, then evict every loser for the event id — which is order-independent, as the fold requires: a clone delivers artifacts in wire order.
Notes are no longer a tab. Fossil's built-in menu has none, and libfossil, which uses them as a milestone feed, overrode mainmenu to add TODOs and FIXMEs without adding one. They are reached from the About panel, the docs page and the timeline filter instead, and the listing is a feed with excerpts rather than a list of links.
Every edit stays readable, as a wiki page's revisions are.
Fixes an authoring bug found on the way: zitcli never passed --time, so an edit re-filed the note at the moment it was edited and it moved in the timeline. The filing time is now a field on both forms. (user: dmestas, size: 18125) [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" "sort" "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 Excerpt string // technotes only: the note's opening prose 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 } // branchSlots are the hues a branch line can take: nine, spaced at // least 30 degrees apart, with the two bands a reader already reads as // meaning something left out — the mainline's blue (~213) and the // accent orange (~18). Two lines can then never be the near-identical // magentas that made `ticket-list` and `fossil-caps` one colour. var branchSlots = []int{60, 90, 120, 150, 180, 240, 270, 300, 330} // branchHue is the cleanroom analogue of fossil's hash_color: a stable // hue from the branch name. It is the PREFERENCE, not the answer — // palette resolves collisions among the branches actually in view. func branchHue(branch string) int { h := fnv.New32a() h.Write([]byte(branch)) return branchSlots[int(h.Sum32())%len(branchSlots)] } // palette assigns each branch on one page a distinguishable hue. // // Distinctness is per PAGE, deliberately. A hash alone is globally // stable but collides — two names landing five degrees apart render as // one colour, and the reader cannot tell two lines apart, which is the // only thing the colour is for. So a branch keeps its hashed hue // unless another branch in view wants the same slot; then the later // one (by name, so the outcome never depends on commit order) moves to // the next free slot. type palette struct { defaultBranch string hue map[string]int } func newPalette(defaultBranch string, entries []timelineEntry) *palette { p := &palette{defaultBranch: defaultBranch, hue: map[string]int{}} seen := map[string]bool{} var names []string for _, e := range entries { b := e.Branch if b == "" || b == defaultBranch || b == "trunk" || seen[b] { continue } seen[b] = true names = append(names, b) } sort.Strings(names) taken := map[int]bool{} for _, b := range names { want := branchHue(b) slot := want for i := 0; i < len(branchSlots); i++ { cand := branchSlots[(indexOfSlot(want)+i)%len(branchSlots)] if !taken[cand] { slot = cand break } } taken[slot] = true p.hue[b] = slot } return p } func indexOfSlot(hue int) int { for i, h := range branchSlots { if h == hue { return i } } return 0 } func (p *palette) stroke(branch string) string { switch { case branch == "": // 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" case branch == "trunk" || (p != nil && branch == p.defaultBranch): return "#7086a8" } return fmt.Sprintf("hsl(%d, 55%%, 45%%)", p.hueOf(branch)) } func (p *palette) tint(branch string) template.CSS { if branch == "" || branch == "trunk" || (p != nil && branch == p.defaultBranch) { return "" } // Same weight as --surface (10% alpha), so a branch card and a trunk // card read as one material that differs only in hue. return template.CSS(fmt.Sprintf("background: hsla(%d, 45%%, 50%%, 0.10)", p.hueOf(branch))) } func (p *palette) hueOf(branch string) int { if p != nil { if h, ok := p.hue[branch]; ok { return h } } return 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(pal *palette, 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, pal.stroke(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, pal.stroke(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 { // One palette per page: hues are resolved against the branches // actually in view, so two lines are never the same colour. pal := newPalette(defaultBranch, entries) 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(pal, &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] // "Technote: " prefixes the stored comment so a mixed // timeline says what a row IS. The note's own card carries // that in the badge beside it, so the prefix would be the // word twice. title := e.Comment if e.Kind == "e" { title = strings.TrimPrefix(title, "Technote: ") } 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: title, Excerpt: e.Excerpt, Tail: c, Width: w, }) continue } // Which rails were expecting this check-in? joins := find(e.Hash) var nodeRail int // A line above this node exists only when a rail was ALREADY // heading here. A freshly allocated rail is not one: alloc sets // want to this very hash, so reading it back draws a line above // every branch tip — a leaf claiming something follows it. hadAbove := false switch { case mainline && e.Branch == defaultBranch && (contains(joins, 0) || !rails[0].active): // The mainline 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 — the shape a // reader expects: branches hang off trunk, trunk does not // hop into a branch's column. // // Only when rail 0 is FREE to take it, though. A merge whose // two parents are both on the mainline puts two chains in // front of one column, and rail 0 can carry one: claiming it // anyway both stranded the line already heading elsewhere // (rail 0 stopped being drawn above this row — the gap) and // overwrote the commit it was still travelling to. nodeRail = 0 hadAbove = contains(joins, 0) rails[0].active = true rails[0].reserved = true case len(joins) > 0: nodeRail = joins[0] hadAbove = true 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. 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 := pal.stroke(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(pal, &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, pal.stroke(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, pal.stroke(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, pal.stroke(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. // The mark's shape, measured in the rail's own weight: the // line (2.5), the ring's inner gap and the centre dot are // all one width, so the node reads as the logo rather than // as a squashed version of it. // dot r 1.25 + gap 2.5 + half the ring stroke 1.1 = 4.85 fmt.Fprintf(&hd, `<circle cx="%d" cy="%d" r="4.85" style="fill:var(--bg)" stroke="%s" stroke-width="2.2"/>`, nx, cy, stroke) fmt.Fprintf(&hd, `<circle cx="%d" cy="%d" r="1.25" fill="%s"/>`, nx, cy, stroke) } else { // The same DISC as the tip, filled. Matching the ring's r // (4.85) is what looks wrong: the ring is stroked 2.2, so // it paints out to 4.85 + 1.1, and a 4.8 disc beside it // reads a fifth smaller. The radius that matches is the // ring's outer edge. fmt.Fprintf(&hd, `<circle cx="%d" cy="%d" r="5.95" 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(pal, &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), pal.stroke(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: pal.tint(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 }