cb37377346
The row sat flush against the tab bar while the VBox gap below was already one theme padding; matching that on top balances the spacing. Co-authored-by: Cursor <cursoragent@cursor.com>
362 lines
13 KiB
Go
362 lines
13 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"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
|
|
|
|
// 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()) {
|
|
jobs := svc.Jobs()
|
|
runtimes := make(map[int]*domain.JobRuntime, len(jobs))
|
|
syncFromService := func() {
|
|
jobs = svc.Jobs()
|
|
for id := range runtimes {
|
|
delete(runtimes, id)
|
|
}
|
|
for _, current := range jobs {
|
|
if rt := svc.Runtime(current.ID); rt != nil {
|
|
runtimes[current.ID] = rt
|
|
}
|
|
}
|
|
}
|
|
syncFromService()
|
|
runtimeFor := func(index int) *domain.JobRuntime {
|
|
if index < 0 || index >= len(jobs) {
|
|
return &domain.JobRuntime{}
|
|
}
|
|
if rt := runtimes[jobs[index].ID]; rt != nil {
|
|
return rt
|
|
}
|
|
return &domain.JobRuntime{}
|
|
}
|
|
|
|
selected := 0
|
|
if len(jobs) == 0 {
|
|
selected = -1
|
|
}
|
|
selectedFolder := allFolders
|
|
schedulerPaused := svc.Store().Config.Paused
|
|
listView := svc.Store().Config.JobListView
|
|
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
|
|
|
|
dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
|
|
if selected >= 0 {
|
|
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
|
|
} else {
|
|
dp.clear()
|
|
}
|
|
|
|
updateDetails := func(index int) {
|
|
if index < 0 || index >= len(jobs) {
|
|
// A folder filter can temporarily leave no selectable rows. Clearing
|
|
// the details panel avoids showing stale information for a hidden job.
|
|
dp.clear()
|
|
return
|
|
}
|
|
selected = index
|
|
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
|
|
}
|
|
|
|
// list and folderSelect are declared early so closures below can reference
|
|
// them before the widget.NewList / widget.NewSelect calls assign the values.
|
|
var list *widget.List
|
|
var folderSelect *widget.Select
|
|
|
|
refreshView := func() {
|
|
syncFromService()
|
|
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
|
updateDetails(selected)
|
|
dp.logs.Refresh()
|
|
if list != nil {
|
|
list.Refresh()
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
applyRowMode := func(inlineStatus, meta, status fyne.CanvasObject) {
|
|
if listView.IsCompact() {
|
|
inlineStatus.Show()
|
|
meta.Hide()
|
|
status.Hide()
|
|
return
|
|
}
|
|
inlineStatus.Hide()
|
|
meta.Show()
|
|
status.Show()
|
|
}
|
|
|
|
list = widget.NewList(
|
|
func() int { return len(filteredJobs) },
|
|
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")
|
|
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) {
|
|
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)
|
|
|
|
current := jobs[filteredJobs[id]]
|
|
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, runtimes[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.
|
|
applyRowMode(inlineStatus, meta, status)
|
|
},
|
|
)
|
|
list.OnSelected = func(id widget.ListItemID) {
|
|
if id < 0 || id >= len(filteredJobs) {
|
|
updateDetails(-1)
|
|
return
|
|
}
|
|
updateDetails(filteredJobs[id])
|
|
}
|
|
if len(filteredJobs) > 0 && selected >= 0 {
|
|
list.Select(app.DisplayIndex(filteredJobs, selected))
|
|
}
|
|
|
|
folderSelect = widget.NewSelect(folderOptions(jobs), func(value string) {
|
|
if value == "" {
|
|
return
|
|
}
|
|
selectedFolder = value
|
|
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
|
if len(filteredJobs) == 0 {
|
|
// The "No folder" filter is intentionally allowed to be empty. It is a
|
|
// real filter choice, not an error state, so the selection is cleared.
|
|
// This path returns without reaching refreshView(), so it is the one
|
|
// place the list has to be redrawn by hand.
|
|
selected = -1
|
|
updateDetails(-1)
|
|
list.Refresh()
|
|
return
|
|
}
|
|
selected = filteredJobs[0]
|
|
list.Select(0)
|
|
refreshView()
|
|
})
|
|
folderSelect.SetSelected(selectedFolder)
|
|
|
|
// viewToggleIcon pairs with viewToggleText: both name the action the button
|
|
// performs, not the state it is in, matching stopAllButton's convention.
|
|
viewToggleIcon := func(current domain.JobListView) fyne.Resource {
|
|
if current.IsCompact() {
|
|
return theme.ViewFullScreenIcon()
|
|
}
|
|
return theme.ListIcon()
|
|
}
|
|
viewButton := widget.NewButtonWithIcon(viewToggleText(listView), viewToggleIcon(listView), nil)
|
|
viewButton.OnTapped = func() {
|
|
next := nextJobListView(listView)
|
|
listView = next
|
|
if err := 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.
|
|
listView = nextJobListView(next)
|
|
dialog.ShowError(err, w)
|
|
return
|
|
}
|
|
viewButton.SetText(viewToggleText(listView))
|
|
viewButton.SetIcon(viewToggleIcon(listView))
|
|
// Refresh re-creates the row template, which is what recomputes the
|
|
// cached row height for the new mode. Selection is untouched.
|
|
list.Refresh()
|
|
}
|
|
|
|
addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
|
|
showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}, func(saved job) {
|
|
created, err := svc.CreateJob(saved)
|
|
if err != nil {
|
|
dialog.ShowError(err, w)
|
|
return
|
|
}
|
|
syncFromService()
|
|
folderSelect.Options = folderOptions(jobs)
|
|
folderSelect.Refresh()
|
|
targetFolder := filterValue(created.Folder)
|
|
if selectedFolder != allFolders && selectedFolder != targetFolder {
|
|
selectedFolder = targetFolder
|
|
folderSelect.SetSelected(targetFolder)
|
|
}
|
|
selected = indexOfID(jobs, created.ID)
|
|
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
|
list.Select(app.DisplayIndex(filteredJobs, selected))
|
|
refreshView()
|
|
})
|
|
})
|
|
editButton := widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
|
|
if selected < 0 || selected >= len(jobs) {
|
|
return
|
|
}
|
|
showJobDialog(w, "Edit job", jobs[selected], func(saved job) {
|
|
saved.ID = jobs[selected].ID
|
|
if err := svc.UpdateJob(saved); err != nil {
|
|
dialog.ShowError(err, w)
|
|
return
|
|
}
|
|
syncFromService()
|
|
folderSelect.Options = folderOptions(jobs)
|
|
folderSelect.Refresh()
|
|
refreshView()
|
|
})
|
|
})
|
|
runButton := widget.NewButtonWithIcon("Run now", theme.MediaPlayIcon(), func() {
|
|
if selected < 0 || selected >= len(jobs) {
|
|
return
|
|
}
|
|
// A manual run is allowed even while the scheduler is paused: pause only
|
|
// stops automatic scheduled runs, not the user's explicit "Run now".
|
|
if err := svc.RunNow(jobs[selected].ID); err != nil {
|
|
dialog.ShowError(err, w)
|
|
return
|
|
}
|
|
refreshView()
|
|
})
|
|
|
|
stopAllText, stopAllIcon := "Disable auto", theme.MediaPauseIcon()
|
|
if schedulerPaused {
|
|
stopAllText, stopAllIcon = "Enable auto", theme.MediaPlayIcon()
|
|
}
|
|
schedulerStateText := "Scheduler running"
|
|
if schedulerPaused {
|
|
schedulerStateText = "Scheduler paused"
|
|
}
|
|
schedulerState := widget.NewLabel(schedulerStateText)
|
|
stopAllButton := widget.NewButtonWithIcon(stopAllText, stopAllIcon, nil)
|
|
stopAllButton.OnTapped = func() {
|
|
// SetGlobalPause flips the pause flag, updates every job's next-run text,
|
|
// and emits the activity record the observer logs. Revert if the save fails.
|
|
schedulerPaused = !schedulerPaused
|
|
if err := svc.SetGlobalPause(schedulerPaused); err != nil {
|
|
schedulerPaused = !schedulerPaused
|
|
dialog.ShowError(err, w)
|
|
return
|
|
}
|
|
if schedulerPaused {
|
|
schedulerState.SetText("Scheduler paused")
|
|
stopAllButton.SetText("Enable auto")
|
|
stopAllButton.SetIcon(theme.MediaPlayIcon())
|
|
} else {
|
|
schedulerState.SetText("Scheduler running")
|
|
stopAllButton.SetText("Disable auto")
|
|
stopAllButton.SetIcon(theme.MediaPauseIcon())
|
|
}
|
|
refreshView()
|
|
}
|
|
pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
|
|
if selected < 0 || selected >= len(jobs) {
|
|
return
|
|
}
|
|
current := jobs[selected]
|
|
if err := svc.SetEnabled(current.ID, !current.Enabled); err != nil {
|
|
dialog.ShowError(err, w)
|
|
return
|
|
}
|
|
refreshView()
|
|
})
|
|
deleteButton := widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
|
|
if selected < 0 || selected >= len(jobs) {
|
|
return
|
|
}
|
|
deleted := jobs[selected]
|
|
// Deletion is confirmed because jobs can represent real system actions.
|
|
// There is no undo yet, so accidental removal should require one more click.
|
|
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
|
|
if !confirm {
|
|
return
|
|
}
|
|
if err := svc.DeleteJob(deleted.ID); err != nil {
|
|
dialog.ShowError(err, w)
|
|
return
|
|
}
|
|
syncFromService()
|
|
folderSelect.Options = folderOptions(jobs)
|
|
folderSelect.Refresh()
|
|
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
|
if len(filteredJobs) == 0 && selectedFolder != allFolders {
|
|
selectedFolder = allFolders
|
|
folderSelect.SetSelected(allFolders)
|
|
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
|
}
|
|
if len(filteredJobs) == 0 {
|
|
selected = -1
|
|
} else {
|
|
selected = filteredJobs[0]
|
|
}
|
|
if selected >= 0 {
|
|
list.Select(app.DisplayIndex(filteredJobs, selected))
|
|
}
|
|
refreshView()
|
|
}, w)
|
|
})
|
|
|
|
toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer())
|
|
// 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.
|
|
globalControls := container.New(
|
|
layout.NewCustomPaddedLayout(theme.Padding(), 0, 0, 0),
|
|
container.NewHBox(stopAllButton, schedulerState, layout.NewSpacer()),
|
|
)
|
|
// 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, viewButton, folderSelect)
|
|
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), filterRow, toolbar)
|
|
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, 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(dp.container()))
|
|
panel.SetOffset(initialSplitOffset(sidebar.MinSize().Width))
|
|
return panel, refreshView
|
|
}
|