bd7ebde68e
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>
115 lines
4.2 KiB
Go
115 lines
4.2 KiB
Go
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()
|
|
}
|