~bigbes/sr-ht-dolt

60fdb6dd03bcfe04a5448b24ee3a5db8fd9ebce8 — Eugene Blikh 5 days ago 07c2a63
beads: show the stored rows behind an issue on its detail pane

Every field on the detail pane is a reading of a row: the Issue struct names
the columns bd surfaces and drops the rest, humanizeEvent turns two JSON blobs
into a sentence, an Edge keeps an id and a type out of a dependency row. A
reading that went wrong looks exactly like one that did not, so the detail modes
now carry the rows they were built from — the issue's own row first, then the
rows belonging to it in labels, dependencies, comments and events, in the order
the tables were read and the columns came back in. custom_statuses is read for
the lane and is deliberately absent: its rows describe the tracker's statuses,
not this issue. Related tables are capped at RawMax rows and counted whole, so a
busy issue's audit log cannot turn the pane into a second page.

A raw view may not report an absent cell and an empty string as the same thing,
which is the one place cell()'s flattening of a real NULL to "" is wrong.
rowCells.isNull is now the single site that decides, shared by cell and the raw
row, so the two can never disagree; RawCell carries Null beside Value and drops
browse's "NULL" text, which would have put the collision straight back.

The section renders as a collapsed <details> in the stream layout's Past Stand
idiom — it is a check on the rendering, not the reason a reader came. Values are
escaped and deliberately not run through beadLinks.Text: a linkifier rewrites
what it is given, and a value that has been rewritten is no longer the value
that is stored. A cell holding no value renders as a NULL chip, a cell storing
those four characters as the text it stores, an empty one as (empty); a long
value scrolls inside its own cell.
7 files changed, 774 insertions(+), 11 deletions(-)

M beads/build.go
M beads/model.go
A beads/raw.go
A beads/raw_test.go
M beads/rows.go
M web/beads_test.go
M web/templates/beads.html
M beads/build.go => beads/build.go +17 -2
@@ 77,7 77,7 @@ func Build(ctx context.Context, sess BrowseSession, ref string, query url.Values
			issuesTotal: issuesTotal,
		}
		return buildDetail(ctx, sess, ref, want, issues, issueCols, deps, depCols,
			labelsByIssue, catByStatus, catByIssue, clip), nil
			labels, labelsByIssue, catByStatus, catByIssue, clip), nil
	}

	// Board mode: parse the sticky filters and collect dropdown options from the


