diff --git a/docs/REFACTORING.md b/docs/REFACTORING.md index 03a5d83..565af54 100644 --- a/docs/REFACTORING.md +++ b/docs/REFACTORING.md @@ -272,7 +272,7 @@ Track progress here. Mark tasks complete as they land and pass review. - [x] T4.2 — Extract `jobs_view.go` - [x] T4.3 — Extract `job_dialog.go` - [x] T4.4 — Extract `history_view.go` -- [ ] T4.5 — Extract `settings_view.go` +- [x] T4.5 — Extract `settings_view.go` - [ ] T4.6 — Extract `tray.go`, `singleinstance.go`, `layout.go` - [ ] T4.7 — Confirm app.go is gone; smoke test both platforms diff --git a/src/ui/mainwindow.go b/src/ui/mainwindow.go index 7eabc0d..49e22fd 100644 --- a/src/ui/mainwindow.go +++ b/src/ui/mainwindow.go @@ -1,32 +1,20 @@ package ui import ( - "net/url" - "runtime" - "runtime/debug" - "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/theme" "fyne.io/fyne/v2/widget" ) -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. @@ -135,165 +123,3 @@ func (l minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { object.Resize(size) } } - -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() -} - -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)) -} diff --git a/src/ui/settings_view.go b/src/ui/settings_view.go new file mode 100644 index 0000000..b76bf76 --- /dev/null +++ b/src/ui/settings_view.go @@ -0,0 +1,185 @@ +package ui + +import ( + "net/url" + "runtime" + "runtime/debug" + "strconv" + "strings" + + "gitea.mixdep.ru/mix/gosentry/src/app" + "gitea.mixdep.ru/mix/gosentry/src/platform/autostart" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +const settingsLabelWidth float32 = 140 +const settingsControlWidth float32 = 330 +const settingsStatusWidth float32 = 280 +const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry" + +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() +} + +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)) +}