Compare commits
4 Commits
8b81180c92
...
f53447d3e8
| Author | SHA1 | Date | |
|---|---|---|---|
| f53447d3e8 | |||
| 24a8140e26 | |||
| 9ecb8b61f8 | |||
| 43809e5076 |
@@ -2,6 +2,36 @@
|
|||||||
|
|
||||||
All notable GoSentry changes are recorded in this file.
|
All notable GoSentry changes are recorded in this file.
|
||||||
|
|
||||||
|
## 0.11.0 - 2026-06-25
|
||||||
|
|
||||||
|
**Manual runs while paused, two-column Settings/details, and a more compact job list.**
|
||||||
|
|
||||||
|
**Scheduler:**
|
||||||
|
- "Run now" is now allowed while the scheduler is globally paused. The global
|
||||||
|
pause stops only automatic scheduled runs; an explicit manual run is the user's
|
||||||
|
own one-off action and is no longer blocked (the already-running and
|
||||||
|
sequential-mode guards still apply).
|
||||||
|
|
||||||
|
**Jobs details panel:**
|
||||||
|
- Metadata captions (Folder, Command, Run mode, …) are pinned to a fixed width
|
||||||
|
instead of an even split, so widening the window now grows the value column
|
||||||
|
rather than the short caption.
|
||||||
|
- Fixed a bug where the "Selected job activity" panel kept showing the previous
|
||||||
|
job's entries when a different job was selected; the list now refreshes on
|
||||||
|
every selection change.
|
||||||
|
|
||||||
|
**Jobs list:**
|
||||||
|
- List rows (name, schedule/command, status) are condensed with a tight,
|
||||||
|
negative-gap layout so more jobs are visible without scrolling.
|
||||||
|
|
||||||
|
**Settings tab:**
|
||||||
|
- The form is reorganized into two columns — Application and Queue on the left,
|
||||||
|
Storage and About on the right — with the Save button spanning the full width
|
||||||
|
below. The Autostart status moved onto its own line so the section fits a
|
||||||
|
half-width column.
|
||||||
|
- Removed the blank row that sat between the Save button and the following
|
||||||
|
separator.
|
||||||
|
|
||||||
## 0.10.2 - 2026-06-25
|
## 0.10.2 - 2026-06-25
|
||||||
|
|
||||||
**Condensed details/settings panels and a window that shrinks to 720p.**
|
**Condensed details/settings panels and a window that shrinks to 720p.**
|
||||||
|
|||||||
+29
-14
@@ -335,21 +335,27 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunNowRefusedWhilePaused(t *testing.T) {
|
func TestRunNowAllowedWhilePaused(t *testing.T) {
|
||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
var ran bool
|
done := make(chan struct{}, 1)
|
||||||
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
||||||
ran = true
|
select {
|
||||||
return domain.RunRecord{}
|
case done <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return domain.RunRecord{State: "Success"}
|
||||||
}
|
}
|
||||||
if err := svc.SetGlobalPause(true); err != nil {
|
if err := svc.SetGlobalPause(true); err != nil {
|
||||||
t.Fatalf("SetGlobalPause: %v", err)
|
t.Fatalf("SetGlobalPause: %v", err)
|
||||||
}
|
}
|
||||||
if err := svc.RunNow(1); err == nil {
|
// Pause stops only scheduled runs; an explicit manual run is still allowed.
|
||||||
t.Error("expected RunNow to be refused while paused")
|
if err := svc.RunNow(1); err != nil {
|
||||||
|
t.Fatalf("RunNow should be allowed while paused: %v", err)
|
||||||
}
|
}
|
||||||
if ran {
|
select {
|
||||||
t.Error("runner must not be invoked while paused")
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Error("runner was not invoked for a manual run while paused")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,23 +592,32 @@ func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) {
|
|||||||
svc2 := NewService(svc.store, svc.Jobs())
|
svc2 := NewService(svc.store, svc.Jobs())
|
||||||
|
|
||||||
var ran int32
|
var ran int32
|
||||||
|
runStarted := make(chan struct{}, 1)
|
||||||
svc2.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
svc2.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
||||||
atomic.AddInt32(&ran, 1)
|
atomic.AddInt32(&ran, 1)
|
||||||
|
select {
|
||||||
|
case runStarted <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
return domain.RunRecord{}
|
return domain.RunRecord{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunDue must not start any job while paused.
|
// RunDue must not start any job while paused: the scheduler stays paused after
|
||||||
|
// a restart that rebuilt the service from a paused store.
|
||||||
svc2.RunDue(time.Now().Add(2 * time.Minute))
|
svc2.RunDue(time.Now().Add(2 * time.Minute))
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
if atomic.LoadInt32(&ran) != 0 {
|
if atomic.LoadInt32(&ran) != 0 {
|
||||||
t.Error("RunDue ran a job on a service rebuilt from a paused store")
|
t.Error("RunDue ran a job on a service rebuilt from a paused store")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunNow must be refused.
|
// A manual RunNow is still allowed while paused — pause only stops the
|
||||||
if err := svc2.RunNow(1); err == nil {
|
// scheduler, not the user's explicit action.
|
||||||
t.Error("RunNow should be refused on a service rebuilt from a paused store")
|
if err := svc2.RunNow(1); err != nil {
|
||||||
|
t.Errorf("RunNow should be allowed while paused: %v", err)
|
||||||
}
|
}
|
||||||
if atomic.LoadInt32(&ran) != 0 {
|
select {
|
||||||
t.Error("runner was invoked despite global pause")
|
case <-runStarted:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Error("manual run was not started on a service rebuilt from a paused store")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-6
@@ -11,8 +11,9 @@ import (
|
|||||||
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunNow starts a manual run of a job. It refuses to run while globally paused —
|
// RunNow starts a manual run of a job. Global pause stops only the scheduler's
|
||||||
// the pause is an emergency stop for all execution — and will not start a job
|
// automatic runs (see RunDue), so a manual "Run now" is allowed even while
|
||||||
|
// paused — it is the user's explicit, one-off action. It will not start a job
|
||||||
// that is already running. In sequential execution mode it also refuses while
|
// that is already running. In sequential execution mode it also refuses while
|
||||||
// any other job is running, so a manual run never breaks the one-at-a-time
|
// any other job is running, so a manual run never breaks the one-at-a-time
|
||||||
// guarantee. The run itself happens on a background goroutine that records the
|
// guarantee. The run itself happens on a background goroutine that records the
|
||||||
@@ -21,10 +22,6 @@ import (
|
|||||||
// "Running" status), not the run's own outcome.
|
// "Running" status), not the run's own outcome.
|
||||||
func (s *Service) RunNow(id int) error {
|
func (s *Service) RunNow(id int) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.paused {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return errors.New("scheduler is paused")
|
|
||||||
}
|
|
||||||
job := s.findByIDLocked(id)
|
job := s.findByIDLocked(id)
|
||||||
if job == nil {
|
if job == nil {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|||||||
+1
-1
@@ -3,4 +3,4 @@ package app
|
|||||||
// Version is the application version shown in the GUI and used by build
|
// Version is the application version shown in the GUI and used by build
|
||||||
// scripts in artifact names. It is a var rather than a const so release builds
|
// scripts in artifact names. It is a var rather than a const so release builds
|
||||||
// can override it with Go ldflags when CI tags a build.
|
// can override it with Go ldflags when CI tags a build.
|
||||||
var Version = "0.10.2"
|
var Version = "0.11.0"
|
||||||
|
|||||||
+11
-9
@@ -28,6 +28,12 @@ const maxJobActivityRows = 3
|
|||||||
// padding, tightening the block so it fits comfortably on 720p screens.
|
// padding, tightening the block so it fits comfortably on 720p screens.
|
||||||
const detailRowSpacing float32 = -8
|
const detailRowSpacing float32 = -8
|
||||||
|
|
||||||
|
// jobRowSpacing is the (negative) gap between the name, metadata, and status
|
||||||
|
// lines within each job list row. Like the details panel, it overlaps the
|
||||||
|
// labels' built-in vertical padding so each row reads as one compact block and
|
||||||
|
// more jobs are visible without scrolling.
|
||||||
|
const jobRowSpacing float32 = -8
|
||||||
|
|
||||||
// newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar.
|
// newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar.
|
||||||
// It returns the assembled panel and a refresh function the caller invokes
|
// It returns the assembled panel and a refresh function the caller invokes
|
||||||
// whenever the service state may have changed (e.g., from the event subscriber
|
// whenever the service state may have changed (e.g., from the event subscriber
|
||||||
@@ -97,7 +103,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
|||||||
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
meta := widget.NewLabel("schedule")
|
meta := widget.NewLabel("schedule")
|
||||||
status := widget.NewLabel("status")
|
status := widget.NewLabel("status")
|
||||||
return container.NewVBox(name, meta, status)
|
return container.New(compactVBoxLayout{spacing: jobRowSpacing}, name, meta, status)
|
||||||
},
|
},
|
||||||
func(id widget.ListItemID, item fyne.CanvasObject) {
|
func(id widget.ListItemID, item fyne.CanvasObject) {
|
||||||
row := item.(*fyne.Container)
|
row := item.(*fyne.Container)
|
||||||
@@ -185,14 +191,10 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
|||||||
if selected < 0 || selected >= len(jobs) {
|
if selected < 0 || selected >= len(jobs) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if schedulerPaused {
|
// A manual run is allowed even while the scheduler is paused: pause only
|
||||||
// The global pause is treated as an emergency stop for all execution,
|
// stops automatic scheduled runs, not the user's explicit "Run now".
|
||||||
// including manual "Run now", so the user has one reliable switch.
|
// RunNow still refuses an already-running job (it returns an error); the UI
|
||||||
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
|
// has always ignored that case silently, so the run simply does not start.
|
||||||
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 {
|
if err := svc.RunNow(jobs[selected].ID); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-11
@@ -87,6 +87,10 @@ func (d *detailsPanel) update(j job, rt *domain.JobRuntime, globalOverlapPolicy
|
|||||||
d.stats.SetText(app.DisplayStats(rt))
|
d.stats.SetText(app.DisplayStats(rt))
|
||||||
d.commandOutput.SetText(rt.Output)
|
d.commandOutput.SetText(rt.Output)
|
||||||
d.selectedLogs = lastJobLogs(rt.Logs)
|
d.selectedLogs = lastJobLogs(rt.Logs)
|
||||||
|
// The activity list renders d.selectedLogs but, unlike the labels above whose
|
||||||
|
// SetText refreshes them, only its backing slice changed. Refresh it here so
|
||||||
|
// switching jobs immediately redraws the panel instead of keeping stale rows.
|
||||||
|
d.logs.Refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *detailsPanel) clear() {
|
func (d *detailsPanel) clear() {
|
||||||
@@ -103,6 +107,7 @@ func (d *detailsPanel) clear() {
|
|||||||
d.stats.SetText("")
|
d.stats.SetText("")
|
||||||
d.commandOutput.SetText("")
|
d.commandOutput.SetText("")
|
||||||
d.selectedLogs = nil
|
d.selectedLogs = nil
|
||||||
|
d.logs.Refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
// container assembles the details pane layout: metadata rows pin to the top,
|
// container assembles the details pane layout: metadata rows pin to the top,
|
||||||
@@ -111,12 +116,13 @@ func (d *detailsPanel) container() fyne.CanvasObject {
|
|||||||
// Metadata is laid out in two columns so the block stays half as tall,
|
// Metadata is laid out in two columns so the block stays half as tall,
|
||||||
// keeping the details pane usable on 720p screens where a single column of
|
// keeping the details pane usable on 720p screens where a single column of
|
||||||
// ten rows pushes the minimum window height past the available space.
|
// ten rows pushes the minimum window height past the available space.
|
||||||
|
capW := detailCaptionWidth()
|
||||||
rows := container.New(compactVBoxLayout{spacing: detailRowSpacing},
|
rows := container.New(compactVBoxLayout{spacing: detailRowSpacing},
|
||||||
detailRowPair("Folder", d.folder, "Schedule", d.schedule),
|
detailRowPair(capW, "Folder", d.folder, "Schedule", d.schedule),
|
||||||
detailRowPair("Command", d.command, "Arguments", d.arguments),
|
detailRowPair(capW, "Command", d.command, "Arguments", d.arguments),
|
||||||
detailRowPair("Run mode", d.runMode, "Overlap policy", d.overlapPolicy),
|
detailRowPair(capW, "Run mode", d.runMode, "Overlap policy", d.overlapPolicy),
|
||||||
detailRowPair("Last run", d.lastRun, "Next run", d.nextRun),
|
detailRowPair(capW, "Last run", d.lastRun, "Next run", d.nextRun),
|
||||||
detailRowPair("State", d.state, "Statistics", d.stats),
|
detailRowPair(capW, "State", d.state, "Statistics", d.stats),
|
||||||
)
|
)
|
||||||
top := container.NewVBox(
|
top := container.NewVBox(
|
||||||
d.title,
|
d.title,
|
||||||
@@ -147,16 +153,36 @@ func activityRowsHeight(rows int) float32 {
|
|||||||
return (itemHeight+padding)*float32(rows) - padding + 1
|
return (itemHeight+padding)*float32(rows) - padding + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// detailRowPair places two label/value pairs side by side, producing the
|
// detailCaptionWidth returns the width reserved for every metadata caption,
|
||||||
// four-column caption|value|caption|value rows the compact metadata grid uses.
|
// derived from the widest caption label so the value columns all start at the
|
||||||
func detailRowPair(l1 string, v1 fyne.CanvasObject, l2 string, v2 fyne.CanvasObject) fyne.CanvasObject {
|
// same x and no caption truncates. Measuring a real label keeps it DPI- and
|
||||||
return container.NewGridWithColumns(2, detailRow(l1, v1), detailRow(l2, v2))
|
// theme-aware instead of relying on a hand-tuned constant.
|
||||||
|
func detailCaptionWidth() float32 {
|
||||||
|
captions := []string{
|
||||||
|
"Folder", "Schedule", "Command", "Arguments", "Run mode",
|
||||||
|
"Overlap policy", "Last run", "Next run", "State", "Statistics",
|
||||||
|
}
|
||||||
|
var width float32
|
||||||
|
for _, c := range captions {
|
||||||
|
if w := widget.NewLabelWithStyle(c, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}).MinSize().Width; w > width {
|
||||||
|
width = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return width
|
||||||
}
|
}
|
||||||
|
|
||||||
func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
|
// detailRowPair places two label/value pairs side by side, producing the
|
||||||
|
// four-column caption|value|caption|value rows the compact metadata grid uses.
|
||||||
|
func detailRowPair(captionWidth float32, l1 string, v1 fyne.CanvasObject, l2 string, v2 fyne.CanvasObject) fyne.CanvasObject {
|
||||||
|
return container.NewGridWithColumns(2, detailRow(captionWidth, l1, v1), detailRow(captionWidth, l2, v2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailRow(captionWidth float32, label string, value fyne.CanvasObject) fyne.CanvasObject {
|
||||||
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
caption.Wrapping = fyne.TextTruncate
|
caption.Wrapping = fyne.TextTruncate
|
||||||
return container.NewGridWithColumns(2, caption, value)
|
// A fixed caption width (rather than an even split) means widening the window
|
||||||
|
// feeds the extra space to the value, not the short caption.
|
||||||
|
return container.New(captionValueLayout{captionWidth: captionWidth}, caption, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newJobDetailLabel(text string) *widget.Label {
|
func newJobDetailLabel(text string) *widget.Label {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
type minWidthLayout struct {
|
type minWidthLayout struct {
|
||||||
@@ -107,3 +108,45 @@ func (l fixedHeightLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
|
|||||||
object.Resize(fyne.NewSize(size.Width, l.height))
|
object.Resize(fyne.NewSize(size.Width, l.height))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// captionValueLayout places a fixed-width caption on the left and lets the value
|
||||||
|
// fill the remaining width, separated by one theme padding. Capping the caption
|
||||||
|
// stops it from growing with the window (as an even two-column grid would), so
|
||||||
|
// the extra space a wider window provides goes entirely to the value column. It
|
||||||
|
// expects exactly two children: caption first, value second.
|
||||||
|
type captionValueLayout struct {
|
||||||
|
captionWidth float32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l captionValueLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
|
||||||
|
if len(objects) != 2 {
|
||||||
|
return fyne.Size{}
|
||||||
|
}
|
||||||
|
captionMin, valueMin := objects[0].MinSize(), objects[1].MinSize()
|
||||||
|
height := captionMin.Height
|
||||||
|
if valueMin.Height > height {
|
||||||
|
height = valueMin.Height
|
||||||
|
}
|
||||||
|
return fyne.NewSize(l.captionWidth+theme.Padding()+valueMin.Width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l captionValueLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
|
||||||
|
if len(objects) != 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
caption, value := objects[0], objects[1]
|
||||||
|
captionWidth := l.captionWidth
|
||||||
|
if captionWidth > size.Width {
|
||||||
|
captionWidth = size.Width
|
||||||
|
}
|
||||||
|
caption.Move(fyne.NewPos(0, 0))
|
||||||
|
caption.Resize(fyne.NewSize(captionWidth, size.Height))
|
||||||
|
|
||||||
|
valueX := captionWidth + theme.Padding()
|
||||||
|
valueWidth := size.Width - valueX
|
||||||
|
if valueWidth < 0 {
|
||||||
|
valueWidth = 0
|
||||||
|
}
|
||||||
|
value.Move(fyne.NewPos(valueX, 0))
|
||||||
|
value.Resize(fyne.NewSize(valueWidth, size.Height))
|
||||||
|
}
|
||||||
|
|||||||
+77
-22
@@ -17,9 +17,11 @@ import (
|
|||||||
"fyne.io/fyne/v2/widget"
|
"fyne.io/fyne/v2/widget"
|
||||||
)
|
)
|
||||||
|
|
||||||
const settingsLabelWidth float32 = 140
|
// 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 settingsControlWidth float32 = 330
|
||||||
const settingsStatusWidth float32 = 280
|
|
||||||
const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry"
|
const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry"
|
||||||
|
|
||||||
// settingsRowSpacing is the (negative) gap between rows of the settings form,
|
// settingsRowSpacing is the (negative) gap between rows of the settings form,
|
||||||
@@ -29,6 +31,10 @@ const settingsRowSpacing float32 = -6
|
|||||||
|
|
||||||
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||||
store := svc.Store()
|
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()
|
||||||
startOnLogin := widget.NewCheck("Start on login", nil)
|
startOnLogin := widget.NewCheck("Start on login", nil)
|
||||||
startOnLogin.SetChecked(store.Config.StartOnLogin)
|
startOnLogin.SetChecked(store.Config.StartOnLogin)
|
||||||
autostartStatus := widget.NewLabel("")
|
autostartStatus := widget.NewLabel("")
|
||||||
@@ -43,39 +49,52 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
startOnLogin.OnChanged = func(bool) {
|
startOnLogin.OnChanged = func(bool) {
|
||||||
if startOnLogin.Checked != store.Config.StartOnLogin {
|
if startOnLogin.Checked != store.Config.StartOnLogin {
|
||||||
autostartStatus.SetText("Pending: save settings to apply")
|
autostartStatus.SetText("Pending: save settings to apply")
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
}
|
}
|
||||||
|
updateSaveState()
|
||||||
|
}
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
||||||
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
|
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
|
||||||
|
minimizeToTray.OnChanged = func(bool) { updateSaveState() }
|
||||||
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
||||||
notifications.SetChecked(store.Config.NotifyOnFailure)
|
notifications.SetChecked(store.Config.NotifyOnFailure)
|
||||||
|
notifications.OnChanged = func(bool) { updateSaveState() }
|
||||||
executionModeSelect := widget.NewSelect(
|
executionModeSelect := widget.NewSelect(
|
||||||
[]string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)},
|
[]string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)},
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
executionModeSelect.SetSelected(string(store.Config.ExecutionMode))
|
executionModeSelect.SetSelected(string(store.Config.ExecutionMode))
|
||||||
|
executionModeSelect.OnChanged = func(string) { updateSaveState() }
|
||||||
overlapPolicySelect := widget.NewSelect(
|
overlapPolicySelect := widget.NewSelect(
|
||||||
[]string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
|
[]string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy))
|
overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy))
|
||||||
|
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
|
||||||
jobsDir := widget.NewEntry()
|
jobsDir := widget.NewEntry()
|
||||||
jobsDir.SetText(store.Config.JobsDir)
|
jobsDir.SetText(store.Config.JobsDir)
|
||||||
|
jobsDir.OnChanged = func(string) { updateSaveState() }
|
||||||
jobsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
jobsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||||
chooseFolder(w, jobsDir)
|
chooseFolder(w, jobsDir)
|
||||||
})
|
})
|
||||||
logsDir := widget.NewEntry()
|
logsDir := widget.NewEntry()
|
||||||
logsDir.SetText(store.Config.LogsDir)
|
logsDir.SetText(store.Config.LogsDir)
|
||||||
|
logsDir.OnChanged = func(string) { updateSaveState() }
|
||||||
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||||
chooseFolder(w, logsDir)
|
chooseFolder(w, logsDir)
|
||||||
})
|
})
|
||||||
maxLogFiles := widget.NewEntry()
|
maxLogFiles := widget.NewEntry()
|
||||||
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
|
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
|
||||||
|
maxLogFiles.OnChanged = func(string) { updateSaveState() }
|
||||||
maxLogAgeDays := widget.NewEntry()
|
maxLogAgeDays := widget.NewEntry()
|
||||||
maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays))
|
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("")
|
settingsStatus := widget.NewLabel("")
|
||||||
|
|
||||||
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
||||||
@@ -121,27 +140,57 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
}
|
}
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
settingsStatus.SetText("Saved")
|
settingsStatus.SetText("Saved")
|
||||||
|
// The form now matches the persisted config, so disable Save again.
|
||||||
|
updateSaveState()
|
||||||
})
|
})
|
||||||
|
|
||||||
// The form is grouped into sections in an outer VBox. The outer box keeps the
|
// Save stays disabled until a field differs from the saved config, so the
|
||||||
// theme's normal padding, so the separators and the editable Storage fields
|
// button only invites a click when there is something to persist. The numeric
|
||||||
// get proper breathing room; only the label-only sections are condensed with
|
// fields compare against their canonical string form; any unparsable text
|
||||||
// the tight settingsSection spacing. Wrapping the whole thing in a vertical
|
// counts as a change so the user can click Save and see the validation error.
|
||||||
// scroll keeps its minimum height small so it does not dictate the window's
|
updateSaveState = func() {
|
||||||
// minimum height (AppTabs sizes to the tallest tab) and it scrolls on short
|
c := store.Config
|
||||||
// 720p screens.
|
changed := startOnLogin.Checked != c.StartOnLogin ||
|
||||||
return container.NewVScroll(container.NewPadded(container.NewVBox(
|
minimizeToTray.Checked != c.KeepRunningInTray ||
|
||||||
|
notifications.Checked != c.NotifyOnFailure ||
|
||||||
|
executionModeSelect.Selected != string(c.ExecutionMode) ||
|
||||||
|
overlapPolicySelect.Selected != string(c.OverlapPolicy) ||
|
||||||
|
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)
|
||||||
|
if changed {
|
||||||
|
saveSettings.Enable()
|
||||||
|
} else {
|
||||||
|
saveSettings.Disable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateSaveState()
|
||||||
|
|
||||||
|
// 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",
|
settingsSection("Application",
|
||||||
settingsRowWithStatus("Autostart", startOnLogin, autostartStatus),
|
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("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)),
|
||||||
settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)),
|
settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)),
|
||||||
),
|
),
|
||||||
widget.NewSeparator(),
|
widget.NewSeparator(),
|
||||||
settingsSection("Queue",
|
// 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("Execution mode", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)),
|
||||||
settingsRow("Default overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)),
|
settingsRow("Default overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)),
|
||||||
),
|
),
|
||||||
widget.NewSeparator(),
|
)
|
||||||
|
rightColumn := container.NewVBox(
|
||||||
// Storage holds editable entry fields. It uses the default VBox spacing
|
// Storage holds editable entry fields. It uses the default VBox spacing
|
||||||
// (not the condensed section layout) so the entry boxes keep a visible
|
// (not the condensed section layout) so the entry boxes keep a visible
|
||||||
// gap between them instead of merging into one block.
|
// gap between them instead of merging into one block.
|
||||||
@@ -153,8 +202,6 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
settingsRow("Max log files", maxLogFiles),
|
settingsRow("Max log files", maxLogFiles),
|
||||||
settingsRow("Max log age days", maxLogAgeDays),
|
settingsRow("Max log age days", maxLogAgeDays),
|
||||||
),
|
),
|
||||||
saveSettings,
|
|
||||||
settingsStatus,
|
|
||||||
widget.NewSeparator(),
|
widget.NewSeparator(),
|
||||||
settingsSection("About",
|
settingsSection("About",
|
||||||
settingsRow("GoSentry", widget.NewLabel(app.Version)),
|
settingsRow("GoSentry", widget.NewLabel(app.Version)),
|
||||||
@@ -162,6 +209,19 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
settingsRow("Fyne", widget.NewLabel(fyneVersion())),
|
settingsRow("Fyne", widget.NewLabel(fyneVersion())),
|
||||||
settingsRow("Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))),
|
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 button and its status share one row so an empty status (the common
|
||||||
|
// case) does not leave a blank line above the separator. The status appears
|
||||||
|
// beside the button once a save reports a result.
|
||||||
|
container.NewHBox(saveSettings, settingsStatus),
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,8 +294,3 @@ func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
|
|||||||
return container.NewBorder(nil, nil, captionBox, nil, value)
|
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))
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user