Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
.rtk/
.aider*
.headroom/
headroom-content.log
.claude/

# OS / editor
Expand Down
62 changes: 42 additions & 20 deletions internal/tui/screens/dashboard/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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)),
)
}

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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 {
Expand Down
40 changes: 40 additions & 0 deletions internal/tui/screens/dashboard/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down
58 changes: 58 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading