33a246cd13
DefaultTimeoutSeconds now means "no timeout" when 0/empty, and that is the new default, rather than an invalid config forcing a positive value. runner.RunJob avoids context.WithTimeout with a zero duration (which would expire immediately) and instead runs on a plain cancelable context when no timeout is configured. Per-job TimeoutSeconds inherit semantics are unchanged.
377 lines
16 KiB
Go
377 lines
16 KiB
Go
package ui
|
|
|
|
import (
|
|
"net/url"
|
|
"runtime"
|
|
"runtime/debug"
|
|
"strconv"
|
|
"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/theme"
|
|
"fyne.io/fyne/v2/widget"
|
|
)
|
|
|
|
// settingsLabelWidth is wide enough to show the longest caption ("Default
|
|
// overlap policy") in full; the captions truncate, so a narrower width would
|
|
// clip it. All rows share this width so their value controls stay aligned.
|
|
const settingsLabelWidth float32 = 180
|
|
const settingsControlWidth float32 = 330
|
|
const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry"
|
|
|
|
// settingsRowSpacing is the (negative) gap between rows of the settings form,
|
|
// overlapping each control's built-in vertical padding so the column is tighter
|
|
// and more compact, matching the condensed job details panel.
|
|
const settingsRowSpacing float32 = -6
|
|
|
|
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|
store := svc.Store()
|
|
// updateSaveState compares the form to the saved config and enables Save only
|
|
// when something differs. It is defined below (once Save and every field
|
|
// exist) but declared here so the field change handlers can reference it.
|
|
var updateSaveState func()
|
|
// loadFields populates every form control from the given config. It backs
|
|
// both the initial load and the Cancel/Defaults buttons below.
|
|
var loadFields func(domain.Config)
|
|
startOnLogin := widget.NewCheck("Start on login", nil)
|
|
startOnLogin.SetChecked(store.Config.StartOnLogin)
|
|
autostartStatus := widget.NewLabel("")
|
|
refreshAutostartStatus := func() {
|
|
ok, message := svc.AutostartStatus()
|
|
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")
|
|
} else {
|
|
refreshAutostartStatus()
|
|
}
|
|
updateSaveState()
|
|
}
|
|
refreshAutostartStatus()
|
|
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
|
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
|
|
minimizeToTray.OnChanged = func(bool) { updateSaveState() }
|
|
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
|
notifications.SetChecked(store.Config.NotifyOnFailure)
|
|
notifications.OnChanged = func(bool) { updateSaveState() }
|
|
themeSelect := widget.NewSelect([]string{themeLabelDefault, themeLabelGoSentry}, nil)
|
|
themeSelect.SetSelected(themeLabel(store.Config.Theme))
|
|
// Preview the theme the moment it is picked so the choice is visible before
|
|
// saving; Save persists it. Reverting the selection reverts the preview, and
|
|
// closing without saving falls back to the stored theme on next launch.
|
|
themeSelect.OnChanged = func(string) {
|
|
applyTheme(fyne.CurrentApp(), themeFromLabel(themeSelect.Selected))
|
|
updateSaveState()
|
|
}
|
|
executionModeSelect := widget.NewSelect(
|
|
[]string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)},
|
|
nil,
|
|
)
|
|
executionModeSelect.SetSelected(string(store.Config.ExecutionMode))
|
|
executionModeSelect.OnChanged = func(string) { updateSaveState() }
|
|
overlapPolicySelect := widget.NewSelect(
|
|
[]string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
|
|
nil,
|
|
)
|
|
overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy))
|
|
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
|
|
defaultTimeout := widget.NewEntry()
|
|
defaultTimeout.SetPlaceHolder("0 = no timeout")
|
|
defaultTimeout.SetText(strconv.Itoa(store.Config.DefaultTimeoutSeconds))
|
|
defaultTimeout.OnChanged = func(string) { updateSaveState() }
|
|
jobsDir := widget.NewEntry()
|
|
jobsDir.SetText(store.Config.JobsDir)
|
|
jobsDir.OnChanged = func(string) { updateSaveState() }
|
|
jobsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
|
chooseFolder(w, jobsDir)
|
|
})
|
|
logsDir := widget.NewEntry()
|
|
logsDir.SetText(store.Config.LogsDir)
|
|
logsDir.OnChanged = func(string) { updateSaveState() }
|
|
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
|
chooseFolder(w, logsDir)
|
|
})
|
|
maxLogFiles := widget.NewEntry()
|
|
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
|
|
maxLogFiles.OnChanged = func(string) { updateSaveState() }
|
|
maxLogAgeDays := widget.NewEntry()
|
|
maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays))
|
|
maxLogAgeDays.OnChanged = func(string) { updateSaveState() }
|
|
// Autostart status sits on its own row beneath the checkbox (rather than
|
|
// beside it) so the Application section fits within a half-width column.
|
|
// Truncating keeps a long status message from forcing the column wider.
|
|
autostartStatus.Wrapping = fyne.TextTruncate
|
|
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
|
|
}
|
|
timeout, err := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text))
|
|
if err != nil || timeout < 0 {
|
|
settingsStatus.SetText("Default timeout must not be negative (0 = no timeout)")
|
|
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
|
|
config.ExecutionMode = domain.ExecutionMode(executionModeSelect.Selected)
|
|
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
|
|
config.DefaultTimeoutSeconds = timeout
|
|
config.Theme = themeFromLabel(themeSelect.Selected)
|
|
if err := svc.UpdateSettings(config); err != nil {
|
|
settingsStatus.SetText("Save failed: " + err.Error())
|
|
return
|
|
}
|
|
if err := svc.ApplyAutostart(); err != nil {
|
|
refreshAutostartStatus()
|
|
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
|
|
return
|
|
}
|
|
refreshAutostartStatus()
|
|
settingsStatus.SetText("Saved")
|
|
// The form now matches the persisted config, so disable Save again.
|
|
updateSaveState()
|
|
})
|
|
|
|
// Save stays disabled until a field differs from the saved config, so the
|
|
// button only invites a click when there is something to persist. The numeric
|
|
// fields compare against their canonical string form; any unparsable text
|
|
// counts as a change so the user can click Save and see the validation error.
|
|
updateSaveState = func() {
|
|
c := store.Config
|
|
changed := startOnLogin.Checked != c.StartOnLogin ||
|
|
minimizeToTray.Checked != c.KeepRunningInTray ||
|
|
notifications.Checked != c.NotifyOnFailure ||
|
|
executionModeSelect.Selected != string(c.ExecutionMode) ||
|
|
overlapPolicySelect.Selected != string(c.OverlapPolicy) ||
|
|
strings.TrimSpace(defaultTimeout.Text) != strconv.Itoa(c.DefaultTimeoutSeconds) ||
|
|
strings.TrimSpace(jobsDir.Text) != c.JobsDir ||
|
|
strings.TrimSpace(logsDir.Text) != c.LogsDir ||
|
|
strings.TrimSpace(maxLogFiles.Text) != strconv.Itoa(c.MaxLogFiles) ||
|
|
strings.TrimSpace(maxLogAgeDays.Text) != strconv.Itoa(c.MaxLogAgeDays) ||
|
|
themeSelect.Selected != themeLabel(c.Theme)
|
|
if changed {
|
|
saveSettings.Enable()
|
|
} else {
|
|
saveSettings.Disable()
|
|
}
|
|
}
|
|
updateSaveState()
|
|
|
|
// loadFields populates every form control from a config without saving it,
|
|
// backing both the Cancel button (reload the saved config, discarding edits)
|
|
// and the Defaults button (load the built-in defaults for review before
|
|
// Save is clicked).
|
|
loadFields = func(c domain.Config) {
|
|
startOnLogin.SetChecked(c.StartOnLogin)
|
|
minimizeToTray.SetChecked(c.KeepRunningInTray)
|
|
notifications.SetChecked(c.NotifyOnFailure)
|
|
themeSelect.SetSelected(themeLabel(c.Theme))
|
|
applyTheme(fyne.CurrentApp(), themeFromLabel(themeSelect.Selected))
|
|
executionModeSelect.SetSelected(string(c.ExecutionMode))
|
|
overlapPolicySelect.SetSelected(string(c.OverlapPolicy))
|
|
defaultTimeout.SetText(strconv.Itoa(c.DefaultTimeoutSeconds))
|
|
jobsDir.SetText(c.JobsDir)
|
|
logsDir.SetText(c.LogsDir)
|
|
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
|
|
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
|
|
if startOnLogin.Checked != store.Config.StartOnLogin {
|
|
autostartStatus.SetText("Pending: save settings to apply")
|
|
} else {
|
|
refreshAutostartStatus()
|
|
}
|
|
settingsStatus.SetText("")
|
|
updateSaveState()
|
|
}
|
|
cancelSettings := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
|
|
loadFields(store.Config)
|
|
})
|
|
restoreDefaults := widget.NewButtonWithIcon("Defaults", theme.MediaReplayIcon(), func() {
|
|
loadFields(domain.DefaultConfig())
|
|
})
|
|
|
|
// The form is split into two columns so a wide window uses its horizontal
|
|
// space instead of stretching into one tall strip. The left column holds the
|
|
// toggles (Application, Queue); the right holds the editable Storage fields and
|
|
// the read-only About block. Save spans the full width below both columns.
|
|
leftColumn := container.NewVBox(
|
|
settingsSection("Application",
|
|
settingsRow("Autostart", container.New(minWidthLayout{width: settingsControlWidth}, startOnLogin)),
|
|
// Autostart status sits on its own row, aligned under the checkbox via an
|
|
// empty caption, so the Application section fits in a half-width column.
|
|
settingsRow("", autostartStatus),
|
|
settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)),
|
|
settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)),
|
|
settingsRow("Theme", container.New(minWidthLayout{width: settingsControlWidth}, themeSelect)),
|
|
),
|
|
widget.NewSeparator(),
|
|
// Queue holds the execution mode and overlap policy comboboxes. Like
|
|
// Storage, it uses the default VBox spacing (not the condensed section
|
|
// layout) so the comboboxes keep a visible gap between them.
|
|
container.NewVBox(
|
|
widget.NewLabelWithStyle("Queue", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
settingsRow("Execution mode", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)),
|
|
settingsRow("Default overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)),
|
|
settingsRow("Default timeout (s)", container.New(minWidthLayout{width: settingsControlWidth}, defaultTimeout)),
|
|
),
|
|
)
|
|
rightColumn := container.NewVBox(
|
|
// Storage holds editable entry fields. It uses the default VBox spacing
|
|
// (not the condensed section layout) so the entry boxes keep a visible
|
|
// gap between them instead of merging into one block.
|
|
container.NewVBox(
|
|
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
settingsRow("Config JSON", 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),
|
|
),
|
|
widget.NewSeparator(),
|
|
settingsSection("About",
|
|
settingsRow("GoSentry", widget.NewLabel(app.Version)),
|
|
settingsRow("Go", widget.NewLabel(runtime.Version())),
|
|
settingsRow("Fyne", widget.NewLabel(fyneVersion())),
|
|
settingsRow("Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))),
|
|
),
|
|
)
|
|
|
|
// The two columns sit in a top-aligned grid; Save spans the full width below.
|
|
// Wrapping the whole thing in a vertical scroll keeps its minimum height small
|
|
// so it does not dictate the window's minimum height (AppTabs sizes to the
|
|
// tallest tab) and it scrolls on short 720p screens.
|
|
return container.NewVScroll(container.NewPadded(container.NewVBox(
|
|
container.NewGridWithColumns(2, leftColumn, rightColumn),
|
|
widget.NewSeparator(),
|
|
// Save/Cancel/Defaults share one row with the status so an empty status
|
|
// (the common case) does not leave a blank line above the separator. The
|
|
// status appears beside the buttons once a save reports a result.
|
|
container.NewHBox(saveSettings, cancelSettings, restoreDefaults, settingsStatus),
|
|
)))
|
|
}
|
|
|
|
// settingsSection groups a bold header above its rows using the tight
|
|
// settingsRowSpacing so a block of label rows reads as one compact unit. The
|
|
// caller keeps separators and entry-heavy sections in the surrounding VBox so
|
|
// they retain the theme's normal spacing.
|
|
func settingsSection(title string, rows ...fyne.CanvasObject) fyne.CanvasObject {
|
|
children := make([]fyne.CanvasObject, 0, len(rows)+1)
|
|
children = append(children, widget.NewLabelWithStyle(title, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}))
|
|
children = append(children, rows...)
|
|
return container.New(compactVBoxLayout{spacing: settingsRowSpacing}, children...)
|
|
}
|
|
|
|
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 chooseFile(w fyne.Window, target *widget.Entry) {
|
|
fileDialog := dialog.NewFileOpen(func(uri fyne.URIReadCloser, err error) {
|
|
if err != nil || uri == nil {
|
|
return
|
|
}
|
|
target.SetText(uri.URI().Path())
|
|
}, w)
|
|
fileDialog.Resize(fyne.NewSize(900, 640))
|
|
fileDialog.Show()
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
// Theme dropdown labels. These are the human-facing captions; themeLabel and
|
|
// themeFromLabel translate between them and the stored domain.Theme values so the
|
|
// select never leaks the on-disk "default"/"gosentry" strings to the user.
|
|
const (
|
|
themeLabelDefault = "Default"
|
|
themeLabelGoSentry = "GoSentry"
|
|
)
|
|
|
|
func themeLabel(choice domain.Theme) string {
|
|
if choice == domain.ThemeGoSentry {
|
|
return themeLabelGoSentry
|
|
}
|
|
return themeLabelDefault
|
|
}
|
|
|
|
func themeFromLabel(label string) domain.Theme {
|
|
if label == themeLabelGoSentry {
|
|
return domain.ThemeGoSentry
|
|
}
|
|
return domain.ThemeDefault
|
|
}
|
|
|
|
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)
|
|
}
|