18da021526
Phase 11 of PROJECT_REVIEW_PLAN.md: the themed cleanup pass over every low-severity finding still open (2.2-2.3, 3.4-3.6, 4.3-4.7, 6.4-6.7, 7.1-7.3, 8.2-8.3, 9.1-9.4, and the under-documented decisions in §10/§11). Behavioral fixes: - Reassign duplicate job IDs in a hand-edited jobs.json instead of letting two jobs share one runtime, schedule entry, and SeedStats bucket. - Disambiguate run-log file names that collide within the same second. - Compute AvgDurationMS as DurationSumMS/TimedRunCount instead of an incremental integer mean, so it always matches the seeded-from-logs average instead of drifting from truncation error. - Clean absolute paths in ResolveConfiguredPath so two spellings of the same jobs file do not trigger a spurious adoption. - Report InstallDesktopIcon failures through ErrorOccurred instead of discarding them silently. - Move settingsView's blocking AutostartStatus (PowerShell on Windows) off the UI thread. - Give notify-timing.tsv its own extension so CleanupLogs no longer manages it as a run log. - Replace the settingsView Save handler's second copy of validateConfig's rules with a bare parse, letting the Service's own error surface. Cleanups: - Delete collectActivity, the dead yaml tags on RunRecord, and the logArguments/LogArguments alias. - Fold the two systemTrayRegistered/mainWindowHidden globals into one trayState instance Run owns and threads through Settings and the single-instance reveal path. - Fix stale comments/docs: the frozen window-size restore claim, a reference to a renamed recordRun, README's "Pause all" and notification wording, the PowerShell quoting note for TESTS.md's coverage command, and scripts/test.bat's UTF-8 checkmarks under a non-UTF-8 code page. - Document the single-instance fallback's consequence and the unauthenticated instance-channel port in STANDARDS.md; record the config-shim retirement plan in ROADMAP.md. 3.5, 7.3, and 9.4 turned out to already be fixed by earlier phases; no change needed for those three. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
207 lines
8.2 KiB
Go
207 lines
8.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"
|
|
)
|
|
|
|
const allFolders = "All"
|
|
const noFolder = "No folder"
|
|
|
|
// maxJobActivityRows caps the "Selected job activity" panel to the most recent
|
|
// entries. The full per-job history (up to maxJobLogs) remains in the History
|
|
// view; this panel is a quick at-a-glance summary anchored below the output.
|
|
const maxJobActivityRows = 3
|
|
|
|
// jobsView owns the Jobs tab: the widgets, the view-only preferences they draw
|
|
// (list mode and the scheduler pause label), and the jobsViewState the widgets
|
|
// read. It replaces a single constructor whose dozen closures shared seven
|
|
// mutable locals — the state each handler touches is now named on the struct
|
|
// rather than captured, and the invariants that used to be maintained by hand in
|
|
// five places live on jobsViewState.
|
|
type jobsView struct {
|
|
w fyne.Window
|
|
svc *app.Service
|
|
state *jobsViewState
|
|
dp *detailsPanel
|
|
|
|
list *widget.List
|
|
folderSelect *widget.Select
|
|
viewButton *widget.Button
|
|
stopAllButton *widget.Button
|
|
schedulerState *widget.Label
|
|
|
|
// listView and paused mirror Service-owned config so the widgets can be
|
|
// relabelled without a round trip. Both are re-read from the Service on every
|
|
// refresh; neither is a source of truth.
|
|
listView domain.JobListView
|
|
paused bool
|
|
}
|
|
|
|
// newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar.
|
|
// It returns the assembled panel and a refresh function the caller invokes
|
|
// whenever the service state may have changed (e.g., from the event subscriber
|
|
// in mainwindow.go). The refresh function re-reads the service snapshot and
|
|
// redraws all widgets in the jobs view; it does NOT touch history or settings.
|
|
func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
|
config := svc.Config()
|
|
v := &jobsView{
|
|
w: w,
|
|
svc: svc,
|
|
state: newJobsViewState(svc),
|
|
listView: config.JobListView,
|
|
paused: config.Paused,
|
|
}
|
|
v.dp = newDetailsPanel(job{}, &domain.JobRuntime{}, config.OverlapPolicy, config.DefaultTimeoutSeconds)
|
|
v.updateDetails()
|
|
|
|
// Build order follows what refresh() touches: the folder select fires its
|
|
// OnChanged from SetSelected below, which refreshes, so every widget that
|
|
// refresh() reaches has to exist by then.
|
|
v.list = v.newList()
|
|
v.viewButton = v.newViewToggle()
|
|
globalControls := v.newGlobalControls()
|
|
v.folderSelect = v.newFolderSelect()
|
|
v.folderSelect.SetSelected(v.state.folder)
|
|
v.syncListSelection()
|
|
|
|
return v.assemble(globalControls), v.refresh
|
|
}
|
|
|
|
// refresh re-reads the Service and redraws the whole view. It is the single
|
|
// entry point for "something changed": the toolbar handlers call it after a
|
|
// successful operation, and mainwindow's event observer calls it for everything
|
|
// else.
|
|
func (v *jobsView) refresh() {
|
|
v.state.sync()
|
|
// The pause state is Service-owned and can change from outside this view, so
|
|
// it is re-read here rather than mirrored from the tap handler alone — that is
|
|
// what makes this view a consumer of SchedulerStateChanged.
|
|
v.applySchedulerState(v.svc.Config().Paused)
|
|
// updateDetails already ends in a d.logs.Refresh() (both its update and clear
|
|
// paths do), so refreshing the activity list again here would redraw it twice
|
|
// per call.
|
|
v.updateDetails()
|
|
v.list.Refresh()
|
|
v.syncListSelection()
|
|
}
|
|
|
|
// updateDetails repopulates the details pane from the current selection.
|
|
func (v *jobsView) updateDetails() {
|
|
current, ok := v.state.selected()
|
|
if !ok {
|
|
// A folder filter can temporarily leave no selectable rows. Clearing the
|
|
// details panel avoids showing stale information for a hidden job.
|
|
v.dp.clear()
|
|
return
|
|
}
|
|
// Overlap policy and the default timeout are global settings that can change
|
|
// from the Settings tab while this view is open, so they are re-read on every
|
|
// update rather than captured once at construction.
|
|
config := v.svc.Config()
|
|
v.dp.update(current, v.state.runtime(current.ID), config.OverlapPolicy, config.DefaultTimeoutSeconds)
|
|
}
|
|
|
|
// syncListSelection points the list's highlight at the selected job. It is what
|
|
// keeps the highlight and the details pane describing the same job when the row
|
|
// a job sits in moves — a job created or deleted above it, a folder filter
|
|
// applied, or a different jobs file adopted. widget.List.Select returns early
|
|
// when the row is already highlighted, so calling this on every refresh does not
|
|
// fight the user's scrolling.
|
|
func (v *jobsView) syncListSelection() {
|
|
row := v.state.displayRow()
|
|
if row < 0 {
|
|
v.list.UnselectAll()
|
|
return
|
|
}
|
|
v.list.Select(row)
|
|
}
|
|
|
|
// rebuildFolders re-derives the folder filter's options from the current jobs.
|
|
// Creating, editing, and deleting a job can all add or remove a folder.
|
|
func (v *jobsView) rebuildFolders() {
|
|
v.folderSelect.Options = folderOptions(v.state.jobs)
|
|
v.folderSelect.Refresh()
|
|
}
|
|
|
|
// assemble puts the sidebar (global controls, folder filter, toolbar, list) and
|
|
// the details pane into the master/detail split the tab shows.
|
|
func (v *jobsView) assemble(globalControls fyne.CanvasObject) fyne.CanvasObject {
|
|
// The whole filter is one row: caption on the left, view toggle on the right,
|
|
// select filling what is left. The border layout gives both edges their
|
|
// MinSize, so the header is a line shorter than a stacked caption would make it.
|
|
folderCaption := widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
|
filterRow := container.NewBorder(nil, nil, folderCaption, v.viewButton, v.folderSelect)
|
|
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), filterRow, v.newToolbar())
|
|
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, v.list)
|
|
|
|
// A split rather than a Border left slot: the border pinned the sidebar at its
|
|
// MinSize forever, so the user could never trade list width for detail width.
|
|
// The divider lets either pane grow, and neither can be dragged below its own
|
|
// content minimum.
|
|
panel := container.NewHSplit(sidebar, container.NewPadded(v.dp.container()))
|
|
panel.SetOffset(initialSplitOffset(sidebar.MinSize().Width))
|
|
return panel
|
|
}
|
|
|
|
// newFolderSelect builds the folder filter. Selecting a folder narrows the list
|
|
// and, when the selected job is no longer visible, moves the selection to the
|
|
// first row that is (see jobsViewState.applyFilter).
|
|
func (v *jobsView) newFolderSelect() *widget.Select {
|
|
return widget.NewSelect(folderOptions(v.state.jobs), func(value string) {
|
|
if value == "" {
|
|
return
|
|
}
|
|
v.state.applyFilter(value)
|
|
v.refresh()
|
|
})
|
|
}
|
|
|
|
// newGlobalControls builds the pause control row that sits above the filter.
|
|
func (v *jobsView) newGlobalControls() fyne.CanvasObject {
|
|
v.schedulerState = widget.NewLabel("")
|
|
v.stopAllButton = widget.NewButtonWithIcon("", nil, nil)
|
|
v.applySchedulerState(v.paused)
|
|
v.stopAllButton.OnTapped = func() {
|
|
// SetGlobalPause flips the pause flag, updates every job's next-run text,
|
|
// and emits the activity record the observer logs. refresh re-derives the
|
|
// pause state from the Service, so a failed save leaves the control showing
|
|
// what actually happened.
|
|
if err := v.svc.SetGlobalPause(!v.paused); err != nil {
|
|
dialog.ShowError(err, v.w)
|
|
return
|
|
}
|
|
v.refresh()
|
|
}
|
|
// The row sits directly under the tab bar with no AppTabs inset, while the
|
|
// default VBox gap below it is one theme padding — add the same on top so
|
|
// the button is not flush against the tabs.
|
|
return container.New(
|
|
layout.NewCustomPaddedLayout(theme.Padding(), 0, 0, 0),
|
|
container.NewHBox(v.stopAllButton, v.schedulerState, layout.NewSpacer()),
|
|
)
|
|
}
|
|
|
|
// applySchedulerState is the one place that draws the pause control and its
|
|
// status text from a pause value, so refresh can drive it from whatever the
|
|
// Service reports instead of only the tap handler mirroring its own toggle.
|
|
func (v *jobsView) applySchedulerState(paused bool) {
|
|
v.paused = paused
|
|
if paused {
|
|
v.schedulerState.SetText("Scheduler paused")
|
|
v.stopAllButton.SetText("Enable auto")
|
|
v.stopAllButton.SetIcon(theme.MediaPlayIcon())
|
|
return
|
|
}
|
|
v.schedulerState.SetText("Scheduler running")
|
|
v.stopAllButton.SetText("Disable auto")
|
|
v.stopAllButton.SetIcon(theme.MediaPauseIcon())
|
|
}
|