diff --git a/internal/pg/bloat.go b/internal/pg/bloat.go index f5c3fc4..645b480 100644 --- a/internal/pg/bloat.go +++ b/internal/pg/bloat.go @@ -54,7 +54,7 @@ func (c *Client) FillBloat(ctx context.Context, t Table, parts []Part) error { if err != nil { return err } - qualified := fmt.Sprintf("%q.%q", t.Schema, t.Name) + qualified := qualifiedIdent(t.Schema, t.Name) for i := range parts { p := &parts[i] @@ -83,7 +83,7 @@ func (c *Client) FillBloat(ctx context.Context, t Table, parts []Part) error { } } case PartIndex: - indexRef := fmt.Sprintf("%q.%q", t.Schema, p.Name) + indexRef := qualifiedIdent(t.Schema, p.Name) if mode == BloatExact && p.AccessMethod == "btree" { if err := pool.QueryRow(ctx, sqlBloatIndex, indexRef).Scan(&p.WastedBytes); err == nil { p.HasBloat = true diff --git a/internal/pg/queries_buffers.go b/internal/pg/queries_buffers.go index 034b16b..4c2cbff 100644 --- a/internal/pg/queries_buffers.go +++ b/internal/pg/queries_buffers.go @@ -1,5 +1,7 @@ package pg +import "fmt" + // --- shared-buffers view --- // sqlExtensionProbe returns two booleans: whether the named extension is @@ -85,7 +87,14 @@ ORDER BY g.usagecount // pg_buffercache.reldatabase = 0 is the shared catalog buffer pool — included // so system relations a user owns aren't double-counted oddly, though for // user schemas the join via relfilenode usually filters those out. -const sqlBufferStats = ` +// +// sqlBufferStatsTmpl is the shared body of the two variants below, which +// differ only in how rows are picked: by schema (needs the extra pg_namespace +// join in the filenodes arms, %[1]s, and an nspname predicate) or by a single +// relation OID. %[2]s is the row predicate, %[3]s the trailing ORDER BY +// (pointless for the single-row variant). The fragments are the fixed strings +// in the var block below — nothing user-supplied is ever spliced in. +const sqlBufferStatsTmpl = ` WITH bc AS ( SELECT relfilenode, COUNT(*) AS bufs, @@ -97,20 +106,17 @@ WITH bc AS ( ), filenodes AS ( SELECT c.oid AS tab_oid, pg_relation_filenode(c.oid) AS fn - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = $1 AND c.relkind IN ('r','m','p') + FROM pg_class c%[1]s + WHERE %[2]s AND c.relkind IN ('r','m','p') UNION ALL SELECT c.oid, pg_relation_filenode(c.reltoastrelid) - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = $1 AND c.relkind IN ('r','m','p') AND c.reltoastrelid <> 0 + FROM pg_class c%[1]s + WHERE %[2]s AND c.relkind IN ('r','m','p') AND c.reltoastrelid <> 0 UNION ALL SELECT c.oid, pg_relation_filenode(i.indexrelid) - FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace + FROM pg_class c%[1]s JOIN pg_index i ON i.indrelid = c.oid - WHERE n.nspname = $1 AND c.relkind IN ('r','m','p') + WHERE %[2]s AND c.relkind IN ('r','m','p') ), buffered AS ( SELECT f.tab_oid, @@ -136,65 +142,21 @@ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN buffered b ON b.tab_oid = c.oid LEFT JOIN pg_statio_user_tables s ON s.relid = c.oid -WHERE n.nspname = $1 AND c.relkind IN ('r','m','p') -ORDER BY buffered_bytes DESC, c.relname +WHERE %[2]s AND c.relkind IN ('r','m','p')%[3]s ` -// sqlBufferStatByOID is sqlBufferStats scoped to a single relation by OID -// instead of a whole schema — the natural shape for the describe-table view, -// which has the OID but wants only that one table's cache footprint. The -// filenodes CTEs and final SELECT filter on c.oid = $1; the rest matches -// sqlBufferStats so the scanned column list is identical. -const sqlBufferStatByOID = ` -WITH bc AS ( - SELECT relfilenode, - COUNT(*) AS bufs, - COUNT(*) FILTER (WHERE isdirty) AS dirty_bufs, - SUM(usagecount)::bigint AS usage_sum - FROM pg_buffercache - WHERE reldatabase IN (0, (SELECT oid FROM pg_database WHERE datname = current_database())) - GROUP BY relfilenode -), -filenodes AS ( - SELECT c.oid AS tab_oid, pg_relation_filenode(c.oid) AS fn - FROM pg_class c - WHERE c.oid = $1 AND c.relkind IN ('r','m','p') - UNION ALL - SELECT c.oid, pg_relation_filenode(c.reltoastrelid) - FROM pg_class c - WHERE c.oid = $1 AND c.relkind IN ('r','m','p') AND c.reltoastrelid <> 0 - UNION ALL - SELECT c.oid, pg_relation_filenode(i.indexrelid) - FROM pg_class c - JOIN pg_index i ON i.indrelid = c.oid - WHERE c.oid = $1 AND c.relkind IN ('r','m','p') -), -buffered AS ( - SELECT f.tab_oid, - COALESCE(SUM(bc.bufs), 0)::bigint AS bufs, - COALESCE(SUM(bc.dirty_bufs), 0)::bigint AS dirty_bufs, - COALESCE(SUM(bc.usage_sum), 0)::bigint AS usage_sum - FROM filenodes f - LEFT JOIN bc ON bc.relfilenode = f.fn - GROUP BY f.tab_oid +var ( + // sqlBufferStats lists every table in one schema ($1 = nspname). + sqlBufferStats = fmt.Sprintf(sqlBufferStatsTmpl, + "\n JOIN pg_namespace n ON n.oid = c.relnamespace", + "n.nspname = $1", + "\nORDER BY buffered_bytes DESC, c.relname") + // sqlBufferStatByOID is the same query scoped to a single relation + // ($1 = oid) — the natural shape for the describe-table view, which has + // the OID but wants only that one table's cache footprint. The scanned + // column list is identical to sqlBufferStats. + sqlBufferStatByOID = fmt.Sprintf(sqlBufferStatsTmpl, "", "c.oid = $1", "") ) -SELECT c.oid, - n.nspname, - c.relname, - COALESCE(b.bufs, 0) * current_setting('block_size')::int AS buffered_bytes, - pg_total_relation_size(c.oid) AS total_bytes, - COALESCE(s.heap_blks_hit, 0) + COALESCE(s.idx_blks_hit, 0) AS hits, - COALESCE(s.heap_blks_read, 0) + COALESCE(s.idx_blks_read, 0) AS reads, - COALESCE(b.dirty_bufs, 0) * current_setting('block_size')::int AS dirty_bytes, - CASE WHEN COALESCE(b.bufs, 0) > 0 - THEN b.usage_sum::float8 / b.bufs - ELSE 0 END AS usage_avg -FROM pg_class c -JOIN pg_namespace n ON n.oid = c.relnamespace -LEFT JOIN buffered b ON b.tab_oid = c.oid -LEFT JOIN pg_statio_user_tables s ON s.relid = c.oid -WHERE c.oid = $1 AND c.relkind IN ('r','m','p') -` // sqlShmemAllocations dumps the whole Postgres shared-memory segment from // pg_shmem_allocations: every named region (the buffer pool, lock tables, SLRU diff --git a/internal/pg/reindex.go b/internal/pg/reindex.go index ad04120..8648058 100644 --- a/internal/pg/reindex.go +++ b/internal/pg/reindex.go @@ -13,7 +13,7 @@ func (c *Client) ReindexIndex(ctx context.Context, t Table, indexName string) er if err != nil { return err } - stmt := fmt.Sprintf("REINDEX INDEX CONCURRENTLY %q.%q", t.Schema, indexName) + stmt := "REINDEX INDEX CONCURRENTLY " + qualifiedIdent(t.Schema, indexName) if _, err := pool.Exec(ctx, stmt); err != nil { return fmt.Errorf("reindex %q.%q: %w", t.Schema, indexName, err) } diff --git a/internal/pg/statements_test.go b/internal/pg/statements_test.go index f44a04e..6023104 100644 --- a/internal/pg/statements_test.go +++ b/internal/pg/statements_test.go @@ -329,6 +329,40 @@ func TestQueryStatHitRatioAndDerived(t *testing.T) { } } +func TestQueryStatDerivedEdgeCases(t *testing.T) { + // BlocksPerRow: undefined without result rows, defined otherwise. + if _, ok := (QueryStat{SharedBlksHit: 10}).BlocksPerRow(); ok { + t.Error("BlocksPerRow should be undefined with zero rows") + } + if _, ok := (QueryStat{Rows: -1, SharedBlksHit: 10}).BlocksPerRow(); ok { + t.Error("BlocksPerRow should be undefined with negative rows") + } + if bpr, ok := (QueryStat{Rows: 4, SharedBlksHit: 6, SharedBlksRead: 2}).BlocksPerRow(); !ok || bpr != 2 { + t.Errorf("BlocksPerRow = %v ok=%v, want 2 true", bpr, ok) + } + + // IOTime sums all six timing counters; zero when none are set. + if got := (QueryStat{}).IOTime(); got != 0 { + t.Errorf("IOTime zero = %v, want 0", got) + } + full := QueryStat{ + SharedBlkReadTime: 1, SharedBlkWriteTime: 2, + LocalBlkReadTime: 4, LocalBlkWriteTime: 8, + TempBlkReadTime: 16, TempBlkWriteTime: 32, + } + if got := full.IOTime(); got != 63 { + t.Errorf("IOTime = %v, want 63", got) + } + + // HitRatio boundaries: all-hit and all-miss are both defined ratios. + if hr, ok := (QueryStat{SharedBlksHit: 5}).HitRatio(); !ok || hr != 100 { + t.Errorf("HitRatio all-hit = %v ok=%v, want 100 true", hr, ok) + } + if hr, ok := (QueryStat{SharedBlksRead: 5}).HitRatio(); !ok || hr != 0 { + t.Errorf("HitRatio all-miss = %v ok=%v, want 0 true", hr, ok) + } +} + func TestBuildSampleCall(t *testing.T) { cases := []struct { name string diff --git a/internal/pg/vacuum.go b/internal/pg/vacuum.go index 14d9873..29dc42b 100644 --- a/internal/pg/vacuum.go +++ b/internal/pg/vacuum.go @@ -31,7 +31,7 @@ func (c *Client) VacuumTable(ctx context.Context, t Table, onLine func(string)) } defer func() { _ = conn.Close(context.Background()) }() _, _ = conn.Exec(ctx, "SET pg_stat_statements.track = 'none'") - stmt := fmt.Sprintf("VACUUM (VERBOSE, ANALYZE, SKIP_LOCKED) %q.%q", t.Schema, t.Name) + stmt := "VACUUM (VERBOSE, ANALYZE, SKIP_LOCKED) " + qualifiedIdent(t.Schema, t.Name) if _, err := conn.Exec(ctx, stmt); err != nil { return fmt.Errorf("vacuum %q.%q: %w", t.Schema, t.Name, err) } diff --git a/internal/tui/filter.go b/internal/tui/filter.go index f3655cf..e253364 100644 --- a/internal/tui/filter.go +++ b/internal/tui/filter.go @@ -158,6 +158,14 @@ func (s *screen) clampCursor() { } } +// resetCursor jumps the selection back to the top of the list. cursor and +// offset move together so the viewport can't be left scrolled past the +// selection. +func (s *screen) resetCursor() { + s.cursor = 0 + s.offset = 0 +} + // viewportRange adjusts offset so cursor stays inside [offset, offset+height) // and returns the new offset plus the half-open end. Callers use the offset // to scroll the list and the end (clamped to the underlying length elsewhere) diff --git a/internal/tui/filter_test.go b/internal/tui/filter_test.go index 1ed5836..314a6eb 100644 --- a/internal/tui/filter_test.go +++ b/internal/tui/filter_test.go @@ -1,6 +1,9 @@ package tui -import "testing" +import ( + "strings" + "testing" +) func TestFuzzyMatch(t *testing.T) { cases := []struct { @@ -42,6 +45,33 @@ func TestSubstringMatch(t *testing.T) { } } +// TestContainsFold checks the allocation-free fast path against the +// lowercased strings.Contains it replaces, including the non-ASCII needle +// that must take the unicode-correct fallback. +func TestContainsFold(t *testing.T) { + cases := []struct{ s, substr string }{ + {"SELECT * FROM Users", "users"}, + {"select * from users", "USERS"}, + {"select * from users", "orders"}, + {"", ""}, + {"abc", ""}, + {"", "a"}, + {"ab", "abc"}, // needle longer than haystack + {"alpha", "a"}, + {"alpha", "A"}, + {"alph", "ph"}, // match at the very end + {"NAÏVE query", "naïve"}, // non-ASCII needle → fallback path + {"multibyte żółć in the haystack", "haystack"}, // ASCII needle over UTF-8 haystack + {"żółć", "x"}, + } + for _, c := range cases { + want := strings.Contains(strings.ToLower(c.s), strings.ToLower(c.substr)) + if got := containsFold(c.s, c.substr); got != want { + t.Errorf("containsFold(%q, %q) = %v, want %v", c.s, c.substr, got, want) + } + } +} + func TestMatchFilterPerLevel(t *testing.T) { // b…a…t…t…l…e is a subsequence of this query but not a substring. const q = "select b1_0.id, b1_0.created_at from player_state where a > $1" @@ -68,6 +98,10 @@ func TestViewportRange(t *testing.T) { {"cursor above window scrolls up", 1, 5, 10, 20, 1, 11}, {"end clamped to length", 0, 0, 10, 4, 0, 4}, {"negative offset clamped", 0, -3, 10, 20, 0, 10}, + {"cursor on last item", 19, 0, 10, 20, 10, 20}, + {"cursor exactly at viewport tail", 9, 0, 10, 20, 0, 10}, + {"list shorter than viewport", 2, 0, 10, 3, 0, 3}, + {"height one pins offset to cursor", 7, 3, 1, 20, 7, 8}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/internal/tui/format.go b/internal/tui/format.go index 6f254f0..e7173ef 100644 --- a/internal/tui/format.go +++ b/internal/tui/format.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strconv" + "strings" "time" ) @@ -46,8 +47,8 @@ func positionLabel(s *screen) string { } // bloatScanLabel returns a short status indicator for the bloat fetch on -// the parts level. FillBloat is a single round trip that covers every -// part, so the states are "scanning…" (in flight) or "ready" (done) — +// the parts level. FillBloat is a single Cmd that covers every part, so +// the states are "scanning…" (in flight) or "ready" (done) — // any partial scanned count comes from individual rows whose bloat could // not be measured (e.g. unsupported index access methods). func bloatScanLabel(s *screen) string { @@ -162,3 +163,65 @@ func maxInt(a, b int) int { } return b } + +// fmtFloat renders a number with up to 1 decimals, trailing zeros stripped. +func fmtFloat(f float64) string { + s := strconv.FormatFloat(f, 'f', 1, 64) + if strings.ContainsRune(s, '.') { + s = strings.TrimRight(strings.TrimRight(s, "0"), ".") + } + return s +} + +// fmt1 renders a number with exactly one decimal place (60 → "60.0", 98.51 → +// "98.5"). The top-queries numeric columns use it so every value shows a single +// fractional digit rather than a ragged mix of 0/1/2 places. +func fmt1(f float64) string { + return strconv.FormatFloat(f, 'f', 1, 64) +} + +// fmtMs formats a millisecond duration compactly: sub-millisecond and small +// values keep ms; large values switch to seconds so the column stays narrow. +func fmtMs(ms float64) string { + if ms >= 100000 { + return fmt1(ms/1000) + "s" + } + return fmt1(ms) +} + +// fmtAge formats an elapsed time (in ms) as an age with an explicit, scale- +// appropriate unit so values never read ambiguously: "850ms", "31.1s", "11.2m", +// "3.1h", "2.4d". Unlike fmtMs the unit is always present, which is what lets the +// reader tell 105ms from 105s at a glance (paired with durationStyle colouring). +func fmtAge(ms float64) string { + switch { + case ms < 1000: + return fmt.Sprintf("%.0fms", ms) + case ms < 60*1000: + return fmt1(ms/1000) + "s" + case ms < 60*60*1000: + return fmt1(ms/(60*1000)) + "m" + case ms < 24*60*60*1000: + return fmt1(ms/(60*60*1000)) + "h" + default: + return fmt1(ms/(24*60*60*1000)) + "d" + } +} + +// fmtDuration renders a window span with explicit units — "45s", "13m 12s", +// "2h 05m", "3d 4h" — so it never reads as a wall-clock time. The old H:MM:SS +// form made "13:12" ambiguous with a start timestamp, which is the whole reason +// it sits next to "since 05:56:46". +func fmtDuration(d time.Duration) string { + d = d.Round(time.Second) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d/time.Second)) + case d < time.Hour: + return fmt.Sprintf("%dm %02ds", int(d/time.Minute), int(d%time.Minute/time.Second)) + case d < 24*time.Hour: + return fmt.Sprintf("%dh %02dm", int(d/time.Hour), int(d%time.Hour/time.Minute)) + default: + return fmt.Sprintf("%dd %dh", int(d/(24*time.Hour)), int(d%(24*time.Hour)/time.Hour)) + } +} diff --git a/internal/tui/layout_test.go b/internal/tui/layout_test.go new file mode 100644 index 0000000..8046517 --- /dev/null +++ b/internal/tui/layout_test.go @@ -0,0 +1,43 @@ +package tui + +import "testing" + +// TestBarWidth pins the terminal-width clamp: levelDescribe reserves nothing, +// so the bar gets the full width, bounded by [barWidthMin, barWidthMax]. +func TestBarWidth(t *testing.T) { + s := &screen{level: levelDescribe} + cases := []struct{ width, want int }{ + {10, barWidthMin}, // too narrow: fall back to the minimum + {barWidthMin, barWidthMin}, + {50, 50}, // in range: track the terminal + {barWidthMax, barWidthMax}, + {500, barWidthMax}, // very wide: cap so columns keep their share + } + for _, c := range cases { + m := &Model{width: c.width} + if got := m.barWidth(s); got != c.want { + t.Errorf("barWidth(width=%d) = %d, want %d", c.width, got, c.want) + } + } +} + +// TestBarReserveSane is a smoke check over every level: the reserve must be +// non-negative and leave room for a bar on a normal-width terminal. It exists +// to catch a levelless typo in the barReserve arithmetic, not to pin exact +// sums (those live next to their renderers). +func TestBarReserveSane(t *testing.T) { + ams := []string{"btree", "gist", "brin", "gin"} + // levelTableStats is the last enum value; extend here if a level is added after it. + for l := levelTools; l <= levelTableStats; l++ { + for _, tl := range []tool{toolDisk, toolPageInspect} { + for _, am := range ams { + s := &screen{level: l, tool: tl} + s.index.AccessMethod = am + r := barReserve(s) + if r < 0 || r > 150 { + t.Errorf("barReserve(level=%d tool=%d am=%s) = %d, want 0..150", l, tl, am, r) + } + } + } + } +} diff --git a/internal/tui/queries_columns_test.go b/internal/tui/queries_columns_test.go new file mode 100644 index 0000000..d19fbab --- /dev/null +++ b/internal/tui/queries_columns_test.go @@ -0,0 +1,125 @@ +package tui + +import ( + "testing" + + "pgdu/internal/pg" +) + +func TestStmtColumnRegistry(t *testing.T) { + seen := map[stmtColID]bool{} + for _, d := range stmtColumnRegistry() { + if seen[d.id] { + t.Errorf("duplicate column id %q", d.id) + } + seen[d.id] = true + if d.name == "" || d.desc == "" { + t.Errorf("column %q: name and desc must be set", d.id) + } + if d.cell == nil { + t.Errorf("column %q: nil cell builder", d.id) + } + if d.mandatory && !d.defaultOn { + t.Errorf("column %q: mandatory columns must also be default-on", d.id) + } + } + if !seen[colQuery] { + t.Error("registry must contain the mandatory query column") + } +} + +func colIDSet(descs []stmtColDesc) map[stmtColID]bool { + out := make(map[stmtColID]bool, len(descs)) + for _, d := range descs { + out[d.id] = true + } + return out +} + +// TestVisibleStmtColsPlanningGate pins the track_planning availability gate: +// with planning off the plan columns disappear entirely (not just hide), with +// it on only the default-on one appears. +func TestVisibleStmtColsPlanningGate(t *testing.T) { + m := &Model{} + + off := colIDSet(m.visibleStmtCols(stmtCtx{trackPlanning: false})) + for _, id := range []stmtColID{colPlanMs, colMeanPlanMs, colPlans} { + if off[id] { + t.Errorf("track_planning off: column %q must be unavailable", id) + } + } + + on := colIDSet(m.visibleStmtCols(stmtCtx{trackPlanning: true})) + if !on[colMeanPlanMs] { + t.Error("track_planning on: default-on mean_plan_ms should appear") + } + if on[colPlanMs] || on[colPlans] { + t.Error("track_planning on: opt-in plan_ms/plans stay hidden by default") + } +} + +func TestVisibleStmtColsUserToggles(t *testing.T) { + m := &Model{stmtColsVisible: map[stmtColID]bool{ + colHit: false, // hide a default-on column + colDirtied: true, // enable an opt-in column + colQuery: false, // mandatory — the toggle must be ignored + }} + ids := colIDSet(m.visibleStmtCols(stmtCtx{})) + if ids[colHit] { + t.Error("hidden default-on column is still visible") + } + if !ids[colDirtied] { + t.Error("user-enabled opt-in column is missing") + } + if !ids[colQuery] { + t.Error("mandatory query column must survive an off toggle") + } + if !ids[colTotalMs] { + t.Error("untouched default-on column is missing") + } +} + +// TestCellsForStaysParallel pins the invariant the registry design exists for: +// cells, diag columns and descriptors are projected from the same slice, so +// they stay index-parallel for any visibility/availability combination. +func TestCellsForStaysParallel(t *testing.T) { + m := &Model{} + q := pg.QueryStat{Query: "select 1", Calls: 3, Rows: 6, TotalExecTime: 12, + SharedBlksHit: 9, SharedBlksRead: 3} + for _, tp := range []bool{false, true} { + ctx := stmtCtx{windowMs: 100, trackPlanning: tp} + descs := m.visibleStmtCols(ctx) + if len(descs) == 0 { + t.Fatalf("trackPlanning=%v: no visible columns", tp) + } + if cells := cellsFor(descs, q, ctx); len(cells) != len(descs) { + t.Fatalf("trackPlanning=%v: %d cells for %d columns", tp, len(cells), len(descs)) + } + if cols := diagColumnsFrom(descs); len(cols) != len(descs) { + t.Fatalf("trackPlanning=%v: %d diag columns for %d descs", tp, len(cols), len(descs)) + } + } + + descs := m.visibleStmtCols(stmtCtx{}) + cells := cellsFor(descs, q, stmtCtx{}) + qi := indexOfStmtCol(descs, colQuery) + if qi < 0 || cells[qi].Display != "select 1" { + t.Errorf("query cell at index %d = %+v, want display %q", qi, cells, "select 1") + } +} + +func TestLabelStmtFooter(t *testing.T) { + m := &Model{} + descs := m.visibleStmtCols(stmtCtx{}) + total := make([]pg.DiagCell, len(descs)) + labelStmtFooter(descs, total) + + if qi := indexOfStmtCol(descs, colQuery); total[qi].Display != "← Sum" { + t.Errorf("footer query cell = %q, want ← Sum", total[qi].Display) + } + for _, id := range []stmtColID{colTable, colType} { + if i := indexOfStmtCol(descs, id); i >= 0 && total[i].Display != "" { + t.Errorf("footer %q cell = %q, want blank", id, total[i].Display) + } + } +} diff --git a/internal/tui/row.go b/internal/tui/row.go index e7562a3..315969f 100644 --- a/internal/tui/row.go +++ b/internal/tui/row.go @@ -234,21 +234,8 @@ func renderSegmentedBar(heap, idx, toast, max int64, width int) string { // styleBloat for semantic parity with the parts view's bloat overlay. func renderHeapPageBar(live, dead int64, width int) string { const blockSize int64 = 8192 - bytesToCells := func(b int64) int { - if b <= 0 { - return 0 - } - c := int(float64(width) * float64(b) / float64(blockSize)) - if c < 0 { - return 0 - } - if c > width { - return width - } - return c - } - l := bytesToCells(live) - d := bytesToCells(dead) + l := bytesToCells(live, blockSize, width) + d := bytesToCells(dead, blockSize, width) if l+d > width { // Rounding can push us one cell over; trim the dead segment last // since live is the dominant visual. @@ -281,26 +268,23 @@ func renderBufferBar(slices []bufferSlice, thisDBRemainder, otherDB, total int64 if total <= 0 { total = 1 } - bytesToCells := func(b int64) int { - return max0(int(float64(width) * float64(b) / float64(total))) - } segs := make([]barSegment, 0, len(slices)+2) used := 0 for i, sl := range slices { - c := bytesToCells(sl.bytes) + c := bytesToCells(sl.bytes, total, width) if used+c > width { c = width - used } segs = append(segs, barSegment{cells: c, style: bufferSliceStyle(i)}) used += c } - rem := bytesToCells(thisDBRemainder) + rem := bytesToCells(thisDBRemainder, total, width) if used+rem > width { rem = width - used } segs = append(segs, barSegment{cells: rem, style: styleBar}) used += rem - other := bytesToCells(otherDB) + other := bytesToCells(otherDB, total, width) if used+other > width { other = width - used } @@ -317,9 +301,6 @@ func renderServerMemBar(sbUsed, sbFree, otherUsed, cache, total int64, width int if total <= 0 { total = 1 } - bytesToCells := func(b int64) int { - return max0(int(float64(width) * float64(b) / float64(total))) - } used := 0 clamp := func(c int) int { if used+c > width { @@ -331,10 +312,10 @@ func renderServerMemBar(sbUsed, sbFree, otherUsed, cache, total int64, width int used += c return c } - a := clamp(bytesToCells(sbUsed)) - b := clamp(bytesToCells(sbFree)) - c := clamp(bytesToCells(otherUsed)) - d := clamp(bytesToCells(cache)) + a := clamp(bytesToCells(sbUsed, total, width)) + b := clamp(bytesToCells(sbFree, total, width)) + c := clamp(bytesToCells(otherUsed, total, width)) + d := clamp(bytesToCells(cache, total, width)) return paintBar(width, barSegment{cells: a, style: styleBar}, barSegment{cells: b, style: styleSBFree}, @@ -350,6 +331,15 @@ func max0(n int) int { return n } +// bytesToCells scales a byte count to bar cells proportionally to total, +// clamped to [0, width]. Callers guarantee total > 0. +func bytesToCells(b, total int64, width int) int { + if b <= 0 { + return 0 + } + return min(max0(int(float64(width)*float64(b)/float64(total))), width) +} + func padRight(s string, n int) string { w := displayWidth(s) if w >= n { @@ -358,6 +348,27 @@ func padRight(s string, n int) string { return s + strings.Repeat(" ", n-w) } +// infoHeader writes the standard `?`-overlay title + dismiss-hint line into b. +func infoHeader(b *strings.Builder, title string) { + mu := styleMuted.Render + b.WriteString("\n") + b.WriteString(" " + styleSelected.Render(title) + mu(" · press ") + + styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") +} + +// padInfo pads an info overlay's builder to exactly `height` lines so the +// help row stays pinned to the bottom of the screen. +func padInfo(b *strings.Builder, height int) string { + rendered := strings.Count(b.String(), "\n") + for i := rendered; i < height; i++ { + b.WriteString("\n") + } + return b.String() +} + +// swatch renders the legend colour block used by the `?` reference overlays. +func swatch(style lipgloss.Style) string { return style.Render("▇") } + // SGR intensity toggles for emboldening the active-sort column label inside a // header line that is rendered as one styleMuted.Render(line). \x1b[22m turns // bold off again *without* resetting the foreground — a nested lipgloss style diff --git a/internal/tui/sort_test.go b/internal/tui/sort_test.go index 866ea89..c557d09 100644 --- a/internal/tui/sort_test.go +++ b/internal/tui/sort_test.go @@ -68,6 +68,49 @@ func TestSortModeLess(t *testing.T) { } } +// TestLessByExtractor pins the generic comparator behind most sort modes, +// in particular the "unknown sorts below known" rule: less(unknown, known) +// is true and less(known, unknown) false, mirroring the itemRows check above. +func TestLessByExtractor(t *testing.T) { + // The test extractor treats negative sizes as "no value". + ext := func(it item) (int64, bool) { return it.size, it.size >= 0 } + small, big, unknown := item{size: 10}, item{size: 20}, item{size: -1} + + if !lessByExtractor(small, big, ext) { + t.Error("10 should be < 20") + } + if lessByExtractor(big, small, ext) { + t.Error("20 should not be < 10") + } + if lessByExtractor(small, small, ext) { + t.Error("equal values are not less") + } + if lessByExtractor(small, unknown, ext) { + t.Error("known should not sort below unknown") + } + if !lessByExtractor(unknown, small, ext) { + t.Error("unknown should sort below known") + } + if lessByExtractor(unknown, unknown, ext) { + t.Error("two unknowns are equal, not less") + } +} + +func TestLessByStringExtractor(t *testing.T) { + ext := func(it item) (string, bool) { return it.name, it.name != "" } + a, b, unknown := item{name: "alpha"}, item{name: "beta"}, item{} + + if !lessByStringExtractor(a, b, ext) || lessByStringExtractor(b, a, ext) { + t.Error("want lexicographic order: alpha < beta only") + } + if lessByStringExtractor(a, unknown, ext) || !lessByStringExtractor(unknown, a, ext) { + t.Error("unknown-below-known rule must match the numeric variant") + } + if lessByStringExtractor(unknown, unknown, ext) { + t.Error("two unknowns are equal, not less") + } +} + func TestItemRows(t *testing.T) { cases := []struct { name string diff --git a/internal/tui/update_drill_snapshots_test.go b/internal/tui/update_drill_snapshots_test.go new file mode 100644 index 0000000..30269fa --- /dev/null +++ b/internal/tui/update_drill_snapshots_test.go @@ -0,0 +1,80 @@ +package tui + +import ( + "testing" + "time" + + "pgdu/internal/pg" +) + +// TestAppliedWindowPaths pins how the statements screen's window state maps +// onto snapshot-browser row paths: the sentinel anchors (@reset/@session/@now), +// real snapshots resolved back to their file path via CapturedAt, and the "" +// start of a fresh re-base that no browser row represents. +func TestAppliedWindowPaths(t *testing.T) { + t1 := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC) + t2 := t1.Add(time.Hour) + metas := []pg.SnapshotMeta{ + {Path: "/snaps/a.json.gz", CapturedAt: t1}, + {Path: "/snaps/b.json.gz", CapturedAt: t2}, + } + snapA := &pg.Snapshot{CapturedAt: t1} + snapB := &pg.Snapshot{CapturedAt: t2} + sessionStart := t1.Add(-time.Hour) + + cases := []struct { + name string + st screen + wantStart string + wantEnd string + }{ + {"cumulative live", screen{statCumulative: true}, snapReset, snapNow}, + {"cumulative with frozen end", screen{statCumulative: true, statEndSnap: snapB}, + snapReset, "/snaps/b.json.gz"}, + {"snapshot base, live end", screen{statBaseSnap: snapA}, "/snaps/a.json.gz", snapNow}, + {"frozen snapshot-to-snapshot diff", screen{statBaseSnap: snapA, statEndSnap: snapB}, + "/snaps/a.json.gz", "/snaps/b.json.gz"}, + {"base snapshot no longer listed", + screen{statBaseSnap: &pg.Snapshot{CapturedAt: t1.Add(time.Minute)}}, "", snapNow}, + {"end snapshot no longer listed", + screen{statBaseSnap: snapA, statEndSnap: &pg.Snapshot{CapturedAt: t2.Add(time.Minute)}}, + "/snaps/a.json.gz", ""}, + {"session window", screen{statSessionStart: sessionStart, statBaselineAt: sessionStart}, + snapSession, snapNow}, + // A fresh R re-base: baseline is neither a snapshot nor the session + // start, so no row can represent it. + {"fresh re-base", screen{statSessionStart: sessionStart, statBaselineAt: sessionStart.Add(time.Minute)}, + "", snapNow}, + } + m := &Model{} + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + s := &screen{statSnapMetas: metas} + start, end := m.appliedWindowPaths(&c.st, s) + if start != c.wantStart || end != c.wantEnd { + t.Errorf("appliedWindowPaths = (%q, %q), want (%q, %q)", + start, end, c.wantStart, c.wantEnd) + } + }) + } +} + +// TestSnapTimeOrdering pins the ordering roles of the sentinel paths: @reset +// is the earliest possible start, @now the latest possible end, and real +// snapshots order by their CapturedAt. +func TestSnapTimeOrdering(t *testing.T) { + t1 := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC) + m := &Model{} + s := &screen{statSnapMetas: []pg.SnapshotMeta{{Path: "/snaps/a.json.gz", CapturedAt: t1}}} + + reset := m.snapTime(s, snapReset) + now := m.snapTime(s, snapNow) + snap := m.snapTime(s, "/snaps/a.json.gz") + + if !reset.Before(snap) || !snap.Before(now) { + t.Errorf("want @reset < snapshot < @now, got %v / %v / %v", reset, snap, now) + } + if got := m.snapTime(s, "/snaps/gone.json.gz"); !got.IsZero() { + t.Errorf("unknown path should map to the zero time, got %v", got) + } +} diff --git a/internal/tui/update_keys.go b/internal/tui/update_keys.go index 0db2866..b6e236f 100644 --- a/internal/tui/update_keys.go +++ b/internal/tui/update_keys.go @@ -248,8 +248,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if s.heapWindowStart >= s.heapPageCount { s.heapWindowStart = max32(s.heapPageCount-s.heapWindowCount, 0) } - s.cursor = 0 - s.offset = 0 + s.resetCursor() return m, m.loadCurrent() } s.cursor = max(min(s.cursor+m.pageStep(), s.visibleLen()-1), 0) @@ -264,8 +263,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } if (s.level == levelHeapPages || s.level == levelIndexPages) && s.heapWindowStart > 0 { s.heapWindowStart = max32(s.heapWindowStart-s.heapWindowCount, 0) - s.cursor = 0 - s.offset = 0 + s.resetCursor() return m, m.loadCurrent() } s.cursor = max(s.cursor-m.pageStep(), 0) @@ -534,8 +532,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } if msg.Type == tea.KeyEsc && s.filter != "" { s.filter = "" - s.cursor = 0 - s.offset = 0 + s.resetCursor() break } if len(m.stack) > 1 { diff --git a/internal/tui/update_keys_filter.go b/internal/tui/update_keys_filter.go index 5686e25..2e41a5c 100644 --- a/internal/tui/update_keys_filter.go +++ b/internal/tui/update_keys_filter.go @@ -14,16 +14,14 @@ func (m *Model) handleFilterKey(s *screen, msg tea.KeyMsg) (tea.Model, tea.Cmd) case tea.KeyEsc: s.filter = "" s.filterFocused = false - s.cursor = 0 - s.offset = 0 + s.resetCursor() case tea.KeyEnter: s.filterFocused = false s.clampCursor() case tea.KeyBackspace, tea.KeyDelete: if r := []rune(s.filter); len(r) > 0 { s.filter = string(r[:len(r)-1]) - s.cursor = 0 - s.offset = 0 + s.resetCursor() } else { s.filterFocused = false } @@ -40,8 +38,7 @@ func (m *Model) handleFilterKey(s *screen, msg tea.KeyMsg) (tea.Model, tea.Cmd) return m, nil } s.filter += string(msg.Runes) - s.cursor = 0 - s.offset = 0 + s.resetCursor() } return m, nil } diff --git a/internal/tui/view_activity.go b/internal/tui/view_activity.go index 3d41c86..9d2dabd 100644 --- a/internal/tui/view_activity.go +++ b/internal/tui/view_activity.go @@ -158,9 +158,7 @@ func (m *Model) renderActivityInfo(height int) string { mu := styleMuted.Render badge := func(s string) string { return styleBadge.Render(s) } var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("Activity reference") + mu(" · press ") + - badge("?") + mu(" or ") + badge("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "Activity reference") b.WriteString(" " + styleHeader.Render(" what you're seeing ") + "\n") b.WriteString(" " + mu("Live view of pg_stat_activity. Rows are backends connected to the selected database.") + "\n") diff --git a/internal/tui/view_buffers.go b/internal/tui/view_buffers.go index c7f3ddb..9d0d05b 100644 --- a/internal/tui/view_buffers.go +++ b/internal/tui/view_buffers.go @@ -17,12 +17,10 @@ import ( // pinned to the bottom. Shown when the user toggles `?` on // levelBufferTables. func (m *Model) renderBufferInfo(height int) string { - sw := func(style lipgloss.Style) string { return style.Render("▇") } + sw := swatch var b strings.Builder mu := styleMuted.Render - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("Bar reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "Bar reference") b.WriteString(" " + styleHeader.Render(" server memory ") + " " + mu("the whole host's RAM (MemTotal) — the superset") + "\n") @@ -51,11 +49,7 @@ func (m *Model) renderBufferInfo(height int) string { b.WriteString(" " + mu("Tables ranked 11+ use the default bar colour. ") + styleBadge.Render("enter") + mu(" opens a per-table breakdown.") + "\n") - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // renderUsageHeatBar paints the cluster (or any) usagecount histogram as one @@ -227,15 +221,11 @@ func (m *Model) renderBufferDetail(s *screen, height int) string { b.WriteString(line + "\n") } b.WriteString("\n " + mu("0 = cold (evictable) → 5 = hot (frequently reused) · ") + - styleDirty.Render("▇") + mu(" dirty (modified, awaiting flush)") + "\n") + swatch(styleDirty) + mu(" dirty (modified, awaiting flush)") + "\n") } } - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // renderBufferDetailInfo is the ? overlay for the per-table buffer-detail @@ -244,10 +234,8 @@ func (m *Model) renderBufferDetail(s *screen, height int) string { func (m *Model) renderBufferDetailInfo(height int) string { var b strings.Builder mu := styleMuted.Render - sw := func(style lipgloss.Style) string { return style.Render("▇") } - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("Buffer detail reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + sw := swatch + infoHeader(&b, "Buffer detail reference") b.WriteString(" " + styleHeader.Render(" cache footprint ") + " " + mu("how much of this table lives in shared_buffers") + "\n") @@ -268,11 +256,7 @@ func (m *Model) renderBufferDetailInfo(height int) string { b.WriteString(" " + mu(" checkpointer still owes a write; lots of hot+dirty = write pressure") + "\n") b.WriteString(" " + mu("each row's bar is scaled to the table's busiest band, so band sizes compare.") + "\n") - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // summaryBarWidth picks the bar width for the two stacked summary bars. @@ -325,7 +309,7 @@ func (m *Model) renderBufferSummary(s *screen) (string, map[uint32]int) { cache := max(sum.ServerMemAvailableBytes-sum.ServerMemFreeBytes, 0) bar := renderServerMemBar(sbUsed, sbFree, otherUsed, cache, sum.ServerMemBytes, barW) muted := styleMuted.Render - sw := func(style lipgloss.Style) string { return style.Render("▇") + " " } + sw := func(style lipgloss.Style) string { return swatch(style) + " " } stats := muted(fmt.Sprintf("shared buffer %s (", humanize.Bytes(sbTotal))) + sw(styleBar) + muted(fmt.Sprintf("used %s / ", humanize.Bytes(sbUsed))) + sw(styleSBFree) + muted(fmt.Sprintf("free %s) · ", humanize.Bytes(sbFree))) + @@ -351,7 +335,7 @@ func (m *Model) renderBufferSummary(s *screen) (string, map[uint32]int) { usedPct := float64(sum.ThisDBBytes+sum.OtherDBBytes) * 100 / float64(sum.TotalBytes) usedStr := percentStyle(usedPct).Render(fmt.Sprintf("%.1f%% used", usedPct)) muted := styleMuted.Render - sw := func(style lipgloss.Style) string { return style.Render("▇") + " " } + sw := func(style lipgloss.Style) string { return swatch(style) + " " } sbStats := usedStr + muted(" · ") + sw(styleBar) + muted(fmt.Sprintf("this db %s · ", humanize.Bytes(sum.ThisDBBytes))) + sw(styleBarAlt) + muted(fmt.Sprintf("other %s · ", humanize.Bytes(sum.OtherDBBytes))) + @@ -392,7 +376,7 @@ func (m *Model) bufferTemperatureLines(sum *pg.BufferCacheSummary, barW int) str cold := sum.UsageCounts[0].Buffers hot := sum.UsageCounts[len(sum.UsageCounts)-1].Buffers muted := styleMuted.Render - sw := func(style lipgloss.Style) string { return style.Render("▇") + " " } + sw := func(style lipgloss.Style) string { return swatch(style) + " " } stats := muted(fmt.Sprintf("avg %.1f/5 · ", avg)) + sw(usageHeatStyle(0)) + muted(fmt.Sprintf("cold %.0f%% · ", float64(cold)*100/float64(totBufs))) + sw(usageHeatStyle(len(usageHeatPalette)-1)) + muted(fmt.Sprintf("hot %.0f%% · ", float64(hot)*100/float64(totBufs))) + diff --git a/internal/tui/view_diag.go b/internal/tui/view_diag.go index 0e4805d..a1a228f 100644 --- a/internal/tui/view_diag.go +++ b/internal/tui/view_diag.go @@ -150,12 +150,7 @@ func (m *Model) renderDescribe(s *screen, height int) string { } } - // Pad to fill the content area so the help row stays pinned. - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // renderDescribeBufferRows renders the body of the describe-table cache-footprint diff --git a/internal/tui/view_heap_pages.go b/internal/tui/view_heap_pages.go index 3bc8a02..d851039 100644 --- a/internal/tui/view_heap_pages.go +++ b/internal/tui/view_heap_pages.go @@ -16,12 +16,10 @@ import ( // so the help row stays pinned to the bottom. Shown when the user toggles // `?` on levelHeapPages. func (m *Model) renderHeapPagesInfo(height int) string { - sw := func(style lipgloss.Style) string { return style.Render("▇") } + sw := swatch mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("Page reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "Page reference") b.WriteString(" " + styleHeader.Render(" page bar ") + " " + mu("each row is one 8 KiB heap page — the bar shows how that page is packed") + "\n") @@ -50,11 +48,7 @@ func (m *Model) renderHeapPagesInfo(height int) string { b.WriteString(" " + mu("PgUp/PgDn slides the load window ("+strconv.Itoa(int(heapWindowDefault))+" pages per step).") + "\n") b.WriteString(" " + mu("Within a window, j/k or arrows move the cursor; Enter drills into one page.") + "\n") - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // renderHeapTuplesInfo draws a static explainer for the per-tuple drill view: @@ -65,9 +59,7 @@ func (m *Model) renderHeapPagesInfo(height int) string { func (m *Model) renderHeapTuplesInfo(height int) string { mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("Tuple reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "Tuple reference") b.WriteString(" " + styleHeader.Render(" columns ") + " " + mu("one row per item-pointer in the page's LP array") + "\n") @@ -115,11 +107,7 @@ func (m *Model) renderHeapTuplesInfo(height int) string { b.WriteString(" " + padRight("lifecycle:", 12) + mu("MVCC story — who inserted it (and if that committed), then deleted/locked/live") + "\n") b.WriteString(" " + padRight("layout:", 12) + mu("header vs payload bytes (split at t_hoff) with a bar, plus the page byte span") + "\n") - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // heapPageTableLabel reports the qualified relation name for the status line diff --git a/internal/tui/view_maintenance.go b/internal/tui/view_maintenance.go index 9e4ac66..2bcf20a 100644 --- a/internal/tui/view_maintenance.go +++ b/internal/tui/view_maintenance.go @@ -363,10 +363,7 @@ func (m *Model) renderSettingsList(s *screen, height int) string { func (m *Model) renderMaintenanceInfo(height int) string { mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("system overview reference") + - mu(" · press ") + styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + - mu(" to dismiss") + "\n\n") + infoHeader(&b, "system overview reference") b.WriteString(" " + styleHeader.Render(" extension capacity ") + "\n") b.WriteString(" " + mu("pg_stat_statements and pg_qualstats both pre-allocate a fixed shared-memory array (the .max") + "\n") diff --git a/internal/tui/view_overlays.go b/internal/tui/view_overlays.go index bc557a0..c1fc4b1 100644 --- a/internal/tui/view_overlays.go +++ b/internal/tui/view_overlays.go @@ -83,7 +83,7 @@ func (m *Model) renderInfoOverlay(s *screen, height int) string { // levels whose bars are monochrome (no legend needed). func renderLegend(s *screen) string { swatch := func(style lipgloss.Style, label string) string { - return style.Render("▇") + " " + styleMuted.Render(label) + return swatch(style) + " " + styleMuted.Render(label) } sep := styleMuted.Render(" · ") switch s.level { @@ -255,11 +255,7 @@ func (m *Model) renderExtPrompt(s *screen, height int) string { default: b.WriteString(" " + styleErr.Render(p.name+" is not available on this server — ask the DBA to install it") + "\n") } - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // renderUpgradePrompt renders the blocking "extension outdated" screen for the @@ -294,9 +290,5 @@ func (m *Model) renderUpgradePrompt(s *screen, height int) string { b.WriteString(" " + styleErr.Render("the server's own "+p.name+" ("+p.available+ ") is older than pgdu needs — upgrade PostgreSQL / the extension package") + "\n") } - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } diff --git a/internal/tui/view_pages.go b/internal/tui/view_pages.go index 2f642f0..504b15a 100644 --- a/internal/tui/view_pages.go +++ b/internal/tui/view_pages.go @@ -17,12 +17,10 @@ import ( // view. Sized to fill `height` lines so the help row stays pinned to the // bottom. Shown when the user toggles `?` on levelIndexPages. func (m *Model) renderIndexPagesInfo(height int) string { - sw := func(style lipgloss.Style) string { return style.Render("▇") } + sw := swatch mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("B-tree page reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "B-tree page reference") b.WriteString(" " + styleHeader.Render(" page bar ") + " " + mu("each row is one 8 KiB index page — the bar shows how that page is packed") + "\n") @@ -77,11 +75,7 @@ func (m *Model) renderIndexPagesInfo(height int) string { b.WriteString(" " + mu("Within a window, j/k or arrows move the cursor; Enter drills into one page's items.") + "\n") b.WriteString(" " + mu("Block 0 is the metapage — skipped here; it carries the root pointer, not a tree page.") + "\n") - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // renderIndexTuplesInfo explains what the user is looking at on a B-tree @@ -93,9 +87,7 @@ func (m *Model) renderIndexPagesInfo(height int) string { func (m *Model) renderIndexTuplesInfo(height int) string { mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("Index-tuple reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "Index-tuple reference") b.WriteString(" " + styleHeader.Render(" tuple kinds ") + " " + mu("the ctid column reveals which kind each row is") + "\n") @@ -148,11 +140,7 @@ func (m *Model) renderIndexTuplesInfo(height int) string { b.WriteString(" " + mu("don't drill — there's no single heap row to land on.") + "\n\n") b.WriteString(" " + mu("Reading bt_page_items / bt_metap needs a superuser (or pg_read_server_files).") + "\n") - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // renderRelationsList draws the page-inspector tool's flat list of heap @@ -1338,23 +1326,6 @@ func renderGinTupleRow(t pg.GinItem, sampleW, tidW, bytesW, countW int, selected // ─── per-AM ? reference overlays ───────────────────────────────────────────── -// infoHeader writes the standard overlay title + dismiss-hint line into b. -// Shared by the gist/brin/gin reference overlays. -func infoHeader(b *strings.Builder, title string) { - mu := styleMuted.Render - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render(title) + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") -} - -func infoPad(b *strings.Builder, height int) string { - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() -} - func (m *Model) renderGistInfo(height int, tuples bool) string { mu := styleMuted.Render var b strings.Builder @@ -1368,7 +1339,7 @@ func (m *Model) renderGistInfo(height int, tuples bool) string { b.WriteString(" " + mu("Enter descends an internal downlink toward the leaves, or opens the heap row a") + "\n") b.WriteString(" " + mu("leaf entry points at. GiST keys have no total order, so there's no key-seek —") + "\n") b.WriteString(" " + mu("use the ") + styleBadge.Render("/") + mu(" filter to search the rendered keys text.") + "\n") - return infoPad(&b, height) + return padInfo(&b, height) } infoHeader(&b, "GiST page reference") b.WriteString(" " + mu("GiST has no metapage — block 0 is the root, so every page is browsable.") + "\n\n") @@ -1378,7 +1349,7 @@ func (m *Model) renderGistInfo(height int, tuples bool) string { b.WriteString(" " + padRight("free", 8) + mu("free space as a percent of the page") + "\n\n") b.WriteString(" " + mu("PgUp/PgDn slides the load window ("+strconv.Itoa(int(heapWindowDefault))+" pages per step); Enter drills a page's items.") + "\n") b.WriteString(" " + mu("Reading gist_page_* needs a superuser (or pg_read_server_files).") + "\n") - return infoPad(&b, height) + return padInfo(&b, height) } func (m *Model) renderBrinInfo(height int, tuples bool) string { @@ -1394,7 +1365,7 @@ func (m *Model) renderBrinInfo(height int, tuples bool) string { b.WriteString(" " + padRight("summary", 12) + mu("the opclass-rendered summary value (e.g. a min…max range)") + "\n\n") b.WriteString(" " + mu("Press ") + styleBadge.Render("s") + mu(" to seek to the range covering a heap block number.") + "\n") b.WriteString(" " + mu("Enter jumps to the heap pages of the summarised block range.") + "\n") - return infoPad(&b, height) + return padInfo(&b, height) } infoHeader(&b, "BRIN page reference") b.WriteString(" " + mu("BRIN pages come in three kinds; the banner above carries the metapage summary.") + "\n\n") @@ -1403,7 +1374,7 @@ func (m *Model) renderBrinInfo(height int, tuples bool) string { b.WriteString(" " + styleBarAlt.Render(padRight("meta", 9)) + mu("metapage (block 0): pages-per-range, version") + "\n\n") b.WriteString(" " + mu("PgUp/PgDn slides the load window; Enter on a regular page lists its summaries.") + "\n") b.WriteString(" " + mu("Reading brin_* needs a superuser (or pg_read_server_files).") + "\n") - return infoPad(&b, height) + return padInfo(&b, height) } func (m *Model) renderGinInfo(height int, tuples bool) string { @@ -1418,7 +1389,7 @@ func (m *Model) renderGinInfo(height int, tuples bool) string { b.WriteString(" " + padRight("tids", 12) + mu("number of heap TIDs packed into the segment") + "\n\n") b.WriteString(" " + styleHeapToastTag.Render("Note") + mu(": pageinspect cannot list GIN entry-tree keys, so only data-leaf") + "\n") b.WriteString(" " + mu("pages are itemizable. These rows are terminal (no per-row drill).") + "\n") - return infoPad(&b, height) + return padInfo(&b, height) } infoHeader(&b, "GIN page reference") b.WriteString(" " + mu("A GIN index is an entry tree (keys) over posting trees/lists (heap tids).") + "\n\n") @@ -1428,5 +1399,5 @@ func (m *Model) renderGinInfo(height int, tuples bool) string { b.WriteString(" " + styleBarAlt.Render(padRight("meta", 10)) + mu("metapage (block 0): entry/data page counts, pending list") + "\n\n") b.WriteString(" " + mu("The banner shows entry/data page counts and pending-list size. PgUp/PgDn slides") + "\n") b.WriteString(" " + mu("the window; Enter on a data-leaf page lists its posting segments.") + "\n") - return infoPad(&b, height) + return padInfo(&b, height) } diff --git a/internal/tui/view_queries.go b/internal/tui/view_queries.go index af36a1d..012d4a9 100644 --- a/internal/tui/view_queries.go +++ b/internal/tui/view_queries.go @@ -2,9 +2,7 @@ package tui import ( "fmt" - "strconv" "strings" - "time" "github.com/charmbracelet/lipgloss" @@ -97,22 +95,6 @@ func flattenQuery(q string) string { return strings.Join(strings.Fields(q), " ") } -// fmtFloat renders a number with up to 1 decimals, trailing zeros stripped. -func fmtFloat(f float64) string { - s := strconv.FormatFloat(f, 'f', 1, 64) - if strings.ContainsRune(s, '.') { - s = strings.TrimRight(strings.TrimRight(s, "0"), ".") - } - return s -} - -// fmt1 renders a number with exactly one decimal place (60 → "60.0", 98.51 → -// "98.5"). The top-queries numeric columns use it so every value shows a single -// fractional digit rather than a ragged mix of 0/1/2 places. -func fmt1(f float64) string { - return strconv.FormatFloat(f, 'f', 1, 64) -} - // planTimeMetric renders the detail-view plan-time line, distinguishing a real // zero from "not collected" (pg_stat_statements.track_planning off). func planTimeMetric(q pg.QueryStat, trackPlanning bool, mu func(...string) string) string { @@ -122,34 +104,6 @@ func planTimeMetric(q pg.QueryStat, trackPlanning bool, mu func(...string) strin return fmtMs(q.TotalPlanTime) + " ms" + mu(fmt.Sprintf(" (%s plans)", formatRows(q.Plans))) } -// fmtMs formats a millisecond duration compactly: sub-millisecond and small -// values keep ms; large values switch to seconds so the column stays narrow. -func fmtMs(ms float64) string { - if ms >= 100000 { - return fmt1(ms/1000) + "s" - } - return fmt1(ms) -} - -// fmtAge formats an elapsed time (in ms) as an age with an explicit, scale- -// appropriate unit so values never read ambiguously: "850ms", "31.1s", "11.2m", -// "3.1h", "2.4d". Unlike fmtMs the unit is always present, which is what lets the -// reader tell 105ms from 105s at a glance (paired with durationStyle colouring). -func fmtAge(ms float64) string { - switch { - case ms < 1000: - return fmt.Sprintf("%.0fms", ms) - case ms < 60*1000: - return fmt1(ms/1000) + "s" - case ms < 60*60*1000: - return fmt1(ms/(60*1000)) + "m" - case ms < 24*60*60*1000: - return fmt1(ms/(60*60*1000)) + "h" - default: - return fmt1(ms/(24*60*60*1000)) + "d" - } -} - // --- window-status header (levelStatements) --- func (m *Model) renderStatementsHeader(s *screen) string { @@ -212,33 +166,13 @@ func (m *Model) refreshSentence() string { return "It re-samples every " + m.statRefresh.String() + " — press t to cycle the cadence (2s → 60s → off)." } -// fmtDuration renders a window span with explicit units — "45s", "13m 12s", -// "2h 05m", "3d 4h" — so it never reads as a wall-clock time. The old H:MM:SS -// form made "13:12" ambiguous with a start timestamp, which is the whole reason -// it sits next to "since 05:56:46". -func fmtDuration(d time.Duration) string { - d = d.Round(time.Second) - switch { - case d < time.Minute: - return fmt.Sprintf("%ds", int(d/time.Second)) - case d < time.Hour: - return fmt.Sprintf("%dm %02ds", int(d/time.Minute), int(d%time.Minute/time.Second)) - case d < 24*time.Hour: - return fmt.Sprintf("%dh %02dm", int(d/time.Hour), int(d%time.Hour/time.Minute)) - default: - return fmt.Sprintf("%dd %dh", int(d/(24*time.Hour)), int(d%(24*time.Hour)/time.Hour)) - } -} - // renderStatementsInfo is the ? overlay for the top-queries tool: it explains // the window model (which is the subtle part — pg_stat_statements has no time // axis) and every column. func (m *Model) renderStatementsInfo(height int) string { mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("Top queries reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "Top queries reference") b.WriteString(" " + styleHeader.Render(" the window ") + " " + mu("why numbers start at zero and grow") + "\n") diff --git a/internal/tui/view_queries_detail.go b/internal/tui/view_queries_detail.go index 201e822..b11ee45 100644 --- a/internal/tui/view_queries_detail.go +++ b/internal/tui/view_queries_detail.go @@ -381,12 +381,7 @@ func (m *Model) renderStatementSamples(s *screen, height int) string { styleErr.Render("executes the query for real") + "\n") } - // Pad to fill the content area so the help row stays pinned. - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // wrapDetail hard-wraps text to the detail panel's usable width (terminal minus diff --git a/internal/tui/view_shmem.go b/internal/tui/view_shmem.go index 4ced7e1..19ef352 100644 --- a/internal/tui/view_shmem.go +++ b/internal/tui/view_shmem.go @@ -184,7 +184,7 @@ func (m *Model) renderShmemSummary(s *screen) string { bar := paintBar(barW, segs...) muted := styleMuted.Render - sw := func(style lipgloss.Style) string { return style.Render("▇") + " " } + sw := func(style lipgloss.Style) string { return swatch(style) + " " } var stats strings.Builder stats.WriteString(muted(fmt.Sprintf("total %s · ", humanize.Bytes(grand)))) for _, c := range shmemCatOrder { @@ -255,10 +255,8 @@ func renderShmemRow(it item, a pg.ShmemAllocation, maxSize, grand int64, barW in func (m *Model) renderShmemInfo(height int) string { var b strings.Builder mu := styleMuted.Render - sw := func(c shmemCat) string { return shmemCatStyle(c).Render("▇") } - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("Shared-memory map") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + sw := func(c shmemCat) string { return swatch(shmemCatStyle(c)) } + infoHeader(&b, "Shared-memory map") b.WriteString(" " + mu("Every region of the Postgres shared-memory segment (pg_shmem_allocations),") + "\n") b.WriteString(" " + mu("not just the buffer pool. The bar groups allocations by subsystem; the muted") + "\n") @@ -286,9 +284,5 @@ func (m *Model) renderShmemInfo(height int) string { b.WriteString("\n " + mu("Reading pg_shmem_allocations needs pg_read_all_stats / superuser; without it") + "\n") b.WriteString(" " + mu("the view shows a permission error instead of the map.") + "\n") - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } diff --git a/internal/tui/view_shmem_test.go b/internal/tui/view_shmem_test.go new file mode 100644 index 0000000..178aeb4 --- /dev/null +++ b/internal/tui/view_shmem_test.go @@ -0,0 +1,79 @@ +package tui + +import ( + "testing" + + "pgdu/internal/pg" +) + +// TestShmemCatOf pins the category bucketing, in particular the match-order +// subtleties documented on shmemCatOf: "Backend … Buffer" regions must not +// read as the buffer pool, "Buffer Blocks" contains the substring "lock", and +// the lock tables contain "PROC". +func TestShmemCatOf(t *testing.T) { + cases := []struct { + name string + a pg.ShmemAllocation + want shmemCat + }{ + // The two NULL-name rows are classified by flag, never by name. + {"anonymous", pg.ShmemAllocation{Anonymous: true}, catAnon}, + {"free tail", pg.ShmemAllocation{Free: true}, catFree}, + + // Per-backend regions named "Backend … Buffer" must match before the + // buffer-pool test. + {"backend activity buffer", pg.ShmemAllocation{Name: "Backend Activity Buffer"}, catBackends}, + {"backend status array", pg.ShmemAllocation{Name: "Backend Status Array"}, catBackends}, + {"shmInvalBuffer", pg.ShmemAllocation{Name: "shmInvalBuffer"}, catBackends}, + + // "Buffer Blocks" contains "lock" (b·lock·s): buffer must win over locks. + {"buffer blocks", pg.ShmemAllocation{Name: "Buffer Blocks"}, catBuffer}, + {"buffer descriptors", pg.ShmemAllocation{Name: "Buffer Descriptors"}, catBuffer}, + {"checkpointer data", pg.ShmemAllocation{Name: "Checkpointer Data"}, catBuffer}, + {"checkpoint bufferids", pg.ShmemAllocation{Name: "Checkpoint BufferIds"}, catBuffer}, + + // Lock tables contain "PROC": locks must win over the broad "proc" + // backend test. "SERIALIZABLEXACT" also contains "xact": locks first. + {"lock manager", pg.ShmemAllocation{Name: "Lock Manager"}, catLocks}, + {"proclock hash", pg.ShmemAllocation{Name: "PROCLOCK hash"}, catLocks}, + {"predicate lock target hash", pg.ShmemAllocation{Name: "PREDICATELOCKTARGET hash"}, catLocks}, + {"serializable xact hash", pg.ShmemAllocation{Name: "SERIALIZABLEXACT hash"}, catLocks}, + + {"xlog ctl", pg.ShmemAllocation{Name: "XLOG Ctl"}, catWAL}, + {"wal receiver ctl", pg.ShmemAllocation{Name: "Wal Receiver Ctl"}, catWAL}, + + {"async queue control", pg.ShmemAllocation{Name: "Async Queue Control"}, catXact}, + {"known assigned xids", pg.ShmemAllocation{Name: "KnownAssignedXids"}, catXact}, + {"shared multixact state", pg.ShmemAllocation{Name: "Shared MultiXact State"}, catXact}, + + {"proc header", pg.ShmemAllocation{Name: "Proc Header"}, catBackends}, + {"pmsignal state", pg.ShmemAllocation{Name: "PMSignalState"}, catBackends}, + {"background worker data", pg.ShmemAllocation{Name: "Background Worker Data"}, catBackends}, + + {"pg_stat_statements", pg.ShmemAllocation{Name: "pg_stat_statements"}, catStats}, + {"shared memory stats", pg.ShmemAllocation{Name: "Shared Memory Stats"}, catStats}, + + {"unmatched name", pg.ShmemAllocation{Name: "Archiver Data"}, catOther}, + {"empty name", pg.ShmemAllocation{}, catOther}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := shmemCatOf(c.a); got != c.want { + t.Errorf("shmemCatOf(%q) = %v (%s), want %v (%s)", + c.a.Name, got, got.label(), c.want, c.want.label()) + } + }) + } +} + +func TestShmemDisplayName(t *testing.T) { + if got := shmemDisplayName(pg.ShmemAllocation{Anonymous: true}); got != "" { + t.Errorf("anonymous display = %q", got) + } + if got := shmemDisplayName(pg.ShmemAllocation{Free: true}); got != "" { + t.Errorf("free display = %q", got) + } + if got := shmemDisplayName(pg.ShmemAllocation{Name: "XLOG Ctl"}); got != "XLOG Ctl" { + t.Errorf("named display = %q", got) + } +} diff --git a/internal/tui/view_snapshots.go b/internal/tui/view_snapshots.go index f522320..04f93df 100644 --- a/internal/tui/view_snapshots.go +++ b/internal/tui/view_snapshots.go @@ -52,10 +52,7 @@ func (m *Model) renderStatementSnapshots(s *screen, height int) string { if len(s.items) == 0 { b.WriteString(" " + mu("no snapshots yet — press ") + styleBadge.Render("S") + mu(" in the queries view to save one") + "\n") - for i := strings.Count(b.String(), "\n"); i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } listH := max(height-used, 1) @@ -120,12 +117,7 @@ func (m *Model) renderStatementSnapshots(s *screen, height int) string { b.WriteString("\n") } - // Pad to fill the content area so the help row stays pinned. - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() + return padInfo(&b, height) } // snapshotAge renders the age column for a browser row. The synthetic anchors diff --git a/internal/tui/view_tablestats.go b/internal/tui/view_tablestats.go index 1ce321f..2f7a3f0 100644 --- a/internal/tui/view_tablestats.go +++ b/internal/tui/view_tablestats.go @@ -62,10 +62,7 @@ func (m *Model) renderTableStatsInfo(height int) string { mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("table overview reference") + - mu(" · press ") + styleBadge.Render("?") + mu(" or ") + - styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "table overview reference") b.WriteString(" " + mu("One row per base / partitioned / materialized table in the schema. Write and") + "\n") b.WriteString(" " + mu("scan counters (ins/upd/del, seq/idx, cache) are cumulative since the last stats") + "\n") b.WriteString(" " + mu("reset; sizes and ages are point-in-time. Press ") + styleBadge.Render("C") + diff --git a/internal/tui/view_wal.go b/internal/tui/view_wal.go index 406dc2c..c017b5d 100644 --- a/internal/tui/view_wal.go +++ b/internal/tui/view_wal.go @@ -556,12 +556,10 @@ func renderWALRelRow(it item, st pg.WALRelStat, maxSize int64, barW int, selecte // the record-vs-FPI byte split means, and why FPI matters for tuning. Sized // to fill `height` lines so the help row stays pinned to the bottom. func (m *Model) renderWALInfo(height int) string { - sw := func(style lipgloss.Style) string { return style.Render("▇") } + sw := swatch mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("WAL inspector reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "WAL inspector reference") b.WriteString(" " + styleHeader.Render(" what WAL is ") + " " + mu("the write-ahead log — Postgres's durability & replication journal") + "\n") @@ -621,9 +619,7 @@ func (m *Model) renderWALInfo(height int) string { func (m *Model) renderWALRecordsInfo(height int) string { mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("WAL records reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "WAL records reference") b.WriteString(" " + styleHeader.Render(" this view ") + " " + mu("every WAL record the selected resource manager wrote in the window, oldest first") + "\n") @@ -639,7 +635,7 @@ func (m *Model) renderWALRecordsInfo(height int) string { b.WriteString(" " + padRight("description", 12) + mu("pg_walinspect's human-readable decode of the record's payload") + "\n\n") b.WriteString(" " + styleHeader.Render(" the bar ") + " " + - styleBar.Render("▇") + mu(" record bytes · ") + styleBarAlt.Render("▇") + + swatch(styleBar) + mu(" record bytes · ") + swatch(styleBarAlt) + mu(" FPI bytes — scaled to the biggest record in this list") + "\n\n") b.WriteString(" " + styleHeader.Render(" summary table ") + " " + @@ -659,9 +655,7 @@ func (m *Model) renderWALRecordsInfo(height int) string { func (m *Model) renderWALBlocksInfo(height int) string { mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("WAL block references reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "WAL block references reference") b.WriteString(" " + styleHeader.Render(" this view ") + " " + mu("the page(s) one WAL record modified — its tie-back from the log to physical storage") + "\n") @@ -681,7 +675,7 @@ func (m *Model) renderWALBlocksInfo(height int) string { b.WriteString(" " + padRight("db", 12) + mu("reldatabase OID — 0 for shared catalogs that live outside any one database") + "\n\n") b.WriteString(" " + styleHeader.Render(" the bar & fpi ") + " " + - styleBarAlt.Render("▇") + mu(" full-page-image bytes for this block (empty = the record logged only the change)") + "\n") + swatch(styleBarAlt) + mu(" full-page-image bytes for this block (empty = the record logged only the change)") + "\n") b.WriteString(" " + padRight("data", 12) + mu("block_data_length — bytes of per-block change data (tuple, offsets, …)") + "\n") b.WriteString(" " + padRight("fpi-info", 12) + mu("flags on the page image, e.g. APPLY (replayed) / HAS_HOLE / COMPRESS_*") + "\n\n") @@ -707,12 +701,10 @@ func (m *Model) renderWALBlocksInfo(height int) string { // renderWALRelationsInfo explains the by-relation breakdown: how the window is // re-aggregated per table/index, what the columns mean, and how to drill. func (m *Model) renderWALRelationsInfo(height int) string { - sw := func(style lipgloss.Style) string { return style.Render("▇") } + sw := swatch mu := styleMuted.Render var b strings.Builder - b.WriteString("\n") - b.WriteString(" " + styleSelected.Render("WAL by relation reference") + mu(" · press ") + - styleBadge.Render("?") + mu(" or ") + styleBadge.Render("esc") + mu(" to dismiss") + "\n\n") + infoHeader(&b, "WAL by relation reference") b.WriteString(" " + styleHeader.Render(" this view ") + " " + mu("which table/index generated the WAL in the window — \"what caused the change\"") + "\n") @@ -736,14 +728,3 @@ func (m *Model) renderWALRelationsInfo(height int) string { return padInfo(&b, height) } - -// padInfo pads an info overlay's builder to exactly `height` lines so the -// help row stays pinned to the bottom of the screen. Mirrors the inline -// padding loop the other render*Info helpers use. -func padInfo(b *strings.Builder, height int) string { - rendered := strings.Count(b.String(), "\n") - for i := rendered; i < height; i++ { - b.WriteString("\n") - } - return b.String() -}