@@ 209,7 209,7 @@ func buildDetail(
	ctx context.Context, sess BrowseSession, ref, want string,
	issues *browse.RowPage, issueCols map[string]int,
	deps *browse.RowPage, depCols map[string]int,
	labelsByIssue map[string][]string,
	labels *browse.RowPage, labelsByIssue map[string][]string,
	catByStatus, catByIssue map[string]string,
	clip readClip,
) *Data {


@@ 271,6 271,19 @@ func buildDetail(
		Labels:             labelsByIssue[want],
	}

	// The stored rows behind everything above, in the order the tables were read.
	// Only the tables that hold rows *of this issue* are here: custom_statuses is
	// read for the lane, but its rows describe the tracker's statuses rather than
	// this issue, so there is no row of it that belongs on this pane. comments and
	// events are added below, where they are read.
	data.addRaw(rawTableOf("issues", issues, matchColumn("id", want)))
	data.addRaw(rawTableOf("labels", labels, matchColumn("issue_id", want)))
	data.addRaw(rawTableOf("dependencies", deps, func(cols map[string]int, r rowCells) bool {
		// Both directions: an edge is this issue's whether it points out of it or
		// into it, and the pane draws both lists from exactly these rows.
		return cell(cols, r, "issue_id") == want || cell(cols, r, "depends_on_issue_id") == want
	}))

	edge := func(id, typ string) Edge {
		st := statusByIssue[id]
		return Edge{


@@ 349,6 362,7 @@ func buildDetail(
	// counts towards the flag like any other input.
	if comments, commentsTotal, err := readRowsOptional(ctx, sess, ref, "comments"); err == nil && comments != nil {
		data.Truncated = data.Truncated || commentsTotal > Max
		data.addRaw(rawTableOf("comments", comments, matchColumn("issue_id", want)))
		ccols := indexCols(comments.Columns)
		for _, r := range rowsOf(comments) {
			if cell(ccols, r, "issue_id") != want {


@@ 372,6 386,7 @@ func buildDetail(
	// in the History tab as humanized, time-ordered entries.
	if events, eventsTotal, err := readRowsOptional(ctx, sess, ref, "events"); err == nil && events != nil {
		data.Truncated = data.Truncated || eventsTotal > Max
		data.addRaw(rawTableOf("events", events, matchColumn("issue_id", want)))
		ecols := indexCols(events.Columns)
		for _, r := range rowsOf(events) {
			if cell(ecols, r, "issue_id") != want {

M beads/model.go => beads/model.go +11 -0
@@ 85,6 85,17 @@ type Data struct {
	Subtasks     []Subtask
	SubtaskDone  int // # of subtasks in the closed category
	SubtaskTotal int // len(Subtasks); the progress denominator

	// Raw is the stored rows this detail was built from, in the order the tables
	// were read: the issue's own row first, then the rows belonging to it in
	// labels, dependencies, comments and events. Set in the detail modes only,
	// and empty when the issue was not found — there is nothing stored to show.
	//
	// It is what makes everything above it checkable: every other field here is a
	// reading of these rows, and a reading that dropped a column or humanized an
	// event into the wrong sentence looks exactly like a correct one from the
	// pane alone. See beads/raw.go for why the tables stop where they do.
	Raw []RawTable
}

// ClippedTable is one table a projection read that exceeded Max: its name, how

A beads/raw.go => beads/raw.go +133 -0
@@ 0,0 1,133 @@
package beads

import "sourcecraft.dev/bigbes/sr-ht-dolt/browse"

// --- the stored rows behind one issue ----------------------------------------
//
// Everything else in this package is a reading: the Issue struct names the
// columns bd surfaces and drops the rest, humanizeEvent turns two JSON blobs
// into a sentence, an Edge keeps an id and a type out of a dependency row. Each
// of those is a choice, and a choice can be wrong — a column bd added that no
// field here models, a status this projection mapped to the wrong category, an
// event whose stored old_value says something the humanized line does not — and
// none of it is visible from the pane that made the choice.
//
// So the detail modes also carry the rows they were built from, as read. The
// point is to be checkable against the rendering above it, which is why nothing
// here is normalised, sorted or rewritten: the columns come in the order the
// table was read in, the values are the strings browse returned, and a cell that
// holds no value says so rather than rendering as one.

// RawMax caps how many rows of one related table the raw section carries. The
// issues row is one row by construction; a busy issue's events or comments are
// not, and a section that dumps three hundred audit rows is not a check on the
// rendering, it is a second page nobody reads. RawTable.Matched says how many
// rows actually belong to the issue, so a capped table is visibly capped.
const RawMax = 50

// RawCell is one stored cell: the column it came from, the value browse rendered
// for it, and whether the cell holds a value at all.
//
// Null is the whole reason this type exists rather than a plain string pair.
// browse renders a cell that holds no value as the text "NULL", which is exactly
// what a cell storing those four characters renders as, and cell() resolves that
// collision by flattening a real NULL to "". That is right everywhere else here
// — a missing closed_at is rendered as nothing — and wrong in this one place: a
// view whose purpose is to be compared against the database may not report an
// absent cell and an empty string as the same thing.
//
// Value is "" for a null cell rather than the "NULL" browse printed. Carrying
// that text would put the collision straight back: a consumer that renders Value
// without reading Null would print "NULL" for both readings again, which is the
// state this type exists to leave behind.
type RawCell struct {
	Column string
	Value  string
	Null   bool
}

// RawRow is one stored row: its cells in the order the columns were read in.
// Column order is data here — it is the table's own order, and a raw view that
// sorted it would be hiding one of the things it is meant to show.
type RawRow struct {
	Cells []RawCell
}

// RawTable is the rows one table contributed to one issue.
//
// Matched is how many rows of the table belong to the issue; Rows holds at most
// RawMax of them, in table order. The two differ only when the cap bit, and that
// difference is what the pane says out loud.
type RawTable struct {
	Table   string
	Rows    []RawRow
	Matched int
}

// Clipped reports that the table had more rows for this issue than RawMax, so
// Rows is the first of them and not all of them.
func (t RawTable) Clipped() bool { return t.Matched > len(t.Rows) }

// rawRow renders one row as stored, in the page's own column order.
//
// A cell's nullness is read exactly as cell() reads it — through rowCells.isNull,
// which is the one place in this package that decides — so the raw section and
// the fields above it can never disagree about which cells hold a value.
func rawRow(columns []string, r rowCells) RawRow {
	out := RawRow{Cells: make([]RawCell, 0, len(columns))}
	for i, name := range columns {
		if i >= len(r.values) {
			// A row shorter than its header is a malformed page, not a null cell:
			// there is no cell here to report either way, so it is left out.
			break
		}
		if r.isNull(i) {
			out.Cells = append(out.Cells, RawCell{Column: name, Null: true})
			continue
		}
		out.Cells = append(out.Cells, RawCell{Column: name, Value: r.values[i]})
	}
	return out
}

// rawTableOf collects the rows of a page that belong to one issue, capped at
// RawMax and counted whole. A table with no rows for this issue yields nil — the
// pane lists the tables that had something to say, not every table it read.
func rawTableOf(name string, page *browse.RowPage, match func(map[string]int, rowCells) bool) *RawTable {
	if page == nil {
		return nil
	}
	cols := indexCols(page.Columns)
	tbl := RawTable{Table: name}
	for _, r := range rowsOf(page) {
		if !match(cols, r) {
			continue
		}
		tbl.Matched++
		if len(tbl.Rows) < RawMax {
			tbl.Rows = append(tbl.Rows, rawRow(page.Columns, r))
		}
	}
	if tbl.Matched == 0 {
		return nil
	}
	return &tbl
}

// matchColumn matches the rows whose named column holds the wanted id — the
// shape of every per-issue table here except dependencies, which has two such
// columns and gets its own matcher at the call site.
func matchColumn(column, want string) func(map[string]int, rowCells) bool {
	return func(cols map[string]int, r rowCells) bool {
		return cell(cols, r, column) == want
	}
}

// addRaw appends a table's rows to the raw section, skipping the tables that had
// none.
func (d *Data) addRaw(t *RawTable) {
	if t == nil {
		return
	}
	d.Raw = append(d.Raw, *t)
}

A beads/raw_test.go => beads/raw_test.go +282 -0
@@ 0,0 1,282 @@
package beads

import (
	"context"
	"fmt"
	"net/url"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
)

// The raw section is the pane's own check on itself: the rows the detail was
// built from, as read. What it has to get right is what every other projection
// here is allowed to get wrong — the column order it was read in, and the
// difference between a cell that holds no value and one that holds a string
// which happens to read like one.

// rawTableByName finds one table's entry in a built detail's raw section.
func rawTableByName(d *Data, name string) *RawTable {
	for i := range d.Raw {
		if d.Raw[i].Table == name {
			return &d.Raw[i]
		}
	}
	return nil
}

// rawCellByName finds one cell of a raw row by its column name.
func rawCellByName(r RawRow, column string) *RawCell {
	for i := range r.Cells {
		if r.Cells[i].Column == column {
			return &r.Cells[i]
		}
	}
	return nil
}

// rawColumns lists a raw row's columns in the order they are carried.
func rawColumns(r RawRow) []string {
	out := make([]string, 0, len(r.Cells))
	for _, c := range r.Cells {
		out = append(out, c.Column)
	}
	return out
}

// The issues row arrives in the table's own column order. beadsFixture orders
// its columns so nothing sits at a natural index, so an implementation that
// sorted or re-derived the order could not match this by accident.
func TestRawIssuesRowKeepsTheColumnOrderItWasReadIn(t *testing.T) {
	d, err := Build(context.Background(), beadsFixture(), "main", url.Values{"issue": {"i-done"}})
	require.NoError(t, err)

	tbl := rawTableByName(d, "issues")
	require.NotNil(t, tbl, "the issue's own row is the whole point of the section")
	require.Len(t, tbl.Rows, 1)
	assert.Equal(t, 1, tbl.Matched)
	assert.False(t, tbl.Clipped())

	assert.Equal(t,
		[]string{"id", "title", "status", "priority", "issue_type", "assignee",
			"created_at", "closed_at", "close_reason", "is_blocked"},
		rawColumns(tbl.Rows[0]),
		"column order is data: it is the order the table was read in")

	// Every column carries its own row value, addressed by name.
	require.NotNil(t, rawCellByName(tbl.Rows[0], "close_reason"))
	assert.Equal(t, "Fixed in commit abc123", rawCellByName(tbl.Rows[0], "close_reason").Value)
}

// The three states the section exists for. A cell browse reported as NULL, a
// cell storing the four characters "NULL", and a cell storing the empty string
// must be three distinguishable things in the model — the first two render
// identically as strings, and the last two are identical as strings once a null
// has been flattened.
func TestRawCellsSeparateAbsentFromStoredNullFromEmpty(t *testing.T) {
	issues := &browse.RowPage{
		Columns: []string{"id", "title", "assignee", "notes", "status"},
		Rows: [][]string{
			//     id     title   assignee  notes  status
			{"i-1", "NULL", "NULL", "", "open"},
		},
		Nulls: [][]bool{
			// title holds no value at all; assignee stores those four characters;
			// notes stores the empty string.
			{false, true, false, false, false},
		},
		Total: 1,
	}

	sess := &fakeSession{rowsByTable: map[string]*browse.RowPage{
		"issues": issues,
		"dependencies": {
			Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
			Rows:    [][]string{},
			Nulls:   [][]bool{},
		},
	}}

	d, err := Build(context.Background(), sess, "main", url.Values{"issue": {"i-1"}})
	require.NoError(t, err)
	tbl := rawTableByName(d, "issues")
	require.NotNil(t, tbl)
	require.Len(t, tbl.Rows, 1)
	row := tbl.Rows[0]

	absent := rawCellByName(row, "title")
	require.NotNil(t, absent)
	assert.True(t, absent.Null, "a cell that holds no value says so")
	assert.Equal(t, "", absent.Value,
		"an absent cell carries no text: browse's \"NULL\" is a rendering of absence, not stored data")

	stored := rawCellByName(row, "assignee")
	require.NotNil(t, stored)
	assert.False(t, stored.Null, "those four characters are stored, so the cell holds a value")
	assert.Equal(t, "NULL", stored.Value)

	empty := rawCellByName(row, "notes")
	require.NotNil(t, empty)
	assert.False(t, empty.Null, "an empty string is a value")
	assert.Equal(t, "", empty.Value)

	// Pairwise distinguishable, which is the property the pane depends on: no two
	// of the three agree on both (Value, Null).
	assert.NotEqual(t, absent.Null, stored.Null, "absent vs. the stored text \"NULL\"")
	assert.NotEqual(t, absent.Null, empty.Null, "absent vs. the empty string")
	assert.NotEqual(t, stored.Value, empty.Value, "the stored text \"NULL\" vs. the empty string")
}

// A page that carries no mask at all — a hand-built one; browse fills a mask for
// every page it returns — is read the way this package read every page before
// the mask existed, and the raw section reads it the same way the fields above
// it do. One reading, one decision site.
func TestRawFollowsTheSameNullReadingAsTheFields(t *testing.T) {
	// beadsFixture's pages carry no Nulls, so "NULL" is how absence arrives.
	d, err := Build(context.Background(), beadsFixture(), "main", url.Values{"issue": {"i-open"}})
	require.NoError(t, err)
	require.NotNil(t, d.Issue)
	assert.Equal(t, "", d.Issue.ClosedAt, "the field reads the unmasked \"NULL\" as absent")

	tbl := rawTableByName(d, "issues")
	require.NotNil(t, tbl)
	closed := rawCellByName(tbl.Rows[0], "closed_at")
	require.NotNil(t, closed)
	assert.True(t, closed.Null, "and so does the raw row: they cannot disagree")
	assert.Equal(t, "", closed.Value)
}

// Every table the pane draws rows of this issue from is carried, in read order,
// and only the rows that belong to the issue: another issue's comment is another
// issue's business.
func TestRawCarriesEveryPerIssueTableInReadOrder(t *testing.T) {
	d, err := Build(context.Background(), beadsFixture(), "main", url.Values{"issue": {"i-open"}})
	require.NoError(t, err)

	var names []string
	for _, tbl := range d.Raw {
		names = append(names, tbl.Table)
	}
	assert.Equal(t, []string{"issues", "labels", "dependencies", "comments"}, names,
		"read order, and only the tables that had rows for this issue")

	labels := rawTableByName(d, "labels")
	require.NotNil(t, labels)
	assert.Equal(t, 2, labels.Matched, "both of i-open's label rows")

	// i-open is the target of i-blocked's edge: an edge is this issue's in either
	// direction, because the pane draws both lists from these rows.
	deps := rawTableByName(d, "dependencies")
	require.NotNil(t, deps)
	require.Len(t, deps.Rows, 1)
	assert.Equal(t, "i-blocked", rawCellByName(deps.Rows[0], "issue_id").Value)

	comments := rawTableByName(d, "comments")
	require.NotNil(t, comments)
	require.Len(t, comments.Rows, 1)
	assert.Equal(t, "first!", rawCellByName(comments.Rows[0], "text").Value,
		"i-prog's comment belongs to i-prog")

	// i-open has no audit events in this fixture, and a table with nothing to say
	// is not listed at all.
	assert.Nil(t, rawTableByName(d, "events"))

	// custom_statuses is read (the lane comes from it) and is deliberately absent:
	// its rows describe the tracker's statuses, not this issue.
	assert.Nil(t, rawTableByName(d, "custom_statuses"))
}

// The events table is where stored and rendered are furthest apart —
// humanizeEvent turns two JSON blobs into one sentence — so the rows behind the
// History tab are carried with the strings intact.
func TestRawCarriesTheStoredEventStrings(t *testing.T) {
	d, err := Build(context.Background(), beadsEpicFixture(), "main", url.Values{"issue": {"i-epic"}})
	require.NoError(t, err)

	events := rawTableByName(d, "events")
	require.NotNil(t, events)
	assert.Equal(t, 4, events.Matched, "i-c1's created event is not this issue's")
	require.Len(t, events.Rows, 4)
	assert.Equal(t, `{"status":"open"}`, rawCellByName(events.Rows[1], "old_value").Value,
		"the exact stored string behind the humanized line")
}

// The epic mode is the detail mode with a rollup on top, and it carries the raw
// rows for the same reason.
func TestRawIsCarriedInEpicMode(t *testing.T) {
	d, err := Build(context.Background(), beadsEpicFixture(), "main", url.Values{"issue": {"i-epic"}})
	require.NoError(t, err)
	require.Equal(t, "epic", d.Mode)

	tbl := rawTableByName(d, "issues")
	require.NotNil(t, tbl)
	require.Len(t, tbl.Rows, 1)
	assert.Equal(t, "i-epic", rawCellByName(tbl.Rows[0], "id").Value)
	assert.Equal(t, "epic", rawCellByName(tbl.Rows[0], "issue_type").Value)

	// The three parent-child edges are the issue's, in both directions.
	deps := rawTableByName(d, "dependencies")
	require.NotNil(t, deps)
	assert.Equal(t, 3, deps.Matched)
}

// An issue that is not there has no stored row, and the section has nothing to
// show rather than something empty to show.
func TestRawIsEmptyForAMissingIssue(t *testing.T) {
	d, err := Build(context.Background(), beadsFixture(), "main", url.Values{"issue": {"nope"}})
	require.NoError(t, err)
	require.True(t, d.Missing())
	assert.Empty(t, d.Raw, "nothing was found, so there is nothing stored to show")
}

// The board is not a detail pane and carries none of this: it renders no stored
// row, and a board that carried every row of every card would be the table
// browser with lanes drawn on it.
func TestRawIsNotCarriedOnTheBoard(t *testing.T) {
	d, err := Build(context.Background(), beadsFixture(), "main", url.Values{})
	require.NoError(t, err)
	require.Equal(t, "board", d.Mode)
	assert.Empty(t, d.Raw)
}

// A table with more of this issue's rows than RawMax is cut, and says how many
// there were: a section that dumps three hundred audit rows is a second page
// nobody reads, and one that silently shows fifty of three hundred is a lie.
func TestRawCapsARelatedTableAndCountsItWhole(t *testing.T) {
	sess := beadsFixture()
	rows := make([][]string, 0, RawMax+7)
	for i := range RawMax + 7 {
		rows = append(rows, []string{"i-open", "alice", fmt.Sprintf("comment %d", i), "2024-01-05"})
	}
	sess.rowsByTable["comments"] = &browse.RowPage{
		Columns: []string{"issue_id", "author", "text", "created_at"},
		Rows:    rows,
		Total:   len(rows),
	}

	d, err := Build(context.Background(), sess, "main", url.Values{"issue": {"i-open"}})
	require.NoError(t, err)

	tbl := rawTableByName(d, "comments")
	require.NotNil(t, tbl)
	assert.Len(t, tbl.Rows, RawMax)
	assert.Equal(t, RawMax+7, tbl.Matched)
	assert.True(t, tbl.Clipped())
	assert.Equal(t, "comment 0", rawCellByName(tbl.Rows[0], "text").Value, "the first of them, in table order")
}

// A row shorter than its own header is a malformed page. There is no cell to
// report for the missing columns, so they are left out rather than invented as
// nulls — and the cells that do exist still line up with their column names.
func TestRawSkipsColumnsAShortRowDoesNotHave(t *testing.T) {
	cols := []string{"id", "title", "status"}
	row := rowCells{values: []string{"i-1", "Ahoy"}}

	got := rawRow(cols, row)
	require.Len(t, got.Cells, 2)
	assert.Equal(t, RawCell{Column: "id", Value: "i-1"}, got.Cells[0])
	assert.Equal(t, RawCell{Column: "title", Value: "Ahoy"}, got.Cells[1])
}

M beads/rows.go => beads/rows.go +21 -9
@@ 102,17 102,29 @@ func cell(cols map[string]int, row rowCells, name string) string {
	if !ok || i < 0 || i >= len(row.values) {
		return ""
	}
	if i < len(row.nulls) {
		if row.nulls[i] {
			return ""
		}
		return row.values[i]
	}
	v := row.values[i]
	if v == "NULL" {
	if row.isNull(i) {
		return ""
	}
	return v
	return row.values[i]
}

// isNull reports whether the i-th cell of this row holds no value.
//
// It is the one place in this package that decides, so cell — which flattens an
// absent cell to "" — and the raw section — which must not — can never come to
// different answers about the same cell. The reading is the one cell documents:
// the mask decides when the row has one, and a row with none falls back to the
// pre-mask reading, where the text "NULL" is how absence arrived.
//
// A cell past the end of the row is not a value, so it reads as absent.
func (r rowCells) isNull(i int) bool {
	if i < 0 || i >= len(r.values) {
		return true
	}
	if i < len(r.nulls) {
		return r.nulls[i]
	}
	return r.values[i] == "NULL"
}

// indexStatusCategories maps a status name (lowercased) to its category, from

M web/beads_test.go => web/beads_test.go +224 -0
@@ 1248,6 1248,230 @@ func fieldBody(t *testing.T, body, label string) string {
	return rest[:j]
}

// --- the stored rows section ---------------------------------------------------

// beadsNullFixture is one issue carrying, in one row, the three states a raw
// view may never conflate: a cell that holds no value (closed_at), a cell that
// stores the four characters "NULL" (assignee), and a cell that stores the empty
// string (notes). The mask is what tells the first two apart, so it is set here
// exactly as browse fills it for a real read.
func beadsNullFixture() *fakeSession {
	issues := &browse.RowPage{
		Columns: []string{"id", "title", "status", "assignee", "closed_at", "notes"},
		Rows: [][]string{
			{"i-null", "Three states", "open", "NULL", "NULL", ""},
		},
		Nulls: [][]bool{
			{false, false, false, false, true, false},
		},
		Total: 1,
	}
	return &fakeSession{
		branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
		tables:   beadsTables(),
		rowsByTable: map[string]*browse.RowPage{
			"issues": issues,
			"dependencies": {
				Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
				Nulls:   [][]bool{},
				Total:   0,
			},
		},
	}
}

// rawSection returns the markup of the collapsed stored-rows block.
func rawSection(t *testing.T, body string) string {
	t.Helper()
	i := strings.Index(body, `<details class="bead-raw">`)
	if i < 0 {
		t.Fatalf("no stored-rows section in the page: %s", body)
	}
	rest := body[i:]
	j := strings.Index(rest, "</details>")
	if j < 0 {
		t.Fatalf("unterminated stored-rows section")
	}
	return rest[:j]
}

// rawTableBlock returns one table's markup inside the stored-rows section.
func rawTableBlock(t *testing.T, body, table string) string {
	t.Helper()
	sec := rawSection(t, body)
	head := `<div class="raw-table" data-table="` + table + `">`
	i := strings.Index(sec, head)
	if i < 0 {
		t.Fatalf("no %s rows in the stored-rows section: %s", table, sec)
	}
	rest := sec[i+len(head):]
	if j := strings.Index(rest, `<div class="raw-table"`); j >= 0 {
		rest = rest[:j]
	}
	return rest
}

// rawCellValue returns the rendered value cell for one column of one table's
// first row in the stored-rows section.
func rawCellValue(t *testing.T, body, table, column string) string {
	t.Helper()
	block := rawTableBlock(t, body, table)
	head := `<td class="raw-col">` + column + `</td>`
	i := strings.Index(block, head)
	if i < 0 {
		t.Fatalf("no %s.%s cell in the stored-rows section: %s", table, column, block)
	}
	rest := block[i+len(head):]
	open := `<td class="raw-val">`
	k := strings.Index(rest, open)
	if k < 0 {
		t.Fatalf("no value cell after %s.%s", table, column)
	}
	rest = rest[k+len(open):]
	j := strings.Index(rest, "</td>")
	if j < 0 {
		t.Fatalf("unterminated value cell for %s.%s", table, column)
	}
	return rest[:j]
}

// The detail pane carries the rows it was built from, and carries them closed:
// this is a tool for checking the rendering, not the reason a reader opened the
// page, so it is a <details> with no open attribute — the stream layout's Past
// Stand idiom.
func TestBeadsDetailShowsTheStoredRowsCollapsed(t *testing.T) {
	h := newHarness(t)
	h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
	h.browse.sess = beadsFixture()
	setViews(t, h, &beadsView{})

	rec := h.do("GET", "/~alice/db/view/beads?issue=i-open", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()

	if !strings.Contains(body, `<details class="bead-raw">`) {
		t.Fatalf("no stored-rows section on the detail pane; body=%s", body)
	}
	if strings.Contains(body, `<details class="bead-raw" open`) || strings.Contains(body, `<details open class="bead-raw"`) {
		t.Errorf("the stored-rows section opens expanded; body=%s", body)
	}
	if !strings.Contains(body, "Stored rows") {
		t.Errorf("the section has no summary a reader can find it by; body=%s", body)
	}

	// Every table that holds rows of this issue is there; custom_statuses is read
	// for the lane but holds no row of this issue, so it is not.
	for _, table := range []string{"issues", "labels", "dependencies", "comments"} {
		if !strings.Contains(body, `data-table="`+table+`"`) {
			t.Errorf("the stored-rows section is missing the %s rows; body=%s", table, body)
		}
	}
	if strings.Contains(body, `data-table="custom_statuses"`) {
		t.Errorf("custom_statuses has no row of this issue and should not be listed; body=%s", body)
	}

	// The issue's own row is rendered column by column, values as stored.
	if got := rawCellValue(t, body, "issues", "title"); got != `<span class="raw-text">Ready to roll</span>` {
		t.Errorf("issues.title rendered as %q", got)
	}
	if got := rawCellValue(t, body, "comments", "text"); got != `<span class="raw-text">first!</span>` {
		t.Errorf("comments.text rendered as %q", got)
	}
}

// The three states, on the page. A cell that holds no value renders as a NULL
// chip, a cell storing those four characters renders as the text it stores, and
// a cell storing the empty string says it is empty — the rendering used to make
// the first two identical and the last two indistinguishable.
func TestBeadsDetailStoredRowsSeparateNullFromEmpty(t *testing.T) {
	h := newHarness(t)
	h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
	h.browse.sess = beadsNullFixture()
	setViews(t, h, &beadsView{})

	rec := h.do("GET", "/~alice/db/view/beads?issue=i-null", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()

	absent := rawCellValue(t, body, "issues", "closed_at")
	stored := rawCellValue(t, body, "issues", "assignee")
	empty := rawCellValue(t, body, "issues", "notes")

	if absent != `<span class="raw-null">NULL</span>` {
		t.Errorf("a cell holding no value rendered as %q", absent)
	}
	if stored != `<span class="raw-text">NULL</span>` {
		t.Errorf("a cell storing the text \"NULL\" rendered as %q", stored)
	}
	if empty != `<span class="raw-text"></span>` {
		t.Errorf("a cell storing the empty string rendered as %q", empty)
	}
	if absent == stored || absent == empty || stored == empty {
		t.Errorf("the three states are not three renderings: %q / %q / %q", absent, stored, empty)
	}
}

// Stored values are escaped, and they are not linkified. A script tag stored in
// a description reaches this section as text, and the ids in it stay the
// characters that are stored — the bodies above the section link them, but a raw
// view whose values have been rewritten is no longer showing what is stored.
func TestBeadsDetailStoredRowsEscapeAndDoNotLink(t *testing.T) {
	h, _, _ := linkHarness(t)

	rec := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()

	desc := rawCellValue(t, body, "issues", "description")
	if strings.Contains(desc, "<script") {
		t.Fatalf("a stored script tag reached the stored-rows section as markup: %s", desc)
	}
	if !strings.Contains(desc, "&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;") {
		t.Errorf("the stored script tag is not escaped in the stored-rows section: %s", desc)
	}
	if strings.Contains(desc, "<a ") {
		t.Errorf("the stored-rows section linkified a value: %s", desc)
	}
	// The bodies above still link, so this is the section's own choice and not a
	// linkifier that stopped working.
	if !strings.Contains(fieldBody(t, body, "Description"), `<a href="/~bob/beta/view/beads?issue=beta-46c.2">`) {
		t.Errorf("the description above the section stopped linking; body=%s", body)
	}
	// Escaped and unlinked, the cell still decodes to exactly what is stored.
	want := "blocked by beta-46c.2, and by alpha-2.\nnosuch-9z is nobody's.\n<script>alert('x')</script>"
	got := html.UnescapeString(strings.TrimSuffix(strings.TrimPrefix(desc, `<span class="raw-text">`), "</span>"))
	if got != want {
		t.Errorf("the stored description does not render as stored: %q", got)
	}
}

// The board renders no stored rows: it is not a detail pane, and a board that
// carried every row behind every card would be the table browser with lanes
// drawn on it.
func TestBeadsBoardHasNoStoredRowsSection(t *testing.T) {
	h := newHarness(t)
	h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
	h.browse.sess = beadsFixture()
	setViews(t, h, &beadsView{})

	rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	for _, notWant := range []string{`class="bead-raw"`, "Stored rows", `<td class="raw-col">`} {
		if strings.Contains(body, notWant) {
			t.Errorf("the board rendered the stored-rows section (%q); body=%s", notWant, body)
		}
	}
}

func TestBeadsEpicViewRender(t *testing.T) {
	h := newHarness(t)
	h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})

M web/templates/beads.html => web/templates/beads.html +86 -0
@@ 206,6 206,50 @@ pre.field-body {
.bead-timeline .tl-head { font-size: .85rem; color: var(--bd-fg); }
.bead-timeline .tl-when { color: var(--bd-muted); }
.bead-timeline .tl-body { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--bd-fg); margin-top: .15rem; padding-left: .1rem; }

/* stored rows: the pane's own check on itself, and not the reason a reader came
   — so it opens collapsed, exactly as the stream's Past Stand section does. The
   idiom is the rest of the page's: flat, square, hairline borders, monospace for
   anything that came out of the database. */
.bead-raw { margin: 1.5rem 0 0; border-top: 1px solid var(--bd-border); padding-top: .6rem; }
.bead-raw > summary { cursor: pointer; font-size: .8rem; color: var(--bd-muted); }
.bead-raw > summary .raw-note { font-style: italic; }
.bead-raw .raw-table { margin: .6rem 0 0; }
.bead-raw .raw-name {
  display: flex; align-items: baseline; gap: .4rem; padding: .2rem .4rem;
  background: var(--bd-panel); border: 1px solid var(--bd-border);
  font-family: monospace; font-size: .75rem; color: var(--bd-fg);
}
.bead-raw .raw-name .raw-count { margin-left: auto; font-family: inherit; color: var(--bd-muted); }
.bead-raw table.raw-row {
  width: 100%; border: 1px solid var(--bd-border); border-top: none;
  border-collapse: collapse; margin: 0;
}
.bead-raw table.raw-row + table.raw-row { border-top: 2px solid var(--bd-border); }
.bead-raw table.raw-row td { padding: .1rem .4rem; vertical-align: top; border-top: 1px solid var(--bd-border); }
.bead-raw table.raw-row tr:first-child td { border-top: none; }
.bead-raw td.raw-col {
  width: 11rem; white-space: nowrap; font-size: .72rem; color: var(--bd-muted);
  border-right: 1px solid var(--bd-border);
}
/* A long value scrolls inside its own cell rather than stretching the page: this
   is a section a reader glances into, and one 8kB description must not push
   everything else out of reach. */
.bead-raw .raw-text {
  display: block; max-height: 9rem; overflow: auto;
  font-family: monospace; font-size: .75rem; color: var(--bd-fg);
  white-space: pre-wrap; overflow-wrap: anywhere;
}
/* The two absences a raw view may never conflate. A cell that holds no value is
   the only place the word NULL is written by the page itself — a chip, muted and
   italic, so it cannot be mistaken for the four characters a row might store —
   and a cell holding the empty string says that it is empty instead of rendering
   as nothing at all, which is what a null used to look like. */
.bead-raw .raw-null {
  font-size: .72rem; font-style: italic; color: var(--bd-muted);
  border: 1px solid var(--bd-border); padding: 0 .25rem;
}
.bead-raw .raw-text:empty::after { content: "(empty)"; font-style: italic; color: var(--bd-muted); }
</style>

<div class="beads">


@@ 407,6 451,48 @@ pre.field-body {
    {{else}}<p class="beads-empty">No activity yet.</p>{{end}}
  </div>
</div>

{{/* ------------- the stored rows behind all of the above -------------
     Everything on this pane is a reading of these rows — a field set that names
     some columns and drops the rest, an event humanized into a sentence, an edge
     reduced to an id and a type — and a reading that went wrong looks exactly
     like one that did not. So the rows themselves are here, as they were read,
     to be compared against the rendering above them.

     Collapsed, because this is a tool for checking the page and not the reason
     anyone opened it — the same <details> the stream layout gives Past Stand.

     The values go through {{.Value}} and nothing else: they are stored text, so
     html/template escapes them, and they are deliberately NOT run through
     $.Links.Text the way the description and the comment bodies above are. A
     linkifier rewrites what it is given — an id inside a value would come out as
     an anchor — and a value that has been rewritten is no longer the value that
     is stored, which is the only thing this section is for. */}}
{{if $.Data.Raw}}
<details class="bead-raw">
  <summary>Stored rows <span class="raw-note">— what this pane was built from, as read</span></summary>
  {{range $.Data.Raw}}
  <div class="raw-table" data-table="{{.Table}}">
    <div class="raw-name">
      {{.Table}}
      <span class="raw-count">{{if .Clipped}}first {{len .Rows}} of {{.Matched}} rows{{else}}{{.Matched}} row{{if ne .Matched 1}}s{{end}}{{end}}</span>
    </div>
    {{range .Rows}}
    <table class="raw-row">
      <tbody>
        {{range .Cells}}
        <tr>
          <td class="raw-col">{{.Column}}</td>
          <td class="raw-val">{{if .Null}}<span class="raw-null">NULL</span>{{else}}<span class="raw-text">{{.Value}}</span>{{end}}</td>
        </tr>
        {{end}}
      </tbody>
    </table>
    {{end}}
  </div>
  {{end}}
</details>
{{end}}
{{else}}
{{/* Two ways an issue can be missing, and the page may only claim the one it
     can see. A complete read that does not carry the id is an absence; a read