diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 967adf1..7a7cca8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -39,6 +39,13 @@ the app icon (experimental).** longer than its own interval no longer accumulates an unbounded backlog that then runs back-to-back indefinitely. The job details pane now shows the queued-run count (", N queued") whenever it is non-zero. +- The History tab no longer grows without bound: it keeps the newest 1000 + records and drops the oldest, the way a job's own activity list is capped. + Column widths are also folded in one record at a time instead of being + re-measured across every row on every event, so recording a run no longer + gets slower the longer the app has been running. Measured on 5000 accumulated + records, one History redraw went from **15.8 ms to 0.9 ms**; at the new cap + the width rescan alone accounted for 1.5 ms of every redraw. **Jobs:** diff --git a/docs/STANDARDS.md b/docs/STANDARDS.md index 179efc0..018bceb 100644 --- a/docs/STANDARDS.md +++ b/docs/STANDARDS.md @@ -65,6 +65,14 @@ change to their shape has to stay compatible on its own. - **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the current process. Log files on disk feed aggregate statistics via `SeedStats` only. See [ARCHITECTURE.md](ARCHITECTURE.md). +- **History is capped and its columns only widen.** The tab keeps the newest + `maxHistoryRows` records and drops the oldest, the way `maxJobLogs` caps a + job's own activity list — an app left in the tray records thousands of runs a + day, each carrying the run's full captured output. Column widths are folded in + one record at a time instead of rescanned from every row, so a column never + narrows when a record ages out: the rows on screen were laid out against the + wider value. A theme change is the one case that rescans, because every stored + width was measured at the old text size. - Several tests share a coverage profile with another test on purpose, and a few functions sit at 0% on purpose. Both lists live in [TESTS.md](TESTS.md) — check them before reporting a test as redundant or a diff --git a/docs/TESTS.md b/docs/TESTS.md index 236f476..e5d3793 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -494,6 +494,10 @@ column-width behaviour of the assembled table. | `TestHistoryCellTemplateIsPlainText` | Verifies the cell template already carries the zero `TextStyle`, since the per-cell assignment that used to reset it is gone. | | `TestTextColumnWidthClamps` | Covers the three shapes of `textColumnWidth`: below the minimum, in range, and capped at the maximum. | | `TestHistoryColumnsFitTheirContent` | Verifies every column is at least as wide as its widest known or present value, at the default text size and at a scaled theme. | +| `TestHistoryLogCapsRecords` | Regression guard for the unbounded History list: the log keeps the newest `maxHistoryRows` records, drops the oldest from the front, and trims a list handed in already over the cap. | +| `TestHistoryLogWidthsMatchAFullScan` | Verifies the incremental column widths equal a full rescan while every measured record is still present — the cheaper path must not clip what the old one showed. | +| `TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut` | Verifies a column keeps its width after the record that set it is dropped by the cap, since the rows on screen were laid out against it. | +| `TestHistoryLogRescansOnThemeChange` | Verifies a theme change falls back to a full rescan, the one case the incremental fold cannot handle because every stored width was measured at the old text size. | --- diff --git a/src/ui/history_view.go b/src/ui/history_view.go index d47d777..c0b11bb 100644 --- a/src/ui/history_view.go +++ b/src/ui/history_view.go @@ -102,23 +102,113 @@ const historyTimeSample = "2026-01-02 15:04:05" // Detail and Log are free text, so their width tracks the values actually // present, bounded the same way the Log column always was. func historyColumnWidths(rows []event) [6]float32 { - jobNames := make([]string, 0, len(rows)) - details := make([]string, 0, len(rows)) - logNames := make([]string, 0, len(rows)) + var content [3][]string + for i := range content { + content[i] = make([]string, 0, len(rows)) + } for _, current := range rows { - jobNames = append(jobNames, current.JobName) - details = append(details, current.Detail) - logNames = append(logNames, logFileName(current.LogFile)) + for i, value := range historyContentValues(current) { + content[i] = append(content[i], value) + } } min, max := textColumnMinWidth(), textColumnMaxWidth() - return [6]float32{ - textWidth(historyTimeSample) + cellPadding(), - textColumnWidth(historyTriggerSamples, min, max), - textColumnWidth(jobNames, min, max), - textColumnWidth(historyStateSamples, min, max), - textColumnWidth(details, min, max), - textColumnWidth(logNames, min, max), + widths := [6]float32{ + 0: textWidth(historyTimeSample) + cellPadding(), + 1: textColumnWidth(historyTriggerSamples, min, max), + 3: textColumnWidth(historyStateSamples, min, max), } + for i, col := range historyContentCols { + widths[col] = textColumnWidth(content[i], min, max) + } + return widths +} + +// maxHistoryRows caps the session History list, the way app.maxJobLogs caps a +// job's own activity list. History is never persisted and every record carries +// the run's full captured output, so an app left running in the tray — the mode +// GoSentry is designed for — would otherwise hold every record of every run +// forever, and pay a full resort plus a full column-width rescan on each new +// one. One job on @every 10s produces ~8 600 records a day. +const maxHistoryRows = 1000 + +// historyLog is the session History: the capped record list plus the column +// widths measured from it. It exists so the widths can be folded in one record +// at a time instead of being recomputed from every row on every event, which +// is what made the per-event cost grow with the number of rows. +type historyLog struct { + records []event + widths [6]float32 + // textSize and padding are the theme metrics widths were last measured at. + // A theme change invalidates every measurement, so it forces a full rescan + // rather than folding new records into stale numbers. + textSize float32 + padding float32 +} + +func newHistoryLog(records []event) *historyLog { + h := &historyLog{records: trimHistory(records)} + h.rescan() + return h +} + +// trimHistory drops the oldest records past the cap. The tail of the backing +// array is zeroed because a dropped record holds the run's whole output, which +// would otherwise stay reachable until the slice happens to be reallocated. +func trimHistory(records []event) []event { + if len(records) <= maxHistoryRows { + return records + } + kept := copy(records, records[len(records)-maxHistoryRows:]) + for i := kept; i < len(records); i++ { + records[i] = event{} + } + return records[:kept] +} + +// add appends one record and widens any content-measured column the record +// does not fit. Widths only ever grow within a theme: a column is never +// narrowed when a record ages out, because the rows still on screen were laid +// out against the wider value. +func (h *historyLog) add(record event) { + h.records = trimHistory(append(h.records, record)) + if h.stale() { + h.rescan() + return + } + min, max := textColumnMinWidth(), textColumnMaxWidth() + for i, value := range historyContentValues(record) { + if width := textColumnWidth([]string{value}, min, max); width > h.widths[historyContentCols[i]] { + h.widths[historyContentCols[i]] = width + } + } +} + +// columnWidths returns the widths to apply to the table, rescanning every +// record only when the theme's text metrics have changed since the last scan. +func (h *historyLog) columnWidths() [6]float32 { + if h.stale() { + h.rescan() + } + return h.widths +} + +func (h *historyLog) stale() bool { + return theme.TextSize() != h.textSize || cellPadding() != h.padding +} + +func (h *historyLog) rescan() { + h.textSize, h.padding = theme.TextSize(), cellPadding() + h.widths = historyColumnWidths(h.records) +} + +// historyContentCols are the columns whose width follows the values actually +// present, in the order historyContentValues returns them. Both the +// incremental fold in add and the full scan in historyColumnWidths go through +// this pair, so they cannot disagree about which columns follow content. +var historyContentCols = [3]int{2, 4, 5} + +func historyContentValues(record event) [3]string { + return [3]string{record.JobName, record.Detail, logFileName(record.LogFile)} } // historyHeader is a bold tappable label used in the History table header row. @@ -156,7 +246,7 @@ func (h *historyHeader) SetText(text string) { // Time caption is built per update because it carries the sort direction arrow. var historyHeaders = [...]string{"Time", "Trigger", "Job", "State", "Detail", "Log"} -func newHistoryView(events *[]event) (*fyne.Container, func()) { +func newHistoryView(log *historyLog) (*fyne.Container, func()) { descending := false headerText := func(id widget.TableCellID) string { if id.Row < 0 && id.Col == 0 { @@ -179,7 +269,7 @@ func newHistoryView(events *[]event) (*fyne.Container, func()) { // per redraw: at build time, on a sort toggle, and from refresh(). var rows []event resort := func() { - rows = append(rows[:0], (*events)...) + rows = append(rows[:0], log.records...) sort.SliceStable(rows, func(left int, right int) bool { if descending { return rows[left].Time > rows[right].Time @@ -224,16 +314,17 @@ func newHistoryView(events *[]event) (*fyne.Container, func()) { table.Unselect(id) } setColumnWidths := func() { - for col, width := range historyColumnWidths(rows) { + for col, width := range log.columnWidths() { table.SetColumnWidth(col, width) } } setColumnWidths() - // refresh re-reads the event list into the sorted snapshot and recomputes - // every content-fit column width before redrawing, so newly recorded events - // appear in the current sort order and longer values widen their column - // instead of being truncated. + // refresh re-reads the event list into the sorted snapshot and re-applies + // the column widths before redrawing, so newly recorded events appear in + // the current sort order and longer values widen their column instead of + // being truncated. The widths come from historyLog, which folded each new + // record in as it arrived — this does not rescan every row. refresh := func() { resort() setColumnWidths() diff --git a/src/ui/history_view_test.go b/src/ui/history_view_test.go index 67f20b3..168ca07 100644 --- a/src/ui/history_view_test.go +++ b/src/ui/history_view_test.go @@ -1,6 +1,7 @@ package ui import ( + "strconv" "strings" "testing" "time" @@ -144,7 +145,8 @@ func TestHistorySortToggleKeepsRowsInSync(t *testing.T) { {Time: "2026-06-01 11:00:00", JobName: "B"}, {Time: "2026-06-01 12:00:00", JobName: "C"}, } - content, refresh := newHistoryView(&events) + log := newHistoryLog(events) + content, refresh := newHistoryView(log) table, ok := content.Objects[0].(*widget.Table) if !ok { t.Fatal("history view does not wrap a table") @@ -192,7 +194,7 @@ func TestHistorySortToggleKeepsRowsInSync(t *testing.T) { // A new run arrives while the table is sorted newest-first: it must be // counted and placed in the order currently on screen, not the build-time one. - events = append(events, event{Time: "2026-06-01 13:00:00", JobName: "D"}) + log.add(event{Time: "2026-06-01 13:00:00", JobName: "D"}) refresh() assertOrder("descending after refresh", "D", "C", "B", "A") @@ -207,8 +209,7 @@ func TestHistoryCellTemplateIsPlainText(t *testing.T) { testApp := test.NewApp() defer testApp.Quit() - var events []event - content, _ := newHistoryView(&events) + content, _ := newHistoryView(newHistoryLog(nil)) table := content.Objects[0].(*widget.Table) label, ok := table.CreateCell().(*widget.Label) if !ok { @@ -292,6 +293,96 @@ func TestHistoryColumnsFitTheirContent(t *testing.T) { check("scaled theme") } +// TestHistoryLogCapsRecords is the regression guard for the unbounded History +// list: an app left in the tray records thousands of runs a day, each carrying +// the run's whole captured output, so the list must drop the oldest instead of +// growing forever. +func TestHistoryLogCapsRecords(t *testing.T) { + testApp := test.NewApp() + defer testApp.Quit() + + log := newHistoryLog(nil) + for i := 0; i < maxHistoryRows+25; i++ { + log.add(event{Time: "t", JobName: "Job " + strconv.Itoa(i)}) + } + if len(log.records) != maxHistoryRows { + t.Fatalf("record count = %d, want capped at %d", len(log.records), maxHistoryRows) + } + if got, want := log.records[0].JobName, "Job 25"; got != want { + t.Errorf("oldest kept record = %q, want %q — the cap must drop from the front", got, want) + } + last := log.records[len(log.records)-1].JobName + if want := "Job " + strconv.Itoa(maxHistoryRows+24); last != want { + t.Errorf("newest record = %q, want %q", last, want) + } + // A list handed in above the cap is trimmed too, not only one grown into it. + oversized := make([]event, maxHistoryRows+10) + if got := len(newHistoryLog(oversized).records); got != maxHistoryRows { + t.Errorf("pre-filled log length = %d, want %d", got, maxHistoryRows) + } +} + +// TestHistoryLogWidthsMatchAFullScan pins the incremental column widths: while +// every measured record is still in the list, folding each one in as it +// arrives must give exactly what rescanning every row would, or the cheaper +// path would clip values the old one showed. +func TestHistoryLogWidthsMatchAFullScan(t *testing.T) { + testApp := test.NewApp() + defer testApp.Quit() + + log := newHistoryLog(nil) + for _, record := range []event{ + {Time: "1", JobName: "A", Detail: "short", LogFile: `/logs/a.log`}, + {Time: "2", JobName: "A moderately long job name", Detail: "a longer detail message", LogFile: `/logs/20260601-120000_SomeJobName.log`}, + {Time: "3", JobName: "B", Detail: "s", LogFile: `/logs/b.log`}, + } { + log.add(record) + } + if got, want := log.columnWidths(), historyColumnWidths(log.records); got != want { + t.Errorf("incremental widths = %v, want the full-scan widths %v", got, want) + } +} + +// TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut covers the other half of the +// rule: widths only grow. Dropping the record that set a column's width must +// not narrow the column, because the rows on screen were laid out against it. +func TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut(t *testing.T) { + testApp := test.NewApp() + defer testApp.Quit() + + log := newHistoryLog(nil) + log.add(event{Time: "1", JobName: "A job name long enough to widen its column"}) + widest := log.columnWidths()[2] + for i := 0; i < maxHistoryRows; i++ { + log.add(event{Time: "t", JobName: "x"}) + } + if got := log.columnWidths()[2]; got != widest { + t.Errorf("Job column width = %v after the wide record aged out, want it held at %v", got, widest) + } +} + +// TestHistoryLogRescansOnThemeChange guards the one case the incremental fold +// cannot handle: every stored width was measured at the old text size, so a +// theme change has to fall back to a full rescan. +func TestHistoryLogRescansOnThemeChange(t *testing.T) { + testApp := test.NewApp() + defer testApp.Quit() + + log := newHistoryLog([]event{ + {Time: "1", JobName: "A moderately long job name", Detail: "a longer detail message"}, + }) + before := log.columnWidths() + + testApp.Settings().SetTheme(test.NewTheme()) + after := log.columnWidths() + if after == before { + t.Fatal("widths unchanged after a theme change; the fixture theme must alter text metrics") + } + if want := historyColumnWidths(log.records); after != want { + t.Errorf("widths after theme change = %v, want the rescanned %v", after, want) + } +} + func TestNewEventUsesConsistentTimestampShape(t *testing.T) { ev := newEvent(1, "Job", "OK", "detail") if _, err := time.Parse("2006-01-02 15:04:05", ev.Time); err != nil { diff --git a/src/ui/mainwindow.go b/src/ui/mainwindow.go index ab1615c..62311e7 100644 --- a/src/ui/mainwindow.go +++ b/src/ui/mainwindow.go @@ -34,11 +34,11 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time. initialRuntimes[j.ID] = rt } } - events := collectActivity(initialJobs, initialRuntimes) + events := newHistoryLog(collectActivity(initialJobs, initialRuntimes)) jobsPanel, refreshJobsView := newJobsView(w, svc) - history, refreshHistory := newHistoryView(&events) + history, refreshHistory := newHistoryView(events) recordStartup := func(duration time.Duration, windowShown bool) { // Startup is recorded as an in-memory History event instead of being // persisted into jobs.json. It is session diagnostics, not durable job @@ -48,7 +48,7 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time. if !windowShown { detail = "Started in tray in " + duration.Round(time.Millisecond).String() } - events = append(events, newEvent(0, "Application", "Started", detail)) + events.add(newEvent(0, "Application", "Started", detail)) refreshHistory() } @@ -70,7 +70,7 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time. jobsLoaded, isJobsLoaded := ev.(app.JobsLoaded) fyne.Do(func() { if isRecorded { - events = append(events, recorded.Record) + events.add(recorded.Record) r := recorded.Record if r.State == "Failed" && (r.Trigger == "Manual" || r.Trigger == "Schedule") && @@ -96,13 +96,13 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time. } } if isError { - events = append(events, newEvent(0, "Service", "Error", errOccurred.Err.Error())) + events.add(newEvent(0, "Service", "Error", errOccurred.Err.Error())) } if isJobsLoaded { // Selecting an existing jobs file replaces the job list without a // prompt, so History carries the receipt: how many jobs, from where. detail := strconv.Itoa(jobsLoaded.Count) + " jobs from " + jobsLoaded.Path - events = append(events, newEvent(0, "Service", "Jobs loaded", detail)) + events.add(newEvent(0, "Service", "Jobs loaded", detail)) } refresh() })