d0dc17a067
The jobs view pinned the sidebar in a Border left slot, so it rendered at its MinSize and nothing else: the user could never trade list width for detail width, and the details value column had no escape from the width the metadata grid left it. container.NewHSplit replaces the Border. Either pane can now be dragged, and container.Split clamps both at their own content minimum, so the divider degrades a pane rather than clipping it. The initial offset is derived, not a literal: SetOffset takes a ratio, but the sidebar's natural width is absolute (448 px), and a ratio that fits 1024 px would hand the sidebar 700 px at 1600 px. initialSplitOffset divides that width by the default window width less the divider, which opens the sidebar at exactly its content width with the rest to details. The divider position is not persisted; a restart reopens at the computed default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
356 lines
12 KiB
Go
356 lines
12 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())
|
|
globalControls := 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
|
|
}
|