Files
gosentry/src/ui/mainwindow.go
T
mixeme fb12fab71d T4.4: Extract history_view.go
Move newHistoryView, historyCellText, logFileName, newEvent, and
collectActivity out of mainwindow.go into src/ui/history_view.go.
Drop the now-unused "sort" import from mainwindow.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:30:21 +03:00

300 lines
11 KiB
Go

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.
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
}
// Build the initial event history from the current runtime state. Jobs and
// runtimes are read here only for this one-time initialization; the jobs view
// owns all subsequent state via its own syncFromService closure.
initialJobs := svc.Jobs()
initialRuntimes := make(map[int]*domain.JobRuntime, len(initialJobs))
for _, j := range initialJobs {
if rt := svc.Runtime(j.ID); rt != nil {
initialRuntimes[j.ID] = rt
}
}
events := collectActivity(initialJobs, initialRuntimes)
jobsPanel, refreshJobsView := newJobsView(w, svc)
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()
}
refresh := func() {
refreshJobsView()
history.Refresh()
}
// 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()
})
}))
svc.Start(scheduler.NewRealClock())
tabs := container.NewAppTabs(
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsPanel),
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 (l minWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
width := l.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 (l 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 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))
}