Compare commits

...

3 Commits

Author SHA1 Message Date
mix bd7ebde68e refactor: extract the Jobs view state, track selection by job ID
Phase 10 of the whole-project review (findings 5.1 and 5.2), folded into
the ROADMAP file-split item as that plan asks.

5.2 was a real defect. `selected` was an index into a snapshot of the jobs
slice, and every path that changed the slice patched it by hand. The one
path that could not — adopting a different jobs file, where the Service
replaces the whole list and the view only hears about it through the
refresh JobsLoaded triggers — left the details pane redrawing from an
index that belonged to the previous list, describing whichever job now sat
there (or clearing when the new list was shorter) while the list highlight
stayed put. The selection is now a job ID; rows are derived from it at
render time, and refresh ends by pointing the highlight at the selected
job, so the two can no longer disagree.

5.1: newJobsView was one 330-line constructor whose dozen closures shared
seven mutable locals. It is now a jobsView struct over a jobsViewState
that owns the snapshot, the folder filter, and the selection — the
invariant that used to be maintained by hand in five places lives in one
place — split across jobs_view.go (construction, refresh, layout),
jobs_view_state.go, jobs_view_list.go, and jobs_view_toolbar.go. The
folder-option rebuild that appeared verbatim in three handlers is one
method.

Behaviour that changed beyond the fix: switching the folder filter keeps
the current selection when the new filter still shows it, instead of
always jumping to the folder's first job.

Docs: ARCHITECTURE records the new file layout and the selection-by-ID
contract; ROADMAP drops jobs_view.go from the over-guideline table and
refreshes the other five numbers (finding 2.4); TESTS documents the new
state test file and the adoption regression test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:38:47 +03:00
mix ca2a8c8aa7 fix: accept zero retention limits, retire Store() for typed accessors
Phase 8 (PROJECT_REVIEW_PLAN.md 8.1): 0 in MaxLogFiles/MaxLogAgeDays now
means "keep everything" end to end. runner.CleanupLogs already treated
<= 0 as disabled; validateConfig, the Settings form, and
loadOrCreateConfig's backfill were the only things making that state
unreachable.

Phase 9 (1.1, rolling up 1.2, 1.3, 7.3): added Service.Config() and
Service.Paths(), copying under mu, and converted every UI site that read
Service state through the raw *storage.Store returned by Store() (now
removed). jobs_view's pause control is now driven by refreshView reading
svc.Config().Paused on every event instead of only mirroring its own tap
handler, which makes it an actual consumer of SchedulerStateChanged.
mainwindow's event listener is a real type switch, and events.go's doc
comment no longer claims a compiler exhaustiveness check Go doesn't have.
Unexported the redundant SetAutostart/AutostartStatus package functions
in platform/autostart now that only the Manager methods are used outside
the package.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 22:15:19 +03:00
mix 0c8442a8d1 perf: keep file I/O off Service.mu and untie StartOnly from the app context
Phase 7 of the whole-project review (findings 3.2 and 3.3).

Service.mu is the lock the Fyne main thread takes on every Jobs() and
Runtime() call, so anything blocking inside it makes a UI refresh wait on
the disk. Three things did:

- Every SaveJobs/SaveConfig was a marshal, fsync, and rename under mu.
  Writes are now prepared under the lock (Store.PrepareSaveJobs /
  PrepareSaveConfig snapshot the payload and target path) and run after
  it is released. deferSaveLocked takes saveMu while mu is still held, so
  writes still reach the file in the order their snapshots were taken and
  an older snapshot can never land on top of a newer one.
- executeRun ran runner.CleanupLogs under mu after every run. It needs
  only the values already snapshotted into runEnv, so it now runs after
  the unlock — including when the job is gone, since the run still wrote
  a log file that retention covers.
- adoptJobsLocked ran runner.SeedStats under mu, reached from
  UpdateSettings on the UI thread. Seeding moved out into
  applySeededStatsLocked; UpdateSettings now reads the new jobs file and
  seeds its statistics before taking the lock, and re-checks the
  "no jobs-file switch while running" guard once it has it.

SeedStats also opened every log file twice — once to find the job, again
to read the result. readLogSummary reads job_id, state, and duration in
one pass, so each log is opened once.

StartOnly runs were built with exec.CommandContext on the app's lifecycle
context. os/exec keeps a watcher goroutine alive until Wait returns or the
context is done, and StartOnly never calls Wait, so one goroutine leaked
per run and would then try to kill a process whose handle startJobOnly had
already released. The invocation now uses context.Background(), whose nil
Done channel means no watcher is started at all.

