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:
2026-08-06 22:38:47 +03:00
parent ca2a8c8aa7
commit bd7ebde68e
10 changed files with 913 additions and 355 deletions
+114
View File
@@ -0,0 +1,114 @@
package ui
import (
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
// newList builds the sidebar's job list. Rows are drawn from jobsViewState's
// filtered view, so the row index the widget reports is a position in the
// filter, never an index into the job snapshot.
func (v *jobsView) newList() *widget.List {
list := widget.NewList(
func() int { return len(v.state.filtered) },
func() fyne.CanvasObject {
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
// Truncating stops a long name from pushing the compact row's status
// off the right-hand edge. Labels default to TextWrapOff, which grows
// the widget to fit instead.
name.Truncation = fyne.TextTruncateClip
inlineStatus := widget.NewLabel("status")
meta := widget.NewLabel("schedule")
status := widget.NewLabel("status")
v.applyRowMode(inlineStatus, meta, status)
nameLine := container.NewBorder(nil, nil, nil, inlineStatus, name)
return container.New(layout.NewCustomPaddedVBoxLayout(rowOverlap()), nameLine, meta, status)
},
func(id widget.ListItemID, item fyne.CanvasObject) {
current, ok := v.state.jobAt(int(id))
if !ok {
return
}
row := item.(*fyne.Container)
// NewBorder keeps the center object first and appends the border slots
// after it, so nameLine is [name, inlineStatus].
nameLine := row.Objects[0].(*fyne.Container)
name := nameLine.Objects[0].(*widget.Label)
inlineStatus := nameLine.Objects[1].(*widget.Label)
meta := row.Objects[1].(*widget.Label)
status := row.Objects[2].(*widget.Label)
name.SetText(current.Name)
// Keep each row compact: folder, schedule, and command are shown in one
// metadata line so the left pane stays useful even with many jobs.
meta.SetText(app.DisplayFolder(current.Folder) + " " + current.Schedule + " " + app.DisplayInvocation(current))
statusText := app.StatusText(current, v.state.runtime(current.ID))
status.SetText(statusText)
inlineStatus.SetText(statusText)
// A full Refresh reuses rows built under the previous mode, so
// visibility cannot be left to the create callback alone.
v.applyRowMode(inlineStatus, meta, status)
},
)
list.OnSelected = func(id widget.ListItemID) {
v.state.selectRow(int(id))
v.updateDetails()
}
return list
}
// applyRowMode expresses the current view mode as visibility on the row's
// four labels. widget.List caches the row template's MinSize, and
// list.Refresh() re-creates the template and recomputes it, so hiding lines
// is what actually shrinks the rows: layout.NewCustomPaddedVBoxLayout and the
// border layout both skip hidden children when measuring.
func (v *jobsView) applyRowMode(inlineStatus, meta, status fyne.CanvasObject) {
if v.listView.IsCompact() {
inlineStatus.Show()
meta.Hide()
status.Hide()
return
}
inlineStatus.Hide()
meta.Show()
status.Show()
}
// newViewToggle builds the compact/detailed switch that sits at the right edge
// of the filter row.
func (v *jobsView) newViewToggle() *widget.Button {
button := widget.NewButtonWithIcon(viewToggleText(v.listView), viewToggleIcon(v.listView), nil)
button.OnTapped = func() {
next := nextJobListView(v.listView)
v.listView = next
if err := v.svc.SetJobListView(next); err != nil {
// Roll the mode back and leave the button as it was, so the button
// never claims a preference that did not reach disk.
v.listView = nextJobListView(next)
dialog.ShowError(err, v.w)
return
}
button.SetText(viewToggleText(v.listView))
button.SetIcon(viewToggleIcon(v.listView))
// Refresh re-creates the row template, which is what recomputes the
// cached row height for the new mode. Selection is untouched.
v.list.Refresh()
}
return button
}
// viewToggleIcon pairs with viewToggleText: both name the action the button
// performs, not the state it is in, matching stopAllButton's convention.
func viewToggleIcon(current domain.JobListView) fyne.Resource {
if current.IsCompact() {
return theme.ViewFullScreenIcon()
}
return theme.ListIcon()
}