Implements items 1-3 of the whole-project review's suggested order
(docs/PROJECT_REVIEW_PLAN.md):
- Restore TestJobListViewIsCompact, accidentally dropped by 5b0e6fe;
drop the redundant TestDefaultConfigUsesDetailedJobList row from
TESTS.md and document the two other doc gaps the review found.
- Fix quoteLeadingWindowsProgramPath to find the earliest file-extension
match at a word boundary instead of the first extension in list order,
so a .bat/.cmd command whose argument ends in .exe no longer has its
whole command line mistaken for the program path.
- Write gosentry.json, jobs.json, and run log files atomically (temp
file + rename) so a crash or power loss mid-write can no longer leave
a truncated file. Wire Service.Stop() into the app shutdown path so
it actually runs, cancelling the run context for in-flight runs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fyne toasts read App.Icon without SetIcon, preserving the PE multi-size
window and taskbar icon while giving failure notifications app artwork.
Co-authored-by: Cursor <cursoragent@cursor.com>
Seed Failure notification test in defaultJobs so new installs can verify
Settings notifications via Run now without scheduler spam.
Co-authored-by: Cursor <cursoragent@cursor.com>
Autostart entries pass --start-in-tray only when the tray is enabled; Settings warns that the notification icon needs a restart (Fyne limitation).
Co-authored-by: Cursor <cursoragent@cursor.com>
Move post-v1.0.0 changes out of the 1.0.0 changelog section into 1.0.1,
where they belong: GoSentry theme as default, System label, About GitHub link,
Disable auto inset, README @every docs, and the completed test-suite review.
Co-authored-by: Cursor <cursoragent@cursor.com>
Item 4 of the test-suite review:
- Delete TestEmitWithNoObserversIsNoop (no assertion; ranging a nil slice
cannot panic) and TestStoreReturnsWiredStore (a getter returning its
own field).
- Collapse the four TestFilteredJobIndexes* tests into one table-driven
TestFilteredJobIndexes, matching TestFilterValue above it.
- Replace the TestMainViewBuilds smoke test with
TestMainViewRecordStartupAddsHistoryRow, which calls the recordStartup
closure for both wordings run.go selects between and asserts the rows
reach the History table through its own cell callbacks. Keeps the
unique coverage the review identified and adds the !windowShown branch.
Item 5 is declined with measurements: the three RunJob tests cost 0.14 s
combined, so merging them saves ~90 ms while forcing their three
fixtures (including the only Manual trigger) into one. The runner
package's runtime is the two timeout tests, not subprocess spawns.
go vet and go test -race pass for src/app and src/ui.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Items 1-3 of the 2026-08-04 test-suite review: TestCleanupLogsKeepsFilesWithinAgeLimit,
TestRunDueEmptyOverlapInheritsGlobal, and TestSameWindowsPathHandlesSpaces had
byte-identical coverage to an existing test and no assertion the survivor lacked.
storage.defaultJobs, the one accidental 0% coverage gap the review found, is now
covered and TESTS.md corrected to match. seed_test.go's itoa is replaced with
strconv.FormatInt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The row sat flush against the tab bar while the VBox gap below was already one theme padding; matching that on top balances the spacing.
Co-authored-by: Cursor <cursoragent@cursor.com>
The Application section stacks its rows with rowOverlap(), a negative
spacing that trades away one label's duplicated text inset. The Theme
row's value is a Select, which paints its box out to the row's edge and
has no inset to give, so the overlap closed the gap instead: 0.46 px
between the Notifications checkbox and the dropdown, against ~8 px
between the checkbox rows.
cancelRowOverlap adds that one padding back on the Theme row's top edge
only, restoring the gap to 7.5 px without touching the other rows or the
column width.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 0.16.0 release was never tagged or pushed, so what it described ships
as 1.0.0 instead: the changelog section is renamed rather than followed by
an empty one, and there is no 0.16.0 for anyone to have seen.
The last remainder of the layout review's F9 goes in with it. The value
column in captionValueLayout has no minimum of its own — it takes whatever
the container leaves after the caption — and what actually keeps it
readable is the 460 px minimum on commandOutputScroll, a constant that
exists for command output being legible. The dependency was invisible at
both ends; both now state it, so lowering that width is a decision rather
than an accident. The HSplit divider is the user's side of the same thing:
it is how the value column can be widened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 9 of the GUI layout plan: the roadmap item the review was raised
under is closed, so the plan and the findings document go with it — what
they established now lives in STANDARDS and the CHANGELOG.
STANDARDS gains the rule the review produced: a size that must follow the
theme is measured at build time, not written as a pixel constant, because
a hand-tuned number is only correct for the theme it was tuned against.
rowOverlap, captionColumnWidth, textColumnWidth, activityRowsHeight and
initialSplitOffset are the worked examples.
The CHANGELOG entry keeps to what the user can see: the window opens at
the size it asks for and drags smaller, the Jobs divider is draggable,
History columns hold their content on a scaled UI, and the Settings
button row and block spacing are as their layouts intended.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The jobs view pinned the sidebar in a Border left slot, so it rendered at
its MinSize and nothing else: the user could never trade list width for
detail width, and the details value column had no escape from the width
the metadata grid left it.
container.NewHSplit replaces the Border. Either pane can now be dragged,
and container.Split clamps both at their own content minimum, so the
divider degrades a pane rather than clipping it.
The initial offset is derived, not a literal: SetOffset takes a ratio,
but the sidebar's natural width is absolute (448 px), and a ratio that
fits 1024 px would hand the sidebar 700 px at 1600 px. initialSplitOffset
divides that width by the default window width less the divider, which
opens the sidebar at exactly its content width with the rest to details.
The divider position is not persisted; a restart reopens at the computed
default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 5: generalize logColumnWidth into textColumnWidth so History's
Trigger/Job/State/Detail/Log columns size from measured text instead of
pixel constants that clipped at larger text sizes (F6, F14).
Stage 6: captionColumnWidth replaces detailCaptionWidth and
settingsLabelWidth with one theme-derived helper; jobs_view_details.go
now builds its metadata rows and their width from a single
metadataRows() list instead of two hand-kept ones (F10); the Settings
button row drops its transparent-rectangle spacers for a
CustomPaddedLayout (F8); the remaining eight fyne.TextTruncate call
sites move to the non-deprecated Truncation field (N1).
Stage 7: settings_view.go split into settings_view.go (field
construction/save/load/validate), settings_view_layout.go (the
two-column layout and settingsSection/settingsRow), and
settings_view_helpers.go (fyneVersion, dialogs, path helpers),
mirroring the jobs_view.go split. Along the way, Queue/Storage's inline
VBox and Application/About's settingsSection collapse into one
settingsSection(title, spacing, rows...) constructor, and
chooseFile/chooseJSONFile merge into one function with a filter
argument.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Stage 4 of the GUI layout plan (F11, F12).
History: the cell callback copied and sorted the whole event list on every
call, and a full-window Refresh issues one call per visible cell — 126 sorts
of a 300-element slice per redraw, measured. The sorted snapshot now lives in
`rows`, refilled by `resort()` at build time, on a sort toggle, and from
`refresh()`. The length callback moves to `len(rows)` with it: cells and the
row count have to read the same slice, which was only incidentally true while
each cell re-derived the order for itself. The column captions become a
package-level array instead of a slice reallocated per header update, and the
per-cell `TextStyle`/`Refresh()` pair goes — the template already carries the
zero style and `SetText` refreshes.
Jobs: `refreshView()` already re-reads the service snapshot and refreshes the
list, so the six `list.Refresh()` calls that preceded it, and the duplicate
`syncFromService()` in the pause handler, were redundant. The folder filter's
early-return path never reaches `refreshView()`, so its `list.Refresh()` moves
into that branch rather than being deleted.
Both changes carry regression tests, each verified to fail against the
behaviour it guards: `TestHistorySortToggleKeepsRowsInSync` for the
cache-versus-length hazard, `TestToolbarButtonRedrawsRowAndDetails` for the
handlers that now rely on `refreshView` alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Part A stages 1-3 of the GUI layout cleanup plan:
- Stage 1 (F1-F3): delete settingsControlWidth's redundant wrapper (the
Border centre slot already stretches controls), truncate the config
path label, and name the default window size so it can be asserted
against. Settings no longer widens the window past what it asks for.
- Stage 2 (F4-F5): drop compactVBoxLayout for the stock
layout.NewCustomPaddedVBoxLayout and a single derived rowOverlap()
spacing, replacing three hand-tuned spacing constants.
- Stage 3 (F7): delete the inert 400px sidebar width floor; the Border
left slot already renders it at content MinSize.
See docs/PLAN-gui-layout.md.
The Jobs directory row named a folder and assumed the file inside it was
called jobs.json. It is now a Jobs file row: Browse opens a file picker
filtered to .json, the field stays editable so a file that does not exist yet
can be typed, and the job list can live under any name.
Config.JobsDir/jobs_dir becomes Config.JobsFile/jobs_file, holding the whole
path; Paths.JobsDir is derived from it so saves still create the folder. An
older gosentry.json is migrated on load by joining its jobs_dir with
jobs.json — the exact file that version used — and the retired key is dropped
when the config is rewritten. The default clears before unmarshalling, or a
file that omits jobs_file and a file that sets it would be indistinguishable
and the migration would never run.
Saving used to write the current job list over whatever was at the new path,
which made switching to an existing jobs file impossible: its contents were
destroyed. An existing file now wins. Its jobs are loaded, normalized, and
adopted, with runtimes, schedule cache, next-run times and log-seeded
statistics rebuilt around them by adoptJobsLocked — the same helper NewService
now uses, so construction and adoption cannot drift. A path with no file
behind it still receives the current jobs, which is how the file is renamed or
relocated. The new file is read before anything is written, so an unparsable
one leaves both the config and the jobs untouched.
Adoption drops every runtime, and a run finishing afterwards would write its
result onto whichever job inherited its ID, so the switch is refused while a
job is running. Unrelated settings still save during a run. Because the
replacement happens without a prompt, the Service emits JobsLoaded with the
path and count, and History carries the receipt.
A path that names only a folder (trailing separator, a dot, or two dots) is
rejected with a validation error instead of failing later with an opaque OS
error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Close the Unreleased section as 0.14.0 and fill the gaps in it: the Folder
caption moving onto the filter row, the padding around the Settings button
row, the Truncation-field refactor, the Docker build cache mount, and the
review/standards documents added since 0.13.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reading a log file meant copying the configured path out of Settings and
pasting it into a file manager. The Logs directory row now carries an Open
button beside Browse that reveals the folder directly.
The new src/platform/filemanager package holds the platform split — explorer
on Windows, xdg-open on Linux, an "unsupported" error elsewhere — and starts
the handler without waiting on it, since Explorer exits non-zero even after it
opens the window and blocking would stall the UI thread. A missing path, a
path that is a file, and a handler that will not start are all reported to the
user; the logs directory does not exist until the first run, so that case is
reachable.
The button opens whatever the field currently holds rather than the saved
config, so an edit can be checked before Save. Resolving a relative directory
against the application folder is the store's rule, so resolveConfiguredDir is
now exported as storage.ResolveConfiguredDir instead of being duplicated in
the UI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hint was a lone example path, which left the one-argument-per-line
convention to guesswork. It now names the rule and shows a flag plus a
path containing a space, so the absence of quoting is visible too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The caption sat on its own line above the select, costing the sidebar a full
label of height before the job list started. It now occupies the border
layout's left slot, next to the select and the view toggle, so the whole filter
is one row and the list begins a line higher.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fyne.TextTruncate is deprecated in Fyne 2.7.4 in favour of the widget's
Truncation field. Behaviour is unchanged: a long job name is clipped rather
than widening the row, which is what keeps the compact row's status pinned to
the right-hand edge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each job can now render as a single line — name on the left, status on the
right — instead of the three-line block, so many more jobs fit without
scrolling. A toggle button beside the Folder filter switches between the two
modes and is labelled with the action it performs, matching the existing
"Disable auto" convention.
The choice is persisted as Config.JobListView ("detailed" / "compact", stored
as job_list_view in gosentry.json). Empty, legacy, and unrecognised values all
normalize to detailed, so existing installs keep the current look and the file
never gains a value no reader understands.
Selection, the details panel, the folder filter, and live status updates work
unchanged in both modes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A per-job timeout now has three distinct states: unset inherits the global
default, an explicit 0 means no timeout and does not inherit, and a positive
value is the per-job limit. Job.TimeoutSeconds became *int so unset and 0 stay
distinguishable in jobs.json.
Also fixes the global default, which could not persist a 0. loadOrCreateConfig
normalized DefaultTimeoutSeconds <= 0 back to 30 on every read of an existing
gosentry.json, so "no timeout" only held until the next restart. The field is
now written unconditionally (no omitempty) and read back as-is.
Existing jobs and configs are unaffected: a job with no timeout_seconds still
inherits, and a saved global default of 30 stays 30.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Save/Cancel/Defaults row sat flush against the separator above it
and the tab's left edge, tighter than the other vertical gaps in the
tab. Add matching padding spacers to bring it in line.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
DefaultTimeoutSeconds now means "no timeout" when 0/empty, and that is
the new default, rather than an invalid config forcing a positive
value. runner.RunJob avoids context.WithTimeout with a zero duration
(which would expire immediately) and instead runs on a plain
cancelable context when no timeout is configured. Per-job
TimeoutSeconds inherit semantics are unchanged.
Give the light variant a soft teal window canvas with white inputs,
menus, dialogs, and buttons on top, plus teal-tinted separators, input
borders, and table headers. Cards and fields now lift off a branded
background instead of reading as a plain accent swap on gray. Text stays
dark (delegated to the base foreground) for high contrast on both the
teal canvas and the white surfaces.
Lock the teal canvas and white input background into the theme tests so
the light look cannot silently regress.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cancel discards unsaved edits by reloading the saved config; Defaults
loads built-in default values into the form for review before saving.
Extracts the default config into domain.DefaultConfig() so the store
and settings UI share one source of truth.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add a custom Fyne theme derived from the logo and app icon (deep teal
primary, amber accent, branded job-status colors) with light and dark
variants, and let the user choose between it and Fyne's default theme
from Settings. The dark variant uses deep-teal surfaces to echo the app
icon.
The choice is persisted as a new Config.Theme field ("default" /
"gosentry"), applied at startup before the first frame and live-previewed
when picked in Settings. Empty/legacy configs normalize to the default
theme so existing installs keep the original look.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an optional per-job run timeout following the overlap_policy inherit
pattern: Job.TimeoutSeconds (0 = inherit) resolves against a new
Config.DefaultTimeoutSeconds (default 30s), replacing the hard-coded 30s
guard in runner.RunJob.
- domain/storage: new fields, default 30, load-time normalization
- runner: RunJob takes an explicit timeout; StartOnly stays untimed so it
keeps measuring launch latency only
- app: effectiveTimeout resolves under mu into runEnv, threaded to runJob;
seam signature and validation updated; DisplayTimeout helper
- ui: Timeout entry in the job dialog, Default timeout in Settings, and a
Timeout row in the details panel
- tests + docs (ARCHITECTURE, STANDARDS, ROADMAP, CHANGELOG) updated;
version bumped to 0.12.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace CODE_REVIEW.md with a living maturity checklist, document
session-only History, inject Service into newMainView for testability,
add UI and scheduler regression tests, and fix RunNow error surfacing
plus empty jobs view handling.
Co-authored-by: Cursor <cursoragent@cursor.com>
StartOnly jobs previously forced DurationMS to 0 because GoSentry does
not wait for the process to exit, leaving the Statistics line stuck at
"last 0 ms, avg 0 ms". The runner already measures launch latency (time
to spawn the process) for the History detail; this now returns that
value as the run duration so the existing duration-driven stats pipeline
folds it into last/avg/max.
Sub-millisecond launches still round to 0 and are excluded from the
average, matching prior behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace overlap Pending flag with PendingRuns counter, match seed stats
by job_id, align average duration with TimedRunCount, and tidy docs.
Co-authored-by: Cursor <cursoragent@cursor.com>
Snapshot store paths under lock before async runs, roll back failed
start/save state, emit UI events only after successful persistence,
surface log write failures, and sync stale YAML docs to JSON.
Co-authored-by: Cursor <cursoragent@cursor.com>
Saving w.Canvas().Size() when the window is maximized persists the maximized
dimensions, corrupting the stored size for the next launch. Commented out the
save calls until per-OS maximized detection (IsZoomed / _NET_WM_STATE /
NSWindow.isZoomed) is implemented. Roadmap entry updated to reflect frozen
status.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Persist window size on quit/close and restore it on next launch
- Update appID from ru.mixdep.gosentry.desktop to ru.mixeme.gosentry.desktop
- Use markdown headers for Build sections in DEVELOPMENT.md
- Remove stale go.yaml.in/yaml/v4 dependency from DEVELOPMENT.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fyne 2.7.4 made Table.Select() reject cells with Row < 0, so OnSelected
was never fired for header clicks and the Time sort toggle stopped working.
Replace the plain Label header with a custom historyHeader widget that
implements fyne.Tappable; UpdateHeader wires OnTapped for col 0 directly.
Also swap "asc"/"desc" text for ▲/▼ glyphs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Give the Queue selects the same default spacing as the Storage fields
- Widen the settings caption column so "Default overlap policy" fits
- Disable Save until a field differs from the saved config, re-enabling on
change and re-disabling after a successful save
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Allow manual "Run now" while the scheduler is paused: pause now stops only
automatic scheduled runs (RunDue), not the user's explicit action. Drop the
paused guard in Service.RunNow and the UI pause dialog; update tests.
- Cap the details metadata caption width via a new captionValueLayout so a wider
window feeds extra space to the value column instead of the short caption.
- Reorganize the Settings tab into two columns (Application+Queue / Storage+About)
with Save spanning the full width; move the Autostart status onto its own line.
- Condense the Jobs list rows with compactVBoxLayout to fit more jobs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>