Regression tests: TestRunJobStartOnlyLeavesNoContextWatcher (fails with 5
leaked goroutines on the old code), TestConcurrentJobOperationsLeaveTheFileMatchingMemory,
and TestUpdateSettingsSeedsAdoptedJobsFromLogs. STANDARDS gains the
no-I/O-under-mu rule and the "a StartOnly process outlives GoSentry" entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:44:51 +03:00
30 changed files with 1498 additions and 541 deletions
+29 -13
View File
@@ -136,10 +136,13 @@ example window-maximized detection, which would need per-OS native calls).
`UpdateSettings` has one extra step: when the configured jobs file changes
and a file already exists at the new path, that file is authoritative. The
Service loads it, calls `adoptJobsLocked` to rebuild the jobs slice, runtime
map, schedule cache, next-run times, and log-seeded statistics around it, and
emits `JobsLoaded` plus a broad `JobChanged`. A path with no file behind it
receives the current jobs instead. Adoption drops all runtime state, so it is
refused while a job is running.
map, schedule cache, and next-run times around it, applies the statistics
seeded from the new logs directory, and emits `JobsLoaded` plus a broad
`JobChanged`. A path with no file behind it receives the current jobs instead.
Adoption drops all runtime state, so it is refused while a job is running.
Reading the new file and seeding its statistics both happen before `mu` is
taken (the no-I/O-under-`mu` rule in [STANDARDS.md](STANDARDS.md)), so the
running-job check is re-evaluated under the lock before anything is replaced.
3. Scheduled run:
`scheduler.Scheduler` fires a tick every second. On each tick it calls
@@ -163,8 +166,9 @@ example window-maximized detection, which would need per-OS native calls).
6. History update:
When a run goroutine completes, `Service` updates the job's runtime
(including the statistics aggregate), saves JSON, triggers log cleanup, and
emits `RunRecorded`. The UI observer appends the record to the History tab.
(including the statistics aggregate) under `mu`, then — after releasing it —
runs log cleanup and emits `RunRecorded`. Nothing is saved: a run changes only
`JobRuntime`, which is never persisted. The UI observer appends the record to the History tab.
History rows exist only for the current process session; restarting the app
clears the table (aggregate stats in the details panel are still seeded from
log files).
@@ -220,9 +224,9 @@ resolves the effective duration under `mu` and `startRunLocked` snapshots it int
resolved duration as an argument, so the runner stays ignorant of the global
config: a positive duration applies the timeout via `context.WithTimeout` and
reports `Timed out after <timeout>` on expiry; a non-positive duration runs
without a deadline, bounded only by `ctx` (app shutdown). `StartOnly` jobs run on
the untimed context and so measure launch latency only, unaffected by the run
timeout.
without a deadline, bounded only by `ctx` (app shutdown). `StartOnly` jobs are
built on `context.Background()` instead — neither the timeout nor app shutdown
applies to them — and so measure launch latency only.
### Run-time statistics
@@ -266,16 +270,28 @@ the moment the window opens.
### `jobs_view.go` file structure
The size guideline for a file in this project is ~250 lines.
`src/ui/jobs_view.go` is split across three files along these seams; the view
file itself has grown back over the guideline since — see the split item in
[ROADMAP.md](ROADMAP.md), which tracks every file currently over it:
`src/ui/jobs_view.go` is split across five files along these seams:
| File | Contents |
|------|----------|
| `jobs_view.go` | `newJobsView` — list, toolbar, button wiring, and layout |
| `jobs_view.go` | `jobsView` struct — construction, `refresh`, `updateDetails`, the pause control, and layout assembly |
| `jobs_view_state.go` | `jobsViewState` — the jobs/runtime snapshot, the folder filter, and the selection |
| `jobs_view_list.go` | The sidebar list: row template, row rendering, row mode, and the compact/detailed toggle |
| `jobs_view_toolbar.go` | The per-job button row — new, edit, run, pause, delete |
| `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` |
| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` |
The widgets hold no job state of their own: they read `jobsViewState`, which is
the only thing that reads the Service. The **selection is a job ID, not a row
index.** Every path that changes the job list replaces the state's snapshot —
create, delete, and edit from this view's own handlers, adopting a different
jobs file from the Service, which the view only learns about through the refresh
`JobsLoaded` triggers. An index that outlives its snapshot points at whichever
job now sits there, so the details pane would describe one job while the list
highlighted another. Rows are derived from the ID at render time
(`selectedIndex`, `displayRow`), and `jobsView.refresh` ends by pointing the
list's highlight at the selected job.
### `settings_view.go` file structure
`src/ui/settings_view.go` is split across three files the same way, once its
+29
View File
@@ -39,6 +39,11 @@ the app icon (experimental).**
longer than its own interval no longer accumulates an unbounded backlog that
then runs back-to-back indefinitely. The job details pane now shows the
queued-run count (", N queued") whenever it is non-zero.
- **Start-only jobs are no longer tied to the application's lifetime.** A job
with *Start only* checked is launched on an uncancelable context, so quitting
GoSentry (or a run context being cancelled) can no longer try to kill a
process it deliberately stopped waiting for. This also removes a goroutine
that leaked on every start-only run and lived until the app exited.
- The History tab no longer grows without bound: it keeps the newest 1000
records and drops the oldest, the way a job's own activity list is capped.
Column widths are also folded in one record at a time instead of being
@@ -46,6 +51,17 @@ the app icon (experimental).**
gets slower the longer the app has been running. Measured on 5000 accumulated
records, one History redraw went from **15.8 ms to 0.9 ms**; at the new cap
the width rescan alone accounted for 1.5 ms of every redraw.
- **The Jobs tab keeps its selection on the job, not on the row.** Selecting a
different jobs file in Settings replaces the whole job list; the details pane
then described whichever job happened to land on the previously selected row —
or went blank if the new list was shorter — while the highlight in the list
stayed where it was. The selection now follows the job itself, and the
highlight and the details pane always describe the same one.
- **Max log files and max log age days now accept 0, meaning "keep
everything."** Log cleanup already supported disabling either policy; the
Settings form and the Service validator rejected the value that would have
turned it on. A config that already set either to 0 is no longer silently
rewritten back to the 100/30 defaults on load.
**Jobs:**
@@ -64,6 +80,19 @@ the app icon (experimental).**
- App-side failure-notification timing is appended to `logs/notify-timing.log`
for diagnosing toast delay (OS latency excluded). `scripts/measure-windows-toast.ps1`
measures the PowerShell baseline on Windows.
- File I/O no longer happens while `Service.mu` is held — that is the lock the
UI thread takes on every job and runtime read, so a JSON write, the
post-run log cleanup, or the startup log scan used to make a UI refresh wait
on the disk. Saves are now prepared under the lock and written after it is
released, in preparation order, so `jobs.json` still ends up matching the
in-memory list. Seeding statistics from logs also opens each log file once
instead of twice.
- The Jobs tab was split into `jobs_view.go` (construction, refresh, layout),
`jobs_view_state.go` (the job/runtime snapshot, folder filter, and selection),
`jobs_view_list.go`, and `jobs_view_toolbar.go`. What used to be one 330-line
constructor whose dozen closures shared seven mutable locals is now widgets
reading one named state object — which is what made the selection fix above a
change in one place instead of five.
## 1.0.1 - 2026-08-04
+22 -20
View File
@@ -123,24 +123,24 @@ Design notes / open questions:
[ARCHITECTURE.md](ARCHITECTURE.md) sets a ~250-line guideline per source file
and records the `jobs_view.go` and `settings_view.go` splits as the worked
examples. Six non-test files are over it at 1.0.0, including both files that
were already split once:
examples. `jobs_view.go` was split again in 1.0.2 — into view, state, list, and
toolbar — because the selection defect it carried was a symptom of the size
(one 330-line constructor over seven shared locals). Five non-test files are
over the guideline as of that pass:
| File | Lines |
|------|-------|
| `src/app/operations.go` | 490 |
| `src/ui/jobs_view.go` | 355 |
| `src/app/run.go` | 287 |
| `src/ui/history_view.go` | 282 |
| `src/ui/settings_view.go` | 277 |
| `src/storage/store.go` | 265 |
| `src/app/operations.go` | 529 |
| `src/ui/history_view.go` | 373 |
| `src/storage/store.go` | 365 |
| `src/ui/settings_view.go` | 318 |
| `src/app/run.go` | 274 |
This is deliberately deferred to the next whole-project review rather than done
piecemeal: a future review already asks item 2 to look for exactly this,
a split touches every reader of the file, and doing all six in one pass keeps
the seams consistent instead of settling them six different ways. Splitting is
The remaining five are deliberately deferred rather than done piecemeal: a
split touches every reader of the file, and doing them in one pass keeps the
seams consistent instead of settling them five different ways. Splitting is
also the kind of change that reads as pure movement while quietly dropping a
function, so it wants one careful pass, not six hurried ones.
function, so it wants one careful pass, not five hurried ones.
Seams visible today, as a starting point rather than a decision:
@@ -152,13 +152,15 @@ Seams visible today, as a starting point rather than a decision:
- **`history_view.go`** — the column-measuring helpers (`textWidth` through
`historyColumnWidths`) are pure, already unit-tested, and independent of the
table they size.
- **`jobs_view.go`** — nearly all of it is one `newJobsView` constructor, so the
split has to break that function up (list template, toolbar handlers,
assembly) rather than move whole functions. Larger judgement call than the
others.
- **`run.go`**, **`settings_view.go`**, **`store.go`** — barely over. Worth
re-measuring at the time; if a pass elsewhere has shrunk them, leave them
alone rather than splitting for the sake of the number.
- **`store.go`** — path resolution, the config load/normalize path, and the jobs
load/normalize path are three separate concerns in one file.
- **`run.go`**, **`settings_view.go`** — barely over. Worth re-measuring at the
time; if a pass elsewhere has shrunk them, leave them alone rather than
splitting for the sake of the number.
The `jobs_view.go` pass is the worked example for the rest: the constructor was
broken up along the state it shared, not along line count, and the split landed
with the selection fix rather than promising it separately.
Scope note: the guideline is about source files. Test files are much larger and
that is fine — a table-driven test file grows with the cases it covers.
+25
View File
@@ -11,6 +11,15 @@ in [ARCHITECTURE.md](ARCHITECTURE.md); test conventions in [TESTS.md](TESTS.md).
- Fixes with severity ≥ medium → regression test.
- Documented intentional behavior → section below, not a backlog bug.
- UI view constructors accept `*app.Service`; call `app.Open()` only from `run.go`.
- **No blocking file I/O under `Service.mu`.** It is the lock the Fyne main
thread takes on every `Jobs()` and `Runtime()` call, so a JSON write, a
log-directory scan, or a pass over every log header inside it makes a UI
refresh wait on the disk. Mutate state under the lock, snapshot what the I/O
needs, and run the I/O after `mu.Unlock()` — the way `emit()` already is.
Store writes go through `Service.deferSaveLocked` and `Store.PrepareSaveJobs` /
`Store.PrepareSaveConfig`, which take `saveMu` while `mu` is still held so
writes still reach the file in the order their snapshots were taken; log
cleanup and `runner.SeedStats` run from plain snapshots.
- A size that must follow the theme is **measured at build time, not written as
a pixel constant.** `theme.Padding()` and text metrics depend on the running
app's theme, text size, and DPI, so a hand-tuned number is only correct for
@@ -62,6 +71,22 @@ change to their shape has to stay compatible on its own.
= 0) and is overridable per job (`Job.TimeoutSeconds *int`: unset = inherit the
global default, 0 = no timeout, positive = seconds). Neither zero may be
normalized away on load — 0 is a value, not a missing field.
- **`Config.MaxLogFiles` and `Config.MaxLogAgeDays` of 0 mean "keep everything",
not "unset".** `runner.CleanupLogs` already treated `<= 0` as "policy
disabled"; `app.validateConfig` and the Settings form now accept 0 (only a
negative count is rejected), and `storage.loadOrCreateConfig` no longer
backfills 0 to 100 / 30 — a config written before either field existed still
picks up the default because `json.Unmarshal` leaves an absent key holding
whatever `DefaultConfig()` set, the same mechanism `DefaultTimeoutSeconds`
relies on.
- **A `StartOnly` process is expected to outlive GoSentry.** The option exists to
launch something and let go of it, so the runner builds that invocation on
`context.Background()`, not on the application's lifecycle context: quitting
GoSentry (or cancelling a run) does not stop a process it started this way, and
`Service.Stop()` reaches only jobs the runner is still waiting on. The
uncancelable context is also what keeps `os/exec` from leaving a watcher
goroutine per run — it only starts one when the context can be done, and
`StartOnly` never calls `Wait` to end it.
- **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the
current process. Log files on disk feed aggregate statistics via `SeedStats`
only. See [ARCHITECTURE.md](ARCHITECTURE.md).
+27 -1
View File
@@ -166,6 +166,8 @@ Tests all mutating operations on the Service, scheduler integration, and setting
| `TestUpdateSettingsAdoptsExistingJobsFile` | Verifies that selecting a jobs file that already exists replaces the job list with its contents, rebuilds runtimes, and emits `JobsLoaded`. |
| `TestUpdateSettingsKeepsJobsWhenTheNewFileIsMissing` | Verifies that a path with no file behind it receives the current jobs instead (the rename/relocate case). |
| `TestUpdateSettingsRefusesJobsFileSwitchWhileRunning` | Verifies that switching the jobs file is refused (and not persisted) while a job runs, while unrelated settings still save. |
| `TestUpdateSettingsSeedsAdoptedJobsFromLogs` | Verifies that statistics reconstructed from the new logs directory still reach the runtime map, now that the log scan happens before `UpdateSettings` takes `mu`. |
| `TestConcurrentJobOperationsLeaveTheFileMatchingMemory` | Verifies that saves prepared under `mu` and run after it is released still land in mutation order, so `jobs.json` matches the in-memory list after concurrent create/disable operations. |
| `TestSetJobListViewPersistsToConfigFile` | Verifies the Jobs-list density preference reaches `gosentry.json`, so the chosen view reopens after a restart. |
| `TestSetJobListViewNormalizesUnknownValue` | Verifies anything but `"compact"` is stored as `"detailed"`, so the config never gains a value no reader understands. |
| `TestPrependLogCapsActivityList` | Verifies that the activity log never grows beyond its maximum cap. |
@@ -319,6 +321,7 @@ Tests command execution, exit code handling, output capture, and the run timeout
|------|---------|
| `TestRunJobStartOnlyDoesNotWaitForExitCode` | Verifies that `StartOnly: true` jobs launch and return "OK" immediately without waiting for the process to exit. |
| `TestRunJobStartOnlyReportsStartFailure` | Verifies that `StartOnly: true` jobs still report "Failed" if the process cannot be started. |
| `TestRunJobStartOnlyLeavesNoContextWatcher` | Verifies that a start-only run leaves no `os/exec` context-watcher goroutine behind, since it never calls `Wait` and the started process is meant to outlive the app. |
---
@@ -468,11 +471,34 @@ widgets are assembled.
| `TestJobListViewCompactConfigOpensCompact` | Verifies the persisted density is honoured at build time, not only after a tap. |
| `TestJobsSidebarWidthIsItsContent` | Regression guard: nothing but the sidebar's own toolbar row imposes a width floor on it. |
| `TestJobsSplitOpensAtTheSidebarWidth` | Verifies the derived split offset opens the divider at the sidebar's own width at the default window size — enough that the toolbar is never born clipped, and no more. |
| `TestToolbarButtonRedrawsRowAndDetails` | Regression guard: with the duplicate refreshes removed from the handlers, `refreshView` alone must re-snapshot the jobs and repopulate the details pane. |
| `TestToolbarButtonRedrawsRowAndDetails` | Regression guard: with the duplicate refreshes removed from the handlers, `jobsView.refresh` alone must re-snapshot the jobs and repopulate the details pane. |
| `TestJobsViewSelectionSurvivesAJobsFileSwitch` | Regression guard: adopting a different jobs file replaces the whole list from the Service, and the refresh that follows must leave the details pane and the list highlight describing the same job — not redraw the pane from a row index that belonged to the previous list. |
| `TestDetailCaptionWidthCoversEveryCaption` | Verifies every caption `metadataRows` returns fits the measured caption column, which is what makes the single row list self-enforcing. |
---
### src/ui/jobs_view_state_test.go
**Package:** `ui`
Tests `jobsViewState`, the Jobs tab's model: the job/runtime snapshot, the
folder filter, and the ID-based selection. No Fyne app is built — the state
touches no widgets, so these run in milliseconds.
| Test | Purpose |
|------|---------|
| `TestJobsViewStateSelectsTheFirstJob` | Verifies the opening state selects the first row, so the details pane is never blank when there is something to show. |
| `TestJobsViewStateEmptyListSelectsNothing` | Verifies an empty job list leaves nothing selected and no row to highlight (`displayRow` = -1). |
| `TestJobsViewStateSelectionFollowsTheJobNotTheRow` | Regression guard: a job removed above the selected one (through the Service, the way an external change reaches the view) must not slide the selection onto its neighbour — the selection is a job ID, and only its row moves. |
| `TestJobsViewStateDropsSelectionWhenItsJobIsGone` | Verifies a selection whose job no longer exists falls back to the first visible row instead of describing whichever job inherited its position. |
| `TestJobsViewStateApplyFilter` | Verifies the folder filter keeps a selection it still shows, moves it to the folder's first row when it does not, and that "No folder" matches the job without one. |
| `TestJobsViewStateEmptyFilterSelectsNothing` | Verifies a filter matching no job is a filter choice, not an error state: nothing selected, nothing highlighted, and the selection returns when the filter is cleared. |
| `TestJobsViewStateHiddenSelectionIsNotHighlighted` | Verifies a selected job the filter hides reports no display row rather than falling back to row 0, which would highlight an unrelated job. |
| `TestJobsViewStateRuntimeIsNeverNil` | Verifies `runtime` returns an empty `JobRuntime` for a job the Service has none for, so callers need no nil check. |
| `TestJobsViewStateJobAtRejectsRowsOutsideTheFilter` | Verifies row lookups are bounded by the filtered rows, which is what the list widget draws from. |
---
### src/ui/history_view_test.go
**Package:** `ui`
+6 -3
View File
@@ -4,9 +4,12 @@ import "gitea.mixdep.ru/mix/gosentry/src/domain"
// Event is something the Service did to its state that observers may want to
// react to. It is a sealed interface: the concrete types in this file are the
// only implementations (enforced by the unexported isEvent marker), so a UI
// listener can exhaustively type-switch over them and the compiler will flag a
// new event type that a switch forgot to handle.
// only implementations (enforced by the unexported isEvent marker), so an
// Event handed to an Observer is always one of the types declared here — a
// caller outside this package cannot manufacture a new one. Go's type switch
// has no exhaustiveness check, so sealing buys that guarantee, not a
// compile-time warning when a new event type is added and a listener forgets
// to handle it; the listener still has to be updated by hand.
//
// Events replace the old single onChange callback. Instead of the scheduler
// reaching into the GUI, the Service emits typed events and the UI subscribes —
+66 -35
View File
@@ -43,15 +43,23 @@ func (s *Service) CreateJob(job domain.Job) (domain.Job, error) {
s.parseScheduleLocked(&job)
record := uiRecord(job.ID, job.Name, "Created", "Job was added")
prependLog(runtime, record)
err := s.store.SaveJobs(s.jobs)
if err != nil {
s.jobs = s.jobs[:len(s.jobs)-1]
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
s.mu.Unlock()
if err := save(); err != nil {
// The write is atomic, so a failure left the file holding the previous
// list: take the job back out so memory matches what is on disk. Another
// operation may have run in between, so it is removed by ID rather than by
// truncating the slice.
s.mu.Lock()
if index := s.indexByIDLocked(job.ID); index >= 0 {
s.jobs = append(s.jobs[:index], s.jobs[index+1:]...)
}
delete(s.runtimes, job.ID)
delete(s.schedules, job.ID)
s.mu.Unlock()
return domain.Job{}, err
}
s.mu.Unlock()
s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: job.ID})
return job, nil
@@ -87,10 +95,10 @@ func (s *Service) UpdateJob(job domain.Job) error {
s.refreshNextRunLocked(existing, runtime)
record := uiRecord(job.ID, job.Name, "Updated", "Job settings changed")
prependLog(runtime, record)
err := s.store.SaveJobs(s.jobs)
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
s.mu.Unlock()
if err != nil {
if err := save(); err != nil {
return err
}
s.emit(RunRecorded{Record: record})
@@ -113,10 +121,10 @@ func (s *Service) DeleteJob(id int) error {
delete(s.runtimes, id)
delete(s.schedules, id)
record := uiRecord(id, deleted.Name, "Deleted", "Job was removed")
err := s.store.SaveJobs(s.jobs)
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
s.mu.Unlock()
if err != nil {
if err := save(); err != nil {
return err
}
s.emit(RunRecorded{Record: record})
@@ -155,10 +163,10 @@ func (s *Service) SetEnabled(id int, enabled bool) error {
record = uiRecord(id, job.Name, "Paused", "Job was disabled")
}
prependLog(runtime, record)
err := s.store.SaveJobs(s.jobs)
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
s.mu.Unlock()
if err != nil {
if err := save(); err != nil {
return err
}
s.emit(RunRecorded{Record: record})
@@ -188,10 +196,10 @@ func (s *Service) SetGlobalPause(paused bool) error {
}
s.refreshNextRunFromLocked(job, runtime, now)
}
err := s.store.SaveConfig()
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
s.mu.Unlock()
if err != nil {
if err := save(); err != nil {
return err
}
state, detail := "Resumed", "All job execution resumed"
@@ -219,9 +227,9 @@ func (s *Service) SetJobListView(view domain.JobListView) error {
return nil
}
s.store.Config.JobListView = view
err := s.store.SaveConfig()
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
s.mu.Unlock()
return err
return save()
}
// ShouldNotifyOnFailure reports whether the user has enabled desktop
@@ -251,54 +259,75 @@ func (s *Service) UpdateSettings(config domain.Config) error {
config.JobsFile = strings.TrimSpace(config.JobsFile)
s.mu.Lock()
jobsPath := storage.ResolveConfiguredPath(s.store.Paths.AppDir, config.JobsFile)
// AppDir is fixed for the process and only UpdateSettings itself — a UI
// action — can move JobsPath, so this snapshot stays valid across the reads
// below.
appDir := s.store.Paths.AppDir
jobsPath := storage.ResolveConfiguredPath(appDir, config.JobsFile)
switching := jobsPath != s.store.Paths.JobsPath
if switching && s.anyRunningLocked() {
running := s.anyRunningLocked()
s.mu.Unlock()
if switching && running {
return errors.New("cannot change the jobs file while a job is running")
}
// Read the new file before anything is written, so a file that cannot be
// parsed leaves both the config and the current jobs untouched.
// Read the new file, and reconstruct its jobs' statistics from the logs the
// new config points at, before anything is written and while no lock is held:
// both are file I/O, and SeedStats opens every log in the directory. A file
// that cannot be parsed leaves both the config and the current jobs untouched.
var adopted []domain.Job
var seeds map[int]runner.SeededStats
if switching {
jobs, found, err := storage.LoadJobsFile(jobsPath)
if err != nil {
s.mu.Unlock()
return fmt.Errorf("read jobs file %s: %w", jobsPath, err)
}
if found {
adopted = jobs
seeds = runner.SeedStats(storage.ResolveConfiguredPath(appDir, config.LogsDir), jobs, config.MaxLogFiles)
}
}
s.store.Config = config
if err := s.store.SaveConfig(); err != nil {
s.mu.Lock()
// The guard above was evaluated before the reads, off the lock, so re-check
// it: a scheduled run may have started in the meantime, and adoption drops
// every runtime.
if switching && s.anyRunningLocked() {
s.mu.Unlock()
return err
return errors.New("cannot change the jobs file while a job is running")
}
s.store.Config = config
saveConfig := s.store.PrepareSaveConfig()
if adopted != nil {
s.adoptJobsLocked(adopted)
s.applySeededStatsLocked(seeds)
}
// SaveConfig re-resolved the paths from the new config, so SaveJobs writes to
// the (possibly new) jobs file and cleanup targets the new logs dir. Adopted
// jobs are written back too, which persists the IDs and defaults that
// normalization filled in, exactly as loading them at startup would.
if err := s.store.SaveJobs(s.jobs); err != nil {
s.mu.Unlock()
return err
}
// PrepareSaveConfig re-resolved the paths from the new config, so the jobs
// write targets the (possibly new) jobs file and cleanup targets the new logs
// dir. Adopted jobs are written back too, which persists the IDs and defaults
// that normalization filled in, exactly as loading them at startup would. The
// jobs write is skipped when the config write fails, because both writes run
// in the order prepared and stop at the first error.
save := s.deferSaveLocked(saveConfig, s.store.PrepareSaveJobs(s.jobs))
loaded := len(s.jobs)
logsDir := s.store.Paths.LogsDir
maxFiles := s.store.Config.MaxLogFiles
maxAge := s.store.Config.MaxLogAgeDays
s.mu.Unlock()
saveErr := save()
if adopted != nil {
// A broad JobChanged redraws the job list; JobsLoaded tells the user in
// History which file those jobs came from, since nothing was asked.
// History which file those jobs came from, since nothing was asked. Both
// are emitted even when the write failed: the adopted jobs are already the
// in-memory list, and a job list the user cannot see would be worse than
// the error they are about to be shown.
s.emit(JobsLoaded{Path: jobsPath, Count: loaded})
s.emit(JobChanged{})
}
if saveErr != nil {
return saveErr
}
return runner.CleanupLogs(logsDir, maxFiles, maxAge)
}
@@ -474,11 +503,13 @@ func validateConfig(config domain.Config) error {
if strings.TrimSpace(config.LogsDir) == "" {
return errors.New("logs directory is required")
}
if config.MaxLogFiles <= 0 {
return errors.New("max log files must be a positive number")
// 0 means "keep everything" (see runner.CleanupLogs); only a negative count
// is rejected, the same three-state shape as DefaultTimeoutSeconds below.
if config.MaxLogFiles < 0 {
return errors.New("max log files must be zero (unlimited) or a positive number")
}
if config.MaxLogAgeDays <= 0 {
return errors.New("max log age days must be a positive number")
if config.MaxLogAgeDays < 0 {
return errors.New("max log age days must be zero (unlimited) or a positive number")
}
if config.ExecutionMode != domain.ExecutionModeParallel && config.ExecutionMode != domain.ExecutionModeSequential {
return errors.New("execution mode must be 'parallel' or 'sequential'")
+119 -8
View File
@@ -3,8 +3,10 @@ package app
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
@@ -520,9 +522,9 @@ func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
svc := newTempService(t, nil)
bad := svc.store.Config
bad.MaxLogFiles = 0
bad.MaxLogFiles = -1
if err := svc.UpdateSettings(bad); err == nil {
t.Error("expected validation error for non-positive max log files")
t.Error("expected validation error for negative max log files")
}
good := svc.store.Config
@@ -531,8 +533,21 @@ func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
if err := svc.UpdateSettings(good); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if svc.Store().Config.MaxLogAgeDays != 7 || svc.Store().Config.NotifyOnFailure {
t.Errorf("config not applied: %+v", svc.Store().Config)
if svc.store.Config.MaxLogAgeDays != 7 || svc.store.Config.NotifyOnFailure {
t.Errorf("config not applied: %+v", svc.store.Config)
}
// 0 means "keep everything" (see STANDARDS §Intentional behavior), not an
// invalid value, so it must be accepted and persisted rather than rejected
// or silently backfilled.
unlimited := svc.store.Config
unlimited.MaxLogFiles = 0
unlimited.MaxLogAgeDays = 0
if err := svc.UpdateSettings(unlimited); err != nil {
t.Fatalf("UpdateSettings with zero retention limits: %v", err)
}
if svc.store.Config.MaxLogFiles != 0 || svc.store.Config.MaxLogAgeDays != 0 {
t.Errorf("zero retention limits not preserved: %+v", svc.store.Config)
}
}
@@ -547,8 +562,8 @@ func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
{"missing jobs file", func(c *domain.Config) { c.JobsFile = " " }},
{"jobs file without a file name", func(c *domain.Config) { c.JobsFile = "jobs" + string(filepath.Separator) }},
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
{"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }},
{"non-positive max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
{"negative max files", func(c *domain.Config) { c.MaxLogFiles = -1 }},
{"negative max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
{"negative default timeout", func(c *domain.Config) { c.DefaultTimeoutSeconds = -1 }},
}
for _, tc := range tests {
@@ -710,12 +725,12 @@ func TestUpdateSettingsRefusesJobsFileSwitchWhileRunning(t *testing.T) {
if err := svc.UpdateSettings(config); err == nil {
t.Error("expected the jobs-file switch to be refused while a job is running")
}
if svc.Store().Config.JobsFile == config.JobsFile {
if svc.store.Config.JobsFile == config.JobsFile {
t.Error("the refused switch must not have been persisted")
}
// A setting that does not touch the jobs file still saves during a run.
unrelated := svc.Store().Config
unrelated := svc.store.Config
unrelated.NotifyOnFailure = !unrelated.NotifyOnFailure
if err := svc.UpdateSettings(unrelated); err != nil {
t.Errorf("unrelated setting should still save during a run: %v", err)
@@ -725,6 +740,102 @@ func TestUpdateSettingsRefusesJobsFileSwitchWhileRunning(t *testing.T) {
waitRecord(t, done)
}
// Adoption reconstructs the adopted jobs' aggregate statistics from the log
// files the new configuration points at. That scan opens every log in the
// directory, so UpdateSettings runs it before taking the state lock; this pins
// that its result still reaches the runtime map.
func TestUpdateSettingsSeedsAdoptedJobsFromLogs(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Local", Schedule: "@every 1m", Command: "echo local", Enabled: true}})
logsDir := svc.store.Paths.LogsDir
if err := os.MkdirAll(logsDir, 0o755); err != nil {
t.Fatal(err)
}
log := "time: 2026-08-05 10:00:00\njob_id: 7\njob_name: Adopted\ntrigger: Schedule\nstate: Failed\ndetail: boom\nduration: 1500\n\nstdout:\n<empty>\n"
if err := os.WriteFile(filepath.Join(logsDir, "20260805-100000_Adopted.log"), []byte(log), 0o644); err != nil {
t.Fatal(err)
}
shared := filepath.Join(svc.store.Paths.AppDir, "shared.json")
data, err := json.Marshal(domain.JobsFile{Jobs: []domain.Job{
{ID: 7, Name: "Adopted", Schedule: "@every 5m", Command: "echo adopted", Enabled: true},
}})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(shared, data, 0o644); err != nil {
t.Fatal(err)
}
config := svc.store.Config
config.JobsFile = shared
if err := svc.UpdateSettings(config); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
runtime := svc.Runtime(7)
if runtime == nil {
t.Fatal("the adopted job has no runtime")
}
if runtime.RunCount != 1 || runtime.FailCount != 1 || runtime.LastDurationMS != 1500 {
t.Errorf("seeded stats: RunCount=%d FailCount=%d LastDurationMS=%d, want 1/1/1500",
runtime.RunCount, runtime.FailCount, runtime.LastDurationMS)
}
}
// Job saves run after mu is released, so one operation can be writing while
// another mutates state. deferSaveLocked takes its own lock while mu is still
// held, which is what keeps writes in mutation order: whatever changed the list
// last also wrote it last, so the file ends up matching memory instead of
// holding an older snapshot.
func TestConcurrentJobOperationsLeaveTheFileMatchingMemory(t *testing.T) {
svc := newTempService(t, nil)
const workers = 8
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
job, err := svc.CreateJob(domain.Job{Name: fmt.Sprintf("Job %d", i), Schedule: "@every 1m", Command: "echo hi", Enabled: true})
if err != nil {
t.Errorf("CreateJob %d: %v", i, err)
return
}
if err := svc.SetEnabled(job.ID, false); err != nil {
t.Errorf("SetEnabled %d: %v", job.ID, err)
}
}(i)
}
wg.Wait()
memory := svc.Jobs()
if len(memory) != workers {
t.Fatalf("jobs in memory = %d, want %d", len(memory), workers)
}
saved, found, err := storage.LoadJobsFile(svc.store.Paths.JobsPath)
if err != nil || !found {
t.Fatalf("read jobs file: found=%v err=%v", found, err)
}
if len(saved) != len(memory) {
t.Fatalf("jobs on disk = %d, want %d: the last write must be the last mutation", len(saved), len(memory))
}
onDisk := make(map[int]domain.Job, len(saved))
for _, job := range saved {
onDisk[job.ID] = job
}
for _, job := range memory {
got, ok := onDisk[job.ID]
if !ok {
t.Errorf("job %d (%q) is in memory but missing from the file", job.ID, job.Name)
continue
}
if got.Name != job.Name || got.Enabled != job.Enabled {
t.Errorf("job %d on disk = %q/%v, want %q/%v", job.ID, got.Name, got.Enabled, job.Name, job.Enabled)
}
}
}
func TestPrependLogCapsActivityList(t *testing.T) {
runtime := &domain.JobRuntime{}
for i := 0; i < maxJobLogs+10; i++ {
+6 -2
View File
@@ -147,7 +147,6 @@ func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger st
record, logErr := s.runJob(ctx, &jobCopy, trigger, env.logsDir, env.timeout)
s.mu.Lock()
var cleanupErr error
var rerunStarted bool
if current := s.findByIDLocked(jobCopy.ID); current != nil {
runtime := s.runtimeForLocked(current)
@@ -166,10 +165,15 @@ func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger st
} else {
s.refreshNextRunLocked(current, runtime)
}
cleanupErr = runner.CleanupLogs(env.logsDir, env.maxFiles, env.maxAge)
}
s.mu.Unlock()
// Cleanup is a directory scan plus up to MaxLogFiles unlinks. It needs only
// the values already snapshotted into runEnv, so it runs after mu is released
// rather than making every UI refresh wait behind it. It runs even when the
// job is gone, because the run still wrote a log file that retention covers.
cleanupErr := runner.CleanupLogs(env.logsDir, env.maxFiles, env.maxAge)
if logErr != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("write run log for %q: %w", jobCopy.Name, logErr)})
}
+69 -13
View File
@@ -26,7 +26,10 @@ import (
// it; unexported helpers ending in "Locked" assume the caller already holds it.
// The Service must never call back into the UI (or any code that might re-enter
// the Service) while holding mu — in particular emit() is always called after
// mu is released.
// mu is released. Blocking file I/O follows the same rule: mu is the lock the
// Fyne main thread takes on every Jobs() and Runtime() call, so a JSON write, a
// log-directory scan, or a pass over every log header must not happen inside it
// (see deferSaveLocked, executeRun, and applySeededStatsLocked).
type Service struct {
mu sync.Mutex
store *storage.Store
@@ -56,6 +59,13 @@ type Service struct {
// do not exercise autostart; Open() wires it via autostart.New().
manager autostart.Manager
// saveMu serializes the store writes that operations prepare under mu and run
// after releasing it. It is taken while mu is still held and released once the
// write is done, so writes reach the file in the same order their snapshots
// were taken and an older snapshot can never land on top of a newer one.
// Nothing may take mu while holding saveMu.
saveMu sync.Mutex
// observers and their guard live in events.go. dispatchMu is separate from mu
// so that emitting an event never requires (or is held under) the state lock:
// the Service must release mu before dispatching, per the locking contract.
@@ -63,6 +73,26 @@ type Service struct {
observers []Observer
}
// deferSaveLocked prepares the store writes for the caller to run after mu is
// released, and takes saveMu now so a later operation's write cannot overtake
// this one. The caller must hold mu, must unlock it before calling the returned
// function, and must call that function exactly once. Keeping the marshal, the
// fsync, and the rename out of the critical section is what stops a settings
// change or a job edit from blocking a scheduler tick or a finishing run. The
// writes run in the order given and stop at the first error.
func (s *Service) deferSaveLocked(writes ...func() error) func() error {
s.saveMu.Lock()
return func() error {
defer s.saveMu.Unlock()
for _, write := range writes {
if err := write(); err != nil {
return err
}
}
return nil
}
}
// NewService wires the Service to a loaded store and its jobs. It builds the
// initial runtime map from the durable jobs so every job has transient state
// from the moment the Service exists, and parses each job's schedule once. The
@@ -77,18 +107,19 @@ func NewService(store *storage.Store, jobs []domain.Job) *Service {
// No lock is needed here: construction is single-threaded, before Start
// launches the timing loop.
s.adoptJobsLocked(jobs)
s.applySeededStatsLocked(runner.SeedStats(store.Paths.LogsDir, s.jobs, store.Config.MaxLogFiles))
return s
}
// adoptJobsLocked makes jobs the Service's durable state and rebuilds everything
// derived from it: the runtime map, the parsed-schedule cache, each job's first
// next-run — so the Service is ready to schedule the moment it exists, mirroring
// the old scheduler's reset-on-construction — and the statistics seeded from
// existing log files, so the details panel shows accumulated run history
// immediately rather than only runs since this process started.
// derived from it: the runtime map, the parsed-schedule cache, and each job's
// first next-run — so the Service is ready to schedule the moment it exists,
// mirroring the old scheduler's reset-on-construction.
//
// It backs both construction and a Settings change that points at a different
// jobs file. The caller must hold mu.
// jobs file. Statistics seeded from existing log files are applied separately by
// applySeededStatsLocked, because reconstructing them is file I/O. The caller
// must hold mu.
func (s *Service) adoptJobsLocked(jobs []domain.Job) {
s.jobs = jobs
s.runtimes = domain.NewRuntimes(jobs)
@@ -100,7 +131,16 @@ func (s *Service) adoptJobsLocked(jobs []domain.Job) {
s.parseScheduleLocked(job)
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
}
for id, seed := range runner.SeedStats(s.store.Paths.LogsDir, s.jobs, s.store.Config.MaxLogFiles) {
}
// applySeededStatsLocked folds statistics reconstructed from existing log files
// into the runtime map, so the details panel shows accumulated run history
// immediately rather than only runs since this process started. It is separate
// from adoptJobsLocked because producing the seeds opens every log file in the
// directory, which must not happen under mu: callers compute the map first and
// apply it here. The caller must hold mu.
func (s *Service) applySeededStatsLocked(seeds map[int]runner.SeededStats) {
for id, seed := range seeds {
runtime := s.runtimes[id]
if runtime == nil {
continue
@@ -164,11 +204,27 @@ func Open() (*Service, error) {
return svc, nil
}
// Store returns the underlying store. It is exposed so callers that still need
// resolved paths and config (the GUI, during the transition) can reach them;
// later phases narrow this surface.
func (s *Service) Store() *storage.Store {
return s.store
// Config returns a copy of the current application configuration, safe to
// call from any goroutine. UpdateSettings, SetGlobalPause, and SetJobListView
// are the only writers and all mutate store.Config under mu; copying under the
// same lock is what keeps a UI read from racing them, instead of holding onto
// the *storage.Store this used to hand out (see STANDARDS: the UI reads
// Service state through typed events and accessors, never shared mutable
// state).
func (s *Service) Config() domain.Config {
s.mu.Lock()
defer s.mu.Unlock()
return s.store.Config
}
// Paths returns a copy of the store's resolved filesystem paths. AppDir and
// ConfigPath are fixed for the process; JobsPath, JobsDir, and LogsDir are
// re-derived under mu on every settings save (storage.Store.applyConfigPaths),
// so this copies under the same lock as Config for the same reason.
func (s *Service) Paths() storage.Paths {
s.mu.Lock()
defer s.mu.Unlock()
return s.store.Paths
}
// Jobs returns a copy of the durable jobs slice. Returning a copy keeps callers
+4 -4
View File
@@ -18,16 +18,16 @@ type linuxManager struct{}
func New() Manager { return linuxManager{} }
func (linuxManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
return SetAutostart(enabled, startInTray, executablePath, iconPath)
return setAutostart(enabled, startInTray, executablePath, iconPath)
}
func (linuxManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, startInTray, executablePath)
return autostartStatus(expectedEnabled, startInTray, executablePath)
}
const autostartDesktopFileName = "gosentry.desktop"
func SetAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
func setAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
desktopPath, err := autostartDesktopPath()
if err != nil {
return err
@@ -58,7 +58,7 @@ X-GNOME-Autostart-enabled=true
return nil
}
func AutostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
func autostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
desktopPath, err := autostartDesktopPath()
if err != nil {
return false, "Cannot resolve XDG autostart directory"
@@ -14,7 +14,7 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
executablePath := "/opt/Go Sentry/gosentry"
if err := SetAutostart(true, true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
if err := setAutostart(true, true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
t.Fatalf("enable autostart: %v", err)
}
@@ -37,7 +37,7 @@ func TestLinuxAutostartWithoutTrayFlag(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
executablePath := "/opt/Go Sentry/gosentry"
if err := SetAutostart(true, false, executablePath, ""); err != nil {
if err := setAutostart(true, false, executablePath, ""); err != nil {
t.Fatalf("enable autostart: %v", err)
}
+4 -4
View File
@@ -10,21 +10,21 @@ type otherManager struct{}
func New() Manager { return otherManager{} }
func (otherManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
return SetAutostart(enabled, startInTray, executablePath, iconPath)
return setAutostart(enabled, startInTray, executablePath, iconPath)
}
func (otherManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, startInTray, executablePath)
return autostartStatus(expectedEnabled, startInTray, executablePath)
}
func SetAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
func setAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
if !enabled {
return nil
}
return fmt.Errorf("autostart is not implemented for this platform")
}
func AutostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
func autostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
if !expectedEnabled {
return true, "Autostart is off"
}
+5 -5
View File
@@ -17,17 +17,17 @@ type windowsManager struct{}
func New() Manager { return windowsManager{} }
func (windowsManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
return SetAutostart(enabled, startInTray, executablePath, iconPath)
return setAutostart(enabled, startInTray, executablePath, iconPath)
}
func (windowsManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, startInTray, executablePath)
return autostartStatus(expectedEnabled, startInTray, executablePath)
}
const autostartName = "GoSentry"
const startupShortcutFile = autostartName + ".lnk"
func SetAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
func setAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
// Windows autostart used to write HKCU\Run values, but that approach became
// brittle once paths with spaces and the "--start-in-tray" argument entered
// the picture. A Startup-folder shortcut stores target path and arguments as
@@ -44,7 +44,7 @@ func SetAutostart(enabled bool, startInTray bool, executablePath string, iconPat
return removeIfExists(shortcutPath)
}
func AutostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
func autostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
shortcutPath, err := startupShortcutPath()
if err != nil {
return false, "Startup folder cannot be resolved"
@@ -126,7 +126,7 @@ func readShortcut(shortcutPath string) (string, string, error) {
// OEM code page (e.g. CP866 on Russian Windows). Without this override,
// [Console]::Out.Write encodes Cyrillic and other non-ASCII characters as
// OEM bytes; Go then reads them as UTF-8 and gets a different string from
// os.Executable, causing AutostartStatus to report "shortcut points to
// os.Executable, causing autostartStatus to report "shortcut points to
// another executable" for any install path that contains non-ASCII chars.
// New-Object System.Text.UTF8Encoding($false) is UTF-8 without BOM.
script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)`
@@ -134,7 +134,7 @@ func TestAutostartStatusRequiresMatchingTrayFlag(t *testing.T) {
t.Fatalf("create shortcut: %v", err)
}
ok, message := AutostartStatus(true, false, targetPath)
ok, message := autostartStatus(true, false, targetPath)
if ok {
t.Fatalf("expected problem when tray flag mismatches, got OK: %s", message)
}
+5
View File
@@ -9,6 +9,11 @@ import (
"time"
)
// CleanupLogs enforces the count and age retention policies on the .log files
// in logsDir. maxFiles <= 0 disables the count policy and maxAgeDays <= 0
// disables the age policy, independently — "keep everything" is a value the
// user can choose in Settings, not just an internal default (STANDARDS
// §Intentional behavior).
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
entries, err := os.ReadDir(logsDir)
if err != nil {
+9 -1
View File
@@ -36,7 +36,15 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
var detail string
var durationMS int64
if job.StartOnly {
invocation := jobInvocation(ctx, *job)
// A StartOnly process is deliberately never waited for, so it must not be
// tied to any cancelable context: exec.CommandContext leaves a watcher
// goroutine alive until Wait returns or the context is done, and since
// StartOnly never calls Wait that goroutine would live for the rest of the
// process — one per run — and then try to kill a process whose handle
// startJobOnly has already released. context.Background() has a nil Done
// channel, so os/exec starts no watcher at all and the started process is
// left to outlive GoSentry, which is the point of the option.
invocation := jobInvocation(context.Background(), *job)
// StartOnly jobs don't wait for process exit, so the duration measures
// launch latency (time to spawn the process) rather than run time.
state, detail, output, durationMS = startJobOnly(invocation, *job, started)
+52
View File
@@ -401,6 +401,58 @@ func TestRunJobZeroTimeoutMeansNoTimeout(t *testing.T) {
}
}
// A StartOnly run must not leave a watcher goroutine behind. exec.CommandContext
// keeps one alive until Wait returns or the context is done, and StartOnly never
// waits, so binding it to the caller's cancelable context would leak one
// goroutine per run for the lifetime of the app — and then, on shutdown, kill a
// process whose handle startJobOnly has already released.
func TestRunJobStartOnlyLeavesNoContextWatcher(t *testing.T) {
command := "sh"
arguments := "-c\nexit 0"
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
arguments = "/C\nexit /b 0"
}
job := domain.Job{
ID: 53,
Name: "Start Only Goroutines",
Command: command,
Arguments: arguments,
StartOnly: true,
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
const runs = 5
before := settledGoroutines()
for i := 0; i < runs; i++ {
if _, err := RunJob(ctx, &job, "Manual", t.TempDir(), 30*time.Second); err != nil {
t.Fatal(err)
}
}
// Counted before cancel on purpose: a watcher would still be parked on
// ctx.Done() at this point, and cancelling first would release it.
if leaked := settledGoroutines() - before; leaked > 1 {
t.Errorf("%d goroutines left after %d StartOnly runs, want none tied to the run context", leaked, runs)
}
}
// settledGoroutines returns the goroutine count once it has stopped falling, so
// a goroutine that is still on its way out is not mistaken for a leak.
func settledGoroutines() int {
lowest := runtime.NumGoroutine()
for stable, i := 0, 0; stable < 3 && i < 100; i++ {
time.Sleep(10 * time.Millisecond)
if count := runtime.NumGoroutine(); count < lowest {
lowest, stable = count, 0
continue
}
stable++
}
return lowest
}
func TestRunJobStartOnlyIgnoresTimeout(t *testing.T) {
command := "sh"
arguments := "-c\nsleep 5"
+51 -55
View File
@@ -45,8 +45,8 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
return result
}
byID := make(map[int][]string)
byName := make(map[string][]string)
byID := make(map[int][]logSummary)
byName := make(map[string][]logSummary)
for _, entry := range entries {
if entry.IsDir() {
continue
@@ -55,9 +55,10 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
if !strings.HasSuffix(strings.ToLower(name), ".log") {
continue
}
path := filepath.Join(logsDir, name)
if jobID, ok := readLogJobID(path); ok {
byID[jobID] = append(byID[jobID], name)
summary := readLogSummary(filepath.Join(logsDir, name))
summary.name = name
if summary.hasJobID {
byID[summary.jobID] = append(byID[summary.jobID], summary)
continue
}
base := name[:len(name)-len(".log")]
@@ -65,7 +66,7 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
if idx < 0 {
continue
}
byName[base[idx+1:]] = append(byName[base[idx+1:]], name)
byName[base[idx+1:]] = append(byName[base[idx+1:]], summary)
}
for _, job := range jobs {
@@ -76,37 +77,37 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
if len(files) == 0 {
continue
}
// The timestamp prefix sorts chronologically, so a lexical sort puts the
// oldest first; keep the newest maxFiles to honor the retention bound.
sort.Strings(files)
// The timestamp prefix sorts chronologically, so a lexical sort by file
// name puts the oldest first; keep the newest maxFiles to honor the
// retention bound.
sort.Slice(files, func(i, j int) bool { return files[i].name < files[j].name })
if maxFiles > 0 && len(files) > maxFiles {
files = files[len(files)-maxFiles:]
}
result[job.ID] = aggregateLogStats(logsDir, files)
result[job.ID] = aggregateLogStats(files)
}
return result
}
// aggregateLogStats folds the header of each log file (oldest first) into one
// SeededStats. Files lacking a duration line contribute to the run/fail counts
// but not to the duration aggregates.
func aggregateLogStats(logsDir string, files []string) SeededStats {
// aggregateLogStats folds the already-read header of each log file (oldest
// first) into one SeededStats. Files lacking a duration line contribute to the
// run/fail counts but not to the duration aggregates.
func aggregateLogStats(files []logSummary) SeededStats {
var stats SeededStats
var durationSum int64
var durationCount int
for _, file := range files {
state, durationMS, hasDuration := readLogHeader(filepath.Join(logsDir, file))
stats.RunCount++
if state == "Failed" {
if file.state == "Failed" {
stats.FailCount++
}
if hasDuration {
if file.hasDuration {
// Files are oldest first, so the last assignment is the newest run.
stats.LastDurationMS = durationMS
if durationMS > stats.MaxDurationMS {
stats.MaxDurationMS = durationMS
stats.LastDurationMS = file.durationMS
if file.durationMS > stats.MaxDurationMS {
stats.MaxDurationMS = file.durationMS
}
durationSum += durationMS
durationSum += file.durationMS
durationCount++
}
}
@@ -117,39 +118,29 @@ func aggregateLogStats(logsDir string, files []string) SeededStats {
return stats
}
// readLogJobID reads the job_id field from a log file header.
func readLogJobID(path string) (int, bool) {
file, err := os.Open(path)
if err != nil {
return 0, false
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
break
}
if rest, ok := strings.CutPrefix(line, "job_id: "); ok {
id, err := strconv.Atoi(strings.TrimSpace(rest))
if err != nil {
return 0, false
}
return id, true
}
}
return 0, false
// logSummary is everything SeedStats needs from one run log: the file name it
// sorts by, which job wrote it, how the run ended, and how long it took.
type logSummary struct {
name string
jobID int
hasJobID bool
state string
durationMS int64
hasDuration bool
}
// readLogHeader reads the "state" and "duration" fields from a log file's
// header (the lines before the first blank line). hasDuration reports whether a
// well-formed duration line was present, distinguishing a legacy duration-less
// log from one that genuinely recorded a zero-millisecond run.
func readLogHeader(path string) (state string, durationMS int64, hasDuration bool) {
// readLogSummary reads the job_id, state, and duration fields from a log file's
// header (the lines before the first blank line) in a single pass, so seeding
// opens each log once rather than once to find its job and again to read its
// result. The has* flags report whether a well-formed line was present,
// distinguishing a legacy log written before the field existed from one that
// genuinely recorded a zero value. An unreadable file yields a zero summary,
// which falls back to matching by the job name in the file name.
func readLogSummary(path string) logSummary {
var summary logSummary
file, err := os.Open(path)
if err != nil {
return "", 0, false
return summary
}
defer file.Close()
@@ -159,14 +150,19 @@ func readLogHeader(path string) (state string, durationMS int64, hasDuration boo
if line == "" {
break // end of header
}
if rest, ok := strings.CutPrefix(line, "state: "); ok {
state = strings.TrimSpace(rest)
if rest, ok := strings.CutPrefix(line, "job_id: "); ok {
if id, err := strconv.Atoi(strings.TrimSpace(rest)); err == nil {
summary.jobID = id
summary.hasJobID = true
}
} else if rest, ok := strings.CutPrefix(line, "state: "); ok {
summary.state = strings.TrimSpace(rest)
} else if rest, ok := strings.CutPrefix(line, "duration: "); ok {
if value, err := strconv.ParseInt(strings.TrimSpace(rest), 10, 64); err == nil {
durationMS = value
hasDuration = true
summary.durationMS = value
summary.hasDuration = true
}
}
}
return state, durationMS, hasDuration
return summary
}
+41 -13
View File
@@ -65,19 +65,47 @@ func OpenStore() (*Store, []domain.Job, error) {
return store, jobs, nil
}
func (s *Store) SaveConfig() error {
// PrepareSaveConfig re-resolves the derived paths from the current config and
// snapshots everything the write needs, returning the write itself as a closure.
// It exists so a caller that guards the Store with its own lock can do the file
// I/O — a marshal, an fsync, and a rename — after releasing that lock: the
// snapshot cannot change under the closure, so running it unlocked is safe.
// Prepared writes must be run in the order they were prepared, or an older
// snapshot can land on top of a newer one.
func (s *Store) PrepareSaveConfig() func() error {
s.applyConfigPaths()
if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil {
dir := s.Paths.AppDir
path := s.Paths.ConfigPath
config := s.Config
return func() error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return writeJSON(s.Paths.ConfigPath, s.Config)
return writeJSON(path, config)
}
}
// PrepareSaveJobs is PrepareSaveConfig for the jobs file. The jobs slice is
// copied, so the caller may keep mutating its own slice as soon as this returns.
func (s *Store) PrepareSaveJobs(jobs []domain.Job) func() error {
dir := s.Paths.JobsDir
path := s.Paths.JobsPath
snapshot := make([]domain.Job, len(jobs))
copy(snapshot, jobs)
return func() error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return writeJSON(path, domain.JobsFile{Jobs: snapshot})
}
}
func (s *Store) SaveConfig() error {
return s.PrepareSaveConfig()()
}
func (s *Store) SaveJobs(jobs []domain.Job) error {
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
return err
}
return writeJSON(s.Paths.JobsPath, domain.JobsFile{Jobs: jobs})
return s.PrepareSaveJobs(jobs)()
}
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
@@ -116,12 +144,12 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
if strings.TrimSpace(config.LogsDir) == "" {
config.LogsDir = "logs"
}
if config.MaxLogFiles <= 0 {
config.MaxLogFiles = 100
}
if config.MaxLogAgeDays <= 0 {
config.MaxLogAgeDays = 30
}
// MaxLogFiles and MaxLogAgeDays are deliberately not normalized: 0 means
// "keep everything" (see runner.CleanupLogs), not a missing value, so
// backfilling it here would make that choice impossible to persist. A config
// written before either field existed already carries 0 from json.Unmarshal
// leaving the DefaultConfig() value in config untouched, so old files still
// pick up 100 / 30 without an explicit backfill.
if config.ExecutionMode == "" {
config.ExecutionMode = domain.ExecutionModeParallel
}
+29
View File
@@ -183,6 +183,35 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
}
}
// TestLoadOrCreateConfigPreservesZeroRetentionLimits verifies that 0 in
// max_log_files / max_log_age_days is read back as 0 ("keep everything"), not
// backfilled to the 100 / 30 defaults, since a config that already has the
// field set is not the "field is missing" case loadOrCreateConfig backfills.
func TestLoadOrCreateConfigPreservesZeroRetentionLimits(t *testing.T) {
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, ConfigFileName),
}
want := domain.DefaultConfig()
want.MaxLogFiles = 0
want.MaxLogAgeDays = 0
if err := writeJSON(paths.ConfigPath, want); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateConfig(paths)
if err != nil {
t.Fatal(err)
}
if got.MaxLogFiles != 0 {
t.Errorf("MaxLogFiles: got %d, want 0 (unlimited)", got.MaxLogFiles)
}
if got.MaxLogAgeDays != 0 {
t.Errorf("MaxLogAgeDays: got %d, want 0 (unlimited)", got.MaxLogAgeDays)
}
}
// TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun verifies that a missing
// jobs.json is created with the sample jobs from defaultJobs, so a new user
// sees scheduled and manual execution without inventing a command.
+151 -308
View File
@@ -1,8 +1,6 @@
package ui
import (
"fmt"
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain"
@@ -22,340 +20,185 @@ const noFolder = "No folder"
// view; this panel is a quick at-a-glance summary anchored below the output.
const maxJobActivityRows = 3
// jobsView owns the Jobs tab: the widgets, the view-only preferences they draw
// (list mode and the scheduler pause label), and the jobsViewState the widgets
// read. It replaces a single constructor whose dozen closures shared seven
// mutable locals — the state each handler touches is now named on the struct
// rather than captured, and the invariants that used to be maintained by hand in
// five places live on jobsViewState.
type jobsView struct {
w fyne.Window
svc *app.Service
state *jobsViewState
dp *detailsPanel
list *widget.List
folderSelect *widget.Select
viewButton *widget.Button
stopAllButton *widget.Button
schedulerState *widget.Label
// listView and paused mirror Service-owned config so the widgets can be
// relabelled without a round trip. Both are re-read from the Service on every
// refresh; neither is a source of truth.
listView domain.JobListView
paused bool
}
// newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar.
// 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
// in mainwindow.go). The refresh function re-reads the service snapshot and
// redraws all widgets in the jobs view; it does NOT touch history or settings.
func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
jobs := svc.Jobs()
runtimes := make(map[int]*domain.JobRuntime, len(jobs))
syncFromService := func() {
jobs = svc.Jobs()
for id := range runtimes {
delete(runtimes, id)
config := svc.Config()
v := &jobsView{
w: w,
svc: svc,
state: newJobsViewState(svc),
listView: config.JobListView,
paused: config.Paused,
}
for _, current := range jobs {
if rt := svc.Runtime(current.ID); rt != nil {
runtimes[current.ID] = rt
}
}
}
syncFromService()
runtimeFor := func(index int) *domain.JobRuntime {
if index < 0 || index >= len(jobs) {
return &domain.JobRuntime{}
}
if rt := runtimes[jobs[index].ID]; rt != nil {
return rt
}
return &domain.JobRuntime{}
v.dp = newDetailsPanel(job{}, &domain.JobRuntime{}, config.OverlapPolicy, config.DefaultTimeoutSeconds)
v.updateDetails()
// Build order follows what refresh() touches: the folder select fires its
// OnChanged from SetSelected below, which refreshes, so every widget that
// refresh() reaches has to exist by then.
v.list = v.newList()
v.viewButton = v.newViewToggle()
globalControls := v.newGlobalControls()
v.folderSelect = v.newFolderSelect()
v.folderSelect.SetSelected(v.state.folder)
v.syncListSelection()
return v.assemble(globalControls), v.refresh
}
selected := 0
if len(jobs) == 0 {
selected = -1
}
selectedFolder := allFolders
schedulerPaused := svc.Store().Config.Paused
listView := svc.Store().Config.JobListView
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
if selected >= 0 {
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
} else {
dp.clear()
// refresh re-reads the Service and redraws the whole view. It is the single
// entry point for "something changed": the toolbar handlers call it after a
// successful operation, and mainwindow's event observer calls it for everything
// else.
func (v *jobsView) refresh() {
v.state.sync()
// The pause state is Service-owned and can change from outside this view, so
// it is re-read here rather than mirrored from the tap handler alone — that is
// what makes this view a consumer of SchedulerStateChanged.
v.applySchedulerState(v.svc.Config().Paused)
v.updateDetails()
v.dp.logs.Refresh()
v.list.Refresh()
v.syncListSelection()
}
updateDetails := func(index int) {
if index < 0 || index >= len(jobs) {
// A folder filter can temporarily leave no selectable rows. Clearing
// the details panel avoids showing stale information for a hidden job.
dp.clear()
// updateDetails repopulates the details pane from the current selection.
func (v *jobsView) updateDetails() {
current, ok := v.state.selected()
if !ok {
// A folder filter can temporarily leave no selectable rows. Clearing the
// details panel avoids showing stale information for a hidden job.
v.dp.clear()
return
}
selected = index
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
// Overlap policy and the default timeout are global settings that can change
// from the Settings tab while this view is open, so they are re-read on every
// update rather than captured once at construction.
config := v.svc.Config()
v.dp.update(current, v.state.runtime(current.ID), config.OverlapPolicy, config.DefaultTimeoutSeconds)
}
// list and folderSelect are declared early so closures below can reference
// them before the widget.NewList / widget.NewSelect calls assign the values.
var list *widget.List
var folderSelect *widget.Select
refreshView := func() {
syncFromService()
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
updateDetails(selected)
dp.logs.Refresh()
if list != nil {
list.Refresh()
// syncListSelection points the list's highlight at the selected job. It is what
// keeps the highlight and the details pane describing the same job when the row
// a job sits in moves — a job created or deleted above it, a folder filter
// applied, or a different jobs file adopted. widget.List.Select returns early
// when the row is already highlighted, so calling this on every refresh does not
// fight the user's scrolling.
func (v *jobsView) syncListSelection() {
row := v.state.displayRow()
if row < 0 {
v.list.UnselectAll()
return
}
v.list.Select(row)
}
// applyRowMode expresses the current view mode as visibility on the row's
// four labels. widget.List caches the row template's MinSize, and
// list.Refresh() re-creates the template and recomputes it, so hiding lines
// is what actually shrinks the rows: layout.NewCustomPaddedVBoxLayout and the
// border layout both skip hidden children when measuring.
applyRowMode := func(inlineStatus, meta, status fyne.CanvasObject) {
if listView.IsCompact() {
inlineStatus.Show()
meta.Hide()
status.Hide()
return
}
inlineStatus.Hide()
meta.Show()
status.Show()
// rebuildFolders re-derives the folder filter's options from the current jobs.
// Creating, editing, and deleting a job can all add or remove a folder.
func (v *jobsView) rebuildFolders() {
v.folderSelect.Options = folderOptions(v.state.jobs)
v.folderSelect.Refresh()
}
list = widget.NewList(
func() int { return len(filteredJobs) },
func() fyne.CanvasObject {
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
// Truncating stops a long name from pushing the compact row's status
// off the right-hand edge. Labels default to TextWrapOff, which grows
// the widget to fit instead.
name.Truncation = fyne.TextTruncateClip
inlineStatus := widget.NewLabel("status")
meta := widget.NewLabel("schedule")
status := widget.NewLabel("status")
applyRowMode(inlineStatus, meta, status)
nameLine := container.NewBorder(nil, nil, nil, inlineStatus, name)
return container.New(layout.NewCustomPaddedVBoxLayout(rowOverlap()), nameLine, meta, status)
},
func(id widget.ListItemID, item fyne.CanvasObject) {
row := item.(*fyne.Container)
// NewBorder keeps the center object first and appends the border slots
// after it, so nameLine is [name, inlineStatus].
nameLine := row.Objects[0].(*fyne.Container)
name := nameLine.Objects[0].(*widget.Label)
inlineStatus := nameLine.Objects[1].(*widget.Label)
meta := row.Objects[1].(*widget.Label)
status := row.Objects[2].(*widget.Label)
current := jobs[filteredJobs[id]]
name.SetText(current.Name)
// Keep each row compact: folder, schedule, and command are shown in one
// metadata line so the left pane stays useful even with many jobs.
meta.SetText(app.DisplayFolder(current.Folder) + " " + current.Schedule + " " + app.DisplayInvocation(current))
statusText := app.StatusText(current, runtimes[current.ID])
status.SetText(statusText)
inlineStatus.SetText(statusText)
// A full Refresh reuses rows built under the previous mode, so
// visibility cannot be left to the create callback alone.
applyRowMode(inlineStatus, meta, status)
},
)
list.OnSelected = func(id widget.ListItemID) {
if id < 0 || id >= len(filteredJobs) {
updateDetails(-1)
return
}
updateDetails(filteredJobs[id])
}
if len(filteredJobs) > 0 && selected >= 0 {
list.Select(app.DisplayIndex(filteredJobs, selected))
}
folderSelect = widget.NewSelect(folderOptions(jobs), func(value string) {
if value == "" {
return
}
selectedFolder = value
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
if len(filteredJobs) == 0 {
// The "No folder" filter is intentionally allowed to be empty. It is a
// real filter choice, not an error state, so the selection is cleared.
// This path returns without reaching refreshView(), so it is the one
// place the list has to be redrawn by hand.
selected = -1
updateDetails(-1)
list.Refresh()
return
}
selected = filteredJobs[0]
list.Select(0)
refreshView()
})
folderSelect.SetSelected(selectedFolder)
// viewToggleIcon pairs with viewToggleText: both name the action the button
// performs, not the state it is in, matching stopAllButton's convention.
viewToggleIcon := func(current domain.JobListView) fyne.Resource {
if current.IsCompact() {
return theme.ViewFullScreenIcon()
}
return theme.ListIcon()
}
viewButton := widget.NewButtonWithIcon(viewToggleText(listView), viewToggleIcon(listView), nil)
viewButton.OnTapped = func() {
next := nextJobListView(listView)
listView = next
if err := svc.SetJobListView(next); err != nil {
// Roll the mode back and leave the button as it was, so the button
// never claims a preference that did not reach disk.
listView = nextJobListView(next)
dialog.ShowError(err, w)
return
}
viewButton.SetText(viewToggleText(listView))
viewButton.SetIcon(viewToggleIcon(listView))
// Refresh re-creates the row template, which is what recomputes the
// cached row height for the new mode. Selection is untouched.
list.Refresh()
}
addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}, func(saved job) {
created, err := svc.CreateJob(saved)
if err != nil {
dialog.ShowError(err, w)
return
}
syncFromService()
folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh()
targetFolder := filterValue(created.Folder)
if selectedFolder != allFolders && selectedFolder != targetFolder {
selectedFolder = targetFolder
folderSelect.SetSelected(targetFolder)
}
selected = indexOfID(jobs, created.ID)
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
list.Select(app.DisplayIndex(filteredJobs, selected))
refreshView()
})
})
editButton := widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
showJobDialog(w, "Edit job", jobs[selected], func(saved job) {
saved.ID = jobs[selected].ID
if err := svc.UpdateJob(saved); err != nil {
dialog.ShowError(err, w)
return
}
syncFromService()
folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh()
refreshView()
})
})
runButton := widget.NewButtonWithIcon("Run now", theme.MediaPlayIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
// A manual run is allowed even while the scheduler is paused: pause only
// stops automatic scheduled runs, not the user's explicit "Run now".
if err := svc.RunNow(jobs[selected].ID); err != nil {
dialog.ShowError(err, w)
return
}
refreshView()
})
stopAllText, stopAllIcon := "Disable auto", theme.MediaPauseIcon()
if schedulerPaused {
stopAllText, stopAllIcon = "Enable auto", theme.MediaPlayIcon()
}
schedulerStateText := "Scheduler running"
if schedulerPaused {
schedulerStateText = "Scheduler paused"
}
schedulerState := widget.NewLabel(schedulerStateText)
stopAllButton := widget.NewButtonWithIcon(stopAllText, stopAllIcon, nil)
stopAllButton.OnTapped = func() {
// SetGlobalPause flips the pause flag, updates every job's next-run text,
// and emits the activity record the observer logs. Revert if the save fails.
schedulerPaused = !schedulerPaused
if err := svc.SetGlobalPause(schedulerPaused); err != nil {
schedulerPaused = !schedulerPaused
dialog.ShowError(err, w)
return
}
if schedulerPaused {
schedulerState.SetText("Scheduler paused")
stopAllButton.SetText("Enable auto")
stopAllButton.SetIcon(theme.MediaPlayIcon())
} else {
schedulerState.SetText("Scheduler running")
stopAllButton.SetText("Disable auto")
stopAllButton.SetIcon(theme.MediaPauseIcon())
}
refreshView()
}
pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
current := jobs[selected]
if err := svc.SetEnabled(current.ID, !current.Enabled); err != nil {
dialog.ShowError(err, w)
return
}
refreshView()
})
deleteButton := widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
deleted := jobs[selected]
// Deletion is confirmed because jobs can represent real system actions.
// There is no undo yet, so accidental removal should require one more click.
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
if !confirm {
return
}
if err := svc.DeleteJob(deleted.ID); err != nil {
dialog.ShowError(err, w)
return
}
syncFromService()
folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh()
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
if len(filteredJobs) == 0 && selectedFolder != allFolders {
selectedFolder = allFolders
folderSelect.SetSelected(allFolders)
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
}
if len(filteredJobs) == 0 {
selected = -1
} else {
selected = filteredJobs[0]
}
if selected >= 0 {
list.Select(app.DisplayIndex(filteredJobs, selected))
}
refreshView()
}, w)
})
toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer())
// The row sits directly under the tab bar with no AppTabs inset, while the
// default VBox gap below it is one theme padding — add the same on top so
// the button is not flush against the tabs.
globalControls := container.New(
layout.NewCustomPaddedLayout(theme.Padding(), 0, 0, 0),
container.NewHBox(stopAllButton, schedulerState, layout.NewSpacer()),
)
// assemble puts the sidebar (global controls, folder filter, toolbar, list) and
// the details pane into the master/detail split the tab shows.
func (v *jobsView) assemble(globalControls fyne.CanvasObject) fyne.CanvasObject {
// The whole filter is one row: caption on the left, view toggle on the right,
// select filling what is left. The border layout gives both edges their
// MinSize, so the header is a line shorter than a stacked caption would make it.
folderCaption := widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
filterRow := container.NewBorder(nil, nil, folderCaption, viewButton, folderSelect)
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), filterRow, toolbar)
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, list)
filterRow := container.NewBorder(nil, nil, folderCaption, v.viewButton, v.folderSelect)
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), filterRow, v.newToolbar())
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, v.list)
// A split rather than a Border left slot: the border pinned the sidebar at its
// MinSize forever, so the user could never trade list width for detail width.
// The divider lets either pane grow, and neither can be dragged below its own
// content minimum.
panel := container.NewHSplit(sidebar, container.NewPadded(dp.container()))
panel := container.NewHSplit(sidebar, container.NewPadded(v.dp.container()))
panel.SetOffset(initialSplitOffset(sidebar.MinSize().Width))
return panel, refreshView
return panel
}
// newFolderSelect builds the folder filter. Selecting a folder narrows the list
// and, when the selected job is no longer visible, moves the selection to the
// first row that is (see jobsViewState.applyFilter).
func (v *jobsView) newFolderSelect() *widget.Select {
return widget.NewSelect(folderOptions(v.state.jobs), func(value string) {
if value == "" {
return
}
v.state.applyFilter(value)
v.refresh()
})
}
// newGlobalControls builds the pause control row that sits above the filter.
func (v *jobsView) newGlobalControls() fyne.CanvasObject {
v.schedulerState = widget.NewLabel("")
v.stopAllButton = widget.NewButtonWithIcon("", nil, nil)
v.applySchedulerState(v.paused)
v.stopAllButton.OnTapped = func() {
// SetGlobalPause flips the pause flag, updates every job's next-run text,
// and emits the activity record the observer logs. refresh re-derives the
// pause state from the Service, so a failed save leaves the control showing
// what actually happened.
if err := v.svc.SetGlobalPause(!v.paused); err != nil {
dialog.ShowError(err, v.w)
return
}
v.refresh()
}
// The row sits directly under the tab bar with no AppTabs inset, while the
// default VBox gap below it is one theme padding — add the same on top so
// the button is not flush against the tabs.
return container.New(
layout.NewCustomPaddedLayout(theme.Padding(), 0, 0, 0),
container.NewHBox(v.stopAllButton, v.schedulerState, layout.NewSpacer()),
)
}
// applySchedulerState is the one place that draws the pause control and its
// status text from a pause value, so refresh can drive it from whatever the
// Service reports instead of only the tap handler mirroring its own toggle.
func (v *jobsView) applySchedulerState(paused bool) {
v.paused = paused
if paused {
v.schedulerState.SetText("Scheduler paused")
v.stopAllButton.SetText("Enable auto")
v.stopAllButton.SetIcon(theme.MediaPlayIcon())
return
}
v.schedulerState.SetText("Scheduler running")
v.stopAllButton.SetText("Disable auto")
v.stopAllButton.SetIcon(theme.MediaPauseIcon())
}
+114
View File
@@ -0,0 +1,114 @@
package ui
import (
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
// newList builds the sidebar's job list. Rows are drawn from jobsViewState's
// filtered view, so the row index the widget reports is a position in the
// filter, never an index into the job snapshot.
func (v *jobsView) newList() *widget.List {
list := widget.NewList(
func() int { return len(v.state.filtered) },
func() fyne.CanvasObject {
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
// Truncating stops a long name from pushing the compact row's status
// off the right-hand edge. Labels default to TextWrapOff, which grows
// the widget to fit instead.
name.Truncation = fyne.TextTruncateClip
inlineStatus := widget.NewLabel("status")
meta := widget.NewLabel("schedule")
status := widget.NewLabel("status")
v.applyRowMode(inlineStatus, meta, status)
nameLine := container.NewBorder(nil, nil, nil, inlineStatus, name)
return container.New(layout.NewCustomPaddedVBoxLayout(rowOverlap()), nameLine, meta, status)
},
func(id widget.ListItemID, item fyne.CanvasObject) {
current, ok := v.state.jobAt(int(id))
if !ok {
return
}
row := item.(*fyne.Container)
// NewBorder keeps the center object first and appends the border slots
// after it, so nameLine is [name, inlineStatus].
nameLine := row.Objects[0].(*fyne.Container)
name := nameLine.Objects[0].(*widget.Label)
inlineStatus := nameLine.Objects[1].(*widget.Label)
meta := row.Objects[1].(*widget.Label)
status := row.Objects[2].(*widget.Label)
name.SetText(current.Name)
// Keep each row compact: folder, schedule, and command are shown in one
// metadata line so the left pane stays useful even with many jobs.
meta.SetText(app.DisplayFolder(current.Folder) + " " + current.Schedule + " " + app.DisplayInvocation(current))
statusText := app.StatusText(current, v.state.runtime(current.ID))
status.SetText(statusText)
inlineStatus.SetText(statusText)
// A full Refresh reuses rows built under the previous mode, so
// visibility cannot be left to the create callback alone.
v.applyRowMode(inlineStatus, meta, status)
},
)
list.OnSelected = func(id widget.ListItemID) {
v.state.selectRow(int(id))
v.updateDetails()
}
return list
}
// applyRowMode expresses the current view mode as visibility on the row's
// four labels. widget.List caches the row template's MinSize, and
// list.Refresh() re-creates the template and recomputes it, so hiding lines
// is what actually shrinks the rows: layout.NewCustomPaddedVBoxLayout and the
// border layout both skip hidden children when measuring.
func (v *jobsView) applyRowMode(inlineStatus, meta, status fyne.CanvasObject) {
if v.listView.IsCompact() {
inlineStatus.Show()
meta.Hide()
status.Hide()
return
}
inlineStatus.Hide()
meta.Show()
status.Show()
}
// newViewToggle builds the compact/detailed switch that sits at the right edge
// of the filter row.
func (v *jobsView) newViewToggle() *widget.Button {
button := widget.NewButtonWithIcon(viewToggleText(v.listView), viewToggleIcon(v.listView), nil)
button.OnTapped = func() {
next := nextJobListView(v.listView)
v.listView = next
if err := v.svc.SetJobListView(next); err != nil {
// Roll the mode back and leave the button as it was, so the button
// never claims a preference that did not reach disk.
v.listView = nextJobListView(next)
dialog.ShowError(err, v.w)
return
}
button.SetText(viewToggleText(v.listView))
button.SetIcon(viewToggleIcon(v.listView))
// Refresh re-creates the row template, which is what recomputes the
// cached row height for the new mode. Selection is untouched.
v.list.Refresh()
}
return button
}
// viewToggleIcon pairs with viewToggleText: both name the action the button
// performs, not the state it is in, matching stopAllButton's convention.
func viewToggleIcon(current domain.JobListView) fyne.Resource {
if current.IsCompact() {
return theme.ViewFullScreenIcon()
}
return theme.ListIcon()
}
+156
View File
@@ -0,0 +1,156 @@
package ui
import (
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
// jobsViewState is the model behind the Jobs tab: the snapshot of the Service's
// jobs and runtimes, the folder filter, and the selection. The widgets in
// jobsView read it and never keep a second copy of any of it.
//
// The selection is a job ID, not an index into the snapshot. Every path that
// changes the job list replaces that snapshot underneath the view — create,
// delete, and edit do it from this view's own handlers, but adopting a different
// jobs file does it from the Service, and the view only learns about it through
// the refresh that JobsLoaded triggers. An index that outlives its snapshot then
// points at whichever job happens to sit there now, so the details pane
// describes one job while the list highlights another. Indexes are derived from
// the ID at render time instead (selectedIndex, displayRow).
type jobsViewState struct {
svc *app.Service
jobs []job
runtimes map[int]*domain.JobRuntime
folder string
// selectedID is 0 when nothing is selected; job IDs start at 1.
selectedID int
// filtered holds the indexes into jobs that the folder filter shows, in list
// row order: filtered[row] is the index of the job drawn in that row.
filtered []int
}
func newJobsViewState(svc *app.Service) *jobsViewState {
s := &jobsViewState{
svc: svc,
runtimes: map[int]*domain.JobRuntime{},
folder: allFolders,
}
s.sync()
return s
}
// sync re-reads the Service snapshot, re-applies the folder filter, and
// re-resolves the selection against the new list. It is the only place the view
// reads job state from the Service.
func (s *jobsViewState) sync() {
s.jobs = s.svc.Jobs()
clear(s.runtimes)
for _, current := range s.jobs {
if rt := s.svc.Runtime(current.ID); rt != nil {
s.runtimes[current.ID] = rt
}
}
s.filtered = filteredJobIndexes(s.jobs, s.folder)
s.resolveSelection()
}
// applyFilter switches the folder filter, keeping the current selection when the
// new filter still shows it. A filter that matches nothing — "No folder" with no
// such job — is a real filter choice, not an error state, so it simply leaves
// nothing selected.
func (s *jobsViewState) applyFilter(folder string) {
s.folder = folder
s.filtered = filteredJobIndexes(s.jobs, s.folder)
if !s.visible(s.selectedID) {
s.selectedID = 0
}
s.resolveSelection()
}
// resolveSelection drops a selection whose job is gone and falls back to the
// first visible row, so the details pane never describes a job the current
// snapshot no longer holds.
func (s *jobsViewState) resolveSelection() {
if s.selectedID != 0 && indexOfID(s.jobs, s.selectedID) < 0 {
s.selectedID = 0
}
if s.selectedID == 0 && len(s.filtered) > 0 {
s.selectedID = s.jobs[s.filtered[0]].ID
}
}
// selectByID records the selection directly, for handlers that know the job they
// want selected (a newly created job, for instance) rather than its row.
func (s *jobsViewState) selectByID(id int) {
s.selectedID = id
}
// selectRow records the selection from a list row, which is what widget.List
// reports through OnSelected.
func (s *jobsViewState) selectRow(row int) {
current, ok := s.jobAt(row)
if !ok {
s.selectedID = 0
return
}
s.selectedID = current.ID
}
// selected returns the selected job, or false when nothing is selected.
func (s *jobsViewState) selected() (job, bool) {
index := s.selectedIndex()
if index < 0 {
return job{}, false
}
return s.jobs[index], true
}
// selectedIndex resolves the selected ID to an index into the current snapshot,
// or -1 when nothing is selected.
func (s *jobsViewState) selectedIndex() int {
if s.selectedID == 0 {
return -1
}
return indexOfID(s.jobs, s.selectedID)
}
// displayRow maps the selection onto a list row, or -1 when nothing is selected
// or the filter hides the selected job — so a caller unselects rather than
// highlighting an unrelated row.
func (s *jobsViewState) displayRow() int {
index := s.selectedIndex()
if index < 0 || !s.visible(s.selectedID) {
return -1
}
return app.DisplayIndex(s.filtered, index)
}
// jobAt returns the job drawn in the given list row.
func (s *jobsViewState) jobAt(row int) (job, bool) {
if row < 0 || row >= len(s.filtered) {
return job{}, false
}
return s.jobs[s.filtered[row]], true
}
// runtime returns a job's runtime, or an empty one when the Service has none
// yet, so callers can read it without a nil check.
func (s *jobsViewState) runtime(id int) *domain.JobRuntime {
if rt := s.runtimes[id]; rt != nil {
return rt
}
return &domain.JobRuntime{}
}
// visible reports whether the folder filter shows the given job.
func (s *jobsViewState) visible(id int) bool {
if id == 0 {
return false
}
for _, index := range s.filtered {
if s.jobs[index].ID == id {
return true
}
}
return false
}
+198
View File
@@ -0,0 +1,198 @@
package ui
import (
"testing"
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
// newStateForTest builds a jobsViewState over a Service holding the given jobs.
// No Fyne app is needed: the state is the view's model and touches no widgets.
func newStateForTest(t *testing.T, jobs []domain.Job) (*jobsViewState, *app.Service) {
t.Helper()
svc := app.NewService(newTestStore(t), jobs)
t.Cleanup(svc.Stop)
return newJobsViewState(svc), svc
}
func threeJobs() []domain.Job {
return []domain.Job{
{ID: 1, Name: "First", Folder: "Maintenance", Schedule: "@every 1m", Command: "echo one", Enabled: true},
{ID: 2, Name: "Second", Schedule: "@every 2m", Command: "echo two", Enabled: true},
{ID: 3, Name: "Third", Folder: "Reports", Schedule: "@every 3m", Command: "echo three", Enabled: true},
}
}
func selectedName(t *testing.T, s *jobsViewState) string {
t.Helper()
current, ok := s.selected()
if !ok {
return ""
}
return current.Name
}
// TestJobsViewStateSelectsTheFirstJob pins the opening state: the first row is
// selected so the details pane is never blank when there is something to show.
func TestJobsViewStateSelectsTheFirstJob(t *testing.T) {
s, _ := newStateForTest(t, threeJobs())
if got := selectedName(t, s); got != "First" {
t.Errorf("selected job = %q, want %q", got, "First")
}
if got := s.displayRow(); got != 0 {
t.Errorf("displayRow = %d, want 0", got)
}
}
func TestJobsViewStateEmptyListSelectsNothing(t *testing.T) {
s, _ := newStateForTest(t, nil)
if _, ok := s.selected(); ok {
t.Error("an empty job list should leave nothing selected")
}
if got := s.displayRow(); got != -1 {
t.Errorf("displayRow with nothing selected = %d, want -1", got)
}
}
// TestJobsViewStateSelectionFollowsTheJobNotTheRow is the regression guard for
// the selection defect: the selection is a job ID, so a job removed above the
// selected one must not slide the selection onto its neighbour. The deletion
// goes through the Service rather than the Delete button, which is how the view
// learns about a job list that changed underneath it (a different jobs file
// adopted, or any other broad JobChanged).
func TestJobsViewStateSelectionFollowsTheJobNotTheRow(t *testing.T) {
s, svc := newStateForTest(t, threeJobs())
s.selectRow(2)
if got := selectedName(t, s); got != "Third" {
t.Fatalf("selected job after selecting row 2 = %q, want %q", got, "Third")
}
if err := svc.DeleteJob(1); err != nil {
t.Fatalf("DeleteJob: %v", err)
}
s.sync()
if got := selectedName(t, s); got != "Third" {
t.Errorf("selected job after the first job was removed = %q, want it still on %q", got, "Third")
}
if got := s.displayRow(); got != 1 {
t.Errorf("displayRow = %d, want the row %q moved to (1)", got, "Third")
}
}
// TestJobsViewStateDropsSelectionWhenItsJobIsGone covers the other half: a
// selected job that no longer exists falls back to the first visible row instead
// of describing whichever job inherited its position.
func TestJobsViewStateDropsSelectionWhenItsJobIsGone(t *testing.T) {
s, svc := newStateForTest(t, threeJobs())
s.selectRow(1)
if err := svc.DeleteJob(2); err != nil {
t.Fatalf("DeleteJob: %v", err)
}
s.sync()
if got := selectedName(t, s); got != "First" {
t.Errorf("selected job after deleting the selected one = %q, want the fallback %q", got, "First")
}
}
func TestJobsViewStateApplyFilter(t *testing.T) {
s, _ := newStateForTest(t, threeJobs())
// The selected job is in the folder being filtered to, so it stays selected.
s.selectRow(2)
s.applyFilter("Reports")
if got := selectedName(t, s); got != "Third" {
t.Errorf("selection after filtering to its own folder = %q, want %q", got, "Third")
}
if got := s.displayRow(); got != 0 {
t.Errorf("displayRow inside the filter = %d, want 0", got)
}
// Filtering to a folder that hides it moves the selection to the first row
// that folder does show.
s.applyFilter("Maintenance")
if got := selectedName(t, s); got != "First" {
t.Errorf("selection after filtering it away = %q, want %q", got, "First")
}
// "No folder" matches the one job without one.
s.applyFilter(noFolder)
if got := selectedName(t, s); got != "Second" {
t.Errorf("selection under the %q filter = %q, want %q", noFolder, got, "Second")
}
s.applyFilter(allFolders)
if got := len(s.filtered); got != 3 {
t.Errorf("rows under %q = %d, want 3", allFolders, got)
}
}
// TestJobsViewStateEmptyFilterSelectsNothing pins that a filter matching no job
// is a filter choice, not an error state: nothing is selected, and nothing is
// highlighted either.
func TestJobsViewStateEmptyFilterSelectsNothing(t *testing.T) {
s, _ := newStateForTest(t, []domain.Job{
{ID: 1, Name: "First", Folder: "Maintenance", Schedule: "@every 1m", Command: "echo one", Enabled: true},
})
s.applyFilter(noFolder)
if _, ok := s.selected(); ok {
t.Error("a filter that matches nothing should leave nothing selected")
}
if got := s.displayRow(); got != -1 {
t.Errorf("displayRow under an empty filter = %d, want -1", got)
}
// The selection comes back when the filter does.
s.applyFilter(allFolders)
if got := selectedName(t, s); got != "First" {
t.Errorf("selection after clearing the filter = %q, want %q", got, "First")
}
}
// TestJobsViewStateHiddenSelectionIsNotHighlighted covers the case the list
// widget cannot express: the selected job still exists but the filter hides it,
// so there is no row to highlight and displayRow must say so rather than fall
// back to row 0.
func TestJobsViewStateHiddenSelectionIsNotHighlighted(t *testing.T) {
s, _ := newStateForTest(t, threeJobs())
s.applyFilter("Maintenance")
// Selecting by ID is how the create handler points the view at a job it just
// made; here it reaches the state a hidden-but-selected job would be in.
s.selectByID(3)
if s.visible(3) {
t.Fatal("job 3 should be hidden by the Maintenance filter")
}
if got := s.displayRow(); got != -1 {
t.Errorf("displayRow for a hidden selection = %d, want -1", got)
}
if got := selectedName(t, s); got != "Third" {
t.Errorf("selected job = %q, want it still %q", got, "Third")
}
}
func TestJobsViewStateRuntimeIsNeverNil(t *testing.T) {
s, _ := newStateForTest(t, threeJobs())
if rt := s.runtime(99); rt == nil {
t.Error("runtime for an unknown job returned nil, want an empty runtime")
}
}
func TestJobsViewStateJobAtRejectsRowsOutsideTheFilter(t *testing.T) {
s, _ := newStateForTest(t, threeJobs())
s.applyFilter("Reports")
if current, ok := s.jobAt(0); !ok || current.Name != "Third" {
t.Errorf("jobAt(0) = (%q, %v), want (%q, true)", current.Name, ok, "Third")
}
if _, ok := s.jobAt(1); ok {
t.Error("jobAt past the last filtered row should report no job")
}
if _, ok := s.jobAt(-1); ok {
t.Error("jobAt(-1) should report no job")
}
}
+76 -1
View File
@@ -1,6 +1,9 @@
package ui
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/app"
@@ -378,7 +381,7 @@ func TestJobsSplitOpensAtTheSidebarWidth(t *testing.T) {
// TestToolbarButtonRedrawsRowAndDetails is the regression guard for F12: the
// toolbar handlers no longer re-read the service or refresh the list
// themselves, so refreshView alone has to re-snapshot the jobs and repopulate
// themselves, so jobsView.refresh alone has to re-snapshot the jobs and repopulate
// the details pane. If it ever stops doing either, the row renders a stale
// status and the details lose the selection — neither is a compile error.
func TestToolbarButtonRedrawsRowAndDetails(t *testing.T) {
@@ -441,6 +444,78 @@ func TestToolbarButtonRedrawsRowAndDetails(t *testing.T) {
}
}
// TestJobsViewSelectionSurvivesAJobsFileSwitch is the view-level regression
// guard for the selection defect. Adopting a different jobs file replaces the
// whole list from the Service; the view only hears about it through the refresh
// that JobsLoaded triggers, which is exactly what this test calls. With the
// selection held as a row index, that refresh redrew the details pane from the
// old index — describing whichever job now sat there, or clearing the pane when
// the new list was shorter — while the list's highlight stayed where it was.
func TestJobsViewSelectionSurvivesAJobsFileSwitch(t *testing.T) {
testApp := test.NewApp()
defer testApp.Quit()
w := testApp.NewWindow("test")
defer w.Close()
store := newTestStore(t)
svc := app.NewService(store, []domain.Job{
{ID: 1, Name: "First", Schedule: "@every 1m", Command: "echo one", Enabled: true},
{ID: 2, Name: "Second", Schedule: "@every 2m", Command: "echo two", Enabled: true},
{ID: 3, Name: "Third", Schedule: "@every 3m", Command: "echo three", Enabled: true},
})
defer svc.Stop()
content, refresh := newJobsView(w, svc)
w.SetContent(content)
list := jobsList(t, content)
list.Select(2)
if got := jobsDetailsTitle(t, content); got != "Third" {
t.Fatalf("details title after selecting row 2 = %q, want %q", got, "Third")
}
// A second jobs file with different jobs and different IDs, so nothing about
// the old selection can resolve into the new list.
other := []domain.Job{
{ID: 10, Name: "Alpha", Schedule: "@every 1m", Command: "echo alpha", Enabled: true},
{ID: 11, Name: "Beta", Schedule: "@every 2m", Command: "echo beta", Enabled: true},
}
payload, err := json.Marshal(domain.JobsFile{Jobs: other})
if err != nil {
t.Fatalf("marshal jobs: %v", err)
}
if err := os.WriteFile(filepath.Join(store.Paths.AppDir, "other.json"), payload, 0o644); err != nil {
t.Fatalf("write jobs file: %v", err)
}
config := svc.Config()
config.JobsFile = "other.json"
if err := svc.UpdateSettings(config); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
refresh()
if got := jobsDetailsTitle(t, content); got != "Alpha" {
t.Errorf("details title after the switch = %q, want the first job of the new file %q", got, "Alpha")
}
if got := list.Length(); got != len(other) {
t.Fatalf("list length after the switch = %d, want %d", got, len(other))
}
// widget.List.Select returns without calling OnSelected when the row is
// already highlighted, so a silent Select(0) is what proves the highlight and
// the details pane are describing the same job.
reselected := false
inner := list.OnSelected
list.OnSelected = func(id widget.ListItemID) {
reselected = true
inner(id)
}
defer func() { list.OnSelected = inner }()
list.Select(0)
if reselected {
t.Error("row 0 was not the highlighted row after the switch, so the highlight and the details pane disagree")
}
}
// TestDetailCaptionWidthCoversEveryCaption is the guard that makes the single
// metadataRows list self-enforcing (F10): every caption it returns must
// measure no wider than captionColumnWidth's result for that same list, or a
+136
View File
@@ -0,0 +1,136 @@
package ui
import (
"fmt"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
// newToolbar builds the per-job button row under the folder filter. Every
// handler works from the selected job — never from a row index — and ends in
// refresh, which is what re-reads the Service and redraws the row, the details
// pane, and the list highlight.
func (v *jobsView) newToolbar() fyne.CanvasObject {
return container.NewHBox(
v.newAddButton(),
v.newEditButton(),
v.newRunButton(),
v.newPauseButton(),
v.newDeleteButton(),
layout.NewSpacer(),
)
}
func (v *jobsView) newAddButton() *widget.Button {
return widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
blank := job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}
showJobDialog(v.w, "New job", blank, func(saved job) {
created, err := v.svc.CreateJob(saved)
if err != nil {
dialog.ShowError(err, v.w)
return
}
v.state.sync()
// The new job may have introduced a folder, so the options are rebuilt
// before the filter is pointed at it.
v.rebuildFolders()
v.state.selectByID(created.ID)
if target := filterValue(created.Folder); v.state.folder != allFolders && v.state.folder != target {
// The current filter would hide the job the user just created. Switch
// to its folder; SetSelected fires OnChanged, which applies the filter
// and refreshes.
v.folderSelect.SetSelected(target)
}
v.refresh()
})
})
}
func (v *jobsView) newEditButton() *widget.Button {
return widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
current, ok := v.state.selected()
if !ok {
return
}
showJobDialog(v.w, "Edit job", current, func(saved job) {
// The ID comes from the job the dialog was opened on, so a list that
// changed underneath the open dialog cannot redirect the save.
saved.ID = current.ID
if err := v.svc.UpdateJob(saved); err != nil {
dialog.ShowError(err, v.w)
return
}
v.state.sync()
// An edit can rename the job's folder, add a new one, or empty the last
// job out of an existing one.
v.rebuildFolders()
v.refresh()
})
})
}
func (v *jobsView) newRunButton() *widget.Button {
return widget.NewButtonWithIcon("Run now", theme.MediaPlayIcon(), func() {
current, ok := v.state.selected()
if !ok {
return
}
// A manual run is allowed even while the scheduler is paused: pause only
// stops automatic scheduled runs, not the user's explicit "Run now".
if err := v.svc.RunNow(current.ID); err != nil {
dialog.ShowError(err, v.w)
return
}
v.refresh()
})
}
func (v *jobsView) newPauseButton() *widget.Button {
return widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
current, ok := v.state.selected()
if !ok {
return
}
if err := v.svc.SetEnabled(current.ID, !current.Enabled); err != nil {
dialog.ShowError(err, v.w)
return
}
v.refresh()
})
}
func (v *jobsView) newDeleteButton() *widget.Button {
return widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
deleted, ok := v.state.selected()
if !ok {
return
}
// Deletion is confirmed because jobs can represent real system actions.
// There is no undo yet, so accidental removal should require one more click.
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
if !confirm {
return
}
if err := v.svc.DeleteJob(deleted.ID); err != nil {
dialog.ShowError(err, v.w)
return
}
// sync drops the deleted job's selection and falls back to the first row
// the filter still shows.
v.state.sync()
v.rebuildFolders()
if len(v.state.filtered) == 0 && v.state.folder != allFolders {
// The deleted job was the last one in its folder, and that folder is
// no longer an option. Fall back to "All" rather than leaving the user
// on an empty filter they did not choose.
v.folderSelect.SetSelected(allFolders)
}
v.refresh()
}, v.w)
})
}
+17 -18
View File
@@ -65,43 +65,42 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.
// 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)
errOccurred, isError := ev.(app.ErrorOccurred)
jobsLoaded, isJobsLoaded := ev.(app.JobsLoaded)
fyne.Do(func() {
if isRecorded {
events.add(recorded.Record)
r := recorded.Record
if r.State == "Failed" &&
(r.Trigger == "Manual" || r.Trigger == "Schedule") &&
// A type switch does not get compiler-enforced exhaustiveness (see
// app.Event's doc comment) — JobChanged and SchedulerStateChanged
// intentionally fall through to the unconditional refresh() below
// without their own case, since a broad state re-read is all they need.
switch e := ev.(type) {
case app.RunRecorded:
events.add(e.Record)
if e.Record.State == "Failed" &&
(e.Record.Trigger == "Manual" || e.Record.Trigger == "Schedule") &&
svc.ShouldNotifyOnFailure() {
timing := notificationTiming{
JobName: r.JobName,
JobName: e.Record.JobName,
EmittedAt: time.Now(),
}
if finished, err := time.ParseInLocation(runRecordTimeLayout, r.Time, time.Local); err == nil {
if finished, err := time.ParseInLocation(runRecordTimeLayout, e.Record.Time, time.Local); err == nil {
timing.RunFinished = finished
}
fyne.Do(func() {
timing.UIQueuedAt = time.Now()
fyne.CurrentApp().SendNotification(&fyne.Notification{
Title: "GoSentry: Job Failed",
Content: r.JobName + ": " + r.Detail,
Content: e.Record.JobName + ": " + e.Record.Detail,
})
timing.AfterSendAt = time.Now()
if err := appendNotificationTimingLog(svc.Store().Paths.LogsDir, timing); err != nil {
if err := appendNotificationTimingLog(svc.Paths().LogsDir, timing); err != nil {
fyne.LogError("Failed to write notification timing log", err)
}
})
}
}
if isError {
events.add(newEvent(0, "Service", "Error", errOccurred.Err.Error()))
}
if isJobsLoaded {
case app.ErrorOccurred:
events.add(newEvent(0, "Service", "Error", e.Err.Error()))
case app.JobsLoaded:
// Selecting an existing jobs file replaces the job list without a
// prompt, so History carries the receipt: how many jobs, from where.
detail := strconv.Itoa(jobsLoaded.Count) + " jobs from " + jobsLoaded.Path
detail := strconv.Itoa(e.Count) + " jobs from " + e.Path
events.add(newEvent(0, "Service", "Jobs loaded", detail))
}
refresh()
+3 -2
View File
@@ -70,12 +70,13 @@ func Run(startInTray bool) {
a.Run()
return
}
keepInTray = svc.Store().Config.KeepRunningInTray
config := svc.Config()
keepInTray = config.KeepRunningInTray
startHidden = resolveStartHidden(startInTray, keepInTray)
applyTrayBehavior(a, w, keepInTray, false)
// Apply the persisted theme before building content so the window renders in
// the chosen theme from the first frame rather than flashing the default one.
applyTheme(a, svc.Store().Config.Theme)
applyTheme(a, config.Theme)
content, recordStartup := newMainView(w, svc)
w.SetContent(content)
serveSingleInstance(instanceListener, w)
+40 -26
View File
@@ -27,7 +27,14 @@ var settingsCaptions = []string{
}
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
store := svc.Store()
// 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.
@@ -36,14 +43,14 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
// both the initial load and the Cancel/Defaults buttons below.
var loadFields func(domain.Config)
startOnLogin := widget.NewCheck("Start on login", nil)
startOnLogin.SetChecked(store.Config.StartOnLogin)
startOnLogin.SetChecked(saved.StartOnLogin)
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
minimizeToTray.SetChecked(saved.KeepRunningInTray)
autostartStatus := widget.NewLabel("")
trayRestartHint := widget.NewLabel("")
trayRestartHint.Truncation = fyne.TextTruncateClip
refreshAutostartStatus := func() {
if settingsPendingAutostart(startOnLogin, minimizeToTray, store.Config) {
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
autostartStatus.SetText("Pending: save settings to apply")
return
}
@@ -67,15 +74,15 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
}
minimizeToTray.OnChanged = func(bool) {
refreshAutostartStatus()
refreshTrayRestartHint(minimizeToTray.Checked != store.Config.KeepRunningInTray)
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
updateSaveState()
}
refreshAutostartStatus()
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
notifications.SetChecked(store.Config.NotifyOnFailure)
notifications.SetChecked(saved.NotifyOnFailure)
notifications.OnChanged = func(bool) { updateSaveState() }
themeSelect := widget.NewSelect([]string{themeLabelSystem, themeLabelGoSentry}, nil)
themeSelect.SetSelected(themeLabel(store.Config.Theme))
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.
@@ -87,20 +94,20 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
[]string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)},
nil,
)
executionModeSelect.SetSelected(string(store.Config.ExecutionMode))
executionModeSelect.SetSelected(string(saved.ExecutionMode))
executionModeSelect.OnChanged = func(string) { updateSaveState() }
overlapPolicySelect := widget.NewSelect(
[]string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
nil,
)
overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy))
overlapPolicySelect.SetSelected(string(saved.OverlapPolicy))
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
defaultTimeout := widget.NewEntry()
defaultTimeout.SetPlaceHolder("0 = no timeout")
defaultTimeout.SetText(strconv.Itoa(store.Config.DefaultTimeoutSeconds))
defaultTimeout.SetText(strconv.Itoa(saved.DefaultTimeoutSeconds))
defaultTimeout.OnChanged = func(string) { updateSaveState() }
jobsFile := widget.NewEntry()
jobsFile.SetText(store.Config.JobsFile)
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.
@@ -108,7 +115,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
chooseJSONFile(w, jobsFile)
})
logsDir := widget.NewEntry()
logsDir.SetText(store.Config.LogsDir)
logsDir.SetText(saved.LogsDir)
logsDir.OnChanged = func(string) { updateSaveState() }
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFolder(w, logsDir)
@@ -118,13 +125,15 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
// 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(store.Paths.AppDir, logsDir.Text))
openFolder(w, settingsFolderPath(paths.AppDir, logsDir.Text))
})
maxLogFiles := widget.NewEntry()
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
maxLogFiles.SetPlaceHolder("0 = unlimited")
maxLogFiles.SetText(strconv.Itoa(saved.MaxLogFiles))
maxLogFiles.OnChanged = func(string) { updateSaveState() }
maxLogAgeDays := widget.NewEntry()
maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays))
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.
@@ -134,13 +143,13 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
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")
if err != nil || files < 0 {
settingsStatus.SetText("Max log files must be zero (unlimited) or 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")
if err != nil || days < 0 {
settingsStatus.SetText("Max log age days must be zero (unlimited) or a positive number")
return
}
if strings.TrimSpace(jobsFile.Text) == "" {
@@ -159,7 +168,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
// 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 := saved
config.JobsFile = strings.TrimSpace(jobsFile.Text)
config.LogsDir = strings.TrimSpace(logsDir.Text)
config.MaxLogFiles = files
@@ -171,11 +180,16 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
config.DefaultTimeoutSeconds = timeout
config.Theme = themeFromLabel(themeSelect.Selected)
previousKeepInTray := store.Config.KeepRunningInTray
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())
@@ -198,7 +212,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
// fields compare against their canonical string form; any unparsable text
// counts as a change so the user can click Save and see the validation error.
updateSaveState = func() {
c := store.Config
c := saved
changed := startOnLogin.Checked != c.StartOnLogin ||
minimizeToTray.Checked != c.KeepRunningInTray ||
notifications.Checked != c.NotifyOnFailure ||
@@ -235,17 +249,17 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
logsDir.SetText(c.LogsDir)
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
if settingsPendingAutostart(startOnLogin, minimizeToTray, store.Config) {
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
autostartStatus.SetText("Pending: save settings to apply")
} else {
refreshAutostartStatus()
}
refreshTrayRestartHint(minimizeToTray.Checked != store.Config.KeepRunningInTray)
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
settingsStatus.SetText("")
updateSaveState()
}
cancelSettings := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
loadFields(store.Config)
loadFields(saved)
})
restoreDefaults := widget.NewButtonWithIcon("Defaults", theme.MediaReplayIcon(), func() {
loadFields(domain.DefaultConfig())
@@ -261,7 +275,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
executionModeSelect: executionModeSelect,
overlapPolicySelect: overlapPolicySelect,
defaultTimeout: defaultTimeout,
configPath: store.Paths.ConfigPath,
configPath: paths.ConfigPath,
jobsFile: jobsFile,
jobsFileBrowse: jobsFileBrowse,
logsDir: logsDir,