diff --git a/.gitignore b/.gitignore index c51cd77..74a3abb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ .rtk/ .aider* .headroom/ +headroom-content.log .claude/ # OS / editor diff --git a/internal/tui/screens/dashboard/render.go b/internal/tui/screens/dashboard/render.go index 6688d83..3c0481a 100644 --- a/internal/tui/screens/dashboard/render.go +++ b/internal/tui/screens/dashboard/render.go @@ -42,14 +42,33 @@ func panelBox(th *theme.Theme) lipgloss.Style { Padding(0, 1) } +// cardChrome is the horizontal cost of panelBox's border (2) plus its +// (0,1) padding (2): lipgloss Width() counts these inside the total, so a +// panel whose content should be W columns wide must be sized W+cardChrome. +const cardChrome = 4 + +// card renders body inside the shared panel, sized so its inner text area +// is exactly contentW columns — callers size and truncate their text to +// contentW and it will never wrap. MaxWidth caps the total so an +// unbreakable styled run (e.g. a padded label wider than a tiny inner +// width) is clipped rather than expanding the box past its column budget. +func card(th *theme.Theme, contentW int, body string) string { + total := contentW + cardChrome + return panelBox(th).Width(total).MaxWidth(total).Render(body) +} + // renderTiles draws the four headline stat tiles in a single row. func (m *Model) renderTiles() string { th := m.ctx.Theme outer := m.w / 4 - inner := outer - 4 // border (2) + padding (2) - if inner < 3 { - inner = 3 + // inner is the content width; card adds cardChrome back so the box total + // stays outer and the four tiles fill m.w. Floor at 1 (not a larger + // aesthetic minimum) so a very narrow terminal can't push a box past + // outer and overflow the row — truncate handles the tiny width. + inner := outer - cardChrome + if inner < 1 { + inner = 1 } tiles := []string{ @@ -64,11 +83,11 @@ func (m *Model) renderTiles() string { } if m.errSummary != "" { - return panelBox(th).Width(m.w - 4).Render( - th.BlockStyle(). - Render("summary unavailable: ") + - th.SubtleStyle(). - Render(truncate(m.errSummary, m.w-24)), + const prefix = "summary unavailable: " + avail := m.w - cardChrome - lipgloss.Width(prefix) + return card(th, m.w-cardChrome, + th.BlockStyle().Render(prefix)+ + th.SubtleStyle().Render(truncate(m.errSummary, avail)), ) } @@ -98,7 +117,7 @@ func (m *Model) blockedTile(inner int) string { gauge := m.blockBar.View() body := strings.Join([]string{lbl, fig, gauge}, "\n") - return panelBox(th).Width(inner).Render(body) + return card(th, inner, body) } // tile renders one stat tile: a subtle label above a large accent figure. @@ -107,7 +126,7 @@ func (m *Model) tile(label, value string, inner int) string { lbl := th.SubtleStyle().Render(truncate(label, inner)) fig := th.AccentStyle().Bold(true).Render(truncate(value, inner)) body := strings.Join([]string{lbl, fig}, "\n") - return panelBox(th).Width(inner).Render(body) + return card(th, inner, body) } // renderSparkline draws the queries-over-time line across the full width. @@ -145,15 +164,18 @@ func (m *Model) renderSparkline() string { } body := strings.Join([]string{title, line}, "\n") - return panelBox(th).Width(inner).Render(body) + return card(th, inner, body) } // renderLower composes the breakdown and top-list columns. func (m *Model) renderLower() string { outer := m.w / 3 - inner := outer - 4 - if inner < 6 { - inner = 6 + // Content width; card adds cardChrome back so three columns fill m.w. + // Floor at 1 (not a larger minimum) so a narrow terminal can't push a + // column past outer and overflow the row. + inner := outer - cardChrome + if inner < 1 { + inner = 1 } col1 := lipgloss.JoinVertical( @@ -225,14 +247,14 @@ func (m *Model) breakdownPanel( []string{head, th.BlockStyle().Render(truncate(errStr, inner))}, "\n", ) - return panelBox(th).Width(inner).Render(body) + return card(th, inner, body) } if len(items) == 0 { body := strings.Join( []string{head, th.SubtleStyle().Render("no data")}, "\n", ) - return panelBox(th).Width(inner).Render(body) + return card(th, inner, body) } max := items[0].count @@ -261,7 +283,7 @@ func (m *Model) breakdownPanel( count := th.TextStyle().Render(padLeft(formatCount(it.count), countW)) rows = append(rows, fmt.Sprintf("%s %s %s", label, bar, count)) } - return panelBox(th).Width(inner).Render(strings.Join(rows, "\n")) + return card(th, inner, strings.Join(rows, "\n")) } // listPanel renders a ranked label/count list. @@ -279,14 +301,14 @@ func (m *Model) listPanel( []string{head, th.BlockStyle().Render(truncate(errStr, inner))}, "\n", ) - return panelBox(th).Width(inner).Render(body) + return card(th, inner, body) } if len(items) == 0 { body := strings.Join( []string{head, th.SubtleStyle().Render("no data")}, "\n", ) - return panelBox(th).Width(inner).Render(body) + return card(th, inner, body) } countW := 0 @@ -307,7 +329,7 @@ func (m *Model) listPanel( count := th.AccentStyle().Render(padLeft(formatCount(it.count), countW)) rows = append(rows, label+" "+count) } - return panelBox(th).Width(inner).Render(strings.Join(rows, "\n")) + return card(th, inner, strings.Join(rows, "\n")) } func padRight(s string, w int) string { diff --git a/internal/tui/screens/dashboard/render_test.go b/internal/tui/screens/dashboard/render_test.go index d3ed351..e2a4e39 100644 --- a/internal/tui/screens/dashboard/render_test.go +++ b/internal/tui/screens/dashboard/render_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" + "charm.land/lipgloss/v2" + "github.com/z19r/tihole/internal/pihole" "github.com/z19r/tihole/internal/theme" "github.com/z19r/tihole/internal/tui/core" @@ -151,6 +153,44 @@ func TestView_ErrorBannersRenderWithoutPanic(t *testing.T) { } } +// TestTileAndLowerRowsNeverOverflowWidth guards the card-sizing math: because +// lipgloss Width() counts border+padding inside the total, a too-large content +// floor could push a box past its per-column budget and overflow the row. The +// headline tile row and the lower three-column row must both stay within m.w at +// every width the dashboard actually renders (down to the ~24-col floor). +func TestTileAndLowerRowsNeverOverflowWidth(t *testing.T) { + // Arrange + m := loadedModel() + + // Act / Assert + for w := 24; w <= 120; w++ { + m.SetSize(w, 30) + + if got := lipgloss.Width(m.renderTiles()); got > w { + t.Fatalf("tile row width %d exceeds m.w=%d", got, w) + } + if got := lipgloss.Width(m.renderLower()); got > w { + t.Fatalf("lower row width %d exceeds m.w=%d", got, w) + } + } +} + +// TestTileLabelDoesNotWrapAtNormalWidth confirms the fix's headline case: a +// two-word tile label fits on one line (no wrap) at a normal terminal width. +func TestTileLabelDoesNotWrapAtNormalWidth(t *testing.T) { + // Arrange + m := loadedModel() + m.SetSize(100, 30) + + // Act + tile := m.tile("Total Queries", "64,473", m.w/4-cardChrome) + + // Assert: label + value = 2 content lines, plus 2 border rows = 4. + if h := lipgloss.Height(tile); h != 4 { + t.Fatalf("tile height %d, want 4 (label wrapped?):\n%s", h, tile) + } +} + func TestRenderSparkline_EmptyHistoryShowsPlaceholder(t *testing.T) { // Arrange m := loadedModel() diff --git a/justfile b/justfile index 28de9bd..4aa9023 100644 --- a/justfile +++ b/justfile @@ -82,6 +82,64 @@ check: lint test clean: rm -rf bin coverage.out +# Delete local branches already merged into origin/main. Uses a content +# check (cherry-pick equivalence) so squash- and merge-commit-merged PRs +# are caught — plain `git branch --merged` misses those, which is how +# stale branches pile up. Pass `just prune-branches dry` to preview only. +prune-branches MODE="": + #!/usr/bin/env bash + set -euo pipefail + if [[ "{{ MODE }}" != "" && "{{ MODE }}" != "dry" ]]; then + echo "Usage: just prune-branches [dry]"; exit 1 + fi + git fetch --prune origin >/dev/null 2>&1 || true + # Resolve the repo's default branch instead of hardcoding "main". + base=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD \ + 2>/dev/null || echo origin/main) + if ! git rev-parse --verify "$base" >/dev/null 2>&1; then + echo "Error: base ref '$base' does not resolve" >&2; exit 1 + fi + base_local="${base#origin/}" + current=$(git rev-parse --abbrev-ref HEAD) + merged=() + while IFS= read -r b; do + [[ "$b" == "$base_local" ]] && continue + [[ "$b" == "$current" ]] && continue + # Empty output = every commit on $b is already in $base. + # A nonzero exit means the comparison itself failed (e.g. bad + # ref) — that must NOT be treated as "merged". + out=$(git rev-list --cherry-pick --right-only \ + --no-merges "$base...$b") && status=0 || status=$? + if [[ $status -ne 0 ]]; then + echo "Error: failed to diff '$b' against '$base'" >&2 + exit 1 + fi + if [[ -z "$out" ]]; then + merged+=("$b") + fi + done < <(git for-each-ref --format='%(refname:short)' refs/heads/) + if [[ ${#merged[@]} -eq 0 ]]; then + echo "No merged branches to prune."; exit 0 + fi + if [[ "{{ MODE }}" == "dry" ]]; then + echo "Would delete:"; printf ' %s\n' "${merged[@]}"; exit 0 + fi + deleted=() + failed=() + for b in "${merged[@]}"; do + if git branch -D "$b"; then + deleted+=("$b") + else + failed+=("$b") + fi + done + echo "" + echo "Deleted ${#deleted[@]} branch(es)." + if [[ ${#failed[@]} -gt 0 ]]; then + echo "Failed to delete:"; printf ' %s\n' "${failed[@]}" + exit 1 + fi + # ─── Release ───────────────────────────────────────────────────── # Regenerate site/src/changelog.js from repo-root CHANGELOG.md