T4.1: Rename gui->ui, split lifecycle into run.go + mainwindow.go

Carve src/gui/app.go into the new src/ui package:
- run.go: process lifecycle (single instance, app/window, tray, startup
  timing).
- mainwindow.go: view assembly + the app.Service event listener.

Route every widget update driven by Service events through fyne.Do so the
run goroutine (executeRun) no longer mutates Fyne widgets directly. Also
wrap serveSingleInstance's Show/RequestFocus, which runs on the Accept
goroutine. (Resolves refactoring problem #4.)

fyne.Do/DoAndWait only exist in Fyne v2.6+, so upgrade fyne.io/fyne/v2
v2.5.3 -> v2.6.3. Mark the tray Quit item IsQuit so Fyne's
addMissingQuitForMenu reuses it instead of appending a second, localized
Quit now that v2.6 ships Russian translations.

go build / go vet / go test -race all clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-06-19 20:55:23 +03:00
parent c5e0ef9617
commit f82eca8777
6 changed files with 245 additions and 786 deletions
+879
View File
@@ -0,0 +1,879 @@
package ui
import (
"fmt"
"net/url"
"runtime"
"runtime/debug"
"sort"
"strconv"
"strings"
"time"
"gitea.mixdep.ru/mix/gosentry/assets"
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/platform/autostart"
"gitea.mixdep.ru/mix/gosentry/src/platform/desktop"
"gitea.mixdep.ru/mix/gosentry/src/scheduler"
"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
const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry"
// The UI package aliases domain types to keep widget callbacks short. The actual
// durable model still lives in src/domain, so UI code does not define a second
// copy of the scheduler data.
type job = domain.Job
type event = domain.RunRecord
func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
svc, err := app.Open()
if err != nil {
return container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())), func(time.Duration, bool) {}
}
store := svc.Store()
if iconPath, err := desktop.InstallDesktopIntegration(appID, store.Paths.ExecutablePath, assets.IconBytes()); err == nil {
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
}
}
}
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)
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
// persisted into jobs.yaml. It is session diagnostics, not durable job
// state, and keeping it ephemeral avoids polluting the human-editable YAML
// file with process-lifetime bookkeeping.
detail := "Window shown in " + duration.Round(time.Millisecond).String()
if !windowShown {
detail = "Started in tray in " + duration.Round(time.Millisecond).String()
}
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()
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
// synchronously (main goroutine), while scheduled and manual run completions
// emit from the run goroutine. fyne.Do marshals all of this widget work onto
// the main thread in both cases, so the engine never mutates Fyne state off
// the UI thread. This is the sole place events touch widgets. (Resolves #4.)
svc.Subscribe(app.ObserverFunc(func(ev app.Event) {
recorded, isRecorded := ev.(app.RunRecorded)
fyne.Do(func() {
if isRecorded {
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("History", theme.HistoryIcon(), history),
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, svc)),
)
tabs.SetTabLocation(container.TabLocationTop)
return tabs, recordStartup
}
type minWidthLayout struct {
width float32
}
func (layout minWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
width := layout.width
var height float32
for _, object := range objects {
if !object.Visible() {
continue
}
min := object.MinSize()
if min.Width > width {
width = min.Width
}
if min.Height > height {
height = min.Height
}
}
return fyne.NewSize(width, height)
}
func (layout minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
for _, object := range objects {
if !object.Visible() {
continue
}
object.Move(fyne.NewPos(0, 0))
object.Resize(size)
}
}
func newEvent(jobID int, jobName string, state string, detail string) event {
// Use the same timestamp shape as command run records so the History tab is
// visually consistent across startup, UI actions, manual runs, and schedules.
return event{
Time: time.Now().Format("2006-01-02 15:04:05"),
JobID: jobID,
JobName: jobName,
Trigger: "UI",
State: state,
Detail: detail,
}
}
func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
var events []event
for _, current := range jobs {
// 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...)
}
}
sort.SliceStable(events, func(left int, right int) bool {
return events[left].Time < events[right].Time
})
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 {
headers := []string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
if id.Row < 0 && id.Col == 0 {
if descending {
return "Time desc"
}
return "Time asc"
}
if id.Row < 0 && id.Col >= 0 && id.Col < len(headers) {
return headers[id.Col]
}
return ""
}
sortedEvents := func() []event {
result := append([]event(nil), (*events)...)
sort.SliceStable(result, func(left int, right int) bool {
if descending {
return result[left].Time > result[right].Time
}
return result[left].Time < result[right].Time
})
return result
}
table := widget.NewTable(
func() (int, int) {
return len(*events), 6
},
func() fyne.CanvasObject {
label := widget.NewLabel("")
label.Wrapping = fyne.TextTruncate
return label
},
func(id widget.TableCellID, item fyne.CanvasObject) {
label := item.(*widget.Label)
label.SetText(historyCellText(id, sortedEvents()))
label.TextStyle = fyne.TextStyle{}
label.Refresh()
},
)
table.ShowHeaderRow = true
table.CreateHeader = func() fyne.CanvasObject {
label := widget.NewLabel("")
label.Wrapping = fyne.TextTruncate
return label
}
table.UpdateHeader = func(id widget.TableCellID, item fyne.CanvasObject) {
label := item.(*widget.Label)
label.SetText(headerText(id))
label.TextStyle = fyne.TextStyle{Bold: true}
label.Refresh()
}
table.OnSelected = func(id widget.TableCellID) {
if id.Row < 0 && id.Col == 0 {
descending = !descending
table.Refresh()
}
table.Unselect(id)
}
table.SetColumnWidth(0, 150)
table.SetColumnWidth(1, 90)
table.SetColumnWidth(2, 170)
table.SetColumnWidth(3, 90)
table.SetColumnWidth(4, 260)
table.SetColumnWidth(5, 240)
return container.NewPadded(table)
}
func historyCellText(id widget.TableCellID, events []event) string {
if id.Row < 0 || id.Row >= len(events) {
return ""
}
current := events[id.Row]
trigger := current.Trigger
if trigger == "" {
trigger = "Unknown"
}
switch id.Col {
case 0:
return current.Time
case 1:
return trigger
case 2:
return current.JobName
case 3:
return current.State
case 4:
return current.Detail
case 5:
return logFileName(current.LogFile)
default:
return ""
}
}
func logFileName(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
path = strings.ReplaceAll(path, "\\", "/")
if slash := strings.LastIndex(path, "/"); slash >= 0 {
return path[slash+1:]
}
return path
}
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
store := svc.Store()
startOnLogin := widget.NewCheck("Start on login", nil)
startOnLogin.SetChecked(store.Config.StartOnLogin)
autostartStatus := widget.NewLabel("")
refreshAutostartStatus := func() {
ok, message := autostart.AutostartStatus(store.Config.StartOnLogin, store.Paths.ExecutablePath)
if ok {
autostartStatus.SetText("OK: " + message)
return
}
autostartStatus.SetText("Problem: " + message)
}
startOnLogin.OnChanged = func(bool) {
if startOnLogin.Checked != store.Config.StartOnLogin {
autostartStatus.SetText("Pending: save settings to apply")
return
}
refreshAutostartStatus()
}
refreshAutostartStatus()
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
notifications.SetChecked(store.Config.NotifyOnFailure)
jobsDir := widget.NewEntry()
jobsDir.SetText(store.Config.JobsDir)
jobsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFolder(w, jobsDir)
})
logsDir := widget.NewEntry()
logsDir.SetText(store.Config.LogsDir)
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFolder(w, logsDir)
})
maxLogFiles := widget.NewEntry()
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
maxLogAgeDays := widget.NewEntry()
maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays))
settingsStatus := widget.NewLabel("")
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
files, err := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
if err != nil || files <= 0 {
settingsStatus.SetText("Max log files must be a positive number")
return
}
days, err := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
if err != nil || days <= 0 {
settingsStatus.SetText("Max log age days must be a positive number")
return
}
if strings.TrimSpace(jobsDir.Text) == "" {
settingsStatus.SetText("Jobs directory is required")
return
}
if strings.TrimSpace(logsDir.Text) == "" {
settingsStatus.SetText("Logs directory is required")
return
}
// Build the new config from the form and hand it to the Service, which
// validates it, persists config and jobs to the (possibly new) directory,
// and runs log cleanup so tightened retention limits take effect at once.
config := store.Config
config.JobsDir = strings.TrimSpace(jobsDir.Text)
config.LogsDir = strings.TrimSpace(logsDir.Text)
config.MaxLogFiles = files
config.MaxLogAgeDays = days
config.StartOnLogin = startOnLogin.Checked
config.KeepRunningInTray = minimizeToTray.Checked
config.NotifyOnFailure = notifications.Checked
if err := svc.UpdateSettings(config); err != nil {
settingsStatus.SetText("Save failed: " + err.Error())
return
}
// Autostart is platform integration the Service leaves to the caller (until
// T5.2 introduces an injectable autostart.Manager), so apply it here.
if err := autostart.SetAutostart(store.Config.StartOnLogin, store.Paths.ExecutablePath, store.Paths.DesktopIcon); err != nil {
refreshAutostartStatus()
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
return
}
refreshAutostartStatus()
settingsStatus.SetText("Saved")
})
return container.NewPadded(container.NewVBox(
widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
settingsRowWithStatus("Autostart", startOnLogin, autostartStatus),
settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)),
settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)),
widget.NewSeparator(),
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
settingsRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)),
settingsRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)),
settingsRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)),
settingsRow("Max log files", maxLogFiles),
settingsRow("Max log age days", maxLogAgeDays),
saveSettings,
settingsStatus,
widget.NewSeparator(),
widget.NewLabelWithStyle("About", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
settingsRow("GoSentry", widget.NewLabel(app.Version)),
settingsRow("Go", widget.NewLabel(runtime.Version())),
settingsRow("Fyne", widget.NewLabel(fyneVersion())),
settingsRow("Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))),
))
}
func fyneVersion() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return "unknown"
}
for _, dependency := range info.Deps {
if dependency.Path == "fyne.io/fyne/v2" {
if dependency.Replace != nil && dependency.Replace.Version != "" {
return dependency.Replace.Version
}
if dependency.Version != "" {
return dependency.Version
}
return "local"
}
}
return "unknown"
}
func mustParseURL(raw string) *url.URL {
parsed, err := url.Parse(raw)
if err != nil {
return &url.URL{}
}
return parsed
}
func chooseFolder(w fyne.Window, target *widget.Entry) {
folderDialog := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil || uri == nil {
return
}
target.SetText(uri.Path())
}, w)
// The default folder picker can be cramped on Windows. A larger size makes
// long paths readable and avoids forcing the user to resize it every time.
folderDialog.Resize(fyne.NewSize(900, 640))
folderDialog.Show()
}