Compare commits
2 Commits
v0.7.1
..
dc53d49c3e
| Author | SHA1 | Date | |
|---|---|---|---|
| dc53d49c3e | |||
| d6c0f7508c |
@@ -56,6 +56,30 @@ jobs:
|
||||
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
||||
fi
|
||||
|
||||
# This exists because `go vet` does not look at import order: the move to
|
||||
# git.ryuvia.com rewrote every import path without re-sorting, the new path sorts
|
||||
# before github.com/..., and both repos sat unformatted through a green CI run and
|
||||
# a release before anyone noticed.
|
||||
#
|
||||
# Both of gofmt's failure modes need handling, and they are not alike. A file that
|
||||
# is merely misformatted is listed on stdout with exit 0 -- so the failure has to
|
||||
# be raised by hand. A file that does not parse is the opposite: nothing on stdout
|
||||
# and exit 2, which a naive `[ -n "$unformatted" ]` reads as success. The first
|
||||
# draft of this step had exactly that hole.
|
||||
- name: Format
|
||||
run: |
|
||||
if ! unformatted=$(gofmt -l .); then
|
||||
echo "::error::gofmt could not parse the tree"
|
||||
gofmt -l . # re-run unredirected so the parse errors reach the log
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "::error::not gofmt'd:"
|
||||
echo "$unformatted"
|
||||
gofmt -d .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
|
||||
@@ -41,6 +41,30 @@ jobs:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
||||
|
||||
# This exists because `go vet` does not look at import order: the move to
|
||||
# git.ryuvia.com rewrote every import path without re-sorting, the new path sorts
|
||||
# before github.com/..., and both repos sat unformatted through a green CI run and
|
||||
# a release before anyone noticed.
|
||||
#
|
||||
# Both of gofmt's failure modes need handling, and they are not alike. A file that
|
||||
# is merely misformatted is listed on stdout with exit 0 -- so the failure has to
|
||||
# be raised by hand. A file that does not parse is the opposite: nothing on stdout
|
||||
# and exit 2, which a naive `[ -n "$unformatted" ]` reads as success. The first
|
||||
# draft of this step had exactly that hole.
|
||||
- name: Format
|
||||
run: |
|
||||
if ! unformatted=$(gofmt -l .); then
|
||||
echo "::error::gofmt could not parse the tree"
|
||||
gofmt -l . # re-run unredirected so the parse errors reach the log
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "::error::not gofmt'd:"
|
||||
echo "$unformatted"
|
||||
gofmt -d .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
|
||||
+25
-7
@@ -4,13 +4,13 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||
)
|
||||
|
||||
// ── Enums ──────────────────────────────────────────────────────────────────
|
||||
@@ -354,27 +354,45 @@ func defaultTableStyles() table.Styles {
|
||||
return s
|
||||
}
|
||||
|
||||
// setRows replaces a table's rows and keeps its cursor in a state the rest of
|
||||
// this package can rely on: valid whenever the table has any rows at all.
|
||||
//
|
||||
// bubbles does not do that on its own. SetRows only clamps the cursor *down*
|
||||
// (`if m.cursor > len(rows)-1`), so setting zero rows drives it to -1 and
|
||||
// nothing ever brings it back — filling the table later leaves -1 in place,
|
||||
// because -1 is not greater than len-1. Every table here is rebuilt from empty
|
||||
// once at startup, when the first WindowSizeMsg arrives before any fetch has
|
||||
// returned, so without this every cursor is -1 until the user happens to press
|
||||
// up or down. Indexing a slice with that panics, which is exactly what
|
||||
// assigning an on-call week did.
|
||||
func setRows(t *table.Model, rows []table.Row) {
|
||||
t.SetRows(rows)
|
||||
if len(rows) > 0 && t.Cursor() < 0 {
|
||||
t.SetCursor(0)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) rebuildIncidentTable() {
|
||||
m.incidentTable.SetColumns(incidentColumns(m.width))
|
||||
m.incidentTable.SetRows(incidentRows(m.incidents))
|
||||
setRows(&m.incidentTable, incidentRows(m.incidents))
|
||||
m.incidentTable.SetHeight(tableHeight(m.height, 8))
|
||||
}
|
||||
|
||||
func (m *Model) rebuildTable() {
|
||||
m.alertTable.SetColumns(alertColumns(m.width))
|
||||
m.alertTable.SetRows(alertRows(m.alerts))
|
||||
setRows(&m.alertTable, alertRows(m.alerts))
|
||||
m.alertTable.SetHeight(tableHeight(m.height, 8))
|
||||
}
|
||||
|
||||
func (m *Model) rebuildArchivedTable() {
|
||||
m.archivedTable.SetColumns(incidentColumns(m.width))
|
||||
m.archivedTable.SetRows(incidentRows(m.archivedIncidents))
|
||||
setRows(&m.archivedTable, incidentRows(m.archivedIncidents))
|
||||
m.archivedTable.SetHeight(tableHeight(m.height, 8))
|
||||
}
|
||||
|
||||
func (m *Model) rebuildScheduleTable() {
|
||||
m.scheduleTable.SetColumns(scheduleColumns(m.width))
|
||||
m.scheduleTable.SetRows(scheduleRows(m.scheduleDays))
|
||||
setRows(&m.scheduleTable, scheduleRows(m.scheduleDays))
|
||||
m.scheduleTable.SetHeight(tableHeight(m.height, 10))
|
||||
}
|
||||
|
||||
@@ -384,7 +402,7 @@ func (m *Model) rebuildUserPickerTable() {
|
||||
for i, u := range m.users {
|
||||
rows[i] = table.Row{u.Username, u.Email}
|
||||
}
|
||||
m.userPickerTable.SetRows(rows)
|
||||
setRows(&m.userPickerTable, rows)
|
||||
m.userPickerTable.SetHeight(tableHeight(m.height, 10))
|
||||
}
|
||||
|
||||
@@ -398,7 +416,7 @@ func (m *Model) rebuildUserManageTable() {
|
||||
}
|
||||
rows[i] = table.Row{u.Username, u.Email, topic, u.CreatedAt.UTC().Format("2006-01-02")}
|
||||
}
|
||||
m.userManageTable.SetRows(rows)
|
||||
setRows(&m.userManageTable, rows)
|
||||
m.userManageTable.SetHeight(tableHeight(m.height, 10))
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
)
|
||||
|
||||
func TestNextFilter(t *testing.T) {
|
||||
|
||||
@@ -3,8 +3,8 @@ package tui
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||
)
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -468,7 +468,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
cursor := m.scheduleTable.Cursor()
|
||||
if cursor >= len(m.scheduleDays) {
|
||||
if cursor < 0 || cursor >= len(m.scheduleDays) {
|
||||
return m, nil
|
||||
}
|
||||
day := m.scheduleDays[cursor]
|
||||
@@ -484,7 +484,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
cursor := m.userManageTable.Cursor()
|
||||
if cursor >= len(m.users) {
|
||||
if cursor < 0 || cursor >= len(m.users) {
|
||||
return m, nil
|
||||
}
|
||||
m.selectedUser = m.users[cursor]
|
||||
@@ -510,7 +510,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
cursor := m.userManageTable.Cursor()
|
||||
if cursor >= len(m.users) {
|
||||
if cursor < 0 || cursor >= len(m.users) {
|
||||
return m, nil
|
||||
}
|
||||
m.selectedUser = m.users[cursor]
|
||||
@@ -527,7 +527,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
cursor := m.userManageTable.Cursor()
|
||||
if cursor >= len(m.users) {
|
||||
if cursor < 0 || cursor >= len(m.users) {
|
||||
return m, nil
|
||||
}
|
||||
m.selectedUser = m.users[cursor]
|
||||
@@ -893,7 +893,7 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
scheduleCursor := m.scheduleTable.Cursor()
|
||||
if scheduleCursor >= len(m.scheduleDays) {
|
||||
if scheduleCursor < 0 || scheduleCursor >= len(m.scheduleDays) {
|
||||
m.mode = modeDashboard
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// press sends one key and returns the resulting model and command. A nil command
|
||||
@@ -676,3 +676,50 @@ func containsAll(s string, subs ...string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// The bug: assigning an on-call week panicked with "index out of range [-1]"
|
||||
// on a perfectly normal schedule, as long as nobody had moved the cursor first.
|
||||
//
|
||||
// The cause is not in this package. bubbles' SetRows clamps the cursor down but
|
||||
// never up, so the empty rebuild every table gets from the first WindowSizeMsg
|
||||
// -- which arrives before any fetch returns -- pins the cursor at -1, and
|
||||
// loading real rows afterwards leaves it there. Pressing up or down hid it,
|
||||
// which is why every existing test missed it: they all call SetCursor, and
|
||||
// SetCursor clamps.
|
||||
//
|
||||
// So this test must NOT touch the cursor. It reproduces the real order of
|
||||
// events: size first, data second, keys third.
|
||||
func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) {
|
||||
m := NewModel(nil, "http://test", time.Minute)
|
||||
m.connected = true
|
||||
m.activeSection = sectionSchedule
|
||||
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// 1. Terminal size arrives while every table is still empty.
|
||||
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
|
||||
m = next.(Model)
|
||||
|
||||
// 2. The schedule and the user list land.
|
||||
next, _ = m.Update(scheduleFetchedMsg{entries: []api.ScheduleEntry{}})
|
||||
m = next.(Model)
|
||||
next, _ = m.Update(usersFetchedMsg{users: []api.User{
|
||||
{ID: 1, Username: "niklas", Email: "n@example.com"},
|
||||
}})
|
||||
m = next.(Model)
|
||||
|
||||
if got := m.scheduleTable.Cursor(); got < 0 {
|
||||
t.Fatalf("schedule cursor is %d after loading %d days; a populated table must have a usable cursor",
|
||||
got, len(m.scheduleDays))
|
||||
}
|
||||
|
||||
// 3. Assign the week to the first user, without ever moving a cursor.
|
||||
m, _ = press(t, m, "W")
|
||||
if m.mode != modeUserPicker {
|
||||
t.Fatalf("W did not open the user picker, got mode %v", m.mode)
|
||||
}
|
||||
m, _ = press(t, m, "enter") // panicked here
|
||||
|
||||
if m.mode == modeUserPicker {
|
||||
t.Fatal("enter left the picker open; the assignment never went anywhere")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Order must match the section constants — renderTabs indexes this by ordinal.
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// ansi matches the escape sequences lipgloss emits when it decides the output
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/config"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/tui"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/updater"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
Reference in New Issue
Block a user