refactor: split source files that exceeded the ~300-line ceiling
Mechanical moves only — operations, store, history_view, and settings_view are now split along their existing seams so every file stays within the 250+20% guideline. Document the new layout in ARCHITECTURE.md and close the ROADMAP item. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -24,87 +24,6 @@ func newEvent(jobID int, jobName string, state string, detail string) event {
|
||||
}
|
||||
}
|
||||
|
||||
// textWidth measures how wide s renders at the theme's current body text size.
|
||||
func textWidth(s string) float32 {
|
||||
return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width
|
||||
}
|
||||
|
||||
// cellPadding is the horizontal space a table cell reserves around its text.
|
||||
// It replaces a hand-tuned pixel constant with the theme's own inner padding
|
||||
// doubled (one side each), so it follows text size and DPI.
|
||||
func cellPadding() float32 { return 2 * theme.InnerPadding() }
|
||||
|
||||
// textColumnMinWidth/textColumnMaxWidth bound every content-measured History
|
||||
// column: the minimum keeps a column readable when its values are short or
|
||||
// absent, the maximum stops one very long value from dominating the table
|
||||
// (the table still scrolls horizontally past it). Expressed as measured text
|
||||
// rather than raw pixels so both follow the theme instead of drifting from it.
|
||||
func textColumnMinWidth() float32 { return textWidth(strings.Repeat("0", 10)) + cellPadding() }
|
||||
func textColumnMaxWidth() float32 { return textWidth(strings.Repeat("0", 30)) + cellPadding() }
|
||||
|
||||
// textColumnWidth measures the widest of samples so a table column can be
|
||||
// sized to fit its content, clamped to [min, max]. Fyne tables do not
|
||||
// auto-size columns, so without this a fixed width clips values like
|
||||
// "20260601-100000_SomeJobName.log" in the Log column.
|
||||
func textColumnWidth(samples []string, min, max float32) float32 {
|
||||
width := min
|
||||
for _, text := range samples {
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if w := textWidth(text) + cellPadding(); w > width {
|
||||
width = w
|
||||
}
|
||||
}
|
||||
if width > max {
|
||||
width = max
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
// historyTriggerSamples is the closed set of Trigger values History ever
|
||||
// shows (see newEvent and app.operations.go/app.run.go, which produce "UI",
|
||||
// "Manual" and "Schedule"; historyCellText falls back to "Unknown"). Add a new
|
||||
// trigger here too if one is introduced there, or the column may clip it.
|
||||
var historyTriggerSamples = []string{"Schedule", "Manual", "UI", "Unknown"}
|
||||
|
||||
// historyStateSamples is the closed set of State values History ever shows:
|
||||
// "OK" and "Failed" come from runner.RunJob (runStateDetail/startJobOnly);
|
||||
// "Started", "Error" and "Jobs loaded" are recorded directly in mainwindow.go.
|
||||
// Add a new state here too if one is introduced in either place.
|
||||
var historyStateSamples = []string{"OK", "Failed", "Started", "Error", "Jobs loaded"}
|
||||
|
||||
// historyTimeSample is the rendered form of the timestamp layout every event
|
||||
// uses (see newEvent), so the Time column needs no content scan: its width is
|
||||
// fixed by the format string.
|
||||
const historyTimeSample = "2026-01-02 15:04:05"
|
||||
|
||||
// historyColumnWidths computes every column's width from the current sorted
|
||||
// rows. Time, Trigger and State are fixed-shape or closed-set columns; Job,
|
||||
// Detail and Log are free text, so their width tracks the values actually
|
||||
// present, bounded the same way the Log column always was.
|
||||
func historyColumnWidths(rows []event) [6]float32 {
|
||||
var content [3][]string
|
||||
for i := range content {
|
||||
content[i] = make([]string, 0, len(rows))
|
||||
}
|
||||
for _, current := range rows {
|
||||
for i, value := range historyContentValues(current) {
|
||||
content[i] = append(content[i], value)
|
||||
}
|
||||
}
|
||||
min, max := textColumnMinWidth(), textColumnMaxWidth()
|
||||
widths := [6]float32{
|
||||
0: textWidth(historyTimeSample) + cellPadding(),
|
||||
1: textColumnWidth(historyTriggerSamples, min, max),
|
||||
3: textColumnWidth(historyStateSamples, min, max),
|
||||
}
|
||||
for i, col := range historyContentCols {
|
||||
widths[col] = textColumnWidth(content[i], min, max)
|
||||
}
|
||||
return widths
|
||||
}
|
||||
|
||||
// maxHistoryRows caps the session History list, the way app.maxJobLogs caps a
|
||||
// job's own activity list. History is never persisted and every record carries
|
||||
// the run's full captured output, so an app left running in the tray — the mode
|
||||
@@ -183,16 +102,6 @@ func (h *historyLog) rescan() {
|
||||
h.widths = historyColumnWidths(h.records)
|
||||
}
|
||||
|
||||
// historyContentCols are the columns whose width follows the values actually
|
||||
// present, in the order historyContentValues returns them. Both the
|
||||
// incremental fold in add and the full scan in historyColumnWidths go through
|
||||
// this pair, so they cannot disagree about which columns follow content.
|
||||
var historyContentCols = [3]int{2, 4, 5}
|
||||
|
||||
func historyContentValues(record event) [3]string {
|
||||
return [3]string{record.JobName, record.Detail, logFileName(record.LogFile)}
|
||||
}
|
||||
|
||||
// historyHeader is a bold tappable label used in the History table header row.
|
||||
// In Fyne 2.7+ OnSelected is not fired for header cells (Row < 0), so the sort
|
||||
// toggle is wired through the Tappable interface instead.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
)
|
||||
|
||||
// textWidth measures how wide s renders at the theme's current body text size.
|
||||
func textWidth(s string) float32 {
|
||||
return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width
|
||||
}
|
||||
|
||||
// cellPadding is the horizontal space a table cell reserves around its text.
|
||||
// It replaces a hand-tuned pixel constant with the theme's own inner padding
|
||||
// doubled (one side each), so it follows text size and DPI.
|
||||
func cellPadding() float32 { return 2 * theme.InnerPadding() }
|
||||
|
||||
// textColumnMinWidth/textColumnMaxWidth bound every content-measured History
|
||||
// column: the minimum keeps a column readable when its values are short or
|
||||
// absent, the maximum stops one very long value from dominating the table
|
||||
// (the table still scrolls horizontally past it). Expressed as measured text
|
||||
// rather than raw pixels so both follow the theme instead of drifting from it.
|
||||
func textColumnMinWidth() float32 { return textWidth(strings.Repeat("0", 10)) + cellPadding() }
|
||||
func textColumnMaxWidth() float32 { return textWidth(strings.Repeat("0", 30)) + cellPadding() }
|
||||
|
||||
// textColumnWidth measures the widest of samples so a table column can be
|
||||
// sized to fit its content, clamped to [min, max]. Fyne tables do not
|
||||
// auto-size columns, so without this a fixed width clips values like
|
||||
// "20260601-100000_SomeJobName.log" in the Log column.
|
||||
func textColumnWidth(samples []string, min, max float32) float32 {
|
||||
width := min
|
||||
for _, text := range samples {
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if w := textWidth(text) + cellPadding(); w > width {
|
||||
width = w
|
||||
}
|
||||
}
|
||||
if width > max {
|
||||
width = max
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
// historyTriggerSamples is the closed set of Trigger values History ever
|
||||
// shows (see newEvent and app.operations.go/app.run.go, which produce "UI",
|
||||
// "Manual" and "Schedule"; historyCellText falls back to "Unknown"). Add a new
|
||||
// trigger here too if one is introduced there, or the column may clip it.
|
||||
var historyTriggerSamples = []string{"Schedule", "Manual", "UI", "Unknown"}
|
||||
|
||||
// historyStateSamples is the closed set of State values History ever shows:
|
||||
// "OK" and "Failed" come from runner.RunJob (runStateDetail/startJobOnly);
|
||||
// "Started", "Error" and "Jobs loaded" are recorded directly in mainwindow.go.
|
||||
// Add a new state here too if one is introduced in either place.
|
||||
var historyStateSamples = []string{"OK", "Failed", "Started", "Error", "Jobs loaded"}
|
||||
|
||||
// historyTimeSample is the rendered form of the timestamp layout every event
|
||||
// uses (see newEvent), so the Time column needs no content scan: its width is
|
||||
// fixed by the format string.
|
||||
const historyTimeSample = "2026-01-02 15:04:05"
|
||||
|
||||
// historyColumnWidths computes every column's width from the current sorted
|
||||
// rows. Time, Trigger and State are fixed-shape or closed-set columns; Job,
|
||||
// Detail and Log are free text, so their width tracks the values actually
|
||||
// present, bounded the same way the Log column always was.
|
||||
func historyColumnWidths(rows []event) [6]float32 {
|
||||
var content [3][]string
|
||||
for i := range content {
|
||||
content[i] = make([]string, 0, len(rows))
|
||||
}
|
||||
for _, current := range rows {
|
||||
for i, value := range historyContentValues(current) {
|
||||
content[i] = append(content[i], value)
|
||||
}
|
||||
}
|
||||
min, max := textColumnMinWidth(), textColumnMaxWidth()
|
||||
widths := [6]float32{
|
||||
0: textWidth(historyTimeSample) + cellPadding(),
|
||||
1: textColumnWidth(historyTriggerSamples, min, max),
|
||||
3: textColumnWidth(historyStateSamples, min, max),
|
||||
}
|
||||
for i, col := range historyContentCols {
|
||||
widths[col] = textColumnWidth(content[i], min, max)
|
||||
}
|
||||
return widths
|
||||
}
|
||||
|
||||
// historyContentCols are the columns whose width follows the values actually
|
||||
// present, in the order historyContentValues returns them. Both the
|
||||
// incremental fold in add and the full scan in historyColumnWidths go through
|
||||
// this pair, so they cannot disagree about which columns follow content.
|
||||
var historyContentCols = [3]int{2, 4, 5}
|
||||
|
||||
func historyContentValues(record event) [3]string {
|
||||
return [3]string{record.JobName, record.Detail, logFileName(record.LogFile)}
|
||||
}
|
||||
+1
-273
@@ -1,14 +1,10 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
@@ -27,275 +23,7 @@ var settingsCaptions = []string{
|
||||
}
|
||||
|
||||
func settingsView(w fyne.Window, svc *app.Service, tray *trayState) fyne.CanvasObject {
|
||||
// saved mirrors the config as last persisted (or freshly loaded at
|
||||
// construction); it is a local copy the closures below compare the form
|
||||
// against and reassign after a successful save, rather than holding onto
|
||||
// the live *storage.Store the Service owns (see app.Service.Config).
|
||||
// paths never changes after construction of this view — AppDir and
|
||||
// ConfigPath are fixed for the process — so it is read once, not refreshed.
|
||||
saved := svc.Config()
|
||||
paths := svc.Paths()
|
||||
// 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(saved.StartOnLogin)
|
||||
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
||||
minimizeToTray.SetChecked(saved.KeepRunningInTray)
|
||||
autostartStatus := widget.NewLabel("")
|
||||
trayRestartHint := widget.NewLabel("")
|
||||
trayRestartHint.Truncation = fyne.TextTruncateClip
|
||||
// autostartCheckGen guards against an in-flight check's result landing after
|
||||
// a newer one started (e.g. the user toggles a checkbox again before the
|
||||
// first check's PowerShell call returns). Both the increment and the compare
|
||||
// happen on the main/Fyne thread, so this needs no lock of its own.
|
||||
var autostartCheckGen int
|
||||
refreshAutostartStatus := func() {
|
||||
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
|
||||
autostartStatus.SetText("Pending: save settings to apply")
|
||||
return
|
||||
}
|
||||
// svc.AutostartStatus() reaches readShortcut on Windows, which spawns
|
||||
// powershell.exe and blocks on CombinedOutput() — hundreds of milliseconds
|
||||
// of cold start. Running it off the main thread keeps that from freezing
|
||||
// the window on construction and on every checkbox toggle.
|
||||
autostartStatus.SetText("Checking...")
|
||||
autostartCheckGen++
|
||||
gen := autostartCheckGen
|
||||
go func() {
|
||||
ok, message := svc.AutostartStatus()
|
||||
fyne.Do(func() {
|
||||
if gen != autostartCheckGen {
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
autostartStatus.SetText("OK: " + message)
|
||||
return
|
||||
}
|
||||
autostartStatus.SetText("Problem: " + message)
|
||||
})
|
||||
}()
|
||||
}
|
||||
refreshTrayRestartHint := func(pending bool) {
|
||||
if pending {
|
||||
trayRestartHint.SetText("Pending: restart GoSentry after save for the tray icon change to take effect.")
|
||||
return
|
||||
}
|
||||
trayRestartHint.SetText("")
|
||||
}
|
||||
startOnLogin.OnChanged = func(bool) {
|
||||
refreshAutostartStatus()
|
||||
updateSaveState()
|
||||
}
|
||||
minimizeToTray.OnChanged = func(bool) {
|
||||
refreshAutostartStatus()
|
||||
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
|
||||
updateSaveState()
|
||||
}
|
||||
refreshAutostartStatus()
|
||||
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
||||
notifications.SetChecked(saved.NotifyOnFailure)
|
||||
notifications.OnChanged = func(bool) { updateSaveState() }
|
||||
themeSelect := widget.NewSelect([]string{themeLabelSystem, themeLabelGoSentry}, nil)
|
||||
themeSelect.SetSelected(themeLabel(saved.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(saved.ExecutionMode))
|
||||
executionModeSelect.OnChanged = func(string) { updateSaveState() }
|
||||
overlapPolicySelect := widget.NewSelect(
|
||||
[]string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
|
||||
nil,
|
||||
)
|
||||
overlapPolicySelect.SetSelected(string(saved.OverlapPolicy))
|
||||
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
|
||||
defaultTimeout := widget.NewEntry()
|
||||
defaultTimeout.SetPlaceHolder("0 = no timeout")
|
||||
defaultTimeout.SetText(strconv.Itoa(saved.DefaultTimeoutSeconds))
|
||||
defaultTimeout.OnChanged = func(string) { updateSaveState() }
|
||||
jobsFile := widget.NewEntry()
|
||||
jobsFile.SetText(saved.JobsFile)
|
||||
jobsFile.OnChanged = func(string) { updateSaveState() }
|
||||
// The picker only offers existing files; a jobs file that does not exist yet
|
||||
// is entered by typing its path, which Save then creates.
|
||||
jobsFileBrowse := widget.NewButtonWithIcon("Browse", theme.FileIcon(), func() {
|
||||
chooseJSONFile(w, jobsFile)
|
||||
})
|
||||
logsDir := widget.NewEntry()
|
||||
logsDir.SetText(saved.LogsDir)
|
||||
logsDir.OnChanged = func(string) { updateSaveState() }
|
||||
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||
chooseFolder(w, logsDir)
|
||||
})
|
||||
// Log files are read outside the app, so the folder gets a direct shortcut
|
||||
// beside its path instead of making the user copy the path into a file
|
||||
// manager. It reveals whatever the field currently holds, so an edit can be
|
||||
// checked before Save.
|
||||
logsDirOpen := widget.NewButtonWithIcon("Open", theme.FolderIcon(), func() {
|
||||
openFolder(w, settingsFolderPath(paths.AppDir, logsDir.Text))
|
||||
})
|
||||
maxLogFiles := widget.NewEntry()
|
||||
maxLogFiles.SetPlaceHolder("0 = unlimited")
|
||||
maxLogFiles.SetText(strconv.Itoa(saved.MaxLogFiles))
|
||||
maxLogFiles.OnChanged = func(string) { updateSaveState() }
|
||||
maxLogAgeDays := widget.NewEntry()
|
||||
maxLogAgeDays.SetPlaceHolder("0 = unlimited")
|
||||
maxLogAgeDays.SetText(strconv.Itoa(saved.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.Truncation = fyne.TextTruncateClip
|
||||
settingsStatus := widget.NewLabel("")
|
||||
|
||||
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
||||
// Only the parse itself happens here: a numeric field has to become an int
|
||||
// before it can go into a domain.Config at all. Everything else — required
|
||||
// fields, negative numbers, valid enum values — is Service.UpdateSettings'
|
||||
// job (see app.validateConfig), so its error is what the user sees rather
|
||||
// than a second copy of the same rules with different wording.
|
||||
files, filesErr := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
|
||||
days, daysErr := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
|
||||
timeout, timeoutErr := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text))
|
||||
if filesErr != nil || daysErr != nil || timeoutErr != nil {
|
||||
settingsStatus.SetText("Max log files, max log age days, and default timeout must be numbers")
|
||||
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 := saved
|
||||
config.JobsFile = strings.TrimSpace(jobsFile.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)
|
||||
previousKeepInTray := saved.KeepRunningInTray
|
||||
if err := svc.UpdateSettings(config); err != nil {
|
||||
settingsStatus.SetText("Save failed: " + err.Error())
|
||||
return
|
||||
}
|
||||
// UpdateSettings may re-resolve paths (a jobs-file switch adopts a
|
||||
// different directory), so pick up the fresh copy rather than assuming
|
||||
// config is exactly what landed.
|
||||
saved = svc.Config()
|
||||
paths = svc.Paths()
|
||||
if err := svc.ApplyAutostart(); err != nil {
|
||||
refreshAutostartStatus()
|
||||
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
|
||||
return
|
||||
}
|
||||
refreshAutostartStatus()
|
||||
tray.apply(fyne.CurrentApp(), w, config.KeepRunningInTray, true)
|
||||
if previousKeepInTray != config.KeepRunningInTray {
|
||||
trayRestartHint.SetText(trayRestartHintText)
|
||||
} else {
|
||||
refreshTrayRestartHint(false)
|
||||
}
|
||||
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 := saved
|
||||
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(jobsFile.Text) != c.JobsFile ||
|
||||
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))
|
||||
jobsFile.SetText(c.JobsFile)
|
||||
logsDir.SetText(c.LogsDir)
|
||||
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
|
||||
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
|
||||
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
|
||||
autostartStatus.SetText("Pending: save settings to apply")
|
||||
} else {
|
||||
refreshAutostartStatus()
|
||||
}
|
||||
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
|
||||
settingsStatus.SetText("")
|
||||
updateSaveState()
|
||||
}
|
||||
cancelSettings := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
|
||||
loadFields(saved)
|
||||
})
|
||||
restoreDefaults := widget.NewButtonWithIcon("Defaults", theme.MediaReplayIcon(), func() {
|
||||
loadFields(domain.DefaultConfig())
|
||||
})
|
||||
|
||||
return newSettingsLayout(settingsFormFields{
|
||||
startOnLogin: startOnLogin,
|
||||
autostartStatus: autostartStatus,
|
||||
minimizeToTray: minimizeToTray,
|
||||
trayRestartHint: trayRestartHint,
|
||||
notifications: notifications,
|
||||
themeSelect: themeSelect,
|
||||
executionModeSelect: executionModeSelect,
|
||||
overlapPolicySelect: overlapPolicySelect,
|
||||
defaultTimeout: defaultTimeout,
|
||||
configPath: paths.ConfigPath,
|
||||
jobsFile: jobsFile,
|
||||
jobsFileBrowse: jobsFileBrowse,
|
||||
logsDir: logsDir,
|
||||
logsDirOpen: logsDirOpen,
|
||||
logsDirBrowse: logsDirBrowse,
|
||||
maxLogFiles: maxLogFiles,
|
||||
maxLogAgeDays: maxLogAgeDays,
|
||||
saveSettings: saveSettings,
|
||||
cancelSettings: cancelSettings,
|
||||
restoreDefaults: restoreDefaults,
|
||||
settingsStatus: settingsStatus,
|
||||
})
|
||||
return newSettingsLayout(buildSettingsForm(w, svc, tray))
|
||||
}
|
||||
|
||||
func settingsPendingAutostart(startOnLogin, minimizeToTray *widget.Check, saved domain.Config) bool {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
// buildSettingsForm constructs every Settings tab widget and wires save, load,
|
||||
// and cancel handlers. settingsView delegates here so the constructor file stays
|
||||
// focused on the thin entry point and theme label helpers.
|
||||
func buildSettingsForm(w fyne.Window, svc *app.Service, tray *trayState) settingsFormFields {
|
||||
// saved mirrors the config as last persisted (or freshly loaded at
|
||||
// construction); it is a local copy the closures below compare the form
|
||||
// against and reassign after a successful save, rather than holding onto
|
||||
// the live *storage.Store the Service owns (see app.Service.Config).
|
||||
// paths never changes after construction of this view — AppDir and
|
||||
// ConfigPath are fixed for the process — so it is read once, not refreshed.
|
||||
saved := svc.Config()
|
||||
paths := svc.Paths()
|
||||
// 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(saved.StartOnLogin)
|
||||
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
||||
minimizeToTray.SetChecked(saved.KeepRunningInTray)
|
||||
autostartStatus := widget.NewLabel("")
|
||||
trayRestartHint := widget.NewLabel("")
|
||||
trayRestartHint.Truncation = fyne.TextTruncateClip
|
||||
// autostartCheckGen guards against an in-flight check's result landing after
|
||||
// a newer one started (e.g. the user toggles a checkbox again before the
|
||||
// first check's PowerShell call returns). Both the increment and the compare
|
||||
// happen on the main/Fyne thread, so this needs no lock of its own.
|
||||
var autostartCheckGen int
|
||||
refreshAutostartStatus := func() {
|
||||
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
|
||||
autostartStatus.SetText("Pending: save settings to apply")
|
||||
return
|
||||
}
|
||||
// svc.AutostartStatus() reaches readShortcut on Windows, which spawns
|
||||
// powershell.exe and blocks on CombinedOutput() — hundreds of milliseconds
|
||||
// of cold start. Running it off the main thread keeps that from freezing
|
||||
// the window on construction and on every checkbox toggle.
|
||||
autostartStatus.SetText("Checking...")
|
||||
autostartCheckGen++
|
||||
gen := autostartCheckGen
|
||||
go func() {
|
||||
ok, message := svc.AutostartStatus()
|
||||
fyne.Do(func() {
|
||||
if gen != autostartCheckGen {
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
autostartStatus.SetText("OK: " + message)
|
||||
return
|
||||
}
|
||||
autostartStatus.SetText("Problem: " + message)
|
||||
})
|
||||
}()
|
||||
}
|
||||
refreshTrayRestartHint := func(pending bool) {
|
||||
if pending {
|
||||
trayRestartHint.SetText("Pending: restart GoSentry after save for the tray icon change to take effect.")
|
||||
return
|
||||
}
|
||||
trayRestartHint.SetText("")
|
||||
}
|
||||
startOnLogin.OnChanged = func(bool) {
|
||||
refreshAutostartStatus()
|
||||
updateSaveState()
|
||||
}
|
||||
minimizeToTray.OnChanged = func(bool) {
|
||||
refreshAutostartStatus()
|
||||
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
|
||||
updateSaveState()
|
||||
}
|
||||
refreshAutostartStatus()
|
||||
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
||||
notifications.SetChecked(saved.NotifyOnFailure)
|
||||
notifications.OnChanged = func(bool) { updateSaveState() }
|
||||
themeSelect := widget.NewSelect([]string{themeLabelSystem, themeLabelGoSentry}, nil)
|
||||
themeSelect.SetSelected(themeLabel(saved.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(saved.ExecutionMode))
|
||||
executionModeSelect.OnChanged = func(string) { updateSaveState() }
|
||||
overlapPolicySelect := widget.NewSelect(
|
||||
[]string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
|
||||
nil,
|
||||
)
|
||||
overlapPolicySelect.SetSelected(string(saved.OverlapPolicy))
|
||||
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
|
||||
defaultTimeout := widget.NewEntry()
|
||||
defaultTimeout.SetPlaceHolder("0 = no timeout")
|
||||
defaultTimeout.SetText(strconv.Itoa(saved.DefaultTimeoutSeconds))
|
||||
defaultTimeout.OnChanged = func(string) { updateSaveState() }
|
||||
jobsFile := widget.NewEntry()
|
||||
jobsFile.SetText(saved.JobsFile)
|
||||
jobsFile.OnChanged = func(string) { updateSaveState() }
|
||||
// The picker only offers existing files; a jobs file that does not exist yet
|
||||
// is entered by typing its path, which Save then creates.
|
||||
jobsFileBrowse := widget.NewButtonWithIcon("Browse", theme.FileIcon(), func() {
|
||||
chooseJSONFile(w, jobsFile)
|
||||
})
|
||||
logsDir := widget.NewEntry()
|
||||
logsDir.SetText(saved.LogsDir)
|
||||
logsDir.OnChanged = func(string) { updateSaveState() }
|
||||
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||
chooseFolder(w, logsDir)
|
||||
})
|
||||
// Log files are read outside the app, so the folder gets a direct shortcut
|
||||
// beside its path instead of making the user copy the path into a file
|
||||
// manager. It reveals whatever the field currently holds, so an edit can be
|
||||
// checked before Save.
|
||||
logsDirOpen := widget.NewButtonWithIcon("Open", theme.FolderIcon(), func() {
|
||||
openFolder(w, settingsFolderPath(paths.AppDir, logsDir.Text))
|
||||
})
|
||||
maxLogFiles := widget.NewEntry()
|
||||
maxLogFiles.SetPlaceHolder("0 = unlimited")
|
||||
maxLogFiles.SetText(strconv.Itoa(saved.MaxLogFiles))
|
||||
maxLogFiles.OnChanged = func(string) { updateSaveState() }
|
||||
maxLogAgeDays := widget.NewEntry()
|
||||
maxLogAgeDays.SetPlaceHolder("0 = unlimited")
|
||||
maxLogAgeDays.SetText(strconv.Itoa(saved.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.Truncation = fyne.TextTruncateClip
|
||||
settingsStatus := widget.NewLabel("")
|
||||
|
||||
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
||||
// Only the parse itself happens here: a numeric field has to become an int
|
||||
// before it can go into a domain.Config at all. Everything else — required
|
||||
// fields, negative numbers, valid enum values — is Service.UpdateSettings'
|
||||
// job (see app.validateConfig), so its error is what the user sees rather
|
||||
// than a second copy of the same rules with different wording.
|
||||
files, filesErr := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
|
||||
days, daysErr := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
|
||||
timeout, timeoutErr := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text))
|
||||
if filesErr != nil || daysErr != nil || timeoutErr != nil {
|
||||
settingsStatus.SetText("Max log files, max log age days, and default timeout must be numbers")
|
||||
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 := saved
|
||||
config.JobsFile = strings.TrimSpace(jobsFile.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)
|
||||
previousKeepInTray := saved.KeepRunningInTray
|
||||
if err := svc.UpdateSettings(config); err != nil {
|
||||
settingsStatus.SetText("Save failed: " + err.Error())
|
||||
return
|
||||
}
|
||||
// UpdateSettings may re-resolve paths (a jobs-file switch adopts a
|
||||
// different directory), so pick up the fresh copy rather than assuming
|
||||
// config is exactly what landed.
|
||||
saved = svc.Config()
|
||||
paths = svc.Paths()
|
||||
if err := svc.ApplyAutostart(); err != nil {
|
||||
refreshAutostartStatus()
|
||||
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
|
||||
return
|
||||
}
|
||||
refreshAutostartStatus()
|
||||
tray.apply(fyne.CurrentApp(), w, config.KeepRunningInTray, true)
|
||||
if previousKeepInTray != config.KeepRunningInTray {
|
||||
trayRestartHint.SetText(trayRestartHintText)
|
||||
} else {
|
||||
refreshTrayRestartHint(false)
|
||||
}
|
||||
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 := saved
|
||||
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(jobsFile.Text) != c.JobsFile ||
|
||||
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))
|
||||
jobsFile.SetText(c.JobsFile)
|
||||
logsDir.SetText(c.LogsDir)
|
||||
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
|
||||
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
|
||||
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
|
||||
autostartStatus.SetText("Pending: save settings to apply")
|
||||
} else {
|
||||
refreshAutostartStatus()
|
||||
}
|
||||
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
|
||||
settingsStatus.SetText("")
|
||||
updateSaveState()
|
||||
}
|
||||
cancelSettings := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
|
||||
loadFields(saved)
|
||||
})
|
||||
restoreDefaults := widget.NewButtonWithIcon("Defaults", theme.MediaReplayIcon(), func() {
|
||||
loadFields(domain.DefaultConfig())
|
||||
})
|
||||
|
||||
return settingsFormFields{
|
||||
startOnLogin: startOnLogin,
|
||||
autostartStatus: autostartStatus,
|
||||
minimizeToTray: minimizeToTray,
|
||||
trayRestartHint: trayRestartHint,
|
||||
notifications: notifications,
|
||||
themeSelect: themeSelect,
|
||||
executionModeSelect: executionModeSelect,
|
||||
overlapPolicySelect: overlapPolicySelect,
|
||||
defaultTimeout: defaultTimeout,
|
||||
configPath: paths.ConfigPath,
|
||||
jobsFile: jobsFile,
|
||||
jobsFileBrowse: jobsFileBrowse,
|
||||
logsDir: logsDir,
|
||||
logsDirOpen: logsDirOpen,
|
||||
logsDirBrowse: logsDirBrowse,
|
||||
maxLogFiles: maxLogFiles,
|
||||
maxLogAgeDays: maxLogAgeDays,
|
||||
saveSettings: saveSettings,
|
||||
cancelSettings: cancelSettings,
|
||||
restoreDefaults: restoreDefaults,
|
||||
settingsStatus: settingsStatus,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user