From 760700f6ace4bd2d995ad2d5ddbf8866dea2b1e9 Mon Sep 17 00:00:00 2001 From: mixeme Date: Sun, 21 Jun 2026 23:07:34 +0300 Subject: [PATCH] T4.2: Extract jobs_view.go (list + details + toolbar) Move the jobs list, details panel, folder filter, toolbar buttons, and all related helpers out of mainwindow.go into the new jobs_view.go. newMainView now calls newJobsView(w, svc) which returns a panel and a refresh closure; the subscriber and history wiring remain in mainwindow.go. showJobDialog lives in jobs_view.go as a temporary home until T4.3 extracts it to job_dialog.go. Co-Authored-By: Claude Sonnet 4.6 --- docs/REFACTORING.md | 2 +- src/ui/jobs_view.go | 486 +++++++++++++++++++++++++++++++++++++++++ src/ui/mainwindow.go | 503 +++---------------------------------------- 3 files changed, 518 insertions(+), 473 deletions(-) create mode 100644 src/ui/jobs_view.go diff --git a/docs/REFACTORING.md b/docs/REFACTORING.md index 509e654..b398014 100644 --- a/docs/REFACTORING.md +++ b/docs/REFACTORING.md @@ -269,7 +269,7 @@ Track progress here. Mark tasks complete as they land and pass review. ### Phase 4 — Carve up the GUI - [x] T4.1 — Rename `gui` → `ui`; split app.go into run.go + mainwindow.go _(required Fyne v2.5.3→v2.6.3 upgrade for `fyne.Do`)_ -- [ ] T4.2 — Extract `jobs_view.go` +- [x] T4.2 — Extract `jobs_view.go` - [ ] T4.3 — Extract `job_dialog.go` - [ ] T4.4 — Extract `history_view.go` - [ ] T4.5 — Extract `settings_view.go` diff --git a/src/ui/jobs_view.go b/src/ui/jobs_view.go new file mode 100644 index 0000000..6e0145c --- /dev/null +++ b/src/ui/jobs_view.go @@ -0,0 +1,486 @@ +package ui + +import ( + "fmt" + "strings" + + "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" +const minJobsSidebarWidth float32 = 480 + +// 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 + selectedFolder := allFolders + schedulerPaused := false + filteredJobs := filteredJobIndexes(jobs, selectedFolder) + + title := widget.NewLabelWithStyle(jobs[selected].Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + title.Wrapping = fyne.TextWrapBreak + folderLabel := newJobDetailLabel(jobs[selected].Folder) + scheduleLabel := newJobDetailLabel(jobs[selected].Schedule) + commandLabel := newJobDetailLabel(jobs[selected].Command) + argumentsLabel := newJobDetailLabel(jobs[selected].Arguments) + successExitCodesLabel := newJobDetailLabel(app.DisplaySuccessExitCodes(jobs[selected].SuccessExitCodes)) + runModeLabel := newJobDetailLabel(app.DisplayRunMode(jobs[selected])) + selectedRuntime := runtimeFor(selected) + lastRunLabel := newJobDetailLabel(selectedRuntime.LastRun) + nextRunLabel := newJobDetailLabel(selectedRuntime.NextRun) + stateLabel := newJobDetailLabel(selectedRuntime.LastState) + schedulerState := widget.NewLabel("Scheduler running") + commandOutput := widget.NewTextGrid() + commandOutput.SetText(selectedRuntime.Output) + commandOutputScroll := container.NewScroll(commandOutput) + // Command output can contain long lines and preserved whitespace. TextGrid is + // used instead of Label so stdout/stderr remains readable and does not vanish + // against the theme when it is placed inside a scroll container. + commandOutputScroll.SetMinSize(fyne.NewSize(520, 160)) + + selectedLogs := append([]event(nil), selectedRuntime.Logs...) + jobLogs := widget.NewList( + func() int { return len(selectedLogs) }, + func() fyne.CanvasObject { return widget.NewLabel("log") }, + func(id widget.ListItemID, item fyne.CanvasObject) { + item.(*widget.Label).SetText(app.EventText(selectedLogs[id])) + }, + ) + + 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. + title.SetText("No job selected") + folderLabel.SetText("") + scheduleLabel.SetText("") + commandLabel.SetText("") + argumentsLabel.SetText("") + successExitCodesLabel.SetText("") + runModeLabel.SetText("") + lastRunLabel.SetText("") + nextRunLabel.SetText("") + stateLabel.SetText("") + commandOutput.SetText("") + selectedLogs = nil + return + } + selected = index + current := jobs[selected] + rt := runtimeFor(selected) + title.SetText(current.Name) + folderLabel.SetText(app.DisplayFolder(current.Folder)) + scheduleLabel.SetText(current.Schedule) + commandLabel.SetText(current.Command) + argumentsLabel.SetText(app.DisplayArguments(current.Arguments)) + successExitCodesLabel.SetText(app.DisplaySuccessExitCodes(current.SuccessExitCodes)) + runModeLabel.SetText(app.DisplayRunMode(current)) + lastRunLabel.SetText(rt.LastRun) + nextRunLabel.SetText(rt.NextRun) + stateLabel.SetText(rt.LastState) + commandOutput.SetText(rt.Output) + selectedLogs = append(selectedLogs[:0], rt.Logs...) + } + + // 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) + jobLogs.Refresh() + if list != nil { + list.Refresh() + } + } + + list = widget.NewList( + func() int { return len(filteredJobs) }, + func() fyne.CanvasObject { + name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + meta := widget.NewLabel("schedule") + status := widget.NewLabel("status") + return container.NewVBox(name, meta, status) + }, + func(id widget.ListItemID, item fyne.CanvasObject) { + row := item.(*fyne.Container) + name := row.Objects[0].(*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)) + status.SetText(app.StatusText(current, runtimes[current.ID])) + }, + ) + list.OnSelected = func(id widget.ListItemID) { + if id < 0 || id >= len(filteredJobs) { + updateDetails(-1) + return + } + updateDetails(filteredJobs[id]) + } + list.Select(selected) + + folderSelect = widget.NewSelect(folderOptions(jobs), func(value string) { + if value == "" { + return + } + selectedFolder = value + filteredJobs = filteredJobIndexes(jobs, selectedFolder) + list.Refresh() + 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. + selected = -1 + updateDetails(-1) + return + } + selected = filteredJobs[0] + list.Select(0) + refreshView() + }) + folderSelect.SetSelected(selectedFolder) + + 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) { + // The Service assigns the ID, stores the job, records the "Created" + // activity, and emits events. The observer appends those to History; we + // only refresh the snapshot and move the selection to the new 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.Refresh() + 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) { + // The job keeps its ID, so the Service preserves the runtime (keyed by + // ID), reflects any enabled/disabled change, recomputes the next run, and + // emits the "Updated" activity the observer records. + saved.ID = jobs[selected].ID + if err := svc.UpdateJob(saved); err != nil { + dialog.ShowError(err, w) + return + } + syncFromService() + folderSelect.Options = folderOptions(jobs) + folderSelect.Refresh() + list.Refresh() + refreshView() + }) + }) + runButton := widget.NewButtonWithIcon("Run now", theme.MediaPlayIcon(), func() { + if selected < 0 || selected >= len(jobs) { + return + } + if schedulerPaused { + // The global pause is treated as an emergency stop for all execution, + // including manual "Run now", so the user has one reliable switch. + dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w) + return + } + // RunNow refuses an already-running job (it returns an error); the UI has + // always ignored that case silently, so the run simply does not start. + if err := svc.RunNow(jobs[selected].ID); err != nil { + return + } + list.Refresh() + refreshView() + }) + stopAllButton := widget.NewButtonWithIcon("Pause all", theme.MediaStopIcon(), nil) + stopAllButton.OnTapped = func() { + // SetGlobalPause flips the Service's pause flag, updates every job's + // next-run text, and emits the activity record the observer logs. Mirror the + // new state into the local flag and the controls; revert it 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("Resume all") + stopAllButton.SetIcon(theme.MediaPlayIcon()) + } else { + schedulerState.SetText("Scheduler running") + stopAllButton.SetText("Pause all") + stopAllButton.SetIcon(theme.MediaStopIcon()) + } + list.Refresh() + refreshView() + } + pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() { + if selected < 0 || selected >= len(jobs) { + return + } + // SetEnabled toggles the job, updates its runtime/next-run, and records the + // "Resumed"/"Paused" activity the observer logs. + current := jobs[selected] + if err := svc.SetEnabled(current.ID, !current.Enabled); err != nil { + dialog.ShowError(err, w) + return + } + syncFromService() + list.Refresh() + 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 + } + // The Service removes the job and its runtime, persists, and records the + // "Deleted" activity the observer logs; the UI re-reads the snapshot and + // fixes up the folder filter and selection. + 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] + } + list.Refresh() + 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()) + sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), folderSelect, toolbar) + sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, list) + + details := container.NewVBox( + title, + widget.NewSeparator(), + detailRow("Folder", folderLabel), + detailRow("Schedule", scheduleLabel), + detailRow("Command", commandLabel), + detailRow("Arguments", argumentsLabel), + detailRow("Success exit codes", successExitCodesLabel), + detailRow("Run mode", runModeLabel), + detailRow("Last run", lastRunLabel), + detailRow("Next run", nextRunLabel), + detailRow("State", stateLabel), + widget.NewSeparator(), + widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + commandOutputScroll, + widget.NewSeparator(), + widget.NewLabelWithStyle("Selected job activity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + jobLogs, + ) + + fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar) + panel := container.NewBorder(nil, nil, fixedSidebar, nil, container.NewPadded(details)) + return panel, refreshView +} + +// showJobDialog opens a create/edit form dialog. +// It will be moved to job_dialog.go in T4.3. +func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { + name := widget.NewEntry() + name.SetPlaceHolder("Nightly backup") + name.SetText(current.Name) + folderEntry := widget.NewEntry() + folderEntry.SetPlaceHolder("Maintenance") + folderEntry.SetText(current.Folder) + scheduleEntry := widget.NewEntry() + scheduleEntry.SetPlaceHolder("@every 1m") + scheduleEntry.SetText(current.Schedule) + commandEntry := widget.NewEntry() + commandEntry.SetPlaceHolder(`C:\Program Files\App\App.exe`) + commandEntry.SetText(current.Command) + argumentsEntry := widget.NewMultiLineEntry() + argumentsEntry.SetPlaceHolder(`D:\Local\Jobs\Auto.ffs_batch`) + argumentsEntry.SetText(current.Arguments) + successExitCodesEntry := widget.NewEntry() + successExitCodesEntry.SetPlaceHolder("0") + successExitCodesEntry.SetText(app.DisplaySuccessExitCodes(current.SuccessExitCodes)) + startOnly := widget.NewCheck("Start only, do not wait for exit", nil) + startOnly.SetChecked(current.StartOnly) + enabled := widget.NewCheck("Enabled", nil) + enabled.SetChecked(current.Enabled) + + form := dialog.NewForm( + title, + "Save", + "Cancel", + []*widget.FormItem{ + widget.NewFormItem("Name", name), + widget.NewFormItem("Folder", folderEntry), + widget.NewFormItem("Schedule", scheduleEntry), + widget.NewFormItem("Command", commandEntry), + widget.NewFormItem("Arguments", argumentsEntry), + widget.NewFormItem("Success exit codes", successExitCodesEntry), + widget.NewFormItem("", startOnly), + widget.NewFormItem("", enabled), + }, + func(saved bool) { + if !saved { + return + } + if strings.TrimSpace(name.Text) == "" || strings.TrimSpace(scheduleEntry.Text) == "" || strings.TrimSpace(commandEntry.Text) == "" { + // These three fields are the minimum executable job definition. + // Folder is optional because ungrouped jobs are a supported workflow. + dialog.ShowError(fmt.Errorf("name, schedule, and command are required"), w) + return + } + current.Name = strings.TrimSpace(name.Text) + current.Folder = strings.TrimSpace(folderEntry.Text) + current.Schedule = strings.TrimSpace(scheduleEntry.Text) + current.Command = strings.TrimSpace(commandEntry.Text) + current.Arguments = strings.TrimSpace(argumentsEntry.Text) + current.SuccessExitCodes = strings.TrimSpace(successExitCodesEntry.Text) + if current.SuccessExitCodes == "" { + current.SuccessExitCodes = "0" + } + current.StartOnly = startOnly.Checked + current.Enabled = enabled.Checked + // The dialog only edits durable configuration now. Runtime status is + // initialized (new jobs) or updated (edits) by the caller against the + // runtime map, keyed by job ID. + onSave(current) + }, + w, + ) + form.Resize(fyne.NewSize(640, 460)) + form.Show() +} + +func filteredJobIndexes(jobs []job, folder string) []int { + indexes := make([]int, 0, len(jobs)) + for index, current := range jobs { + if folder == allFolders || filterValue(current.Folder) == folder { + indexes = append(indexes, index) + } + } + return indexes +} + +func folderOptions(jobs []job) []string { + // "All" and "No folder" are always present so the filter UI is stable even + // before the user creates folders. + options := []string{allFolders, noFolder} + seen := map[string]bool{allFolders: true, noFolder: true} + for _, current := range jobs { + folder := strings.TrimSpace(current.Folder) + if folder == "" || seen[folder] { + continue + } + seen[folder] = true + options = append(options, folder) + } + return options +} + +func filterValue(folder string) string { + if strings.TrimSpace(folder) == "" { + return noFolder + } + return strings.TrimSpace(folder) +} + +func indexOfID(jobs []job, id int) int { + for index, current := range jobs { + if current.ID == id { + return index + } + } + return 0 +} + +func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject { + caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + caption.Wrapping = fyne.TextTruncate + return container.NewGridWithColumns(2, caption, value) +} + +func newJobDetailLabel(text string) *widget.Label { + label := widget.NewLabel(text) + // Job names, commands, and paths can be much wider than the details panel. + // Breaking long runs of text keeps Label.MinSize stable when the selection + // changes, so the right panel does not force the whole window to resize. + label.Wrapping = fyne.TextWrapBreak + return label +} diff --git a/src/ui/mainwindow.go b/src/ui/mainwindow.go index f086789..fc09166 100644 --- a/src/ui/mainwindow.go +++ b/src/ui/mainwindow.go @@ -1,7 +1,6 @@ package ui import ( - "fmt" "net/url" "runtime" "runtime/debug" @@ -20,14 +19,10 @@ import ( "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" -const minJobsSidebarWidth float32 = 480 const settingsLabelWidth float32 = 140 const settingsControlWidth float32 = 330 const settingsStatusWidth float32 = 280 @@ -49,59 +44,20 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { store.Paths.DesktopIcon = iconPath } - // app.Service is the single owner of job and runtime state. The UI keeps a - // read snapshot of the durable jobs plus a map of the live runtime pointers, - // both refreshed from the Service after every change. The Service — not the - // UI — mutates state and drives the scheduler, so there is no shared *[]Job. - 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 runtime := svc.Runtime(current.ID); runtime != nil { - runtimes[current.ID] = runtime - } + // Build the initial event history from the current runtime state. Jobs and + // runtimes are read here only for this one-time initialization; the jobs view + // owns all subsequent state via its own syncFromService closure. + initialJobs := svc.Jobs() + initialRuntimes := make(map[int]*domain.JobRuntime, len(initialJobs)) + for _, j := range initialJobs { + if rt := svc.Runtime(j.ID); rt != nil { + initialRuntimes[j.ID] = rt } } - syncFromService() - runtimeFor := func(index int) *domain.JobRuntime { - if index < 0 || index >= len(jobs) { - return &domain.JobRuntime{} - } - if runtime := runtimes[jobs[index].ID]; runtime != nil { - return runtime - } - return &domain.JobRuntime{} - } - events := collectActivity(jobs, runtimes) + events := collectActivity(initialJobs, initialRuntimes) + + jobsPanel, refreshJobsView := newJobsView(w, svc) - selected := 0 - selectedFolder := allFolders - schedulerPaused := false - filteredJobs := filteredJobIndexes(jobs, selectedFolder) - title := widget.NewLabelWithStyle(jobs[selected].Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - title.Wrapping = fyne.TextWrapBreak - folder := newJobDetailLabel(jobs[selected].Folder) - schedule := newJobDetailLabel(jobs[selected].Schedule) - command := newJobDetailLabel(jobs[selected].Command) - arguments := newJobDetailLabel(jobs[selected].Arguments) - successExitCodes := newJobDetailLabel(app.DisplaySuccessExitCodes(jobs[selected].SuccessExitCodes)) - runMode := newJobDetailLabel(app.DisplayRunMode(jobs[selected])) - selectedRuntime := runtimeFor(selected) - lastRun := newJobDetailLabel(selectedRuntime.LastRun) - nextRun := newJobDetailLabel(selectedRuntime.NextRun) - state := newJobDetailLabel(selectedRuntime.LastState) - schedulerState := widget.NewLabel("Scheduler running") - commandOutput := widget.NewTextGrid() - commandOutput.SetText(selectedRuntime.Output) - commandOutputScroll := container.NewScroll(commandOutput) - // Command output can contain long lines and preserved whitespace. TextGrid is - // used instead of Label so stdout/stderr remains readable and does not vanish - // against the theme when it is placed inside a scroll container. - commandOutputScroll.SetMinSize(fyne.NewSize(520, 160)) history := newHistoryView(&events) recordStartup := func(duration time.Duration, windowShown bool) { // Startup is recorded as an in-memory History event instead of being @@ -115,279 +71,12 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { events = append(events, newEvent(0, "Application", "Started", detail)) history.Refresh() } - selectedLogs := append([]event(nil), selectedRuntime.Logs...) - jobLogs := widget.NewList( - func() int { - return len(selectedLogs) - }, - func() fyne.CanvasObject { return widget.NewLabel("log") }, - func(id widget.ListItemID, item fyne.CanvasObject) { - item.(*widget.Label).SetText(app.EventText(selectedLogs[id])) - }, - ) - 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. - title.SetText("No job selected") - folder.SetText("") - schedule.SetText("") - command.SetText("") - arguments.SetText("") - successExitCodes.SetText("") - runMode.SetText("") - lastRun.SetText("") - nextRun.SetText("") - state.SetText("") - commandOutput.SetText("") - selectedLogs = nil - return - } - selected = index - current := jobs[selected] - runtime := runtimeFor(selected) - title.SetText(current.Name) - folder.SetText(app.DisplayFolder(current.Folder)) - schedule.SetText(current.Schedule) - command.SetText(current.Command) - arguments.SetText(app.DisplayArguments(current.Arguments)) - successExitCodes.SetText(app.DisplaySuccessExitCodes(current.SuccessExitCodes)) - runMode.SetText(app.DisplayRunMode(current)) - lastRun.SetText(runtime.LastRun) - nextRun.SetText(runtime.NextRun) - state.SetText(runtime.LastState) - commandOutput.SetText(runtime.Output) - selectedLogs = append(selectedLogs[:0], runtime.Logs...) - } refresh := func() { - // Several callbacks change jobs, filters, and event history. A single - // refresh closure re-reads the Service snapshot and keeps the different - // widgets synchronized after each change, without a heavier state layer. - syncFromService() - filteredJobs = filteredJobIndexes(jobs, selectedFolder) - updateDetails(selected) - jobLogs.Refresh() + refreshJobsView() history.Refresh() } - list := widget.NewList( - func() int { return len(filteredJobs) }, - func() fyne.CanvasObject { - name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - meta := widget.NewLabel("schedule") - status := widget.NewLabel("status") - return container.NewVBox(name, meta, status) - }, - func(id widget.ListItemID, item fyne.CanvasObject) { - row := item.(*fyne.Container) - name := row.Objects[0].(*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)) - status.SetText(app.StatusText(current, runtimes[current.ID])) - }, - ) - list.OnSelected = func(id widget.ListItemID) { - if id < 0 || id >= len(filteredJobs) { - updateDetails(-1) - return - } - updateDetails(filteredJobs[id]) - } - list.Select(selected) - - folderSelect := widget.NewSelect(folderOptions(jobs), func(value string) { - if value == "" { - return - } - selectedFolder = value - filteredJobs = filteredJobIndexes(jobs, selectedFolder) - list.Refresh() - 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. - selected = -1 - updateDetails(-1) - return - } - selected = filteredJobs[0] - list.Select(0) - refresh() - }) - folderSelect.SetSelected(selectedFolder) - - 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) { - // The Service assigns the ID, stores the job, records the "Created" - // activity, and emits events. The observer appends those to History; we - // only refresh the snapshot and move the selection to the new 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.Refresh() - list.Select(app.DisplayIndex(filteredJobs, selected)) - refresh() - }) - }) - editButton := widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() { - if selected < 0 || selected >= len(jobs) { - return - } - showJobDialog(w, "Edit job", jobs[selected], func(saved job) { - // The job keeps its ID, so the Service preserves the runtime (keyed by - // ID), reflects any enabled/disabled change, recomputes the next run, and - // emits the "Updated" activity the observer records. - saved.ID = jobs[selected].ID - if err := svc.UpdateJob(saved); err != nil { - dialog.ShowError(err, w) - return - } - syncFromService() - folderSelect.Options = folderOptions(jobs) - folderSelect.Refresh() - list.Refresh() - refresh() - }) - }) - runButton := widget.NewButtonWithIcon("Run now", theme.MediaPlayIcon(), func() { - if selected < 0 || selected >= len(jobs) { - return - } - if schedulerPaused { - // The global pause is treated as an emergency stop for all execution, - // including manual "Run now", so the user has one reliable switch. - dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w) - return - } - // RunNow refuses an already-running job (it returns an error); the UI has - // always ignored that case silently, so the run simply does not start. - if err := svc.RunNow(jobs[selected].ID); err != nil { - return - } - list.Refresh() - refresh() - }) - stopAllButton := widget.NewButtonWithIcon("Pause all", theme.MediaStopIcon(), nil) - stopAllButton.OnTapped = func() { - // SetGlobalPause flips the Service's pause flag, updates every job's - // next-run text, and emits the activity record the observer logs. Mirror the - // new state into the local flag and the controls; revert it 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("Resume all") - stopAllButton.SetIcon(theme.MediaPlayIcon()) - } else { - schedulerState.SetText("Scheduler running") - stopAllButton.SetText("Pause all") - stopAllButton.SetIcon(theme.MediaStopIcon()) - } - list.Refresh() - refresh() - } - pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() { - if selected < 0 || selected >= len(jobs) { - return - } - // SetEnabled toggles the job, updates its runtime/next-run, and records the - // "Resumed"/"Paused" activity the observer logs. - current := jobs[selected] - if err := svc.SetEnabled(current.ID, !current.Enabled); err != nil { - dialog.ShowError(err, w) - return - } - syncFromService() - list.Refresh() - refresh() - }) - 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 - } - // The Service removes the job and its runtime, persists, and records the - // "Deleted" activity the observer logs; the UI re-reads the snapshot and - // fixes up the folder filter and selection. - 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] - } - list.Refresh() - if selected >= 0 { - list.Select(app.DisplayIndex(filteredJobs, selected)) - } - refresh() - }, w) - }) - - toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer()) - globalControls := container.NewHBox(stopAllButton, schedulerState, layout.NewSpacer()) - sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), folderSelect, toolbar) - sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, list) - - details := container.NewVBox( - title, - widget.NewSeparator(), - detailRow("Folder", folder), - detailRow("Schedule", schedule), - detailRow("Command", command), - detailRow("Arguments", arguments), - detailRow("Success exit codes", successExitCodes), - detailRow("Run mode", runMode), - detailRow("Last run", lastRun), - detailRow("Next run", nextRun), - detailRow("State", state), - widget.NewSeparator(), - widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), - commandOutputScroll, - widget.NewSeparator(), - widget.NewLabelWithStyle("Selected job activity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), - jobLogs, - ) - // The Service announces every change through events. This single listener is // where the UI reacts: it appends run/activity records to History and redraws. // Events fire from two contexts — UI button handlers call into the Service @@ -402,15 +91,12 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { events = append(events, recorded.Record) } refresh() - list.Refresh() }) })) svc.Start(scheduler.NewRealClock()) - fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar) - jobsView := container.NewBorder(nil, nil, fixedSidebar, nil, container.NewPadded(details)) tabs := container.NewAppTabs( - container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsView), + container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsPanel), container.NewTabItemWithIcon("History", theme.HistoryIcon(), history), container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, svc)), ) @@ -423,8 +109,8 @@ type minWidthLayout struct { width float32 } -func (layout minWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { - width := layout.width +func (l minWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { + width := l.width var height float32 for _, object := range objects { if !object.Visible() { @@ -441,7 +127,7 @@ func (layout minWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { return fyne.NewSize(width, height) } -func (layout minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { +func (l minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { for _, object := range objects { if !object.Visible() { continue @@ -470,8 +156,8 @@ func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event { // At startup this is usually empty because jobs.yaml does not persist // runtime logs. The function still centralizes the merge for future // history loading from log metadata. - if runtime := runtimes[current.ID]; runtime != nil { - events = append(events, runtime.Logs...) + if rt := runtimes[current.ID]; rt != nil { + events = append(events, rt.Logs...) } } sort.SliceStable(events, func(left int, right int) bool { @@ -480,146 +166,6 @@ func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event { return events } -func indexOfID(jobs []job, id int) int { - for index, current := range jobs { - if current.ID == id { - return index - } - } - return 0 -} - -func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject { - caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - caption.Wrapping = fyne.TextTruncate - return container.NewGridWithColumns(2, caption, value) -} - -func newJobDetailLabel(text string) *widget.Label { - label := widget.NewLabel(text) - // Job names, commands, and paths can be much wider than the details panel. - // Breaking long runs of text keeps Label.MinSize stable when the selection - // changes, so the right panel does not force the whole window to resize. - label.Wrapping = fyne.TextWrapBreak - return label -} - -func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject { - caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - caption.Wrapping = fyne.TextTruncate - captionBox := container.New(minWidthLayout{width: settingsLabelWidth}, caption) - return container.NewBorder(nil, nil, captionBox, nil, value) -} - -func settingsRowWithStatus(label string, value fyne.CanvasObject, status fyne.CanvasObject) fyne.CanvasObject { - valueBox := container.New(minWidthLayout{width: settingsControlWidth}, value) - statusBox := container.New(minWidthLayout{width: settingsStatusWidth}, status) - return settingsRow(label, container.NewBorder(nil, nil, valueBox, nil, statusBox)) -} - -func filteredJobIndexes(jobs []job, folder string) []int { - indexes := make([]int, 0, len(jobs)) - for index, current := range jobs { - if folder == allFolders || filterValue(current.Folder) == folder { - indexes = append(indexes, index) - } - } - return indexes -} - -func folderOptions(jobs []job) []string { - // "All" and "No folder" are always present so the filter UI is stable even - // before the user creates folders. - options := []string{allFolders, noFolder} - seen := map[string]bool{allFolders: true, noFolder: true} - for _, current := range jobs { - folder := strings.TrimSpace(current.Folder) - if folder == "" || seen[folder] { - continue - } - seen[folder] = true - options = append(options, folder) - } - return options -} - -func filterValue(folder string) string { - if strings.TrimSpace(folder) == "" { - return noFolder - } - return strings.TrimSpace(folder) -} - -func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { - name := widget.NewEntry() - name.SetPlaceHolder("Nightly backup") - name.SetText(current.Name) - folder := widget.NewEntry() - folder.SetPlaceHolder("Maintenance") - folder.SetText(current.Folder) - schedule := widget.NewEntry() - schedule.SetPlaceHolder("@every 1m") - schedule.SetText(current.Schedule) - command := widget.NewEntry() - command.SetPlaceHolder(`C:\Program Files\App\App.exe`) - command.SetText(current.Command) - arguments := widget.NewMultiLineEntry() - arguments.SetPlaceHolder(`D:\Local\Jobs\Auto.ffs_batch`) - arguments.SetText(current.Arguments) - successExitCodes := widget.NewEntry() - successExitCodes.SetPlaceHolder("0") - successExitCodes.SetText(app.DisplaySuccessExitCodes(current.SuccessExitCodes)) - startOnly := widget.NewCheck("Start only, do not wait for exit", nil) - startOnly.SetChecked(current.StartOnly) - enabled := widget.NewCheck("Enabled", nil) - enabled.SetChecked(current.Enabled) - - form := dialog.NewForm( - title, - "Save", - "Cancel", - []*widget.FormItem{ - widget.NewFormItem("Name", name), - widget.NewFormItem("Folder", folder), - widget.NewFormItem("Schedule", schedule), - widget.NewFormItem("Command", command), - widget.NewFormItem("Arguments", arguments), - widget.NewFormItem("Success exit codes", successExitCodes), - widget.NewFormItem("", startOnly), - widget.NewFormItem("", enabled), - }, - func(saved bool) { - if !saved { - return - } - if strings.TrimSpace(name.Text) == "" || strings.TrimSpace(schedule.Text) == "" || strings.TrimSpace(command.Text) == "" { - // These three fields are the minimum executable job definition. - // Folder is optional because ungrouped jobs are a supported workflow. - dialog.ShowError(fmt.Errorf("name, schedule, and command are required"), w) - return - } - current.Name = strings.TrimSpace(name.Text) - current.Folder = strings.TrimSpace(folder.Text) - current.Schedule = strings.TrimSpace(schedule.Text) - current.Command = strings.TrimSpace(command.Text) - current.Arguments = strings.TrimSpace(arguments.Text) - current.SuccessExitCodes = strings.TrimSpace(successExitCodes.Text) - if current.SuccessExitCodes == "" { - current.SuccessExitCodes = "0" - } - current.StartOnly = startOnly.Checked - current.Enabled = enabled.Checked - // The dialog only edits durable configuration now. Runtime status is - // initialized (new jobs) or updated (edits) by the caller against the - // runtime map, keyed by job ID. - onSave(current) - }, - w, - ) - form.Resize(fyne.NewSize(640, 460)) - form.Show() -} - func newHistoryView(events *[]event) *fyne.Container { descending := false headerText := func(id widget.TableCellID) string { @@ -877,3 +423,16 @@ func chooseFolder(w fyne.Window, target *widget.Entry) { folderDialog.Resize(fyne.NewSize(900, 640)) folderDialog.Show() } + +func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject { + caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + caption.Wrapping = fyne.TextTruncate + captionBox := container.New(minWidthLayout{width: settingsLabelWidth}, caption) + return container.NewBorder(nil, nil, captionBox, nil, value) +} + +func settingsRowWithStatus(label string, value fyne.CanvasObject, status fyne.CanvasObject) fyne.CanvasObject { + valueBox := container.New(minWidthLayout{width: settingsControlWidth}, value) + statusBox := container.New(minWidthLayout{width: settingsStatusWidth}, status) + return settingsRow(label, container.NewBorder(nil, nil, valueBox, nil, statusBox)) +}