refactor: extract the Jobs view state, track selection by job ID
Phase 10 of the whole-project review (findings 5.1 and 5.2), folded into the ROADMAP file-split item as that plan asks. 5.2 was a real defect. `selected` was an index into a snapshot of the jobs slice, and every path that changed the slice patched it by hand. The one path that could not — adopting a different jobs file, where the Service replaces the whole list and the view only hears about it through the refresh JobsLoaded triggers — left the details pane redrawing from an index that belonged to the previous list, describing whichever job now sat there (or clearing when the new list was shorter) while the list highlight stayed put. The selection is now a job ID; rows are derived from it at render time, and refresh ends by pointing the highlight at the selected job, so the two can no longer disagree. 5.1: newJobsView was one 330-line constructor whose dozen closures shared seven mutable locals. It is now a jobsView struct over a jobsViewState that owns the snapshot, the folder filter, and the selection — the invariant that used to be maintained by hand in five places lives in one place — split across jobs_view.go (construction, refresh, layout), jobs_view_state.go, jobs_view_list.go, and jobs_view_toolbar.go. The folder-option rebuild that appeared verbatim in three handlers is one method. Behaviour that changed beyond the fix: switching the folder filter keeps the current selection when the new filter still shows it, instead of always jumping to the folder's first job. Docs: ARCHITECTURE records the new file layout and the selection-by-ID contract; ROADMAP drops jobs_view.go from the over-guideline table and refreshes the other five numbers (finding 2.4); TESTS documents the new state test file and the adoption regression test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+16
-4
@@ -270,16 +270,28 @@ the moment the window opens.
|
||||
### `jobs_view.go` file structure
|
||||
|
||||
The size guideline for a file in this project is ~250 lines.
|
||||
`src/ui/jobs_view.go` is split across three files along these seams; the view
|
||||
file itself has grown back over the guideline since — see the split item in
|
||||
[ROADMAP.md](ROADMAP.md), which tracks every file currently over it:
|
||||
`src/ui/jobs_view.go` is split across five files along these seams:
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `jobs_view.go` | `newJobsView` — list, toolbar, button wiring, and layout |
|
||||
| `jobs_view.go` | `jobsView` struct — construction, `refresh`, `updateDetails`, the pause control, and layout assembly |
|
||||
| `jobs_view_state.go` | `jobsViewState` — the jobs/runtime snapshot, the folder filter, and the selection |
|
||||
| `jobs_view_list.go` | The sidebar list: row template, row rendering, row mode, and the compact/detailed toggle |
|
||||
| `jobs_view_toolbar.go` | The per-job button row — new, edit, run, pause, delete |
|
||||
| `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` |
|
||||
| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` |
|
||||
|
||||
The widgets hold no job state of their own: they read `jobsViewState`, which is
|
||||
the only thing that reads the Service. The **selection is a job ID, not a row
|
||||
index.** Every path that changes the job list replaces the state's snapshot —
|
||||
create, delete, and edit from this view's own handlers, adopting a different
|
||||
jobs file from the Service, which the view only learns about through the refresh
|
||||
`JobsLoaded` triggers. An index that outlives its snapshot points at whichever
|
||||
job now sits there, so the details pane would describe one job while the list
|
||||
highlighted another. Rows are derived from the ID at render time
|
||||
(`selectedIndex`, `displayRow`), and `jobsView.refresh` ends by pointing the
|
||||
list's highlight at the selected job.
|
||||
|
||||
### `settings_view.go` file structure
|
||||
|
||||
`src/ui/settings_view.go` is split across three files the same way, once its
|
||||
|
||||
@@ -51,6 +51,12 @@ the app icon (experimental).**
|
||||
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.
|
||||
- **The Jobs tab keeps its selection on the job, not on the row.** Selecting a
|
||||
different jobs file in Settings replaces the whole job list; the details pane
|
||||
then described whichever job happened to land on the previously selected row —
|
||||
or went blank if the new list was shorter — while the highlight in the list
|
||||
stayed where it was. The selection now follows the job itself, and the
|
||||
highlight and the details pane always describe the same one.
|
||||
- **Max log files and max log age days now accept 0, meaning "keep
|
||||
everything."** Log cleanup already supported disabling either policy; the
|
||||
Settings form and the Service validator rejected the value that would have
|
||||
@@ -81,6 +87,12 @@ the app icon (experimental).**
|
||||
released, in preparation order, so `jobs.json` still ends up matching the
|
||||
in-memory list. Seeding statistics from logs also opens each log file once
|
||||
instead of twice.
|
||||
- The Jobs tab was split into `jobs_view.go` (construction, refresh, layout),
|
||||
`jobs_view_state.go` (the job/runtime snapshot, folder filter, and selection),
|
||||
`jobs_view_list.go`, and `jobs_view_toolbar.go`. What used to be one 330-line
|
||||
constructor whose dozen closures shared seven mutable locals is now widgets
|
||||
reading one named state object — which is what made the selection fix above a
|
||||
change in one place instead of five.
|
||||
|
||||
## 1.0.1 - 2026-08-04
|
||||
|
||||
|
||||
+22
-20
@@ -123,24 +123,24 @@ Design notes / open questions:
|
||||
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md) sets a ~250-line guideline per source file
|
||||
and records the `jobs_view.go` and `settings_view.go` splits as the worked
|
||||
examples. Six non-test files are over it at 1.0.0, including both files that
|
||||
were already split once:
|
||||
examples. `jobs_view.go` was split again in 1.0.2 — into view, state, list, and
|
||||
toolbar — because the selection defect it carried was a symptom of the size
|
||||
(one 330-line constructor over seven shared locals). Five non-test files are
|
||||
over the guideline as of that pass:
|
||||
|
||||
| File | Lines |
|
||||
|------|-------|
|
||||
| `src/app/operations.go` | 490 |
|
||||
| `src/ui/jobs_view.go` | 355 |
|
||||
| `src/app/run.go` | 287 |
|
||||
| `src/ui/history_view.go` | 282 |
|
||||
| `src/ui/settings_view.go` | 277 |
|
||||
| `src/storage/store.go` | 265 |
|
||||
| `src/app/operations.go` | 529 |
|
||||
| `src/ui/history_view.go` | 373 |
|
||||
| `src/storage/store.go` | 365 |
|
||||
| `src/ui/settings_view.go` | 318 |
|
||||
| `src/app/run.go` | 274 |
|
||||
|
||||
This is deliberately deferred to the next whole-project review rather than done
|
||||
piecemeal: a future review already asks item 2 to look for exactly this,
|
||||
a split touches every reader of the file, and doing all six in one pass keeps
|
||||
the seams consistent instead of settling them six different ways. Splitting is
|
||||
The remaining five are deliberately deferred rather than done piecemeal: a
|
||||
split touches every reader of the file, and doing them in one pass keeps the
|
||||
seams consistent instead of settling them five different ways. Splitting is
|
||||
also the kind of change that reads as pure movement while quietly dropping a
|
||||
function, so it wants one careful pass, not six hurried ones.
|
||||
function, so it wants one careful pass, not five hurried ones.
|
||||
|
||||
Seams visible today, as a starting point rather than a decision:
|
||||
|
||||
@@ -152,13 +152,15 @@ Seams visible today, as a starting point rather than a decision:
|
||||
- **`history_view.go`** — the column-measuring helpers (`textWidth` through
|
||||
`historyColumnWidths`) are pure, already unit-tested, and independent of the
|
||||
table they size.
|
||||
- **`jobs_view.go`** — nearly all of it is one `newJobsView` constructor, so the
|
||||
split has to break that function up (list template, toolbar handlers,
|
||||
assembly) rather than move whole functions. Larger judgement call than the
|
||||
others.
|
||||
- **`run.go`**, **`settings_view.go`**, **`store.go`** — barely over. Worth
|
||||
re-measuring at the time; if a pass elsewhere has shrunk them, leave them
|
||||
alone rather than splitting for the sake of the number.
|
||||
- **`store.go`** — path resolution, the config load/normalize path, and the jobs
|
||||
load/normalize path are three separate concerns in one file.
|
||||
- **`run.go`**, **`settings_view.go`** — barely over. Worth re-measuring at the
|
||||
time; if a pass elsewhere has shrunk them, leave them alone rather than
|
||||
splitting for the sake of the number.
|
||||
|
||||
The `jobs_view.go` pass is the worked example for the rest: the constructor was
|
||||
broken up along the state it shared, not along line count, and the split landed
|
||||
with the selection fix rather than promising it separately.
|
||||
|
||||
Scope note: the guideline is about source files. Test files are much larger and
|
||||
that is fine — a table-driven test file grows with the cases it covers.
|
||||
|
||||
+24
-1
@@ -471,11 +471,34 @@ widgets are assembled.
|
||||
| `TestJobListViewCompactConfigOpensCompact` | Verifies the persisted density is honoured at build time, not only after a tap. |
|
||||
| `TestJobsSidebarWidthIsItsContent` | Regression guard: nothing but the sidebar's own toolbar row imposes a width floor on it. |
|
||||
| `TestJobsSplitOpensAtTheSidebarWidth` | Verifies the derived split offset opens the divider at the sidebar's own width at the default window size — enough that the toolbar is never born clipped, and no more. |
|
||||
| `TestToolbarButtonRedrawsRowAndDetails` | Regression guard: with the duplicate refreshes removed from the handlers, `refreshView` alone must re-snapshot the jobs and repopulate the details pane. |
|
||||
| `TestToolbarButtonRedrawsRowAndDetails` | Regression guard: with the duplicate refreshes removed from the handlers, `jobsView.refresh` alone must re-snapshot the jobs and repopulate the details pane. |
|
||||
| `TestJobsViewSelectionSurvivesAJobsFileSwitch` | Regression guard: adopting a different jobs file replaces the whole list from the Service, and the refresh that follows must leave the details pane and the list highlight describing the same job — not redraw the pane from a row index that belonged to the previous list. |
|
||||
| `TestDetailCaptionWidthCoversEveryCaption` | Verifies every caption `metadataRows` returns fits the measured caption column, which is what makes the single row list self-enforcing. |
|
||||
|
||||
---
|
||||
|
||||
### src/ui/jobs_view_state_test.go
|
||||
|
||||
**Package:** `ui`
|
||||
|
||||
Tests `jobsViewState`, the Jobs tab's model: the job/runtime snapshot, the
|
||||
folder filter, and the ID-based selection. No Fyne app is built — the state
|
||||
touches no widgets, so these run in milliseconds.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestJobsViewStateSelectsTheFirstJob` | Verifies the opening state selects the first row, so the details pane is never blank when there is something to show. |
|
||||
| `TestJobsViewStateEmptyListSelectsNothing` | Verifies an empty job list leaves nothing selected and no row to highlight (`displayRow` = -1). |
|
||||
| `TestJobsViewStateSelectionFollowsTheJobNotTheRow` | Regression guard: a job removed above the selected one (through the Service, the way an external change reaches the view) must not slide the selection onto its neighbour — the selection is a job ID, and only its row moves. |
|
||||
| `TestJobsViewStateDropsSelectionWhenItsJobIsGone` | Verifies a selection whose job no longer exists falls back to the first visible row instead of describing whichever job inherited its position. |
|
||||
| `TestJobsViewStateApplyFilter` | Verifies the folder filter keeps a selection it still shows, moves it to the folder's first row when it does not, and that "No folder" matches the job without one. |
|
||||
| `TestJobsViewStateEmptyFilterSelectsNothing` | Verifies a filter matching no job is a filter choice, not an error state: nothing selected, nothing highlighted, and the selection returns when the filter is cleared. |
|
||||
| `TestJobsViewStateHiddenSelectionIsNotHighlighted` | Verifies a selected job the filter hides reports no display row rather than falling back to row 0, which would highlight an unrelated job. |
|
||||
| `TestJobsViewStateRuntimeIsNeverNil` | Verifies `runtime` returns an empty `JobRuntime` for a job the Service has none for, so callers need no nil check. |
|
||||
| `TestJobsViewStateJobAtRejectsRowsOutsideTheFilter` | Verifies row lookups are bounded by the filtered rows, which is what the list widget draws from. |
|
||||
|
||||
---
|
||||
|
||||
### src/ui/history_view_test.go
|
||||
|
||||
**Package:** `ui`
|
||||
|
||||
Reference in New Issue
Block a user