Compare commits
24 Commits
4d014e2f45
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| e482e3261c | |||
| a926e90196 | |||
| 16cf2b9e30 | |||
| 0cf44e1dfa | |||
| 44bc7ee81e | |||
| 0f171edd75 | |||
| 9a45a7be6f | |||
| 9dd461e35e | |||
| 84e81371c1 | |||
| f11fee10e9 | |||
| bde9a2e33e | |||
| c5f300b670 | |||
| d0dc17a067 | |||
| cebd41a5ac | |||
| 57e6fe410e | |||
| 60aceb75af | |||
| 9d39f5c100 | |||
| 70aa4cbc4d | |||
| 721b049100 | |||
| 5a018d03cb | |||
| e5f8c7a812 | |||
| 5e09ba1d58 | |||
| fe13d1f34e | |||
| 7aa1421639 |
@@ -25,13 +25,15 @@ creating, grouping, pausing, running, and monitoring scheduled shell commands.
|
||||
- Job definitions stored in a clean, hand-editable `jobs.json`.
|
||||
- `@every` intervals and standard 5-field cron expressions.
|
||||
- Manual and scheduled command runs.
|
||||
- Parallel or sequential execution mode; configurable overlap policy (skip or queue).
|
||||
- Parallel or sequential execution mode; overlap policy (skip or queue) set globally or per job.
|
||||
- Run timeout, off by default, set globally or per job.
|
||||
- Per-run `.log` files with stdout/stderr capture.
|
||||
- Log cleanup by maximum file count and maximum age.
|
||||
- Global pause/resume for scheduled job execution (manual runs remain available).
|
||||
- Desktop notifications on job failure.
|
||||
- Windows tray icon: left-click to show the window, right-click for the menu.
|
||||
- Autostart on login (Windows shortcut; Linux XDG desktop entry).
|
||||
- Detailed or compact job list, and a default or branded theme; both are remembered.
|
||||
|
||||
## Platforms
|
||||
|
||||
@@ -63,17 +65,29 @@ portable application: moving the program folder also moves its configuration.
|
||||
|
||||
```json
|
||||
{
|
||||
"jobs_dir": ".",
|
||||
"jobs_file": "jobs.json",
|
||||
"logs_dir": "logs",
|
||||
"max_log_files": 100,
|
||||
"max_log_age_days": 30,
|
||||
"keep_running_in_tray": true,
|
||||
"notify_on_failure": true,
|
||||
"execution_mode": "parallel",
|
||||
"overlap_policy": "skip"
|
||||
"overlap_policy": "skip",
|
||||
"default_timeout_seconds": 0,
|
||||
"theme": "default",
|
||||
"job_list_view": "detailed"
|
||||
}
|
||||
```
|
||||
|
||||
That is the file GoSentry writes on first run. `default_timeout_seconds` is the
|
||||
run timeout applied to jobs that do not set their own; `0` means no timeout, and
|
||||
it is written out even though it is zero, because a missing value and a
|
||||
deliberate "no timeout" have to stay distinguishable in a hand-edited file.
|
||||
`theme` is `default` or `gosentry` (the branded teal/amber look), and
|
||||
`job_list_view` is `detailed` or `compact` — both are remembered from the
|
||||
choices made in the app. Keys left at their off value (`start_on_login`,
|
||||
`paused`) are omitted until they are turned on.
|
||||
|
||||
`jobs.json` stores job definitions:
|
||||
|
||||
```json
|
||||
@@ -91,10 +105,15 @@ portable application: moving the program folder also moves its configuration.
|
||||
}
|
||||
```
|
||||
|
||||
`jobs_dir` is the directory GoSentry reads `jobs.json` from. The default `"."`
|
||||
means the same folder as the executable. An absolute path can be used when jobs
|
||||
`jobs_file` is the file GoSentry reads job definitions from, file name included,
|
||||
so the file can be named anything. The default `"jobs.json"` is relative and
|
||||
resolves to the executable's folder. An absolute path can be used when jobs
|
||||
should live elsewhere, such as a shared network drive.
|
||||
|
||||
A `gosentry.json` from an earlier version that carries `jobs_dir` instead keeps
|
||||
working: the directory is combined with `jobs.json` on load, and the file is
|
||||
rewritten with `jobs_file`.
|
||||
|
||||
`logs_dir` is relative to the program folder when it does not start with a
|
||||
drive letter or `/`.
|
||||
|
||||
@@ -132,9 +151,20 @@ Standard 5-field cron expressions:
|
||||
5. Use **Pause** on a single job to suspend it without deleting it.
|
||||
6. Use **Pause all** as a global stop switch for all scheduled runs.
|
||||
7. Open **History** to see past runs, their trigger (`Manual`, `Schedule`, or `UI`), state, and log file.
|
||||
8. Open **Settings** to change storage directories, log cleanup limits, queue behavior, and notifications.
|
||||
8. Open **Settings** to change the storage paths, log cleanup limits, queue behavior, and notifications.
|
||||
|
||||
Changing `jobs_dir` in Settings saves the current job list to the new directory.
|
||||
The **Jobs file** row picks the file itself: **Browse** lists `.json` files, and
|
||||
a path can also be typed to name a file that does not exist yet. What Save does
|
||||
depends on whether that file is already there:
|
||||
|
||||
- **The file exists** — its jobs are loaded and replace the current list, so
|
||||
selecting a jobs file switches to it (another machine's file, a shared one on
|
||||
a network drive). History records how many jobs were loaded and from where.
|
||||
- **The file does not exist** — the current jobs are written to it, which is how
|
||||
the jobs file is renamed or moved somewhere else.
|
||||
|
||||
Switching to a different jobs file is refused while a job is running, because
|
||||
loading a new list discards the run state of the old one.
|
||||
|
||||
The **Start on login** checkbox shows an `OK` or `Problem` status. Saving with
|
||||
it enabled writes an autostart entry using the current executable path.
|
||||
@@ -143,8 +173,8 @@ without opening the main window.
|
||||
|
||||
## Queue Settings
|
||||
|
||||
Two settings in the **Queue** group of the Settings tab control how simultaneous
|
||||
and overlapping runs are handled.
|
||||
Three settings in the **Queue** group of the Settings tab control how
|
||||
simultaneous, overlapping, and over-long runs are handled.
|
||||
|
||||
**Execution mode** — applies when multiple jobs become due at the same tick:
|
||||
|
||||
@@ -153,14 +183,25 @@ and overlapping runs are handled.
|
||||
| `parallel` (default) | All due jobs start at the same time. |
|
||||
| `sequential` | Due jobs are started one after another, in the order they appear in the list. |
|
||||
|
||||
**Overlap policy** — applies when a job's next scheduled run fires while its
|
||||
previous run is still active:
|
||||
**Default overlap policy** — applies when a job's next scheduled run fires while
|
||||
its previous run is still active:
|
||||
|
||||
| Value | Behaviour |
|
||||
|-------|-----------|
|
||||
| `skip` (default) | The new run is discarded; the running instance continues. |
|
||||
| `queue` | The new run is held and starts immediately after the current run finishes. |
|
||||
|
||||
**Default timeout (s)** — how long a run may take before it is killed. `0` (the
|
||||
default) means no limit.
|
||||
|
||||
The last two are defaults: a job's own dialog has an **Overlap policy** and a
|
||||
**Timeout (s)** field that override them. A job that overrides nothing follows
|
||||
whatever the Settings tab says, so changing a default moves every such job with
|
||||
it. In `jobs.json` an override is an `overlap_policy` or `timeout_seconds` key
|
||||
on the job; absent means inherit. A `"timeout_seconds": 0` on a job is an
|
||||
override too — it means that job has no timeout even when the global default
|
||||
sets one.
|
||||
|
||||
## Notifications
|
||||
|
||||
When **Notify on failure** is enabled in Settings, GoSentry sends a desktop
|
||||
@@ -181,7 +222,7 @@ Linux:
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=GoSentry
|
||||
Exec=/opt/gosentry/gosentry-0.9.0-linux-amd64 --start-in-tray
|
||||
Exec=/opt/gosentry/gosentry-<version>-linux-amd64 --start-in-tray
|
||||
Terminal=false
|
||||
```
|
||||
|
||||
@@ -222,7 +263,7 @@ Known workaround:
|
||||
|
||||
```text
|
||||
dist\windows\
|
||||
gosentry-0.9.0-windows-amd64.exe
|
||||
gosentry-<version>-windows-amd64.exe
|
||||
opengl32.dll
|
||||
...
|
||||
```
|
||||
|
||||
+28
-7
@@ -17,7 +17,8 @@ src/
|
||||
storage/ JSON persistence (gosentry.json, jobs.json)
|
||||
platform/
|
||||
autostart/ Manager interface + Windows (shortcut) and Linux (XDG) impls
|
||||
desktop/ display-scale helper (Linux only)
|
||||
desktop/ desktop entry + icon under XDG data home (Linux only)
|
||||
filemanager/ open a folder in the desktop file manager
|
||||
winproc/ hidden-window startup flags (Windows only)
|
||||
ui/ Fyne windows, tabs, and dialogs; reads service via Events
|
||||
```
|
||||
@@ -39,7 +40,7 @@ flowchart LR
|
||||
shell["Platform shell - cmd.exe /C or sh -c"]
|
||||
|
||||
user -->|"edits jobs, settings, runs commands"| ui
|
||||
ui -->|"CreateJob, UpdateJob, DeleteJob, RunNow, UpdateSettings, …"| svc
|
||||
ui -->|"CreateJob, UpdateJob, DeleteJob, RunNow, UpdateSettings, AutostartStatus, …"| svc
|
||||
svc -->|"SaveJobs, SaveConfig, LoadJobs, LoadConfig"| store
|
||||
store -->|"read/write"| config
|
||||
store -->|"read/write"| jobs
|
||||
@@ -50,10 +51,9 @@ flowchart LR
|
||||
runner -->|"execute command"| shell
|
||||
runner -->|"write stdout/stderr log"| logs
|
||||
runner -->|"RunRecord"| svc
|
||||
svc -->|"emit JobChanged / RunRecorded / ErrorOccurred"| ui
|
||||
svc -->|"emit JobChanged / RunRecorded / JobsLoaded / ErrorOccurred"| ui
|
||||
ui -->|"display jobs, history, status"| user
|
||||
|
||||
ui -->|"SetAutostart, AutostartStatus"| autostart
|
||||
svc -->|"Set / Status via Manager"| autostart
|
||||
```
|
||||
|
||||
@@ -63,7 +63,7 @@ flowchart LR
|
||||
`cmd/gosentry` calls `ui.Run`, which creates an `app.Service`, opens the
|
||||
store, loads `gosentry.json` and `jobs.json`, subscribes the UI to service
|
||||
events, builds the main window, and calls `Service.Start` to begin the
|
||||
scheduler loop. On first launch the service seeds per-job run-time statistics
|
||||
scheduler loop. On every launch the service seeds per-job run-time statistics
|
||||
from existing log files so the details panel reflects accumulated history
|
||||
immediately (see §Statistics below).
|
||||
|
||||
@@ -74,6 +74,14 @@ flowchart LR
|
||||
`Event`. The UI's observer receives the event and refreshes the relevant
|
||||
widget on the main thread via `fyne.Do`.
|
||||
|
||||
`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.
|
||||
|
||||
3. Scheduled run:
|
||||
`scheduler.Scheduler` fires a tick every second. On each tick it calls
|
||||
`Service.RunDue(now)`. The Service checks which enabled, non-paused jobs are
|
||||
@@ -196,11 +204,24 @@ the moment the window opens.
|
||||
|
||||
### `jobs_view.go` file structure
|
||||
|
||||
`src/ui/jobs_view.go` is split across three files to stay within the ~250-line
|
||||
size guideline:
|
||||
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:
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `jobs_view.go` | `newJobsView` — list, toolbar, button wiring, and layout |
|
||||
| `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` |
|
||||
| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` |
|
||||
|
||||
### `settings_view.go` file structure
|
||||
|
||||
`src/ui/settings_view.go` is split across three files the same way, once its
|
||||
own size passed the guideline:
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `settings_view.go` | `settingsView` — field construction, save, load, validate; the Theme label translation helpers |
|
||||
| `settings_view_layout.go` | `newSettingsLayout`, `settingsSection`, `settingsRow` — the two-column arrangement and the button row |
|
||||
| `settings_view_helpers.go` | Pure helpers — `fyneVersion`, `mustParseURL`, `settingsFolderPath`, `openFolder`, `chooseFile`/`chooseJSONFile`, `chooseFolder` (`chooseFile` also backs `job_dialog.go`'s command browser) |
|
||||
|
||||
+170
-1
@@ -2,7 +2,141 @@
|
||||
|
||||
All notable GoSentry changes are recorded in this file.
|
||||
|
||||
## Unreleased
|
||||
## 1.0.0 - 2026-07-27
|
||||
|
||||
**The window opens at the size it asks for, and the Jobs divider can be
|
||||
dragged.**
|
||||
|
||||
**Window:**
|
||||
|
||||
- **The window opens at 1024×660 and can now be dragged narrower than it opens.**
|
||||
Fyne treats the assembled content's minimum size as a hard floor over the
|
||||
requested size, and two widgets in Settings pushed that minimum past 1024 px:
|
||||
a fixed width applied to seven controls that the layout already stretched, and
|
||||
the read-only config path, which grew the whole tab with the length of the
|
||||
path it was showing — a 75-character path alone demanded 1501 px. The path now
|
||||
clips when the window is genuinely narrow instead of widening the window, and
|
||||
the content minimum is 972 px.
|
||||
|
||||
**Jobs:**
|
||||
|
||||
- **The divider between the job list and the details pane is draggable.**
|
||||
Previously the list was pinned at its natural width and the details pane took
|
||||
whatever was left, so a long command or a deep folder path could not be given
|
||||
more room. Either pane can now be widened at the other's expense, and neither
|
||||
can be dragged below its own content, so the details pane condenses rather
|
||||
than clipping. The divider opens at the list's natural width; its position is
|
||||
not saved, so a restart reopens at that default.
|
||||
|
||||
**History:**
|
||||
|
||||
- **Columns measure their own content.** Time, Trigger and State were fixed
|
||||
pixel widths with as little as 1.6 px of headroom and truncated their own
|
||||
values on a scaled UI or at a larger text size; all five now size themselves
|
||||
from the text they have to show, under the current theme. Job and Detail stay
|
||||
bounded so one long row cannot take over the table.
|
||||
|
||||
**Settings:**
|
||||
|
||||
- The **Save / Cancel / Restore defaults** row sits 4 px from the left edge, as
|
||||
its layout always intended, rather than 8.
|
||||
- The caption column is as wide as the widest caption instead of a fixed width,
|
||||
which gives each value column about 22 px more and keeps the captions readable
|
||||
at a larger text size.
|
||||
- The **Application** and **About** blocks are about 2 px tighter: every stacked
|
||||
row group in the app now shares one spacing derived from the theme rather than
|
||||
three separately tuned numbers.
|
||||
- The **Theme** dropdown is no longer flush against the **Notifications**
|
||||
checkbox. That shared row spacing pulls rows together by one text inset, which
|
||||
the rows above have to give but a dropdown — which paints its box out to the
|
||||
row's edge — does not, so the gap collapsed to about a pixel. The Theme row
|
||||
now keeps the same gap the checkbox rows have.
|
||||
|
||||
**Documentation:**
|
||||
|
||||
- The **README** describes the application that exists. Its `gosentry.json`
|
||||
sample was three keys short of what the app writes on first run, which made
|
||||
the one file the user is invited to hand-edit the least accurate thing in the
|
||||
document; it is now the real default, with each key explained — including why
|
||||
a zero timeout is written out and an unset one is not. The feature list has
|
||||
caught up with the run timeout, the theme, the compact job list, and the
|
||||
per-job overlap and timeout overrides the job dialog has always offered.
|
||||
- **`docs/DEVELOPMENT.md`** is ordered as stack, external libraries, run from
|
||||
source, build, release, CI, behind a two-level table of contents, instead of
|
||||
opening with MSYS2 setup and burying "Run From Source" mid-document. The
|
||||
library table gains versions and licenses, the `package-*` scripts are
|
||||
documented for the first time and labelled by OS, and the Codeberg
|
||||
`RELEASE_TOKEN` note now states the failure mode rather than leaving it to be
|
||||
inferred from a red job: build and packaging succeed, the upload step fails on
|
||||
authentication and takes the job with it, leaving a published release with no
|
||||
assets. The Project Layout section is gone — it duplicated ARCHITECTURE's
|
||||
package map and had drifted out of date.
|
||||
- **Cutting a GitHub release now documents the push mirror it has to survive.**
|
||||
GitHub is a pruning push mirror of Gitea, so `gh release create` creating the
|
||||
tag itself produces a tag Gitea does not know about, which the next
|
||||
synchronisation deletes — orphaning the release and taking its uploaded
|
||||
archives with it, without a single failed step to point at. The procedure is
|
||||
push the tag to Gitea, wait for the mirror, verify the tag on GitHub, then
|
||||
publish with `--verify-tag`.
|
||||
- **`docs/TESTS.md`** matches the suite it indexes again. It listed 130 tests
|
||||
against 170 in the tree, omitted four test files entirely, and named two tests
|
||||
that no longer exist. Every test function now appears exactly once, under the
|
||||
file it actually lives in.
|
||||
- **`docs/ARCHITECTURE.md`** no longer draws the UI calling the autostart
|
||||
manager directly — it does not, and `src/ui` holds no reference to that
|
||||
package — and `platform/desktop` is described by what it does (the XDG desktop
|
||||
entry and icon) rather than as a display-scale helper.
|
||||
- The **~250-line file guideline** is stated as the target it is, with the six
|
||||
files currently over it recorded as a `docs/ROADMAP.md` item. They are to be
|
||||
split in one pass during the next whole-project review, since six separate
|
||||
passes would settle the same seam question six ways.
|
||||
|
||||
## 0.15.0 - 2026-07-26
|
||||
|
||||
**Settings points at the jobs file itself, not the folder holding it.**
|
||||
|
||||
**Settings:**
|
||||
|
||||
- The **Jobs directory** row is now a **Jobs file** row. Browse opens a file
|
||||
picker filtered to `.json` instead of a folder picker, so the job list can
|
||||
live under any file name — `team-jobs.json`, one file per machine, a file
|
||||
shared over a network drive — rather than a fixed `jobs.json` per folder. The
|
||||
field stays editable, which is how a file that does not exist yet is named.
|
||||
- **Selecting an existing jobs file now loads it.** Previously the current job
|
||||
list was written over whatever was at the new path, which made it impossible
|
||||
to switch to an existing jobs file — its contents were destroyed on Save. Now
|
||||
an existing file wins: its jobs are loaded, normalized, and replace the loaded
|
||||
list, with runtimes, parsed schedules, next-run times, and log-seeded
|
||||
statistics rebuilt around them. A path with no file behind it still receives
|
||||
the current jobs (and its folder is created), which is how the jobs file is
|
||||
renamed or relocated. History records `Jobs loaded — N jobs from <path>`,
|
||||
since the switch happens without a prompt.
|
||||
- Switching to a different jobs file is refused while a job is running: adoption
|
||||
discards every runtime, and a run finishing afterwards would write its result
|
||||
onto whichever job inherited its ID. Settings unrelated to the jobs file still
|
||||
save normally during a run.
|
||||
- Saving a path with no file name (a trailing separator, `.`, `..`) is rejected
|
||||
with "jobs file must include a file name" instead of failing later with an
|
||||
opaque OS error.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
- `Config.JobsDir` / `jobs_dir` is replaced by `Config.JobsFile` / `jobs_file`,
|
||||
which holds the full path including the file name; the default is
|
||||
`"jobs.json"`, resolved against the program folder as before. `Paths.JobsDir`
|
||||
is now derived from the configured file so job saves still create the folder.
|
||||
- A `gosentry.json` written by an earlier version is migrated on load: its
|
||||
`jobs_dir` is joined with `jobs.json`, which is the exact file that version
|
||||
used, and the retired key is dropped when the config is rewritten.
|
||||
- New `app.JobsLoaded{Path, Count}` event, emitted when a selected jobs file
|
||||
replaces the job list; the UI turns it into the History entry. New
|
||||
`storage.LoadJobsFile`, which reads and normalizes a jobs file and reports a
|
||||
missing one as "not found" instead of seeding it the way startup does.
|
||||
|
||||
## 0.14.0 - 2026-07-26
|
||||
|
||||
**Compact job list view, "no timeout" at both timeout levels, and an Open
|
||||
button for the logs folder.**
|
||||
|
||||
**Compact job list view.**
|
||||
|
||||
@@ -17,6 +151,31 @@ All notable GoSentry changes are recorded in this file.
|
||||
so it survives a restart. Empty/legacy configs and any unrecognised value
|
||||
normalize to detailed, so existing installs keep the current look.
|
||||
|
||||
**Jobs sidebar:**
|
||||
|
||||
- The **Folder** caption moved onto the filter row itself, beside the select and
|
||||
the view toggle, instead of occupying its own line above it — the job list now
|
||||
starts a full label higher.
|
||||
|
||||
**Settings:**
|
||||
|
||||
- The **Logs directory** row gained an **Open** button that shows the folder in
|
||||
the desktop file manager (Explorer on Windows, the XDG handler on Linux), so
|
||||
reading a log file no longer means copying the path by hand. It opens the
|
||||
path currently in the field — including an edit that has not been saved yet —
|
||||
resolving a relative directory against the application folder exactly as the
|
||||
store does. A folder that is missing (the logs directory is created on the
|
||||
first run) or cannot be opened is reported in a dialog.
|
||||
- The Save/Cancel/Defaults row sat flush against the separator above it and the
|
||||
tab's left edge; it now uses the same padding as the other vertical gaps in
|
||||
the tab.
|
||||
|
||||
**Job dialog:**
|
||||
|
||||
- The **Arguments** placeholder now states the field's rule — one argument per
|
||||
line, no quoting — instead of showing a lone example path that left the
|
||||
line-per-argument convention to guesswork.
|
||||
|
||||
**Timeouts: 0 now means "no timeout" at both levels.**
|
||||
|
||||
- The global **Default timeout** in Settings now defaults to `0`, meaning jobs
|
||||
@@ -33,6 +192,16 @@ All notable GoSentry changes are recorded in this file.
|
||||
Existing jobs and configs are unaffected: a job with no `timeout_seconds` still
|
||||
inherits, and a saved global default of 30 stays 30.
|
||||
|
||||
**Internal:**
|
||||
|
||||
- Job names in the list are truncated through the widget's `Truncation` field;
|
||||
`fyne.TextTruncate` is deprecated in Fyne 2.7.4. Behavior is unchanged.
|
||||
- Docker release builds mount `.gocache/` from the host, so `--rm` container
|
||||
removal no longer wipes `GOCACHE` between runs.
|
||||
- Added `docs/REVIEW.md` (the project-review agenda) and a "Config file
|
||||
compatibility" section in `docs/STANDARDS.md` recording the rule the `Theme`,
|
||||
`JobListView`, and `TimeoutSeconds` fields already follow. Added `CLAUDE.md`.
|
||||
|
||||
## 0.13.0 - 2026-07-26
|
||||
|
||||
**Branded GoSentry color theme; Cancel/Defaults buttons in Settings.**
|
||||
|
||||
+274
-149
@@ -1,18 +1,51 @@
|
||||
# GoSentry — Development
|
||||
|
||||
Build instructions, project layout, and dependency information for contributors.
|
||||
Toolchain, dependency, build, and release information for contributors.
|
||||
|
||||
## Requirements
|
||||
## Contents
|
||||
|
||||
Common:
|
||||
1. [Technology Stack and Tools](#1-technology-stack-and-tools)
|
||||
- [Toolchain — Windows](#toolchain--windows)
|
||||
- [Toolchain — Linux](#toolchain--linux)
|
||||
- [Repository scripts](#repository-scripts)
|
||||
2. [External Libraries](#2-external-libraries)
|
||||
3. [Run From Source](#3-run-from-source)
|
||||
4. [Building the Executable](#4-building-the-executable)
|
||||
- [Windows](#windows)
|
||||
- [Linux](#linux)
|
||||
- [Linux using Docker](#linux-using-docker)
|
||||
5. [Building a Release](#5-building-a-release)
|
||||
- [All targets from Linux](#all-targets-from-linux)
|
||||
- [Packaging](#packaging)
|
||||
6. [CI](#6-ci)
|
||||
- [Cutting a release](#cutting-a-release)
|
||||
- [Releasing through the GitHub push mirror](#releasing-through-the-github-push-mirror)
|
||||
|
||||
## 1. Technology Stack and Tools
|
||||
|
||||
GoSentry is a single desktop process written in Go with a Fyne GUI. There is no
|
||||
server component and no external runtime: the release artifact is one native
|
||||
executable per platform.
|
||||
|
||||
| Layer | Choice |
|
||||
| --- | --- |
|
||||
| Language | Go 1.22 or newer |
|
||||
| GUI toolkit | Fyne v2 (OpenGL desktop backend) |
|
||||
| Scheduling | `robfig/cron/v3` expression parser |
|
||||
| Persistence | Plain JSON files (`gosentry.json`, `jobs.json`) |
|
||||
| Build | `go build` driven by the scripts in `scripts/` |
|
||||
| Reproducible builds | Docker (`golang:1.22-bookworm` based [Dockerfile](../Dockerfile)) |
|
||||
| CI | GitHub Actions and Forgejo Actions (Codeberg) |
|
||||
|
||||
CGO is mandatory. The Fyne desktop backend links against native OpenGL and
|
||||
window-system libraries, so a C compiler must be present for every build,
|
||||
including `go run` and `go test`.
|
||||
|
||||
### Toolchain — Windows
|
||||
|
||||
- [Go](https://go.dev/) 1.22 or newer.
|
||||
|
||||
Windows:
|
||||
|
||||
- MSYS2 with UCRT64 GCC in `C:\msys64\ucrt64\bin`.
|
||||
|
||||
Install these dependencies on Windows:
|
||||
- MSYS2 with UCRT64 GCC in `C:\msys64\ucrt64\bin` (plus `windres` for the icon
|
||||
resource).
|
||||
|
||||
```powershell
|
||||
# 1. Install Go 1.22 or newer from https://go.dev/dl/.
|
||||
@@ -33,12 +66,12 @@ Test-Path C:\msys64\ucrt64\bin\gcc.exe
|
||||
Test-Path C:\msys64\ucrt64\bin\windres.exe
|
||||
```
|
||||
|
||||
Linux:
|
||||
### Toolchain — Linux
|
||||
|
||||
- [Go](https://go.dev/) 1.22 or newer.
|
||||
- A C compiler.
|
||||
- [Fyne](https://fyne.io/) native build dependencies, including OpenGL/X11 development packages.
|
||||
|
||||
On Debian/Ubuntu, the Linux dependencies are typically:
|
||||
- [Fyne](https://fyne.io/) native build dependencies, including OpenGL/X11
|
||||
development packages.
|
||||
|
||||
```bash
|
||||
# Go builds the application, gcc is required by CGO/Fyne, and the OpenGL/X11
|
||||
@@ -46,124 +79,41 @@ On Debian/Ubuntu, the Linux dependencies are typically:
|
||||
sudo apt install golang gcc libgl1-mesa-dev xorg-dev
|
||||
```
|
||||
|
||||
## Build
|
||||
### Repository scripts
|
||||
|
||||
### Windows
|
||||
| Script | Purpose |
|
||||
| --- | --- |
|
||||
| `scripts/test.bat`, `scripts/test.sh` | `go vet ./...` then `go test -race ./...` |
|
||||
| `scripts/build-windows.bat` | Windows amd64 executable |
|
||||
| `scripts/build-linux.sh` | Linux amd64 executable |
|
||||
| `scripts/build-linux-docker.sh` | Linux amd64 executable, built in Docker |
|
||||
| `scripts/build-release-linux.sh` | Multi-target release artifacts from one Linux/Docker workflow |
|
||||
| `scripts/package-windows.bat`, `scripts/package-linux.sh` | Wrap a built binary into a distributable archive |
|
||||
| `scripts/ci-build-release.sh` | Entry point used by both CI workflows |
|
||||
|
||||
```powershell
|
||||
# Builds dist\windows\gosentry-<version>-windows-amd64.exe. The script changes
|
||||
# to the repository root first, so double-clicking it from Explorer works. It
|
||||
# also adds MSYS2 UCRT64 to PATH for this process only, embeds the Windows icon
|
||||
# when windres is available, and uses the Windows GUI subsystem so no console
|
||||
# window opens at startup.
|
||||
.\scripts\build-windows.bat
|
||||
```
|
||||
Build outputs are written to `dist/`. The package layout is documented in
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
The Windows build is created as a GUI application, so it does not open a terminal window.
|
||||
## 2. External Libraries
|
||||
|
||||
The binary is written to:
|
||||
GoSentry keeps the direct dependency list intentionally small. GoSentry itself
|
||||
is distributed under the [MIT License](../LICENSE).
|
||||
|
||||
```text
|
||||
dist\windows\gosentry-0.9.0-windows-amd64.exe
|
||||
```
|
||||
| Dependency | Version | Repository | License |
|
||||
| --- | --- | --- | --- |
|
||||
| Go toolchain | 1.22+ | https://go.googlesource.com/go | BSD 3-Clause |
|
||||
| `fyne.io/fyne/v2` | v2.7.4 | https://github.com/fyne-io/fyne | BSD 3-Clause |
|
||||
| `github.com/robfig/cron/v3` | v3.0.1 | https://github.com/robfig/cron | MIT |
|
||||
|
||||
### Linux
|
||||
The remaining entries in `go.mod` are indirect dependencies pulled in by Fyne
|
||||
and the Go module resolver. To list every direct and indirect module used by the
|
||||
current checkout:
|
||||
|
||||
```bash
|
||||
# Make the helper executable once, then build a linux/amd64 Fyne binary.
|
||||
chmod +x ./scripts/build-linux.sh
|
||||
./scripts/build-linux.sh
|
||||
go list -m all
|
||||
```
|
||||
|
||||
The binary is written to:
|
||||
|
||||
```text
|
||||
dist/linux/gosentry-0.9.0-linux-amd64
|
||||
```
|
||||
|
||||
### Linux using Docker
|
||||
|
||||
```bash
|
||||
# Builds the Linux binary inside Docker using the versioned image tag
|
||||
# gitea.mixdep.ru/mix/gosentry-builder:<version>. Useful from hosts or CI jobs
|
||||
# where the native Linux/Fyne packages are not installed locally.
|
||||
chmod +x ./scripts/build-linux-docker.sh
|
||||
./scripts/build-linux-docker.sh
|
||||
```
|
||||
|
||||
The binary is copied to:
|
||||
|
||||
```text
|
||||
dist/linux/gosentry-0.9.0-linux-amd64
|
||||
```
|
||||
|
||||
### Release build from Linux
|
||||
|
||||
```bash
|
||||
# Interactively choose Linux amd64, Linux arm64, Windows amd64, or all artifacts
|
||||
# from one Linux/Docker workflow. The Dockerfile contains the builder
|
||||
# environment; the build commands live in this script. Docker runs the build
|
||||
# with the current user's UID/GID so dist/ files are not owned by root.
|
||||
chmod +x ./scripts/build-release-linux.sh
|
||||
./scripts/build-release-linux.sh
|
||||
```
|
||||
|
||||
Non-interactive release builds can pass target names:
|
||||
|
||||
```bash
|
||||
# Build only Linux arm64 and Windows amd64 artifacts.
|
||||
./scripts/build-release-linux.sh linux-arm64 windows-amd64
|
||||
```
|
||||
|
||||
The binaries are copied to:
|
||||
|
||||
```text
|
||||
dist/linux/gosentry-0.9.0-linux-amd64
|
||||
dist/linux/gosentry-0.9.0-linux-arm64
|
||||
dist/windows/gosentry-0.9.0-windows-amd64.exe
|
||||
```
|
||||
|
||||
### Automated release builds (CI)
|
||||
|
||||
Tagged releases are built automatically on both GitHub and Codeberg:
|
||||
|
||||
- `.github/workflows/release.yml` — GitHub Actions.
|
||||
- `.forgejo/workflows/release.yml` — Forgejo Actions (Codeberg).
|
||||
|
||||
Both run inside `golang:1.22-bookworm` (the same base image as the
|
||||
[Dockerfile](../Dockerfile)), install the cross toolchain, and call
|
||||
`scripts/ci-build-release.sh`, which builds and packages all three artifacts:
|
||||
|
||||
```text
|
||||
dist/linux/gosentry-<version>-linux-amd64.tar.gz
|
||||
dist/linux/gosentry-<version>-linux-arm64.tar.gz
|
||||
dist/windows/gosentry-<version>-windows-amd64.zip
|
||||
```
|
||||
|
||||
The Windows binary is cross-compiled with MinGW-w64 from the Linux job, so no
|
||||
Windows runner is required. Each archive contains the executable plus `README.md`
|
||||
and `CHANGELOG.md`, matching the local `package-*` scripts.
|
||||
|
||||
To cut a release, bump `src/app/version.go`, then create and publish a release
|
||||
with a matching `v` tag on the forge (GitHub Releases / Codeberg releases). You
|
||||
can do that from the web UI or the CLI, e.g.:
|
||||
|
||||
```bash
|
||||
git tag v0.11.5
|
||||
git push origin v0.11.5 # and to the Codeberg remote
|
||||
gh release create v0.11.5 --generate-notes # GitHub; publishes the release
|
||||
```
|
||||
|
||||
Publishing the release triggers the workflow: it strips the leading `v` from
|
||||
the tag and injects it as the version (so the tag must match `version.go`),
|
||||
builds the archives, and attaches them to that release. `workflow_dispatch`
|
||||
also allows a manual, upload-free build to smoke-test the pipeline.
|
||||
|
||||
Codeberg publishing needs a repository secret named `RELEASE_TOKEN` (a Codeberg
|
||||
access token with the `write:repository` scope) under
|
||||
**Settings → Actions → Secrets**. GitHub uses the built-in `GITHUB_TOKEN`.
|
||||
|
||||
## Run From Source
|
||||
## 3. Run From Source
|
||||
|
||||
Windows:
|
||||
|
||||
@@ -186,41 +136,216 @@ Linux:
|
||||
CGO_ENABLED=1 go run ./cmd/gosentry
|
||||
```
|
||||
|
||||
## Project Layout
|
||||
The same environment is required for the test suite — see
|
||||
[TESTS.md](TESTS.md):
|
||||
|
||||
- `cmd/gosentry` — entry point; starts the desktop app.
|
||||
- `src/domain` — pure value types: `Job`, `Config`, `RunRecord`, `Schedule`, `JobRuntime`.
|
||||
- `src/app` — `Service`: sole owner of job and runtime state; emits typed events to the UI.
|
||||
- `src/scheduler` — pure timing loop; calls `Service.RunDue` on every tick.
|
||||
- `src/runner` — shell command execution, log file writing, and log cleanup.
|
||||
- `src/storage` — JSON persistence (`gosentry.json`, `jobs.json`).
|
||||
- `src/platform/autostart` — `Manager` interface with Windows (shortcut) and Linux (XDG) implementations.
|
||||
- `src/platform/desktop` — display-scale helper (Linux only).
|
||||
- `src/platform/winproc` — hidden-window startup flags (Windows only).
|
||||
- `src/ui` — Fyne windows, tabs, and dialogs; reads service state through events.
|
||||
- `assets` — app icons embedded into the application binary.
|
||||
- `scripts` — build helpers.
|
||||
- `docs` — architecture notes, changelog, and roadmap.
|
||||
```powershell
|
||||
scripts\test.bat
|
||||
```
|
||||
|
||||
Build outputs are written to `dist/`.
|
||||
## 4. Building the Executable
|
||||
|
||||
## Dependencies
|
||||
### Windows
|
||||
|
||||
GoSentry keeps the direct dependency list intentionally small:
|
||||
```powershell
|
||||
# Builds dist\windows\gosentry-<version>-windows-amd64.exe. The script changes
|
||||
# to the repository root first, so double-clicking it from Explorer works. It
|
||||
# also adds MSYS2 UCRT64 to PATH for this process only, embeds the Windows icon
|
||||
# when windres is available, and uses the Windows GUI subsystem so no console
|
||||
# window opens at startup.
|
||||
.\scripts\build-windows.bat
|
||||
```
|
||||
|
||||
- [`fyne.io/fyne/v2`](https://fyne.io/) for the native GUI.
|
||||
- `github.com/robfig/cron/v3` for cron schedule parsing.
|
||||
The Windows build is created as a GUI application, so it does not open a
|
||||
terminal window. The binary is written to:
|
||||
|
||||
The remaining entries in `go.mod` are indirect dependencies pulled by Fyne and the Go module resolver.
|
||||
```text
|
||||
dist\windows\gosentry-<version>-windows-amd64.exe
|
||||
```
|
||||
|
||||
Source repositories for mirroring:
|
||||
|
||||
- Go toolchain: https://go.googlesource.com/go
|
||||
- Fyne: https://github.com/fyne-io/fyne
|
||||
- robfig/cron: https://github.com/robfig/cron
|
||||
|
||||
To list every direct and indirect Go module used by the current checkout:
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
go list -m all
|
||||
# Make the helper executable once, then build a linux/amd64 Fyne binary.
|
||||
chmod +x ./scripts/build-linux.sh
|
||||
./scripts/build-linux.sh
|
||||
```
|
||||
|
||||
The binary is written to:
|
||||
|
||||
```text
|
||||
dist/linux/gosentry-<version>-linux-amd64
|
||||
```
|
||||
|
||||
### Linux using Docker
|
||||
|
||||
```bash
|
||||
# Builds the Linux binary inside Docker using the versioned image tag
|
||||
# gitea.mixdep.ru/mix/gosentry-builder:<version>. Useful from hosts or CI jobs
|
||||
# where the native Linux/Fyne packages are not installed locally.
|
||||
chmod +x ./scripts/build-linux-docker.sh
|
||||
./scripts/build-linux-docker.sh
|
||||
```
|
||||
|
||||
The binary is copied to:
|
||||
|
||||
```text
|
||||
dist/linux/gosentry-<version>-linux-amd64
|
||||
```
|
||||
|
||||
## 5. Building a Release
|
||||
|
||||
### All targets from Linux
|
||||
|
||||
```bash
|
||||
# Interactively choose Linux amd64, Linux arm64, Windows amd64, or all artifacts
|
||||
# from one Linux/Docker workflow. The Dockerfile contains the builder
|
||||
# environment; the build commands live in this script. Docker runs the build
|
||||
# with the current user's UID/GID so dist/ files are not owned by root.
|
||||
chmod +x ./scripts/build-release-linux.sh
|
||||
./scripts/build-release-linux.sh
|
||||
```
|
||||
|
||||
Non-interactive release builds can pass target names:
|
||||
|
||||
```bash
|
||||
# Build only Linux arm64 and Windows amd64 artifacts.
|
||||
./scripts/build-release-linux.sh linux-arm64 windows-amd64
|
||||
```
|
||||
|
||||
The binaries are copied to:
|
||||
|
||||
```text
|
||||
dist/linux/gosentry-<version>-linux-amd64
|
||||
dist/linux/gosentry-<version>-linux-arm64
|
||||
dist/windows/gosentry-<version>-windows-amd64.exe
|
||||
```
|
||||
|
||||
### Packaging
|
||||
|
||||
The `package-*` scripts build the binary for their platform and wrap it in a
|
||||
distributable archive together with `README.md` and `CHANGELOG.md`:
|
||||
|
||||
Windows:
|
||||
|
||||
```powershell
|
||||
scripts\package-windows.bat
|
||||
```
|
||||
|
||||
```text
|
||||
dist\windows\gosentry-<version>-windows-amd64.zip
|
||||
```
|
||||
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
./scripts/package-linux.sh
|
||||
```
|
||||
|
||||
```text
|
||||
dist/linux/gosentry-<version>-linux-amd64.tar.gz
|
||||
dist/linux/gosentry-<version>-linux-arm64.tar.gz
|
||||
```
|
||||
|
||||
The arm64 archive is produced only when the `aarch64-linux-gnu-gcc` cross
|
||||
compiler is available; otherwise that target is skipped with a message.
|
||||
|
||||
The version stamped into the file names and into the binary comes from
|
||||
`src/app/version.go`.
|
||||
|
||||
## 6. CI
|
||||
|
||||
Tagged releases are built automatically on both GitHub and Codeberg:
|
||||
|
||||
- `.github/workflows/release.yml` — GitHub Actions.
|
||||
- `.forgejo/workflows/release.yml` — Forgejo Actions (Codeberg).
|
||||
|
||||
Both run inside `golang:1.22-bookworm` (the same base image as the
|
||||
[Dockerfile](../Dockerfile)), install the cross toolchain, and call
|
||||
`scripts/ci-build-release.sh`, which builds and packages all three artifacts:
|
||||
|
||||
```text
|
||||
dist/linux/gosentry-<version>-linux-amd64.tar.gz
|
||||
dist/linux/gosentry-<version>-linux-arm64.tar.gz
|
||||
dist/windows/gosentry-<version>-windows-amd64.zip
|
||||
```
|
||||
|
||||
The Windows binary is cross-compiled with MinGW-w64 from the Linux job, so no
|
||||
Windows runner is required. Each archive contains the executable plus `README.md`
|
||||
and `CHANGELOG.md`, matching the local `package-*` scripts.
|
||||
|
||||
### Cutting a release
|
||||
|
||||
Before tagging:
|
||||
|
||||
1. Bump `src/app/version.go`. The tag must match it exactly.
|
||||
2. Add the version's [CHANGELOG.md](CHANGELOG.md) section.
|
||||
3. Retake the README screenshots (`images/screenshot_jobs.PNG`,
|
||||
`images/screenshot_settings.PNG`) if the GUI changed its appearance. This is
|
||||
easy to forget because nothing fails without it: `README.md` is packaged
|
||||
inside every release archive and is what the forge shows on the project page,
|
||||
so a stale shot advertises an application that no longer exists. Take them
|
||||
from a real build, not from a development run with test data.
|
||||
4. Run `scripts/test.bat` (or `go vet ./... && go test -race ./...`) and push
|
||||
`main`, so the tag lands on a commit the forge actually has.
|
||||
|
||||
Then create and publish a release with a matching `v` tag on the forge (GitHub
|
||||
Releases / Codeberg releases). `origin` is the Gitea repository, and GitHub is a
|
||||
push mirror of it, so the tag is pushed to Gitea and reaches GitHub through the
|
||||
mirror — never created on GitHub directly (see
|
||||
[Releasing through the GitHub push mirror](#releasing-through-the-github-push-mirror)):
|
||||
|
||||
```bash
|
||||
git tag v0.11.5
|
||||
git push origin v0.11.5 # Gitea; and to the Codeberg remote
|
||||
|
||||
# wait for the mirror, then confirm GitHub actually has the tag
|
||||
git ls-remote --tags https://github.com/mixeme/gosentry.git v0.11.5
|
||||
|
||||
gh release create v0.11.5 --verify-tag --generate-notes # GitHub; publishes the release
|
||||
```
|
||||
|
||||
Publishing the release triggers the workflow: it strips the leading `v` from
|
||||
the tag and injects it as the version (so the tag must match `version.go`),
|
||||
builds the archives, and attaches them to that release. `workflow_dispatch`
|
||||
also allows a manual, upload-free build to smoke-test the pipeline.
|
||||
|
||||
Codeberg publishing needs a repository secret named `RELEASE_TOKEN` (a Codeberg
|
||||
access token with the `write:repository` scope) under
|
||||
**Settings → Actions → Secrets**. Without it the build and packaging steps still
|
||||
succeed, but the upload step fails on authentication and takes the job down with
|
||||
it, leaving a published release with no attached assets. GitHub needs no such
|
||||
setup: `softprops/action-gh-release` falls back to the built-in `GITHUB_TOKEN`,
|
||||
and the workflow already grants it `contents: write`.
|
||||
|
||||
### Releasing through the GitHub push mirror
|
||||
|
||||
The GitHub repository `mixeme/gosentry` is not a separate remote you push to; it
|
||||
is a push mirror driven by Gitea. Gitea mirrors with pruning, so every ref that
|
||||
exists on GitHub but not in Gitea is deleted on the next synchronisation.
|
||||
|
||||
This is what breaks the obvious way of cutting a GitHub release. `gh release
|
||||
create v1.0.0` creates the tag on GitHub when it is missing — a tag Gitea has
|
||||
never heard of. The next mirror run prunes it, GitHub orphans the release whose
|
||||
tag disappeared and turns it into a draft, and the release looks deleted on the
|
||||
Releases page. The archives go with it. Nothing reports an error: the workflow
|
||||
ran, the assets uploaded, and the release evaporated afterwards.
|
||||
|
||||
The order that works is therefore:
|
||||
|
||||
1. `git push origin <tag>` — the tag enters Gitea, which owns it.
|
||||
2. Wait for the mirror, or force it with **Settings → Repository → Mirror
|
||||
Settings → Synchronize Now** in Gitea.
|
||||
3. `git ls-remote --tags https://github.com/mixeme/gosentry.git <tag>` — confirm
|
||||
GitHub has it.
|
||||
4. `gh release create <tag> --verify-tag …` — `--verify-tag` is the guard, not a
|
||||
nicety: without it `gh` silently creates the doomed tag when the mirror has
|
||||
not caught up yet.
|
||||
|
||||
Release notes and assets are GitHub-side metadata; a mirror push cannot touch
|
||||
them, so once the release sits on a mirrored tag, later synchronisations leave
|
||||
it alone. Two consequences follow. Moving a published tag in Gitea force-pushes
|
||||
it on GitHub and leaves the release pointing at a different commit, and deleting
|
||||
a published tag in Gitea destroys the GitHub release along with its uploaded
|
||||
archives — neither is recoverable from the mirror side. Codeberg is unaffected:
|
||||
its releases live in the same forge as its tags.
|
||||
|
||||
@@ -37,6 +37,94 @@ Design notes / open questions:
|
||||
- *No auto-download.* Scope is detection and notification only; installing the
|
||||
update stays a manual click-through to the release page.
|
||||
|
||||
### Import/export jobs as a cron table
|
||||
|
||||
Jobs can only be moved between machines by copying `jobs.json` by hand. Add
|
||||
"Import" / "Export" actions (Settings tab, file dialogs) that read and write a
|
||||
crontab-style text file, so a job list can be shared, version-controlled, or
|
||||
seeded from an existing Unix crontab.
|
||||
|
||||
Export writes one line per job — schedule fields, then command and arguments —
|
||||
and import parses the same format back into `domain.Job` values.
|
||||
|
||||
Design notes / open questions:
|
||||
|
||||
- *The job model is wider than a crontab line.* `Name`, `Folder`, `StartOnly`,
|
||||
`OverlapPolicy`, `TimeoutSeconds`, and `Enabled` have no cron equivalent.
|
||||
Either accept a lossy export (schedule + command only) or carry the extra
|
||||
fields in a structured comment above each line (`# gosentry: name=… folder=…
|
||||
timeout=…`), which keeps the file readable by real cron while making the
|
||||
round-trip lossless. The comment form is preferred; decide the exact key set
|
||||
before implementing.
|
||||
- *Disabled jobs.* `Enabled: false` maps naturally to a commented-out line, but
|
||||
then a disabled job is indistinguishable from a user's own comment unless the
|
||||
`# gosentry:` marker is present. Pick one representation and document it.
|
||||
- *`@every` is not crontab.* GoSentry accepts `@every 10s` (see
|
||||
[`domain.Parse`](../src/domain/schedule.go)), which no cron implementation
|
||||
understands. Exporting it produces a file that is not a valid crontab;
|
||||
exporting it as an approximation would silently change the schedule. Keep the
|
||||
raw string and flag the file as GoSentry-flavoured, rather than converting.
|
||||
- *Command vs arguments.* Crontab has a single command string; GoSentry splits
|
||||
`Command` and `Arguments`. Import must split the line the same way the runner
|
||||
would (see `runner/invocation*.go`, which differs per OS), and export must
|
||||
join them back without changing quoting.
|
||||
- *What to skip on import.* Environment assignments (`SHELL=`, `PATH=`,
|
||||
`MAILTO=`), six-field (seconds) crontabs, and `@reboot` are outside what
|
||||
`domain.Parse` accepts. Skip them, and report which lines were skipped and
|
||||
why — a partial import that silently drops rows is worse than a failed one.
|
||||
- *Merge semantics.* Import must decide between replacing the job list and
|
||||
appending to it, and must assign fresh IDs rather than trusting the file.
|
||||
Appending with a confirmation dialog is the safer default; replacing needs an
|
||||
explicit "this deletes N jobs" confirmation.
|
||||
- *Where it lives.* Encoding/decoding is pure text handling and belongs in
|
||||
`domain` (or a small `storage` codec) with unit tests over round-trips; the
|
||||
Service exposes import/export operations; the UI only picks the file and
|
||||
shows the outcome.
|
||||
|
||||
### Split the files that are over the size guideline
|
||||
|
||||
[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:
|
||||
|
||||
| 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 |
|
||||
|
||||
This is deliberately deferred to the next whole-project review rather than done
|
||||
piecemeal: [REVIEW.md](REVIEW.md) 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
|
||||
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.
|
||||
|
||||
Seams visible today, as a starting point rather than a decision:
|
||||
|
||||
- **`operations.go`** — the worst overage and the clearest split: the public
|
||||
mutating operations (`CreateJob` … `UpdateSettings`), the `…Locked` state
|
||||
helpers that only they call, and the pure validators and normalizers
|
||||
(`normalizeJob`, `validateJob`, `hasFileName`, `validateConfig`) are three
|
||||
distinct jobs already sitting in three consecutive blocks.
|
||||
- **`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.
|
||||
|
||||
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.
|
||||
|
||||
### Window size persistence *(frozen)*
|
||||
|
||||
Window size is currently **not** saved on quit or close. Saving was disabled
|
||||
|
||||
+25
-2
@@ -12,6 +12,16 @@ what a whole-project review looks at, in [REVIEW.md](REVIEW.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`.
|
||||
- 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
|
||||
the one theme it was tuned against and clips under any other. Measure the real
|
||||
widget, or derive the value from the theme, in a named helper: `rowOverlap`
|
||||
(theme padding), `captionColumnWidth` and `textColumnWidth` (the widest of the
|
||||
actual strings), `activityRowsHeight` (the list's own row template). The same
|
||||
applies to a ratio computed from an absolute width — see `initialSplitOffset`.
|
||||
A raw pixel literal is left only where nothing about it tracks the theme, and
|
||||
says so in a comment.
|
||||
|
||||
## Config file compatibility
|
||||
|
||||
@@ -29,12 +39,24 @@ change to their shape has to stay compatible on its own.
|
||||
one helper that every consumer shares (`JobListView.IsCompact`, `ui.themeFor`),
|
||||
and is normalized before being written back, so the file never gains a value
|
||||
no reader understands.
|
||||
- A renamed key keeps the old field on `Config` (tagged `omitempty`) purely so
|
||||
it can still be read. `storage.loadOrCreateConfig` converts it to the new
|
||||
field and clears it, so the retired key disappears on the next save. See
|
||||
`Config.JobsDir` → `Config.JobsFile`. Where the new field has a non-empty
|
||||
default, clear that default before unmarshalling, or "the file omits it" and
|
||||
"the file sets it" become indistinguishable and the conversion never runs.
|
||||
- Each of the three gets a test: the default in `storage`, the normalization in
|
||||
`domain`, and a round-trip through the real config file in `app`.
|
||||
|
||||
## Intentional behavior (not bugs)
|
||||
|
||||
- `RunNow` is allowed during global pause and for disabled jobs.
|
||||
- Selecting a jobs file that already exists **loads** it: its jobs replace the
|
||||
in-memory list, which is the only way the user can switch between job lists. A
|
||||
path with no file behind it receives the current jobs (rename/relocate). The
|
||||
switch is refused while a job is running, because adoption drops every runtime
|
||||
and a finishing run would then write its result onto whichever job inherited
|
||||
its ID.
|
||||
- Sequential mode runs jobs FIFO by order in `jobs.json`.
|
||||
- Scheduler tick is 1s — sub-second `@every` intervals are not supported.
|
||||
- Command timeout defaults to no timeout globally (`Config.DefaultTimeoutSeconds`
|
||||
@@ -47,5 +69,6 @@ change to their shape has to stay compatible on its own.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Larger or blocked work is tracked in [ROADMAP.md](ROADMAP.md) (window size
|
||||
persistence, History column filters, CI coverage gate).
|
||||
Larger or blocked work is tracked in [ROADMAP.md](ROADMAP.md) (update check from
|
||||
GitHub releases, cron-table import/export, window size persistence, History
|
||||
column filters).
|
||||
|
||||
+164
-21
@@ -22,6 +22,10 @@ Both scripts run:
|
||||
1. `go vet ./...` — static analysis for common errors and suspicious code patterns
|
||||
2. `go test -race ./...` — tests with race condition detection enabled
|
||||
|
||||
The GUI tests build the Fyne desktop backend, so CGO must be enabled; on Windows
|
||||
that means the MSYS2 UCRT64 toolchain described in
|
||||
[DEVELOPMENT.md](DEVELOPMENT.md).
|
||||
|
||||
### Manual test commands
|
||||
|
||||
Run all tests:
|
||||
@@ -74,6 +78,20 @@ Tests schedule parsing and validation.
|
||||
|
||||
---
|
||||
|
||||
### src/domain/config_test.go
|
||||
|
||||
**Package:** `domain`
|
||||
|
||||
Tests the normalization rule shared by every consumer of the jobs-list density
|
||||
setting.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestJobListViewIsCompact` | Verifies only the exact `"compact"` value selects one-line rows: empty, differently-cased, and unrecognised values all read as detailed. |
|
||||
| `TestDefaultConfigUsesDetailedJobList` | Verifies `DefaultConfig` selects the detailed job list. |
|
||||
|
||||
---
|
||||
|
||||
### src/app/service_test.go
|
||||
|
||||
**Package:** `app`
|
||||
@@ -114,13 +132,15 @@ Tests all mutating operations on the Service, scheduler integration, and setting
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestSetGlobalPauseUpdatesRuntimesAndEmits` | Verifies that `SetGlobalPause` updates all job runtimes, emits `SchedulerStateChanged`, and persists state. |
|
||||
| `TestSetGlobalPausePersistsToConfigFile` | Verifies the paused flag reaches `gosentry.json`, which is what makes the pause survive a restart. |
|
||||
| `TestServiceRebuiltFromPausedStoreStartsPaused` | Verifies a Service built from a paused config starts paused, with the paused next-run text applied before the first tick. |
|
||||
| `TestRunNowUsesRunnerAndRecords` | Verifies that `RunNow` invokes the runner, records a `RunRecord`, and emits `RunRecorded`. |
|
||||
| `TestRunNowNotFound` | Verifies that `RunNow` returns an error for an unknown job ID. |
|
||||
| `TestRunNowRefusedWhileAlreadyRunning` | Verifies that a second concurrent `RunNow` on the same job is rejected while the first is in progress. |
|
||||
| `TestRunNowAllowedWhilePaused` | Verifies that `RunNow` is allowed when the global pause flag is set (pause stops scheduled runs only). |
|
||||
| `TestRunDueStartsDueJob` | Verifies that `RunDue` launches a job whose next-run time has passed. |
|
||||
| `TestRunDueSkipsJobNotYetDue` | Verifies that `RunDue` does not launch a job that is not yet due. |
|
||||
| `TestRunDueSkipsJobInRunningState` | Verifies that `RunDue` does not start a second concurrent run for an already-running job. |
|
||||
| `TestRunDueSkipsJobInRunningState` | Verifies that `RunDue` does not start a second concurrent run for an already-running job, even with a stale `NextDue` in the past. |
|
||||
| `TestRunDueDoesNothingWhilePaused` | Verifies that `RunDue` launches nothing when the global pause flag is set. |
|
||||
| `TestStartDrivesRunDueOnTick` | Verifies that `Service.Start` wires `RunDue` to the scheduler tick and that each tick advances state. |
|
||||
|
||||
@@ -130,6 +150,13 @@ Tests all mutating operations on the Service, scheduler integration, and setting
|
||||
|------|---------|
|
||||
| `TestUpdateSettingsPersistsAndValidates` | Verifies that `UpdateSettings` persists a valid config and rewrites autostart if needed. |
|
||||
| `TestUpdateSettingsRejectsInvalidConfigs` | Verifies that `UpdateSettings` returns validation errors without persisting. |
|
||||
| `TestHasFileName` | Verifies the jobs-file path check: a file name passes; a trailing separator, `.`, and `..` do not. |
|
||||
| `TestUpdateSettingsWritesJobsToTheNewFile` | Verifies that changing `JobsFile` re-resolves `Paths.JobsPath` and writes the loaded jobs to the new file, creating its folder. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
---
|
||||
@@ -138,7 +165,8 @@ Tests all mutating operations on the Service, scheduler integration, and setting
|
||||
|
||||
**Package:** `app`
|
||||
|
||||
Tests overlap policy, sequential execution, run statistics, and scheduler edge cases using injected `runJob` and `primeDue`.
|
||||
Tests overlap policy, sequential execution, run statistics, timeout resolution,
|
||||
and scheduler edge cases using injected `runJob` and `primeDue`.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
@@ -155,6 +183,7 @@ Tests overlap policy, sequential execution, run statistics, and scheduler edge c
|
||||
| `TestRunNowSequentialGuard` | Manual run refused while another job runs in sequential mode. |
|
||||
| `TestStartRunLockedRollbackOnSaveFailure` | Regression: run does not start when `SaveJobs` fails. |
|
||||
| `TestRunDueQueueDrainSkippedWhenPaused` | Queued overlaps are not drained while the scheduler is paused. |
|
||||
| `TestEffectiveTimeout` | Verifies the three-state resolution: `nil` inherits the global default, a positive value overrides it, and an explicit `0` means no timeout without inheriting. |
|
||||
|
||||
---
|
||||
|
||||
@@ -182,6 +211,7 @@ Tests display-formatting helpers used by the UI.
|
||||
|------|---------|
|
||||
| `TestStatusText` | Verifies that job status codes map to the correct display strings. |
|
||||
| `TestEventText` | Verifies trigger-type labels for scheduled, manual, and UI triggers. |
|
||||
| `TestEventLine` | Verifies the one-line activity rendering of a `RunRecord`, including the log basename and the `Unknown` fallback for a blank trigger. |
|
||||
| `TestDisplayFolder` | Verifies that an empty folder string shows "No folder". |
|
||||
| `TestDisplayArguments` | Verifies that an empty arguments string shows "None". |
|
||||
| `TestDisplayRunMode` | Verifies run-mode labels for normal and start-only modes. |
|
||||
@@ -189,6 +219,7 @@ Tests display-formatting helpers used by the UI.
|
||||
| `TestDisplayIndex` | Verifies the list position of a job index in a filtered index slice. |
|
||||
| `TestDisplayStats` | Verifies statistics line formatting for the details panel. |
|
||||
| `TestDisplayOverlapPolicy` | Verifies per-job vs inherited global overlap policy labels. |
|
||||
| `TestDisplayTimeout` | Verifies the three timeout states read differently in the details panel: `45 s`, `no timeout`, and `… (global default)`. |
|
||||
|
||||
---
|
||||
|
||||
@@ -196,7 +227,7 @@ Tests display-formatting helpers used by the UI.
|
||||
|
||||
**Package:** `storage`
|
||||
|
||||
Tests JSON round-tripping and default generation.
|
||||
Tests JSON round-tripping, default generation, and backward compatibility.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
@@ -204,6 +235,11 @@ Tests JSON round-tripping and default generation.
|
||||
| `TestConfigRoundTrip` | Verifies that settings saved to JSON are reloaded with identical field values. |
|
||||
| `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default name, schedule, and command for jobs missing those fields. |
|
||||
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults and a sample job. |
|
||||
| `TestLoadOrCreateConfigKeepsZeroTimeoutOnReload` | Verifies that `default_timeout_seconds: 0` survives a reload rather than being normalized away — 0 is a value, not a missing field. |
|
||||
| `TestLoadOrCreateConfigMigratesJobsDir` | Verifies that a pre-0.15 `jobs_dir` becomes `jobs_file` pointing at the same `jobs.json`, and that the retired key is not written back. |
|
||||
| `TestLoadJobsFileReportsMissingWithoutCreating` | Verifies that `LoadJobsFile` reports a missing file as not-found without creating or seeding it, and normalizes the jobs it does load. |
|
||||
| `TestApplyConfigPathsDerivesJobsDir` | Verifies that the configured jobs file resolves against the program folder and that `Paths.JobsDir` is derived from it. |
|
||||
| `TestJobTimeoutRoundTripsThreeStates` | Verifies the on-disk encoding that keeps "inherit" and "no timeout" distinguishable: `nil` is omitted entirely, an explicit `0` is written and read back as set. |
|
||||
| `TestJobsJSONDoesNotPersistRuntimeNoise` | Verifies that `jobs.json` does not persist runtime state (LastRun, NextRun, etc.). Only durable job fields are stored. |
|
||||
|
||||
---
|
||||
@@ -225,7 +261,7 @@ Tests the timing-loop contract using a fake clock.
|
||||
|
||||
**Package:** `runner`
|
||||
|
||||
Tests command execution, exit code handling, output capture, and Windows-specific process behavior.
|
||||
Tests command execution, exit code handling, output capture, and the run timeout.
|
||||
|
||||
#### Log file tests
|
||||
|
||||
@@ -258,6 +294,14 @@ Tests command execution, exit code handling, output capture, and Windows-specifi
|
||||
|------|---------|
|
||||
| `TestRunJobFailsOnNonZeroExitCode` | Verifies that a nonzero process exit code results in "Failed" status with an "exit code N" detail. |
|
||||
|
||||
#### Timeout
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestRunJobTimesOut` | Verifies that a positive timeout kills a long-running command and reports `Timed out after <timeout>`. |
|
||||
| `TestRunJobZeroTimeoutMeansNoTimeout` | Verifies that a non-positive duration runs without a deadline, bounded only by the caller's context. |
|
||||
| `TestRunJobStartOnlyIgnoresTimeout` | Verifies that fire-and-forget jobs run on the untimed context, so the timeout never kills a process the runner is not waiting for. |
|
||||
|
||||
#### Start-only mode
|
||||
|
||||
| Test | Purpose |
|
||||
@@ -265,14 +309,39 @@ Tests command execution, exit code handling, output capture, and Windows-specifi
|
||||
| `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. |
|
||||
|
||||
#### Utility / Windows invocation
|
||||
---
|
||||
|
||||
| Test | Platform | Purpose |
|
||||
|------|----------|---------|
|
||||
| `TestDirectCommandDoesNotHideWindow` | Windows | Verifies that direct executable commands do not request hidden-window startup. |
|
||||
| `TestShellCommandHidesWindow` | Windows | Verifies that shell commands request hidden-window startup to prevent console flash. |
|
||||
| `TestShellCommandUsesWindowsSafeQuoting` | Windows | Verifies `cmd.exe /S /C` quoting for paths with spaces and special characters. |
|
||||
| `TestWindowsShellCommandLineQuotesUnquotedProgramPath` | Windows | Verifies that unquoted program paths in shell commands are quoted while preserving already-quoted arguments. |
|
||||
### src/runner/runner_windows_test.go
|
||||
|
||||
**Location:** `src/runner/runner_windows_test.go`
|
||||
**Build Tags:** `//go:build windows`
|
||||
|
||||
Tests the Windows shell invocation and hidden-window flags.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestDirectCommandDoesNotHideWindow` | Verifies that direct executable commands do not request hidden-window startup. |
|
||||
| `TestShellCommandHidesWindow` | Verifies that shell commands request hidden-window startup to prevent console flash. |
|
||||
| `TestShellCommandUsesWindowsSafeQuoting` | Verifies `cmd.exe /S /C` quoting for paths with spaces and special characters. |
|
||||
| `TestWindowsShellCommandLineQuotesUnquotedProgramPath` | Verifies that unquoted program paths in shell commands are quoted while preserving already-quoted arguments. |
|
||||
|
||||
---
|
||||
|
||||
### src/runner/seed_test.go
|
||||
|
||||
**Package:** `runner`
|
||||
|
||||
Tests `SeedStats`, which rebuilds aggregate run statistics from the `.log` files
|
||||
on disk at startup.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestSeedStatsBasic` | Verifies run/fail counts and the last, average, and maximum durations parsed from a job's log headers. |
|
||||
| `TestSeedStatsDurationLessLegacyLog` | Verifies a log written before the `duration` header still counts as a run but is excluded from the duration aggregates, so a missing duration cannot masquerade as a 0 ms run. |
|
||||
| `TestSeedStatsMaxFilesHonoured` | Verifies that only the newest `MaxLogFiles` logs are parsed when the limit is positive. |
|
||||
| `TestSeedStatsMissingDir` | Verifies a missing logs directory yields an empty map rather than an error or a panic. |
|
||||
| `TestSeedStatsUnknownJobProducesNoEntry` | Verifies log files that match no known job are ignored. |
|
||||
| `TestSeedStatsMatchesByJobID` | Verifies logs are matched by the `job_id` header even when two job names sanitize to the same filename. |
|
||||
|
||||
---
|
||||
|
||||
@@ -303,7 +372,6 @@ Tests Windows autostart via shortcuts in the Startup folder.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestParseRegistryRunValue` | Verifies that legacy `HKCU\...\Run` entry values are parsed correctly from `reg query` output (for migration/cleanup). |
|
||||
| `TestSameWindowsPathIgnoresCaseAndQuotes` | Verifies that Windows path comparison is case-insensitive and handles quote marks correctly. |
|
||||
| `TestSameWindowsPathHandlesSpaces` | Verifies that Windows path comparison matches paths with and without surrounding quotes. |
|
||||
| `TestSameWindowsPathStripsExtendedLengthPrefix` | Verifies that `\\?\`-prefixed paths are compared correctly after stripping the prefix. |
|
||||
@@ -324,7 +392,6 @@ Tests Linux autostart via XDG Desktop Entry files.
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestLinuxAutostartStartsInTray` | Verifies that the XDG Desktop Entry is created with `--start-in-tray` in the `Exec=` field. |
|
||||
| `TestLinuxAutostartRemovesLegacyDesktopEntry` | Verifies that enabling autostart also removes legacy PySentry service files left by earlier builds. |
|
||||
|
||||
---
|
||||
|
||||
@@ -342,11 +409,28 @@ Tests Linux desktop integration (`.desktop` file and icon under XDG data home).
|
||||
|
||||
---
|
||||
|
||||
### src/platform/filemanager/filemanager_test.go
|
||||
|
||||
**Package:** `filemanager`
|
||||
|
||||
Tests the guards around opening a folder in the desktop file manager. The
|
||||
success path is not tested: it would open a real file manager window.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestOpenRejectsMissingFolder` | Verifies that `Open` reports a missing directory (naming the path) instead of launching a handler. |
|
||||
| `TestOpenRejectsFile` | Verifies that `Open` refuses a path that is a file rather than a directory. |
|
||||
| `TestOpenCommandNamesPlatformHandler` | Verifies the per-platform handler (`explorer` / `xdg-open`, none elsewhere) and that the path is passed as one argument. |
|
||||
|
||||
---
|
||||
|
||||
### src/ui/jobs_view_test.go
|
||||
|
||||
**Package:** `ui`
|
||||
|
||||
Tests pure helper functions in the jobs view (no Fyne widget construction).
|
||||
Tests the Jobs tab: pure filter helpers, and — through Fyne's headless
|
||||
`test.NewApp()` — the geometry and redraw behaviour that only shows up once the
|
||||
widgets are assembled.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
@@ -357,9 +441,14 @@ Tests pure helper functions in the jobs view (no Fyne widget construction).
|
||||
| `TestFilteredJobIndexesByNamedFolder` | Verifies that filtering by a named folder returns only jobs in that folder. |
|
||||
| `TestFilteredJobIndexesNoFolder` | Verifies that the "No folder" filter returns only jobs with an empty folder field. |
|
||||
| `TestFilteredJobIndexesEmptySlice` | Verifies that filtering an empty job slice returns an empty index list. |
|
||||
| `TestLastJobLogsCapsAndCopies` | Verifies activity panel cap and defensive copy semantics. |
|
||||
| `TestLastJobLogsEmpty` | Verifies nil/empty log input returns an empty slice. |
|
||||
| `TestIndexOfID` | Verifies job lookup by ID returns `-1` when not found. |
|
||||
| `TestNextJobListViewFlipsBothWays` | Verifies the density toggle alternates between detailed and compact from either starting value. |
|
||||
| `TestViewToggleTextNamesTheAction` | Verifies the toggle button is labelled with the action it performs, not the state it is in. |
|
||||
| `TestJobListViewToggleShrinksRowsAndPersists` | End-to-end: one tap shrinks the row height, relabels the button, and reaches the config; tapping back undoes all three. |
|
||||
| `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. |
|
||||
| `TestDetailCaptionWidthCoversEveryCaption` | Verifies every caption `metadataRows` returns fits the measured caption column, which is what makes the single row list self-enforcing. |
|
||||
|
||||
---
|
||||
|
||||
@@ -367,7 +456,8 @@ Tests pure helper functions in the jobs view (no Fyne widget construction).
|
||||
|
||||
**Package:** `ui`
|
||||
|
||||
Tests pure History tab helpers (no Fyne widget construction).
|
||||
Tests the History tab: the pure activity helpers and the sorted-snapshot and
|
||||
column-width behaviour of the assembled table.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
@@ -376,6 +466,56 @@ Tests pure History tab helpers (no Fyne widget construction).
|
||||
| `TestHistoryCellText` | Verifies table cell text for all columns; empty trigger → `Unknown`. |
|
||||
| `TestLogFileName` | Verifies log path basename extraction on Windows and Unix paths. |
|
||||
| `TestNewEventUsesConsistentTimestampShape` | Verifies UI events use the same timestamp layout as run records. |
|
||||
| `TestLastJobLogsCapsAndCopies` | Verifies activity panel cap and defensive copy semantics. |
|
||||
| `TestLastJobLogsEmpty` | Verifies nil/empty log input returns an empty slice. |
|
||||
| `TestIndexOfID` | Verifies job lookup by ID returns `-1` when not found. |
|
||||
| `TestHistorySortToggleKeepsRowsInSync` | Regression guard for the cached sorted snapshot: the length callback and the cells must be refilled together, or the row count and the cell contents disagree. |
|
||||
| `TestHistoryCellTemplateIsPlainText` | Verifies the cell template already carries the zero `TextStyle`, since the per-cell assignment that used to reset it is gone. |
|
||||
| `TestTextColumnWidthClamps` | Covers the three shapes of `textColumnWidth`: below the minimum, in range, and capped at the maximum. |
|
||||
| `TestHistoryColumnsFitTheirContent` | Verifies every column is at least as wide as its widest known or present value, at the default text size and at a scaled theme. |
|
||||
|
||||
---
|
||||
|
||||
### src/ui/settings_view_test.go
|
||||
|
||||
**Package:** `ui`
|
||||
|
||||
Tests the Settings tab helpers and the row layout.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestSettingsFolderPath` | Verifies the folder the Logs directory "Open" button targets: blank text yields no path, a relative path resolves against the application directory, an absolute path is used as typed. |
|
||||
| `TestSettingsRowStretchesItsControl` | Verifies the row's centre slot already stretches the control to the column width — the property that made a fixed-width wrapper around it redundant. |
|
||||
| `TestChooseFileAppliesFilter` | Verifies the deduplicated picker opens a dialog both with a nil filter (the command browser) and with a concrete one (`chooseJSONFile`). |
|
||||
| `TestSettingsCaptionsCoverEveryRow` | Verifies every caption used in a row is present in `settingsCaptions` and fits the measured caption column. |
|
||||
|
||||
---
|
||||
|
||||
### src/ui/layout_test.go
|
||||
|
||||
**Package:** `ui`
|
||||
|
||||
Tests the theme-derived sizing helpers in `layout.go`.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestRowOverlapMatchesInnerPadding` | Pins `rowOverlap` to `-theme.InnerPadding()` under two themes, the property that lets it follow a theme instead of drifting from a hand-tuned literal. |
|
||||
| `TestCaptionColumnWidth` | Covers no captions, one, and several of varying length, at two text sizes. |
|
||||
|
||||
---
|
||||
|
||||
### src/ui/theme_test.go
|
||||
|
||||
**Package:** `ui`
|
||||
|
||||
Tests the branded theme and the stored theme choice.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestGoSentryThemeBrandColors` | Verifies the brand colors land on the semantically correct `ColorName`s in both the light and dark variants. |
|
||||
| `TestGoSentryThemeDelegatesUnbrandedColors` | Verifies unbranded color names fall through to the base theme rather than rendering transparent. |
|
||||
| `TestThemeForChoice` | Verifies the GoSentry choice yields the branded primary and every other value — including the empty legacy one — yields the default theme. |
|
||||
| `TestThemeLabelRoundTrip` | Verifies the dropdown labels round-trip and that the empty value maps to the Default label rather than a blank option. |
|
||||
|
||||
---
|
||||
|
||||
@@ -383,10 +523,11 @@ Tests pure History tab helpers (no Fyne widget construction).
|
||||
|
||||
**Package:** `ui`
|
||||
|
||||
Smoke test for main view construction with an injected `*app.Service`.
|
||||
Tests main view construction with an injected `*app.Service`.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestMainViewFitsTheDefaultWindowSize` | Verifies the assembled content's minimum fits the window size the app asks for, so Fyne never silently widens the window past it. The store's config path is deliberately long, since it was the path label that used to grow the Settings tab. |
|
||||
| `TestMainViewBuilds` | Verifies `newMainView` assembles tabs without panic using `fyne.io/fyne/v2/test`. |
|
||||
|
||||
---
|
||||
@@ -407,10 +548,12 @@ Smoke test for main view construction with an injected `*app.Service`.
|
||||
|
||||
7. **Regression on serious fixes** — Any fix from an internal review with severity ≥ medium gets a targeted regression test (see `run_test.go` for examples).
|
||||
|
||||
8. **Geometry is measured, not eyeballed** — The `ui` tests that build widgets under `test.NewApp()` assert sizes and offsets, and several re-run under a scaled theme. That is what keeps [STANDARDS.md](STANDARDS.md)'s "measure at build time, never a pixel constant" rule enforceable rather than aspirational.
|
||||
|
||||
---
|
||||
|
||||
## Remaining Test Coverage Gaps
|
||||
|
||||
- Full GUI E2E — tab navigation, dialog flows, and native file pickers are not exercised end-to-end
|
||||
- Full GUI E2E — tab navigation, dialog flows, and native file pickers are not exercised end-to-end; the `ui` tests assemble views and measure them, but nothing drives a real window.
|
||||
- History is session-only by design — `.log` files seed aggregate stats only, not the History table (see [STANDARDS.md](STANDARDS.md))
|
||||
- `layout.go` custom layouts — optional Fyne `test.NewApp()` coverage when CGO is available in CI
|
||||
- Fyne's headless driver cannot report a maximized window, which is why window-size persistence stays frozen in [ROADMAP.md](ROADMAP.md)
|
||||
|
||||
@@ -41,6 +41,17 @@ type SchedulerStateChanged struct {
|
||||
Paused bool
|
||||
}
|
||||
|
||||
// JobsLoaded signals that the whole job list was replaced by the contents of a
|
||||
// jobs file the user selected in Settings. It carries the path and job count
|
||||
// because that is what the user needs to see confirmed — the switch happens
|
||||
// without a prompt, and the previous list is no longer on screen to compare
|
||||
// against. Observers that render jobs should re-read them through the Service;
|
||||
// a broad JobChanged is emitted alongside for exactly that.
|
||||
type JobsLoaded struct {
|
||||
Path string
|
||||
Count int
|
||||
}
|
||||
|
||||
// ErrorOccurred signals a background error that could not be returned to a
|
||||
// caller — typically a failed save or cleanup after an async run. The UI
|
||||
// surfaces it in the History tab so the user is not silently left with
|
||||
@@ -50,6 +61,7 @@ type ErrorOccurred struct {
|
||||
}
|
||||
|
||||
func (JobChanged) isEvent() {}
|
||||
func (JobsLoaded) isEvent() {}
|
||||
func (RunRecorded) isEvent() {}
|
||||
func (SchedulerStateChanged) isEvent() {}
|
||||
func (ErrorOccurred) isEvent() {}
|
||||
|
||||
+68
-4
@@ -3,11 +3,13 @@ package app
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||
)
|
||||
|
||||
// maxJobLogs bounds the in-memory activity list kept per job. The full history
|
||||
@@ -224,30 +226,71 @@ func (s *Service) ShouldNotifyOnFailure() bool {
|
||||
}
|
||||
|
||||
// UpdateSettings validates and persists a new application configuration. The
|
||||
// loaded jobs are re-saved because the jobs directory may have changed, and log
|
||||
// loaded jobs are re-saved because the jobs file may have changed, and log
|
||||
// cleanup runs so a tightened retention policy takes effect immediately.
|
||||
//
|
||||
// Pointing the config at a different jobs file that already exists adopts that
|
||||
// file: its jobs replace the loaded ones, which is the only way the user can
|
||||
// switch between job lists. A path with no file there yet receives the current
|
||||
// jobs instead, which is how the jobs file is renamed or relocated. Adoption
|
||||
// discards all runtime state, so it is refused while a job is running.
|
||||
func (s *Service) UpdateSettings(config domain.Config) error {
|
||||
if err := validateConfig(config); err != nil {
|
||||
return err
|
||||
}
|
||||
// The path is stored exactly as it is resolved, so a hand-typed value with
|
||||
// stray spaces cannot make the saved setting and the file in use disagree.
|
||||
config.JobsFile = strings.TrimSpace(config.JobsFile)
|
||||
|
||||
s.mu.Lock()
|
||||
jobsPath := storage.ResolveConfiguredPath(s.store.Paths.AppDir, config.JobsFile)
|
||||
switching := jobsPath != s.store.Paths.JobsPath
|
||||
if switching && s.anyRunningLocked() {
|
||||
s.mu.Unlock()
|
||||
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.
|
||||
var adopted []domain.Job
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
s.store.Config = config
|
||||
if err := s.store.SaveConfig(); err != nil {
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
if adopted != nil {
|
||||
s.adoptJobsLocked(adopted)
|
||||
}
|
||||
// SaveConfig re-resolved the paths from the new config, so SaveJobs writes to
|
||||
// the (possibly new) jobs directory and cleanup targets the new logs dir.
|
||||
// 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
|
||||
}
|
||||
loaded := len(s.jobs)
|
||||
logsDir := s.store.Paths.LogsDir
|
||||
maxFiles := s.store.Config.MaxLogFiles
|
||||
maxAge := s.store.Config.MaxLogAgeDays
|
||||
s.mu.Unlock()
|
||||
|
||||
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.
|
||||
s.emit(JobsLoaded{Path: jobsPath, Count: loaded})
|
||||
s.emit(JobChanged{})
|
||||
}
|
||||
return runner.CleanupLogs(logsDir, maxFiles, maxAge)
|
||||
}
|
||||
|
||||
@@ -394,10 +437,31 @@ func validateJob(job domain.Job) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasFileName reports whether a path ends in something that can be a file name.
|
||||
// It is a syntax check only — an existing directory whose name looks like a file
|
||||
// name still passes, and fails at write time — but it catches the shapes a user
|
||||
// types when they mean a folder: a trailing separator, "." and "..".
|
||||
func hasFileName(path string) bool {
|
||||
if strings.HasSuffix(path, "/") || strings.HasSuffix(path, string(filepath.Separator)) {
|
||||
return false
|
||||
}
|
||||
switch filepath.Base(path) {
|
||||
case ".", "..", string(filepath.Separator):
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// validateConfig rejects settings that would break persistence or cleanup.
|
||||
func validateConfig(config domain.Config) error {
|
||||
if strings.TrimSpace(config.JobsDir) == "" {
|
||||
return errors.New("jobs directory is required")
|
||||
jobsFile := strings.TrimSpace(config.JobsFile)
|
||||
if jobsFile == "" {
|
||||
return errors.New("jobs file is required")
|
||||
}
|
||||
// A path that names only a folder would be written to as if it were a file
|
||||
// and fail later with an opaque OS error, so require a file name here.
|
||||
if !hasFileName(jobsFile) {
|
||||
return errors.New("jobs file must include a file name")
|
||||
}
|
||||
if strings.TrimSpace(config.LogsDir) == "" {
|
||||
return errors.New("logs directory is required")
|
||||
|
||||
+166
-2
@@ -27,7 +27,7 @@ func newTempService(t *testing.T, jobs []domain.Job) *Service {
|
||||
JobsPath: filepath.Join(dir, "jobs.json"),
|
||||
LogsDir: filepath.Join(dir, "logs"),
|
||||
},
|
||||
Config: domain.Config{JobsDir: ".", LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30, ExecutionMode: domain.ExecutionModeParallel, OverlapPolicy: domain.OverlapPolicySkip, DefaultTimeoutSeconds: 30},
|
||||
Config: domain.Config{JobsFile: "jobs.json", LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30, ExecutionMode: domain.ExecutionModeParallel, OverlapPolicy: domain.OverlapPolicySkip, DefaultTimeoutSeconds: 30},
|
||||
}
|
||||
return NewService(store, jobs)
|
||||
}
|
||||
@@ -527,7 +527,8 @@ func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
|
||||
name string
|
||||
mutate func(c *domain.Config)
|
||||
}{
|
||||
{"missing jobs dir", func(c *domain.Config) { c.JobsDir = " " }},
|
||||
{"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 }},
|
||||
@@ -544,6 +545,169 @@ func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFileName(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"jobs.json", true},
|
||||
{filepath.Join("data", "team.json"), true},
|
||||
{"jobs" + string(filepath.Separator), false},
|
||||
{"data/", false},
|
||||
{".", false},
|
||||
{"..", false},
|
||||
{string(filepath.Separator), false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := hasFileName(tc.path); got != tc.want {
|
||||
t.Errorf("hasFileName(%q) = %v, want %v", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Renaming or relocating the jobs file writes the loaded jobs to the new path,
|
||||
// which is what makes the Settings change take effect without a restart.
|
||||
func TestUpdateSettingsWritesJobsToTheNewFile(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Kept", Schedule: "@every 1m", Command: "echo hi", Enabled: true}})
|
||||
|
||||
config := svc.store.Config
|
||||
config.JobsFile = filepath.Join("data", "team-jobs.json")
|
||||
if err := svc.UpdateSettings(config); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
moved := filepath.Join(svc.store.Paths.AppDir, "data", "team-jobs.json")
|
||||
if svc.store.Paths.JobsPath != moved {
|
||||
t.Errorf("JobsPath: got %q, want %q", svc.store.Paths.JobsPath, moved)
|
||||
}
|
||||
data, err := os.ReadFile(moved)
|
||||
if err != nil {
|
||||
t.Fatalf("read moved jobs file: %v", err)
|
||||
}
|
||||
var file domain.JobsFile
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
t.Fatalf("unmarshal moved jobs file: %v", err)
|
||||
}
|
||||
if len(file.Jobs) != 1 || file.Jobs[0].Name != "Kept" {
|
||||
t.Errorf("moved jobs file: got %+v, want the single 'Kept' job", file.Jobs)
|
||||
}
|
||||
}
|
||||
|
||||
// Pointing Settings at a jobs file that already exists must adopt that file:
|
||||
// its jobs replace the loaded ones instead of being overwritten by them. This is
|
||||
// the only way the user can switch between job lists, so the file's contents
|
||||
// win, the job list is rebuilt around them, and History is told where they came
|
||||
// from.
|
||||
func TestUpdateSettingsAdoptsExistingJobsFile(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Local", Schedule: "@every 1m", Command: "echo local", Enabled: true}})
|
||||
rec := &recorder{}
|
||||
svc.Subscribe(rec)
|
||||
|
||||
shared := filepath.Join(svc.store.Paths.AppDir, "shared.json")
|
||||
existing := domain.JobsFile{Jobs: []domain.Job{
|
||||
{ID: 4, Name: "Adopted", Schedule: "@every 5m", Command: "echo adopted", Enabled: true},
|
||||
{Name: "Needs an ID", Schedule: "@every 9m", Command: "echo second", Enabled: false},
|
||||
}}
|
||||
data, err := json.Marshal(existing)
|
||||
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)
|
||||
}
|
||||
|
||||
jobs := svc.Jobs()
|
||||
if len(jobs) != 2 || jobs[0].Name != "Adopted" {
|
||||
t.Fatalf("jobs after adoption: got %+v, want the two jobs from the selected file", jobs)
|
||||
}
|
||||
// The adopted jobs must be fully live, not just listed: runtime and parsed
|
||||
// schedule are rebuilt for the IDs the file brought (including the one
|
||||
// normalization had to assign).
|
||||
for _, job := range jobs {
|
||||
if svc.Runtime(job.ID) == nil {
|
||||
t.Errorf("job %d (%q) has no runtime after adoption", job.ID, job.Name)
|
||||
}
|
||||
}
|
||||
if svc.Runtime(1) != nil {
|
||||
t.Error("runtime of the replaced job should be gone")
|
||||
}
|
||||
|
||||
var loaded []JobsLoaded
|
||||
for _, e := range rec.events {
|
||||
if jl, ok := e.(JobsLoaded); ok {
|
||||
loaded = append(loaded, jl)
|
||||
}
|
||||
}
|
||||
if len(loaded) != 1 || loaded[0].Path != shared || loaded[0].Count != 2 {
|
||||
t.Errorf("JobsLoaded events: got %+v, want one for %q with 2 jobs", loaded, shared)
|
||||
}
|
||||
}
|
||||
|
||||
// A path with no file behind it is the "rename or relocate" case: the current
|
||||
// jobs are written there rather than an empty list being adopted.
|
||||
func TestUpdateSettingsKeepsJobsWhenTheNewFileIsMissing(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Local", Schedule: "@every 1m", Command: "echo local", Enabled: true}})
|
||||
|
||||
config := svc.store.Config
|
||||
config.JobsFile = filepath.Join("moved", "jobs.json")
|
||||
if err := svc.UpdateSettings(config); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
jobs := svc.Jobs()
|
||||
if len(jobs) != 1 || jobs[0].Name != "Local" {
|
||||
t.Fatalf("jobs after the move: got %+v, want the original job", jobs)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(svc.store.Paths.AppDir, "moved", "jobs.json")); err != nil {
|
||||
t.Errorf("jobs should have been written to the new path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Adoption throws away every runtime, including the state of a run in flight,
|
||||
// and a finishing run would then write its result onto whichever job inherited
|
||||
// its ID. Refusing the switch is what keeps that from happening.
|
||||
func TestUpdateSettingsRefusesJobsFileSwitchWhileRunning(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Long", Schedule: "@every 1h", Command: "echo long", Enabled: true}})
|
||||
entered := make(chan int, 1)
|
||||
release := make(chan struct{})
|
||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
||||
entered <- job.ID
|
||||
<-release
|
||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
||||
}
|
||||
done := completions(svc)
|
||||
|
||||
if err := svc.RunNow(1); err != nil {
|
||||
t.Fatalf("RunNow: %v", err)
|
||||
}
|
||||
<-entered
|
||||
|
||||
config := svc.store.Config
|
||||
config.JobsFile = filepath.Join("elsewhere", "jobs.json")
|
||||
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 {
|
||||
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.NotifyOnFailure = !unrelated.NotifyOnFailure
|
||||
if err := svc.UpdateSettings(unrelated); err != nil {
|
||||
t.Errorf("unrelated setting should still save during a run: %v", err)
|
||||
}
|
||||
|
||||
close(release)
|
||||
waitRecord(t, done)
|
||||
}
|
||||
|
||||
func TestPrependLogCapsActivityList(t *testing.T) {
|
||||
runtime := &domain.JobRuntime{}
|
||||
for i := 0; i < maxJobLogs+10; i++ {
|
||||
|
||||
+25
-16
@@ -69,28 +69,38 @@ type Service struct {
|
||||
// store is the Service's sole channel to persistence.
|
||||
func NewService(store *storage.Store, jobs []domain.Job) *Service {
|
||||
s := &Service{
|
||||
store: store,
|
||||
jobs: jobs,
|
||||
runtimes: domain.NewRuntimes(jobs),
|
||||
schedules: make(map[int]domain.Schedule, len(jobs)),
|
||||
runJob: runner.RunJob,
|
||||
ctx: context.Background(),
|
||||
paused: store.Config.Paused,
|
||||
store: store,
|
||||
runJob: runner.RunJob,
|
||||
ctx: context.Background(),
|
||||
paused: store.Config.Paused,
|
||||
}
|
||||
// Parse every schedule once, then compute 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. No lock is needed: construction is
|
||||
// single-threaded, before Start launches the timing loop.
|
||||
// No lock is needed here: construction is single-threaded, before Start
|
||||
// launches the timing loop.
|
||||
s.adoptJobsLocked(jobs)
|
||||
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.
|
||||
//
|
||||
// It backs both construction and a Settings change that points at a different
|
||||
// jobs file. The caller must hold mu.
|
||||
func (s *Service) adoptJobsLocked(jobs []domain.Job) {
|
||||
s.jobs = jobs
|
||||
s.runtimes = domain.NewRuntimes(jobs)
|
||||
s.schedules = make(map[int]domain.Schedule, len(jobs))
|
||||
|
||||
now := time.Now()
|
||||
for index := range s.jobs {
|
||||
job := &s.jobs[index]
|
||||
s.parseScheduleLocked(job)
|
||||
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
|
||||
}
|
||||
// Seed execution-time statistics from existing log files so the details panel
|
||||
// shows accumulated run history immediately after a restart, not just runs
|
||||
// since this process started.
|
||||
for id, seed := range runner.SeedStats(store.Paths.LogsDir, jobs, store.Config.MaxLogFiles) {
|
||||
for id, seed := range runner.SeedStats(s.store.Paths.LogsDir, s.jobs, s.store.Config.MaxLogFiles) {
|
||||
runtime := s.runtimes[id]
|
||||
if runtime == nil {
|
||||
continue
|
||||
@@ -102,7 +112,6 @@ func NewService(store *storage.Store, jobs []domain.Job) *Service {
|
||||
runtime.MaxDurationMS = seed.MaxDurationMS
|
||||
runtime.TimedRunCount = seed.TimedRunCount
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Start begins scheduling with the real wall clock. It is the production entry
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@ package app
|
||||
// Version is the application version shown in the GUI and used by build
|
||||
// scripts in artifact names. It is a var rather than a const so release builds
|
||||
// can override it with Go ldflags when CI tags a build.
|
||||
var Version = "0.13.0"
|
||||
var Version = "1.0.0"
|
||||
|
||||
+10
-2
@@ -64,7 +64,15 @@ const (
|
||||
// application-level choices: where to read jobs from, where to write logs, and
|
||||
// how the desktop shell should behave.
|
||||
type Config struct {
|
||||
JobsDir string `json:"jobs_dir"`
|
||||
// JobsFile is the full path of the JSON file holding the job definitions,
|
||||
// file name included, so the user can keep jobs under any name they like. A
|
||||
// relative path is resolved against the program folder.
|
||||
JobsFile string `json:"jobs_file"`
|
||||
// JobsDir is the pre-0.15 setting that named only the directory, with the
|
||||
// file name fixed to jobs.json. It is still read so an older gosentry.json
|
||||
// keeps working: storage.loadOrCreateConfig turns it into JobsFile and
|
||||
// clears it, so the field disappears from the file on the next save.
|
||||
JobsDir string `json:"jobs_dir,omitempty"`
|
||||
LogsDir string `json:"logs_dir"`
|
||||
MaxLogFiles int `json:"max_log_files"`
|
||||
MaxLogAgeDays int `json:"max_log_age_days"`
|
||||
@@ -93,7 +101,7 @@ type Config struct {
|
||||
// offers to restore via its "Defaults" button.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
JobsDir: ".",
|
||||
JobsFile: "jobs.json",
|
||||
LogsDir: "logs",
|
||||
MaxLogFiles: 100,
|
||||
MaxLogAgeDays: 30,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Package filemanager opens a directory in the desktop file manager, so the
|
||||
// UI can reveal a configured folder (logs, jobs) without knowing which handler
|
||||
// the platform uses.
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Open shows dir in the platform file manager. A missing path, a path that is
|
||||
// not a directory, and a handler that fails to start are all returned as
|
||||
// errors so the caller can surface them instead of appearing to do nothing.
|
||||
func Open(dir string) error {
|
||||
info, err := os.Stat(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("folder does not exist: %s", dir)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("not a folder: %s", dir)
|
||||
}
|
||||
name, args := openCommand(dir)
|
||||
if name == "" {
|
||||
return fmt.Errorf("opening a folder is not supported on %s", runtime.GOOS)
|
||||
}
|
||||
command := exec.Command(name, args...)
|
||||
if err := command.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
// The handler hands the request to the desktop shell and exits on its own —
|
||||
// Windows Explorer even exits non-zero after opening the window — so its
|
||||
// status carries no information. Wait runs only to release the process
|
||||
// handle, and never blocks the caller.
|
||||
go func() { _ = command.Wait() }()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build linux
|
||||
|
||||
package filemanager
|
||||
|
||||
// openCommand returns the XDG invocation for dir. xdg-open picks whichever
|
||||
// file manager the desktop environment has registered for directories.
|
||||
func openCommand(dir string) (string, []string) {
|
||||
return "xdg-open", []string{dir}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows && !linux
|
||||
|
||||
package filemanager
|
||||
|
||||
// openCommand has no handler to name on platforms GoSentry does not ship for.
|
||||
// An empty name makes Open report that the action is unavailable instead of
|
||||
// running something arbitrary.
|
||||
func openCommand(dir string) (string, []string) {
|
||||
return "", nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The success path is deliberately not tested: it would pop a real file
|
||||
// manager window on the machine running the suite. Only the guards that keep
|
||||
// Open from launching anything are exercised here.
|
||||
|
||||
func TestOpenRejectsMissingFolder(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "no-such-folder")
|
||||
|
||||
err := Open(missing)
|
||||
if err == nil {
|
||||
t.Fatal("Open on a missing folder returned nil, want an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), missing) {
|
||||
t.Errorf("error %q does not name the missing folder %q", err, missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRejectsFile(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "gosentry.log")
|
||||
if err := os.WriteFile(file, []byte("log"), 0o644); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
err := Open(file)
|
||||
if err == nil {
|
||||
t.Fatal("Open on a file returned nil, want an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a folder") {
|
||||
t.Errorf("error %q does not report that the path is not a folder", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenCommandNamesPlatformHandler checks the supported platforms name a
|
||||
// handler (an empty name makes Open report the action as unavailable) and that
|
||||
// the directory is passed as a single argument, so spaces need no quoting.
|
||||
func TestOpenCommandNamesPlatformHandler(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "log files")
|
||||
|
||||
name, args := openCommand(dir)
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
if name != "explorer" {
|
||||
t.Errorf("handler on windows = %q, want %q", name, "explorer")
|
||||
}
|
||||
case "linux":
|
||||
if name != "xdg-open" {
|
||||
t.Errorf("handler on linux = %q, want %q", name, "xdg-open")
|
||||
}
|
||||
default:
|
||||
if name != "" {
|
||||
t.Errorf("handler on %s = %q, want no handler", runtime.GOOS, name)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(args) != 1 || args[0] != filepath.Clean(dir) {
|
||||
t.Errorf("arguments = %q, want the single path %q", args, filepath.Clean(dir))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package filemanager
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
// openCommand returns the Explorer invocation for dir. The path is cleaned
|
||||
// because Explorer ignores an argument that mixes separators, and it is passed
|
||||
// as a single argument so spaces need no quoting.
|
||||
func openCommand(dir string) (string, []string) {
|
||||
return "explorer", []string{filepath.Clean(dir)}
|
||||
}
|
||||
+12
-7
@@ -9,9 +9,11 @@ const (
|
||||
// The config file stays beside the executable so the portable build behaves
|
||||
// predictably: moving the program folder moves its settings with it.
|
||||
ConfigFileName = "gosentry.json"
|
||||
// Jobs are kept in a separate JSON file because the user can choose a
|
||||
// different jobs directory, while application settings remain local to the
|
||||
// installed/copied program.
|
||||
// Jobs are kept in a separate JSON file because the user can point the
|
||||
// configuration at any jobs file they like, while application settings
|
||||
// remain local to the installed/copied program. This is only the default
|
||||
// name, used before the config is read and when an older config that named
|
||||
// just a directory is migrated.
|
||||
JobsFileName = "jobs.json"
|
||||
|
||||
)
|
||||
@@ -23,10 +25,13 @@ type Paths struct {
|
||||
ExecutablePath string
|
||||
AppDir string
|
||||
ConfigPath string
|
||||
JobsDir string
|
||||
JobsPath string
|
||||
LogsDir string
|
||||
DesktopIcon string
|
||||
// JobsDir is the directory containing JobsPath. It is derived from the
|
||||
// configured jobs file, never configured on its own, and exists so writers
|
||||
// can create the folder before saving.
|
||||
JobsDir string
|
||||
JobsPath string
|
||||
LogsDir string
|
||||
DesktopIcon string
|
||||
}
|
||||
|
||||
func ResolvePaths() (Paths, error) {
|
||||
|
||||
+52
-24
@@ -78,14 +78,25 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
||||
if err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
// Clearing the default first keeps "the file sets jobs_file" distinguishable
|
||||
// from "the file omits it", which the jobs_dir migration below depends on.
|
||||
// The fallbacks restore a value in either case.
|
||||
config.JobsFile = ""
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(config.JobsDir) == "" {
|
||||
// A config written before the setting named a file carries jobs_dir instead
|
||||
// of jobs_file. Keep its meaning by appending the fixed name that version
|
||||
// used, then drop the old key so the file is rewritten in the current shape.
|
||||
if strings.TrimSpace(config.JobsFile) == "" && strings.TrimSpace(config.JobsDir) != "" {
|
||||
config.JobsFile = filepath.Join(config.JobsDir, JobsFileName)
|
||||
}
|
||||
config.JobsDir = ""
|
||||
if strings.TrimSpace(config.JobsFile) == "" {
|
||||
// Empty paths are treated as missing values rather than intentional root
|
||||
// directories. This avoids accidentally writing jobs to unexpected places.
|
||||
config.JobsDir = "."
|
||||
config.JobsFile = JobsFileName
|
||||
}
|
||||
if strings.TrimSpace(config.LogsDir) == "" {
|
||||
config.LogsDir = "logs"
|
||||
@@ -112,24 +123,39 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
// Seed harmless sample jobs so a new user can immediately see scheduled
|
||||
// and manual execution without inventing a command.
|
||||
jobs := defaultJobs()
|
||||
normalizeJobs(jobs)
|
||||
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
|
||||
}
|
||||
|
||||
// LoadJobsFile reads and normalizes the job definitions at path. The bool
|
||||
// reports whether the file was there: a missing file is not an error but the
|
||||
// answer to "is this file already a jobs file?", which is what the Settings tab
|
||||
// needs when the user points the application at a different jobs file.
|
||||
func LoadJobsFile(path string) ([]domain.Job, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
var file domain.JobsFile
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
normalizeJobs(file.Jobs)
|
||||
return file.Jobs, true, nil
|
||||
}
|
||||
|
||||
func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
||||
jobs, found, err := LoadJobsFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file.Jobs, nil
|
||||
if found {
|
||||
return jobs, nil
|
||||
}
|
||||
// Seed harmless sample jobs so a new user can immediately see scheduled
|
||||
// and manual execution without inventing a command.
|
||||
jobs = defaultJobs()
|
||||
normalizeJobs(jobs)
|
||||
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
|
||||
}
|
||||
|
||||
func normalizeJobs(jobs []domain.Job) {
|
||||
@@ -162,24 +188,26 @@ func normalizeJobs(jobs []domain.Job) {
|
||||
}
|
||||
}
|
||||
|
||||
func resolveJobsDir(appDir string, jobsDir string) string {
|
||||
return resolveConfiguredDir(appDir, jobsDir)
|
||||
}
|
||||
|
||||
func resolveConfiguredDir(appDir string, dir string) string {
|
||||
if filepath.IsAbs(dir) {
|
||||
return dir
|
||||
// ResolveConfiguredPath turns a file or directory path from the config into the
|
||||
// absolute path the application will actually use. It is exported so callers
|
||||
// outside storage — the settings tab, which opens the configured logs folder —
|
||||
// apply the same rule to a path the user has typed but not yet saved.
|
||||
func ResolveConfiguredPath(appDir string, path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
// Relative paths are resolved against the executable directory, not the
|
||||
// process working directory. This matches ResolvePaths and keeps shortcuts,
|
||||
// Explorer launches, and terminal launches consistent.
|
||||
return filepath.Clean(filepath.Join(appDir, dir))
|
||||
return filepath.Clean(filepath.Join(appDir, path))
|
||||
}
|
||||
|
||||
func (s *Store) applyConfigPaths() {
|
||||
s.Paths.JobsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir)
|
||||
s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName)
|
||||
s.Paths.LogsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir)
|
||||
// The jobs file is configured as a whole path; its directory is derived so
|
||||
// SaveJobs can create the folder when the user points at a new location.
|
||||
s.Paths.JobsPath = ResolveConfiguredPath(s.Paths.AppDir, s.Config.JobsFile)
|
||||
s.Paths.JobsDir = filepath.Dir(s.Paths.JobsPath)
|
||||
s.Paths.LogsDir = ResolveConfiguredPath(s.Paths.AppDir, s.Config.LogsDir)
|
||||
}
|
||||
|
||||
func writeJSON(path string, value any) error {
|
||||
|
||||
+107
-5
@@ -77,7 +77,7 @@ func TestConfigRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
want := domain.Config{
|
||||
JobsDir: "/custom/jobs",
|
||||
JobsFile: "/custom/jobs/team.json",
|
||||
LogsDir: "/custom/logs",
|
||||
MaxLogFiles: 50,
|
||||
MaxLogAgeDays: 14,
|
||||
@@ -94,8 +94,8 @@ func TestConfigRoundTrip(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got.JobsDir != want.JobsDir {
|
||||
t.Errorf("JobsDir: got %q, want %q", got.JobsDir, want.JobsDir)
|
||||
if got.JobsFile != want.JobsFile {
|
||||
t.Errorf("JobsFile: got %q, want %q", got.JobsFile, want.JobsFile)
|
||||
}
|
||||
if got.LogsDir != want.LogsDir {
|
||||
t.Errorf("LogsDir: got %q, want %q", got.LogsDir, want.LogsDir)
|
||||
@@ -156,8 +156,8 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.JobsDir != "." {
|
||||
t.Errorf("default JobsDir = %q, want '.'", got.JobsDir)
|
||||
if got.JobsFile != "jobs.json" {
|
||||
t.Errorf("default JobsFile = %q, want 'jobs.json'", got.JobsFile)
|
||||
}
|
||||
if got.LogsDir != "logs" {
|
||||
t.Errorf("default LogsDir = %q, want 'logs'", got.LogsDir)
|
||||
@@ -207,6 +207,108 @@ func TestLoadOrCreateConfigKeepsZeroTimeoutOnReload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOrCreateConfigMigratesJobsDir covers a gosentry.json written before
|
||||
// the setting named a file: the old jobs_dir keeps pointing at the same jobs
|
||||
// file, and the retired key is dropped so it is not written back.
|
||||
func TestLoadOrCreateConfigMigratesJobsDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
paths := Paths{
|
||||
AppDir: dir,
|
||||
ConfigPath: filepath.Join(dir, ConfigFileName),
|
||||
}
|
||||
legacy := map[string]any{
|
||||
"jobs_dir": filepath.Join(dir, "shared"),
|
||||
"logs_dir": "logs",
|
||||
"max_log_files": 100,
|
||||
"max_log_age_days": 30,
|
||||
}
|
||||
if err := writeJSON(paths.ConfigPath, legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := loadOrCreateConfig(paths)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(dir, "shared", JobsFileName)
|
||||
if got.JobsFile != want {
|
||||
t.Errorf("migrated JobsFile: got %q, want %q", got.JobsFile, want)
|
||||
}
|
||||
if got.JobsDir != "" {
|
||||
t.Errorf("legacy JobsDir should be cleared, got %q", got.JobsDir)
|
||||
}
|
||||
|
||||
// The migrated config must not carry the retired key once it is saved.
|
||||
store := &Store{Paths: paths, Config: got}
|
||||
if err := store.SaveConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(paths.ConfigPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "jobs_dir") {
|
||||
t.Errorf("saved config should not contain jobs_dir:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadJobsFileReportsMissingWithoutCreating covers the loader the Settings
|
||||
// tab uses to decide between adopting a jobs file and writing the current jobs
|
||||
// to it: a missing file is reported as "not found" rather than an error, and —
|
||||
// unlike the startup path — is not seeded with sample jobs.
|
||||
func TestLoadJobsFileReportsMissingWithoutCreating(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missing := filepath.Join(dir, "nothing-here.json")
|
||||
|
||||
jobs, found, err := LoadJobsFile(missing)
|
||||
if err != nil {
|
||||
t.Fatalf("missing file should not be an error: %v", err)
|
||||
}
|
||||
if found || jobs != nil {
|
||||
t.Errorf("missing file: got found=%v jobs=%+v, want false/nil", found, jobs)
|
||||
}
|
||||
if _, err := os.Stat(missing); !os.IsNotExist(err) {
|
||||
t.Error("LoadJobsFile must not create the file it was asked about")
|
||||
}
|
||||
|
||||
// An existing file comes back normalized, so a hand-written jobs file gains
|
||||
// its IDs and defaults before the application adopts it.
|
||||
path := filepath.Join(dir, "hand-written.json")
|
||||
if err := writeJSON(path, domain.JobsFile{Jobs: []domain.Job{{Name: "No ID"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jobs, found, err = LoadJobsFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found || len(jobs) != 1 {
|
||||
t.Fatalf("existing file: got found=%v jobs=%+v, want true and one job", found, jobs)
|
||||
}
|
||||
if jobs[0].ID != 1 || jobs[0].Schedule == "" || jobs[0].Command == "" {
|
||||
t.Errorf("loaded job should be normalized, got %+v", jobs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyConfigPathsDerivesJobsDir checks that the jobs file drives both
|
||||
// resolved paths: relative values resolve against the program folder, and the
|
||||
// containing directory comes from the file name the user chose.
|
||||
func TestApplyConfigPathsDerivesJobsDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := &Store{
|
||||
Paths: Paths{AppDir: dir},
|
||||
Config: domain.Config{JobsFile: filepath.Join("shared", "team.json"), LogsDir: "logs"},
|
||||
}
|
||||
|
||||
store.applyConfigPaths()
|
||||
|
||||
if want := filepath.Join(dir, "shared", "team.json"); store.Paths.JobsPath != want {
|
||||
t.Errorf("JobsPath: got %q, want %q", store.Paths.JobsPath, want)
|
||||
}
|
||||
if want := filepath.Join(dir, "shared"); store.Paths.JobsDir != want {
|
||||
t.Errorf("JobsDir: got %q, want %q", store.Paths.JobsDir, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJobTimeoutRoundTripsThreeStates pins the on-disk encoding that keeps
|
||||
// "inherit" and "no timeout" distinguishable: nil is omitted entirely, while an
|
||||
// explicit 0 is written and read back as a set value.
|
||||
|
||||
+105
-46
@@ -42,37 +42,85 @@ func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
|
||||
return events
|
||||
}
|
||||
|
||||
// logColumnMinWidth/logColumnMaxWidth bound the dynamically sized Log column.
|
||||
// The minimum keeps the column readable when names are short or absent; the
|
||||
// maximum stops a single very long file name from dominating the table (the
|
||||
// table still scrolls horizontally past it).
|
||||
const (
|
||||
logColumnMinWidth = 240
|
||||
logColumnMaxWidth = 520
|
||||
logColumnPadding = 24
|
||||
)
|
||||
// textWidth measures how wide s renders at the theme's current body text size.
|
||||
func textWidth(s string) float32 {
|
||||
return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width
|
||||
}
|
||||
|
||||
// logColumnWidth measures the widest Log cell value so the column can be sized
|
||||
// to fit its content. Fyne tables do not auto-size columns, so without this the
|
||||
// fixed width clips file names like "20260601-100000_SomeJobName.log".
|
||||
func logColumnWidth(events []event) float32 {
|
||||
width := float32(logColumnMinWidth)
|
||||
for _, current := range events {
|
||||
text := logFileName(current.LogFile)
|
||||
// cellPadding is the horizontal space a table cell reserves around its text.
|
||||
// It replaces a hand-tuned pixel constant with the theme's own inner padding
|
||||
// doubled (one side each), so it follows text size and DPI.
|
||||
func cellPadding() float32 { return 2 * theme.InnerPadding() }
|
||||
|
||||
// textColumnMinWidth/textColumnMaxWidth bound every content-measured History
|
||||
// column: the minimum keeps a column readable when its values are short or
|
||||
// absent, the maximum stops one very long value from dominating the table
|
||||
// (the table still scrolls horizontally past it). Expressed as measured text
|
||||
// rather than raw pixels so both follow the theme instead of drifting from it.
|
||||
func textColumnMinWidth() float32 { return textWidth(strings.Repeat("0", 10)) + cellPadding() }
|
||||
func textColumnMaxWidth() float32 { return textWidth(strings.Repeat("0", 30)) + cellPadding() }
|
||||
|
||||
// textColumnWidth measures the widest of samples so a table column can be
|
||||
// sized to fit its content, clamped to [min, max]. Fyne tables do not
|
||||
// auto-size columns, so without this a fixed width clips values like
|
||||
// "20260601-100000_SomeJobName.log" in the Log column.
|
||||
func textColumnWidth(samples []string, min, max float32) float32 {
|
||||
width := min
|
||||
for _, text := range samples {
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
w := fyne.MeasureText(text, theme.TextSize(), fyne.TextStyle{}).Width + logColumnPadding
|
||||
if w > width {
|
||||
if w := textWidth(text) + cellPadding(); w > width {
|
||||
width = w
|
||||
}
|
||||
}
|
||||
if width > logColumnMaxWidth {
|
||||
width = logColumnMaxWidth
|
||||
if width > max {
|
||||
width = max
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
// historyTriggerSamples is the closed set of Trigger values History ever
|
||||
// shows (see newEvent and app.operations.go/app.run.go, which produce "UI",
|
||||
// "Manual" and "Schedule"; historyCellText falls back to "Unknown"). Add a new
|
||||
// trigger here too if one is introduced there, or the column may clip it.
|
||||
var historyTriggerSamples = []string{"Schedule", "Manual", "UI", "Unknown"}
|
||||
|
||||
// historyStateSamples is the closed set of State values History ever shows:
|
||||
// "OK" and "Failed" come from runner.RunJob (runStateDetail/startJobOnly);
|
||||
// "Started", "Error" and "Jobs loaded" are recorded directly in mainwindow.go.
|
||||
// Add a new state here too if one is introduced in either place.
|
||||
var historyStateSamples = []string{"OK", "Failed", "Started", "Error", "Jobs loaded"}
|
||||
|
||||
// historyTimeSample is the rendered form of the timestamp layout every event
|
||||
// uses (see newEvent), so the Time column needs no content scan: its width is
|
||||
// fixed by the format string.
|
||||
const historyTimeSample = "2026-01-02 15:04:05"
|
||||
|
||||
// historyColumnWidths computes every column's width from the current sorted
|
||||
// rows. Time, Trigger and State are fixed-shape or closed-set columns; Job,
|
||||
// Detail and Log are free text, so their width tracks the values actually
|
||||
// present, bounded the same way the Log column always was.
|
||||
func historyColumnWidths(rows []event) [6]float32 {
|
||||
jobNames := make([]string, 0, len(rows))
|
||||
details := make([]string, 0, len(rows))
|
||||
logNames := make([]string, 0, len(rows))
|
||||
for _, current := range rows {
|
||||
jobNames = append(jobNames, current.JobName)
|
||||
details = append(details, current.Detail)
|
||||
logNames = append(logNames, logFileName(current.LogFile))
|
||||
}
|
||||
min, max := textColumnMinWidth(), textColumnMaxWidth()
|
||||
return [6]float32{
|
||||
textWidth(historyTimeSample) + cellPadding(),
|
||||
textColumnWidth(historyTriggerSamples, min, max),
|
||||
textColumnWidth(jobNames, min, max),
|
||||
textColumnWidth(historyStateSamples, min, max),
|
||||
textColumnWidth(details, min, max),
|
||||
textColumnWidth(logNames, min, max),
|
||||
}
|
||||
}
|
||||
|
||||
// historyHeader is a bold tappable label used in the History table header row.
|
||||
// In Fyne 2.7+ OnSelected is not fired for header cells (Row < 0), so the sort
|
||||
// toggle is wired through the Tappable interface instead.
|
||||
@@ -85,7 +133,7 @@ type historyHeader struct {
|
||||
func newHistoryHeader() *historyHeader {
|
||||
h := &historyHeader{label: widget.NewLabel("")}
|
||||
h.label.TextStyle = fyne.TextStyle{Bold: true}
|
||||
h.label.Wrapping = fyne.TextTruncate
|
||||
h.label.Truncation = fyne.TextTruncateClip
|
||||
h.ExtendBaseWidget(h)
|
||||
return h
|
||||
}
|
||||
@@ -104,46 +152,54 @@ func (h *historyHeader) SetText(text string) {
|
||||
h.label.SetText(text)
|
||||
}
|
||||
|
||||
// historyHeaders are the History table's column captions, in column order. The
|
||||
// Time caption is built per update because it carries the sort direction arrow.
|
||||
var historyHeaders = [...]string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
|
||||
|
||||
func newHistoryView(events *[]event) (*fyne.Container, func()) {
|
||||
descending := false
|
||||
headerText := func(id widget.TableCellID) string {
|
||||
headers := []string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
|
||||
if id.Row < 0 && id.Col == 0 {
|
||||
if descending {
|
||||
return "Time ▼"
|
||||
}
|
||||
return "Time ▲"
|
||||
}
|
||||
if id.Row < 0 && id.Col >= 0 && id.Col < len(headers) {
|
||||
return headers[id.Col]
|
||||
if id.Row < 0 && id.Col >= 0 && id.Col < len(historyHeaders) {
|
||||
return historyHeaders[id.Col]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
sortedEvents := func() []event {
|
||||
result := append([]event(nil), (*events)...)
|
||||
sort.SliceStable(result, func(left int, right int) bool {
|
||||
|
||||
// rows is the sorted snapshot every callback below reads — both the length
|
||||
// callback and the cells, which must agree on the same slice. A full redraw
|
||||
// issues one update call per visible cell, so sorting inside the cell
|
||||
// callback re-sorted the whole event list a hundred times per Refresh.
|
||||
// resort() is therefore the only place the order changes, and it runs once
|
||||
// per redraw: at build time, on a sort toggle, and from refresh().
|
||||
var rows []event
|
||||
resort := func() {
|
||||
rows = append(rows[:0], (*events)...)
|
||||
sort.SliceStable(rows, func(left int, right int) bool {
|
||||
if descending {
|
||||
return result[left].Time > result[right].Time
|
||||
return rows[left].Time > rows[right].Time
|
||||
}
|
||||
return result[left].Time < result[right].Time
|
||||
return rows[left].Time < rows[right].Time
|
||||
})
|
||||
return result
|
||||
}
|
||||
resort()
|
||||
|
||||
table := widget.NewTable(
|
||||
func() (int, int) {
|
||||
return len(*events), 6
|
||||
return len(rows), len(historyHeaders)
|
||||
},
|
||||
func() fyne.CanvasObject {
|
||||
label := widget.NewLabel("")
|
||||
label.Wrapping = fyne.TextTruncate
|
||||
label.Truncation = fyne.TextTruncateClip
|
||||
return label
|
||||
},
|
||||
func(id widget.TableCellID, item fyne.CanvasObject) {
|
||||
label := item.(*widget.Label)
|
||||
label.SetText(historyCellText(id, sortedEvents()))
|
||||
label.TextStyle = fyne.TextStyle{}
|
||||
label.Refresh()
|
||||
item.(*widget.Label).SetText(historyCellText(id, rows))
|
||||
},
|
||||
)
|
||||
table.ShowHeaderRow = true
|
||||
@@ -156,6 +212,7 @@ func newHistoryView(events *[]event) (*fyne.Container, func()) {
|
||||
if id.Row < 0 && id.Col == 0 {
|
||||
h.OnTapped = func() {
|
||||
descending = !descending
|
||||
resort()
|
||||
table.Refresh()
|
||||
}
|
||||
} else {
|
||||
@@ -166,18 +223,20 @@ func newHistoryView(events *[]event) (*fyne.Container, func()) {
|
||||
table.OnSelected = func(id widget.TableCellID) {
|
||||
table.Unselect(id)
|
||||
}
|
||||
table.SetColumnWidth(0, 150)
|
||||
table.SetColumnWidth(1, 90)
|
||||
table.SetColumnWidth(2, 170)
|
||||
table.SetColumnWidth(3, 90)
|
||||
table.SetColumnWidth(4, 260)
|
||||
table.SetColumnWidth(5, logColumnWidth(*events))
|
||||
setColumnWidths := func() {
|
||||
for col, width := range historyColumnWidths(rows) {
|
||||
table.SetColumnWidth(col, width)
|
||||
}
|
||||
}
|
||||
setColumnWidths()
|
||||
|
||||
// refresh recomputes the content-fit Log column width before redrawing, so
|
||||
// newly recorded events with longer file names widen the column instead of
|
||||
// being truncated.
|
||||
// refresh re-reads the event list into the sorted snapshot and recomputes
|
||||
// every content-fit column width before redrawing, so newly recorded events
|
||||
// appear in the current sort order and longer values widen their column
|
||||
// instead of being truncated.
|
||||
refresh := func() {
|
||||
table.SetColumnWidth(5, logColumnWidth(*events))
|
||||
resort()
|
||||
setColumnWidths()
|
||||
table.Refresh()
|
||||
}
|
||||
return container.NewPadded(table), refresh
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/test"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
@@ -127,6 +130,168 @@ func TestLogFileName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistorySortToggleKeepsRowsInSync is the regression guard for F11: the
|
||||
// table now reads one cached sorted snapshot instead of re-sorting inside every
|
||||
// cell callback, so the length callback and the cells have to be refilled
|
||||
// together. If either the sort toggle or refresh stops calling resort(), the
|
||||
// row count and the cell contents disagree — which no compiler check catches.
|
||||
func TestHistorySortToggleKeepsRowsInSync(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
|
||||
events := []event{
|
||||
{Time: "2026-06-01 10:00:00", JobName: "A"},
|
||||
{Time: "2026-06-01 11:00:00", JobName: "B"},
|
||||
{Time: "2026-06-01 12:00:00", JobName: "C"},
|
||||
}
|
||||
content, refresh := newHistoryView(&events)
|
||||
table, ok := content.Objects[0].(*widget.Table)
|
||||
if !ok {
|
||||
t.Fatal("history view does not wrap a table")
|
||||
}
|
||||
|
||||
rowCount := func() int {
|
||||
t.Helper()
|
||||
rows, cols := table.Length()
|
||||
if cols != len(historyHeaders) {
|
||||
t.Errorf("column count = %d, want %d", cols, len(historyHeaders))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
// Column 2 is the Job name, the field these fixtures vary.
|
||||
jobAt := func(row int) string {
|
||||
t.Helper()
|
||||
cell := table.CreateCell()
|
||||
table.UpdateCell(widget.TableCellID{Row: row, Col: 2}, cell)
|
||||
return cell.(*widget.Label).Text
|
||||
}
|
||||
// The sort toggle lives on the Time header cell, which is only wired up
|
||||
// when UpdateHeader runs for it.
|
||||
header := table.CreateHeader()
|
||||
table.UpdateHeader(widget.TableCellID{Row: -1, Col: 0}, header)
|
||||
timeHeader, ok := header.(*historyHeader)
|
||||
if !ok {
|
||||
t.Fatal("history table header is not a historyHeader")
|
||||
}
|
||||
assertOrder := func(when string, want ...string) {
|
||||
t.Helper()
|
||||
if got := rowCount(); got != len(want) {
|
||||
t.Fatalf("%s: row count = %d, want %d", when, got, len(want))
|
||||
}
|
||||
for row, name := range want {
|
||||
if got := jobAt(row); got != name {
|
||||
t.Errorf("%s: row %d = %q, want %q", when, row, got, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertOrder("ascending", "A", "B", "C")
|
||||
|
||||
test.Tap(timeHeader)
|
||||
assertOrder("descending", "C", "B", "A")
|
||||
|
||||
// A new run arrives while the table is sorted newest-first: it must be
|
||||
// counted and placed in the order currently on screen, not the build-time one.
|
||||
events = append(events, event{Time: "2026-06-01 13:00:00", JobName: "D"})
|
||||
refresh()
|
||||
assertOrder("descending after refresh", "D", "C", "B", "A")
|
||||
|
||||
test.Tap(timeHeader)
|
||||
assertOrder("ascending after refresh", "A", "B", "C", "D")
|
||||
}
|
||||
|
||||
// TestHistoryCellTemplateIsPlainText guards the dropped per-cell TextStyle
|
||||
// assignment: the template must already carry the zero style, since nothing
|
||||
// resets it any more.
|
||||
func TestHistoryCellTemplateIsPlainText(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
|
||||
var events []event
|
||||
content, _ := newHistoryView(&events)
|
||||
table := content.Objects[0].(*widget.Table)
|
||||
label, ok := table.CreateCell().(*widget.Label)
|
||||
if !ok {
|
||||
t.Fatal("history cell template is not a label")
|
||||
}
|
||||
if label.TextStyle != (fyne.TextStyle{}) {
|
||||
t.Errorf("cell template TextStyle = %+v, want the zero value", label.TextStyle)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTextColumnWidthClamps covers the three shapes textColumnWidth has to
|
||||
// handle: a sample narrower than min, one that lands between the bounds, and
|
||||
// one wide enough to hit the max cap.
|
||||
func TestTextColumnWidthClamps(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
|
||||
min, max := float32(50), float32(120)
|
||||
if got := textColumnWidth([]string{"x"}, min, max); got != min {
|
||||
t.Errorf("below-min sample: got %v, want the floor %v", got, min)
|
||||
}
|
||||
inRange := textWidth("mid-sized value") + cellPadding()
|
||||
if inRange <= min || inRange >= max {
|
||||
t.Skip("fixture sample no longer lands strictly between the bounds under this theme")
|
||||
}
|
||||
if got := textColumnWidth([]string{"mid-sized value"}, min, max); got != inRange {
|
||||
t.Errorf("in-range sample: got %v, want %v", got, inRange)
|
||||
}
|
||||
if got := textColumnWidth([]string{strings.Repeat("0", 200)}, min, max); got != max {
|
||||
t.Errorf("above-max sample: got %v, want the cap %v", got, max)
|
||||
}
|
||||
if got := textColumnWidth(nil, min, max); got != min {
|
||||
t.Errorf("no samples: got %v, want the floor %v", got, min)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryColumnsFitTheirContent guards F6/F14: every column must be at
|
||||
// least as wide as its widest known or actually-present value, at the default
|
||||
// theme and at a scaled one, so nothing that used to be a pixel constant
|
||||
// clips again.
|
||||
func TestHistoryColumnsFitTheirContent(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
|
||||
rows := []event{
|
||||
{
|
||||
Time: "2026-06-01 12:00:00",
|
||||
Trigger: "Schedule",
|
||||
JobName: "A moderately long job name for width testing",
|
||||
State: "Jobs loaded",
|
||||
Detail: "A somewhat longer detail message describing what happened",
|
||||
LogFile: `/logs/20260601-120000_SomeJobName.log`,
|
||||
},
|
||||
}
|
||||
|
||||
check := func(when string) {
|
||||
t.Helper()
|
||||
widths := historyColumnWidths(rows)
|
||||
samples := [][]string{
|
||||
{historyTimeSample},
|
||||
historyTriggerSamples,
|
||||
{rows[0].JobName},
|
||||
historyStateSamples,
|
||||
{rows[0].Detail},
|
||||
{logFileName(rows[0].LogFile)},
|
||||
}
|
||||
min, max := textColumnMinWidth(), textColumnMaxWidth()
|
||||
for col, colSamples := range samples {
|
||||
want := textColumnWidth(colSamples, min, max)
|
||||
if col == 0 {
|
||||
want = textWidth(historyTimeSample) + cellPadding()
|
||||
}
|
||||
if widths[col] < want {
|
||||
t.Errorf("%s: column %d width = %v, want at least %v", when, col, widths[col], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check("default theme")
|
||||
testApp.Settings().SetTheme(test.NewTheme())
|
||||
check("scaled theme")
|
||||
}
|
||||
|
||||
func TestNewEventUsesConsistentTimestampShape(t *testing.T) {
|
||||
ev := newEvent(1, "Job", "OK", "detail")
|
||||
if _, err := time.Parse("2006-01-02 15:04:05", ev.Time); err != nil {
|
||||
|
||||
@@ -35,11 +35,13 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
||||
commandEntry.SetPlaceHolder(`C:\Program Files\App\App.exe`)
|
||||
commandEntry.SetText(current.Command)
|
||||
commandBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||
chooseFile(w, commandEntry)
|
||||
chooseFile(w, commandEntry, nil)
|
||||
})
|
||||
commandRow := container.NewBorder(nil, nil, nil, commandBrowse, commandEntry)
|
||||
argumentsEntry := widget.NewMultiLineEntry()
|
||||
argumentsEntry.SetPlaceHolder(`D:\Local\Jobs\Auto.ffs_batch`)
|
||||
// One argument per line is the whole point of the field: each line is passed
|
||||
// to the process verbatim, so paths with spaces need no quoting.
|
||||
argumentsEntry.SetPlaceHolder("One argument per line, no quotes:\n--input\n" + `C:\Data\My Files\input.txt`)
|
||||
argumentsEntry.SetText(current.Arguments)
|
||||
startOnly := widget.NewCheck("Start only, do not wait for exit", nil)
|
||||
startOnly.SetChecked(current.StartOnly)
|
||||
|
||||
+18
-30
@@ -16,24 +16,12 @@ import (
|
||||
|
||||
const allFolders = "All"
|
||||
const noFolder = "No folder"
|
||||
const minJobsSidebarWidth float32 = 400
|
||||
|
||||
// maxJobActivityRows caps the "Selected job activity" panel to the most recent
|
||||
// entries. The full per-job history (up to maxJobLogs) remains in the History
|
||||
// view; this panel is a quick at-a-glance summary anchored below the output.
|
||||
const maxJobActivityRows = 3
|
||||
|
||||
// detailRowSpacing is the (negative) gap applied between metadata rows in the
|
||||
// details panel. Pulling rows together overlaps the labels' built-in vertical
|
||||
// padding, tightening the block so it fits comfortably on 720p screens.
|
||||
const detailRowSpacing float32 = -8
|
||||
|
||||
// jobRowSpacing is the (negative) gap between the name, metadata, and status
|
||||
// lines within each job list row. Like the details panel, it overlaps the
|
||||
// labels' built-in vertical padding so each row reads as one compact block and
|
||||
// more jobs are visible without scrolling.
|
||||
const jobRowSpacing float32 = -8
|
||||
|
||||
// newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar.
|
||||
// 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
|
||||
@@ -109,8 +97,8 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
// 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: compactVBoxLayout and the border layout
|
||||
// both skip hidden children when measuring.
|
||||
// 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()
|
||||
@@ -136,7 +124,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
status := widget.NewLabel("status")
|
||||
applyRowMode(inlineStatus, meta, status)
|
||||
nameLine := container.NewBorder(nil, nil, nil, inlineStatus, name)
|
||||
return container.New(compactVBoxLayout{spacing: jobRowSpacing}, nameLine, meta, status)
|
||||
return container.New(layout.NewCustomPaddedVBoxLayout(rowOverlap()), nameLine, meta, status)
|
||||
},
|
||||
func(id widget.ListItemID, item fyne.CanvasObject) {
|
||||
row := item.(*fyne.Container)
|
||||
@@ -178,12 +166,14 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
}
|
||||
selectedFolder = value
|
||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
||||
list.Refresh()
|
||||
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]
|
||||
@@ -235,7 +225,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
}
|
||||
selected = indexOfID(jobs, created.ID)
|
||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
||||
list.Refresh()
|
||||
list.Select(app.DisplayIndex(filteredJobs, selected))
|
||||
refreshView()
|
||||
})
|
||||
@@ -253,7 +242,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
syncFromService()
|
||||
folderSelect.Options = folderOptions(jobs)
|
||||
folderSelect.Refresh()
|
||||
list.Refresh()
|
||||
refreshView()
|
||||
})
|
||||
})
|
||||
@@ -267,7 +255,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
dialog.ShowError(err, w)
|
||||
return
|
||||
}
|
||||
list.Refresh()
|
||||
refreshView()
|
||||
})
|
||||
|
||||
@@ -299,7 +286,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
stopAllButton.SetText("Disable auto")
|
||||
stopAllButton.SetIcon(theme.MediaPauseIcon())
|
||||
}
|
||||
list.Refresh()
|
||||
refreshView()
|
||||
}
|
||||
pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
|
||||
@@ -311,8 +297,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
dialog.ShowError(err, w)
|
||||
return
|
||||
}
|
||||
syncFromService()
|
||||
list.Refresh()
|
||||
refreshView()
|
||||
})
|
||||
deleteButton := widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
|
||||
@@ -344,7 +328,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
} else {
|
||||
selected = filteredJobs[0]
|
||||
}
|
||||
list.Refresh()
|
||||
if selected >= 0 {
|
||||
list.Select(app.DisplayIndex(filteredJobs, selected))
|
||||
}
|
||||
@@ -354,14 +337,19 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
|
||||
toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer())
|
||||
globalControls := container.NewHBox(stopAllButton, schedulerState, layout.NewSpacer())
|
||||
// The view toggle sits beside the folder filter: the border layout gives it
|
||||
// its MinSize on the right and lets the select fill the rest, so the header
|
||||
// gains no height.
|
||||
filterRow := container.NewBorder(nil, nil, nil, viewButton, folderSelect)
|
||||
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), filterRow, toolbar)
|
||||
// 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)
|
||||
|
||||
fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar)
|
||||
panel := container.NewBorder(nil, nil, fixedSidebar, nil, container.NewPadded(dp.container()))
|
||||
// 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.SetOffset(initialSplitOffset(sidebar.MinSize().Width))
|
||||
return panel, refreshView
|
||||
}
|
||||
|
||||
+58
-31
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
@@ -59,12 +60,18 @@ func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy do
|
||||
// The height here is only a floor: the scroll grows to fill whatever space the
|
||||
// border layout gives it, so keep the minimum small so the whole window can be
|
||||
// shrunk on short (720p) screens. Long output stays reachable by scrolling.
|
||||
// The width, unlike the height, is load-bearing outside this widget: it is the
|
||||
// details pane's widest minimum, so it is also what keeps the metadata value
|
||||
// column non-empty — captionValueLayout hands the value whatever is left after
|
||||
// the caption and has no floor of its own (see its comment in layout.go).
|
||||
// Lowering it narrows those values with nothing to warn about it; the user can
|
||||
// only widen them, by dragging the jobs split divider left.
|
||||
d.commandOutputScroll.SetMinSize(fyne.NewSize(460, 70))
|
||||
d.logs = widget.NewList(
|
||||
func() int { return len(d.selectedLogs) },
|
||||
func() fyne.CanvasObject {
|
||||
l := widget.NewLabel("log")
|
||||
l.Wrapping = fyne.TextTruncate
|
||||
l.Truncation = fyne.TextTruncateClip
|
||||
return l
|
||||
},
|
||||
func(id widget.ListItemID, item fyne.CanvasObject) {
|
||||
@@ -114,21 +121,59 @@ func (d *detailsPanel) clear() {
|
||||
d.logs.Refresh()
|
||||
}
|
||||
|
||||
// detailRowSpec pairs a metadata caption with the widget that shows its value.
|
||||
// metadataRows and container derive both the caption column width and the row
|
||||
// layout from this single list, so a row added to one is never forgotten in
|
||||
// the other.
|
||||
type detailRowSpec struct {
|
||||
caption string
|
||||
value fyne.CanvasObject
|
||||
}
|
||||
|
||||
// metadataRows lists the details pane's metadata rows in display order. It is
|
||||
// the single source both the caption width measurement and the row layout in
|
||||
// container() read, so a twelfth row added here cannot silently go unmeasured
|
||||
// or unlaid-out the way two separately maintained lists could.
|
||||
func (d *detailsPanel) metadataRows() []detailRowSpec {
|
||||
return []detailRowSpec{
|
||||
{"Folder", d.folder},
|
||||
{"Schedule", d.schedule},
|
||||
{"Command", d.command},
|
||||
{"Arguments", d.arguments},
|
||||
{"Run mode", d.runMode},
|
||||
{"Overlap policy", d.overlapPolicy},
|
||||
{"Timeout", d.timeout},
|
||||
{"State", d.state},
|
||||
{"Last run", d.lastRun},
|
||||
{"Next run", d.nextRun},
|
||||
{"Statistics", d.stats},
|
||||
}
|
||||
}
|
||||
|
||||
// container assembles the details pane layout: metadata rows pin to the top,
|
||||
// the activity panel pins to the bottom, and command output fills the remainder.
|
||||
func (d *detailsPanel) container() fyne.CanvasObject {
|
||||
// Metadata is laid out in two columns so the block stays half as tall,
|
||||
// keeping the details pane usable on 720p screens where a single column of
|
||||
// ten rows pushes the minimum window height past the available space.
|
||||
capW := detailCaptionWidth()
|
||||
rows := container.New(compactVBoxLayout{spacing: detailRowSpacing},
|
||||
detailRowPair(capW, "Folder", d.folder, "Schedule", d.schedule),
|
||||
detailRowPair(capW, "Command", d.command, "Arguments", d.arguments),
|
||||
detailRowPair(capW, "Run mode", d.runMode, "Overlap policy", d.overlapPolicy),
|
||||
detailRowPair(capW, "Timeout", d.timeout, "State", d.state),
|
||||
detailRowPair(capW, "Last run", d.lastRun, "Next run", d.nextRun),
|
||||
detailRow(capW, "Statistics", d.stats),
|
||||
)
|
||||
specs := d.metadataRows()
|
||||
captions := make([]string, len(specs))
|
||||
for i, spec := range specs {
|
||||
captions[i] = spec.caption
|
||||
}
|
||||
capW := captionColumnWidth(captions...)
|
||||
rowObjects := make([]fyne.CanvasObject, 0, (len(specs)+1)/2)
|
||||
for i := 0; i+1 < len(specs); i += 2 {
|
||||
rowObjects = append(rowObjects, detailRowPair(capW, specs[i].caption, specs[i].value, specs[i+1].caption, specs[i+1].value))
|
||||
}
|
||||
// An odd row count leaves one caption without a partner (Statistics, today);
|
||||
// it falls through to a single-column row rather than being paired with
|
||||
// nothing.
|
||||
if len(specs)%2 == 1 {
|
||||
last := specs[len(specs)-1]
|
||||
rowObjects = append(rowObjects, detailRow(capW, last.caption, last.value))
|
||||
}
|
||||
rows := container.New(layout.NewCustomPaddedVBoxLayout(rowOverlap()), rowObjects...)
|
||||
top := container.NewVBox(
|
||||
d.title,
|
||||
widget.NewSeparator(),
|
||||
@@ -152,30 +197,12 @@ func (d *detailsPanel) container() fyne.CanvasObject {
|
||||
// absorbs sub-pixel rounding so the last row is never clipped behind a scrollbar.
|
||||
func activityRowsHeight(rows int) float32 {
|
||||
sample := widget.NewLabel("log")
|
||||
sample.Wrapping = fyne.TextTruncate
|
||||
sample.Truncation = fyne.TextTruncateClip
|
||||
itemHeight := sample.MinSize().Height
|
||||
padding := theme.Padding()
|
||||
return (itemHeight+padding)*float32(rows) - padding + 1
|
||||
}
|
||||
|
||||
// detailCaptionWidth returns the width reserved for every metadata caption,
|
||||
// derived from the widest caption label so the value columns all start at the
|
||||
// same x and no caption truncates. Measuring a real label keeps it DPI- and
|
||||
// theme-aware instead of relying on a hand-tuned constant.
|
||||
func detailCaptionWidth() float32 {
|
||||
captions := []string{
|
||||
"Folder", "Schedule", "Command", "Arguments", "Run mode",
|
||||
"Overlap policy", "Timeout", "Last run", "Next run", "State", "Statistics",
|
||||
}
|
||||
var width float32
|
||||
for _, c := range captions {
|
||||
if w := widget.NewLabelWithStyle(c, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}).MinSize().Width; w > width {
|
||||
width = w
|
||||
}
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
// detailRowPair places two label/value pairs side by side, producing the
|
||||
// four-column caption|value|caption|value rows the compact metadata grid uses.
|
||||
func detailRowPair(captionWidth float32, l1 string, v1 fyne.CanvasObject, l2 string, v2 fyne.CanvasObject) fyne.CanvasObject {
|
||||
@@ -184,7 +211,7 @@ func detailRowPair(captionWidth float32, l1 string, v1 fyne.CanvasObject, l2 str
|
||||
|
||||
func detailRow(captionWidth float32, label string, value fyne.CanvasObject) fyne.CanvasObject {
|
||||
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||
caption.Wrapping = fyne.TextTruncate
|
||||
caption.Truncation = fyne.TextTruncateClip
|
||||
// A fixed caption width (rather than an even split) means widening the window
|
||||
// feeds the extra space to the value, not the short caption.
|
||||
return container.New(captionValueLayout{captionWidth: captionWidth}, caption, value)
|
||||
@@ -192,6 +219,6 @@ func detailRow(captionWidth float32, label string, value fyne.CanvasObject) fyne
|
||||
|
||||
func newJobDetailLabel(text string) *widget.Label {
|
||||
label := widget.NewLabel(text)
|
||||
label.Wrapping = fyne.TextTruncate
|
||||
label.Truncation = fyne.TextTruncateClip
|
||||
return label
|
||||
}
|
||||
|
||||
+225
-14
@@ -7,6 +7,7 @@ import (
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/test"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
@@ -35,8 +36,8 @@ func TestFolderOptionsAlwaysIncludesSentinels(t *testing.T) {
|
||||
func TestFolderOptionsAppendsUniqueFolders(t *testing.T) {
|
||||
jobs := []domain.Job{
|
||||
{Folder: "Maintenance"},
|
||||
{Folder: ""}, // no folder → not a named folder
|
||||
{Folder: " Backups "}, // trimmed to "Backups"
|
||||
{Folder: ""}, // no folder → not a named folder
|
||||
{Folder: " Backups "}, // trimmed to "Backups"
|
||||
{Folder: "Maintenance"}, // duplicate → not added again
|
||||
}
|
||||
opts := folderOptions(jobs)
|
||||
@@ -135,23 +136,24 @@ func findFirst(root fyne.CanvasObject, match func(fyne.CanvasObject) bool) fyne.
|
||||
return nil
|
||||
}
|
||||
|
||||
// jobsSplit returns the view's master/detail split. newJobsView assembles the
|
||||
// panel as container.NewHSplit(sidebar, details), so the two panes are reached
|
||||
// through Leading and Trailing.
|
||||
func jobsSplit(t *testing.T, content fyne.CanvasObject) *container.Split {
|
||||
t.Helper()
|
||||
split, ok := content.(*container.Split)
|
||||
if !ok {
|
||||
t.Fatal("jobs view is not the expected Split container")
|
||||
}
|
||||
return split
|
||||
}
|
||||
|
||||
// jobsSidebar narrows the search to the left pane. The details panel has a
|
||||
// widget.List of its own (the activity log), so a search from the whole view
|
||||
// would find the wrong one.
|
||||
func jobsSidebar(t *testing.T, content fyne.CanvasObject) fyne.CanvasObject {
|
||||
t.Helper()
|
||||
found := findFirst(content, func(o fyne.CanvasObject) bool {
|
||||
wrapper, ok := o.(*fyne.Container)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, ok = wrapper.Layout.(minWidthLayout)
|
||||
return ok
|
||||
})
|
||||
if found == nil {
|
||||
t.Fatal("jobs view has no fixed-width sidebar")
|
||||
}
|
||||
return found
|
||||
return jobsSplit(t, content).Leading
|
||||
}
|
||||
|
||||
func jobsList(t *testing.T, content fyne.CanvasObject) *widget.List {
|
||||
@@ -166,6 +168,71 @@ func jobsList(t *testing.T, content fyne.CanvasObject) *widget.List {
|
||||
return found.(*widget.List)
|
||||
}
|
||||
|
||||
// jobsToolbar finds the add/edit/run/pause/delete button row inside the
|
||||
// sidebar, identified by its first child being the "New job" button.
|
||||
func jobsToolbar(t *testing.T, content fyne.CanvasObject) fyne.CanvasObject {
|
||||
t.Helper()
|
||||
found := findFirst(jobsSidebar(t, content), func(o fyne.CanvasObject) bool {
|
||||
wrapper, ok := o.(*fyne.Container)
|
||||
if !ok || len(wrapper.Objects) == 0 {
|
||||
return false
|
||||
}
|
||||
button, ok := wrapper.Objects[0].(*widget.Button)
|
||||
return ok && button.Text == "New job"
|
||||
})
|
||||
if found == nil {
|
||||
t.Fatal("jobs sidebar has no toolbar row")
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// jobsToolbarButton returns the toolbar button with the given caption.
|
||||
func jobsToolbarButton(t *testing.T, content fyne.CanvasObject, text string) *widget.Button {
|
||||
t.Helper()
|
||||
found := findFirst(jobsToolbar(t, content), func(o fyne.CanvasObject) bool {
|
||||
button, ok := o.(*widget.Button)
|
||||
return ok && button.Text == text
|
||||
})
|
||||
if found == nil {
|
||||
t.Fatalf("jobs toolbar has no %q button", text)
|
||||
}
|
||||
return found.(*widget.Button)
|
||||
}
|
||||
|
||||
// jobsDetails narrows the search to the right pane (see jobsSidebar).
|
||||
func jobsDetails(t *testing.T, content fyne.CanvasObject) fyne.CanvasObject {
|
||||
t.Helper()
|
||||
return jobsSplit(t, content).Trailing
|
||||
}
|
||||
|
||||
// jobsDetailsActivity returns the "Selected job activity" list, the only
|
||||
// widget.List in the details pane.
|
||||
func jobsDetailsActivity(t *testing.T, content fyne.CanvasObject) *widget.List {
|
||||
t.Helper()
|
||||
found := findFirst(jobsDetails(t, content), func(o fyne.CanvasObject) bool {
|
||||
_, ok := o.(*widget.List)
|
||||
return ok
|
||||
})
|
||||
if found == nil {
|
||||
t.Fatal("details pane has no activity list")
|
||||
}
|
||||
return found.(*widget.List)
|
||||
}
|
||||
|
||||
// jobsDetailsTitle reads the details pane's heading, which detailsPanel builds
|
||||
// as the first bold label in the pane.
|
||||
func jobsDetailsTitle(t *testing.T, content fyne.CanvasObject) string {
|
||||
t.Helper()
|
||||
found := findFirst(jobsDetails(t, content), func(o fyne.CanvasObject) bool {
|
||||
label, ok := o.(*widget.Label)
|
||||
return ok && label.TextStyle.Bold
|
||||
})
|
||||
if found == nil {
|
||||
t.Fatal("details pane has no title label")
|
||||
}
|
||||
return found.(*widget.Label).Text
|
||||
}
|
||||
|
||||
func jobsViewToggle(t *testing.T, content fyne.CanvasObject) *widget.Button {
|
||||
t.Helper()
|
||||
found := findFirst(jobsSidebar(t, content), func(o fyne.CanvasObject) bool {
|
||||
@@ -258,6 +325,150 @@ func TestJobListViewCompactConfigOpensCompact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestJobsSidebarWidthIsItsContent is the regression guard for F7: nothing
|
||||
// but the sidebar's own content (here, the toolbar row) should impose a
|
||||
// width floor on it.
|
||||
func TestJobsSidebarWidthIsItsContent(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
w := testApp.NewWindow("test")
|
||||
defer w.Close()
|
||||
|
||||
store := newTestStore(t)
|
||||
svc := app.NewService(store, nil)
|
||||
defer svc.Stop()
|
||||
|
||||
content, _ := newJobsView(w, svc)
|
||||
w.SetContent(content)
|
||||
|
||||
sidebarWidth := jobsSidebar(t, content).MinSize().Width
|
||||
toolbarWidth := jobsToolbar(t, content).MinSize().Width
|
||||
if sidebarWidth != toolbarWidth {
|
||||
t.Errorf("sidebar MinSize().Width = %v, want it to equal the toolbar row's %v", sidebarWidth, toolbarWidth)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJobsSplitOpensAtTheSidebarWidth is the guard for the derived initial
|
||||
// offset (F15): at the default window width the divider must open at the
|
||||
// sidebar's own width — enough that the toolbar is never born clipped, and no
|
||||
// more, since every extra pixel is taken from the details pane. Split's own
|
||||
// clamp guarantees the lower bound, so the upper bound is what actually proves
|
||||
// the offset was derived rather than left at the 0.5 default.
|
||||
func TestJobsSplitOpensAtTheSidebarWidth(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
w := testApp.NewWindow("test")
|
||||
defer w.Close()
|
||||
|
||||
store := newTestStore(t)
|
||||
svc := app.NewService(store, nil)
|
||||
defer svc.Stop()
|
||||
|
||||
content, _ := newJobsView(w, svc)
|
||||
w.SetContent(content)
|
||||
|
||||
split := jobsSplit(t, content)
|
||||
split.Resize(fyne.NewSize(defaultWindowWidth, defaultWindowHeight))
|
||||
|
||||
want := split.Leading.MinSize().Width
|
||||
got := split.Leading.Size().Width
|
||||
// One pixel of slack for the float32 round trip through the offset ratio.
|
||||
if got < want || got > want+1 {
|
||||
t.Errorf("leading pane opens at %v, want its content minimum %v", got, want)
|
||||
}
|
||||
if trailing := split.Trailing.Size().Width; trailing < split.Trailing.MinSize().Width {
|
||||
t.Errorf("trailing pane opens at %v, below its minimum %v", trailing, split.Trailing.MinSize().Width)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
w := testApp.NewWindow("test")
|
||||
defer w.Close()
|
||||
|
||||
store := newTestStore(t)
|
||||
jobs := []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},
|
||||
}
|
||||
svc := app.NewService(store, jobs)
|
||||
defer svc.Stop()
|
||||
|
||||
content, _ := newJobsView(w, svc)
|
||||
w.SetContent(content)
|
||||
|
||||
list := jobsList(t, content)
|
||||
// Row layout: VBox(nameLine, meta, status), nameLine = Border(name, inlineStatus).
|
||||
rowText := func(id int) (name string, status string) {
|
||||
t.Helper()
|
||||
row := list.CreateItem().(*fyne.Container)
|
||||
list.UpdateItem(id, row)
|
||||
nameLine := row.Objects[0].(*fyne.Container)
|
||||
return nameLine.Objects[0].(*widget.Label).Text, row.Objects[2].(*widget.Label).Text
|
||||
}
|
||||
|
||||
activity := jobsDetailsActivity(t, content)
|
||||
|
||||
list.Select(1)
|
||||
if got := jobsDetailsTitle(t, content); got != "Second" {
|
||||
t.Fatalf("details title after selecting row 1 = %q, want %q", got, "Second")
|
||||
}
|
||||
if _, status := rowText(1); status == "Paused" {
|
||||
t.Fatal("the second job should start enabled")
|
||||
}
|
||||
if got := activity.Length(); got != 0 {
|
||||
t.Fatalf("activity rows before the tap = %d, want 0", got)
|
||||
}
|
||||
|
||||
test.Tap(jobsToolbarButton(t, content, "Pause"))
|
||||
|
||||
if svc.Jobs()[1].Enabled {
|
||||
t.Fatal("tapping Pause did not reach the service")
|
||||
}
|
||||
name, status := rowText(1)
|
||||
if name != "Second" || status != "Paused" {
|
||||
t.Errorf("row 1 after Pause = (%q, %q), want (%q, %q)", name, status, "Second", "Paused")
|
||||
}
|
||||
if got := jobsDetailsTitle(t, content); got != "Second" {
|
||||
t.Errorf("details title after Pause = %q, want the selection kept at %q", got, "Second")
|
||||
}
|
||||
// The pause writes an activity record. Seeing it here is what proves
|
||||
// refreshView repopulated the details pane rather than leaving the panel on
|
||||
// the snapshot it held before the tap.
|
||||
if got := activity.Length(); got != 1 {
|
||||
t.Errorf("activity rows after the tap = %d, want the pause record", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// row added to metadataRows without updating the width measurement would
|
||||
// silently truncate.
|
||||
func TestDetailCaptionWidthCoversEveryCaption(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
|
||||
d := newDetailsPanel(job{}, &domain.JobRuntime{}, domain.OverlapPolicySkip, 0)
|
||||
specs := d.metadataRows()
|
||||
captions := make([]string, len(specs))
|
||||
for i, spec := range specs {
|
||||
captions[i] = spec.caption
|
||||
}
|
||||
capW := captionColumnWidth(captions...)
|
||||
for _, c := range captions {
|
||||
if w := widget.NewLabelWithStyle(c, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}).MinSize().Width; w > capW {
|
||||
t.Errorf("caption %q measures %v, wider than captionColumnWidth's %v", c, w, capW)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewToggleTextNamesTheAction(t *testing.T) {
|
||||
cases := []struct {
|
||||
current domain.JobListView
|
||||
|
||||
+60
-37
@@ -2,9 +2,25 @@ package ui
|
||||
|
||||
import (
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
// captionColumnWidth returns the width to reserve for a column of bold
|
||||
// captions: the widest of them, measured under the current theme so it tracks
|
||||
// text size and DPI instead of a hand-tuned constant.
|
||||
func captionColumnWidth(captions ...string) float32 {
|
||||
var width float32
|
||||
for _, caption := range captions {
|
||||
if w := widget.NewLabelWithStyle(caption, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}).MinSize().Width; w > width {
|
||||
width = w
|
||||
}
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
type minWidthLayout struct {
|
||||
width float32
|
||||
}
|
||||
@@ -37,46 +53,42 @@ func (l minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
|
||||
}
|
||||
}
|
||||
|
||||
// compactVBoxLayout stacks children vertically with a configurable gap between
|
||||
// them, producing tighter rows than container.NewVBox (which inserts
|
||||
// theme.Padding() between every child). A negative spacing pulls neighbouring
|
||||
// rows together so they overlap the labels' built-in vertical padding, which is
|
||||
// how the details metadata is condensed to fit 720p screens.
|
||||
type compactVBoxLayout struct {
|
||||
spacing float32
|
||||
// rowOverlap is the (negative) gap that pulls stacked label rows together by
|
||||
// exactly one label's vertical inner padding. Two adjacent labels each inset
|
||||
// their text by theme.InnerPadding(), so the whitespace between two lines of
|
||||
// text is double what a single row needs; removing one label's worth
|
||||
// condenses the block without letting the text lines touch. Derived rather
|
||||
// than hard-coded so it follows a theme that changes SizeNameInnerPadding.
|
||||
func rowOverlap() float32 { return -theme.InnerPadding() }
|
||||
|
||||
// cancelRowOverlap exempts one row from the rowOverlap() spacing of the section
|
||||
// it sits in, by padding its top edge with exactly what rowOverlap takes away.
|
||||
// The overlap assumes both neighbours are text rows: each insets its text, so
|
||||
// one padding's worth is duplicated and can go. A row whose value paints its own
|
||||
// box to the row's edge — a Select, an Entry, a Button — has no such inset, so
|
||||
// the overlap eats the visible gap instead and the box ends up flush against the
|
||||
// row above it.
|
||||
func cancelRowOverlap(row fyne.CanvasObject) fyne.CanvasObject {
|
||||
return container.New(layout.NewCustomPaddedLayout(-rowOverlap(), 0, 0, 0), row)
|
||||
}
|
||||
|
||||
func (l compactVBoxLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
|
||||
var w, h float32
|
||||
var visible int
|
||||
for _, o := range objects {
|
||||
if !o.Visible() {
|
||||
continue
|
||||
}
|
||||
min := o.MinSize()
|
||||
if min.Width > w {
|
||||
w = min.Width
|
||||
}
|
||||
h += min.Height
|
||||
visible++
|
||||
}
|
||||
if visible > 1 {
|
||||
h += l.spacing * float32(visible-1)
|
||||
}
|
||||
return fyne.NewSize(w, h)
|
||||
}
|
||||
|
||||
func (l compactVBoxLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
|
||||
var y float32
|
||||
for _, o := range objects {
|
||||
if !o.Visible() {
|
||||
continue
|
||||
}
|
||||
h := o.MinSize().Height
|
||||
o.Move(fyne.NewPos(0, y))
|
||||
o.Resize(fyne.NewSize(size.Width, h))
|
||||
y += h + l.spacing
|
||||
// initialSplitOffset returns the container.Split offset that opens a horizontal
|
||||
// split with its leading pane at the given natural width. SetOffset takes a
|
||||
// ratio, but a pane's natural width is absolute, so the ratio is derived from
|
||||
// the window width the app opens at rather than written as a literal: 0.44 fits
|
||||
// 1024 px but would hand a 448 px sidebar 700 px on a 1600 px-wide window.
|
||||
//
|
||||
// The divider sits between the panes and is excluded from the ratio, matching
|
||||
// container.Split's own arithmetic (its divider is two theme paddings thick).
|
||||
// Split clamps the offset to both panes' minimums when it lays out, so a result
|
||||
// that is slightly off — the window is a little wider than its content area —
|
||||
// costs at most a few pixels and can never clip either pane.
|
||||
func initialSplitOffset(leadingWidth float32) float64 {
|
||||
available := float64(defaultWindowWidth - 2*theme.Padding())
|
||||
if available <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(leadingWidth) / available
|
||||
}
|
||||
|
||||
// fixedHeightLayout forces its contents to a fixed height while leaving the
|
||||
@@ -114,10 +126,21 @@ func (l fixedHeightLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
|
||||
// stops it from growing with the window (as an even two-column grid would), so
|
||||
// the extra space a wider window provides goes entirely to the value column. It
|
||||
// expects exactly two children: caption first, value second.
|
||||
//
|
||||
// The value column has no minimum of its own: it gets whatever the container's
|
||||
// width leaves, down to zero. What keeps it readable in the details pane is the
|
||||
// 460 px minimum on commandOutputScroll (jobs_view_details.go), which is that
|
||||
// pane's widest minimum and therefore its floor — a constant that exists for an
|
||||
// unrelated reason. A new caller that gives this layout less width gets a value
|
||||
// column that silently renders narrow or empty rather than one that clips.
|
||||
type captionValueLayout struct {
|
||||
captionWidth float32
|
||||
}
|
||||
|
||||
// MinSize and Layout both return silently when given anything but two
|
||||
// objects. That is acceptable here because the type is package-private with a
|
||||
// single constructor (detailRow), which always supplies exactly a caption and
|
||||
// a value — there is no external caller that could pass the wrong count.
|
||||
func (l captionValueLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
|
||||
if len(objects) != 2 {
|
||||
return fyne.Size{}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"fyne.io/fyne/v2/test"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
// TestRowOverlapMatchesInnerPadding pins rowOverlap to theme.InnerPadding, the
|
||||
// property that lets it follow a theme with a different SizeNameInnerPadding
|
||||
// instead of drifting from a hand-tuned literal.
|
||||
func TestRowOverlapMatchesInnerPadding(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
|
||||
if got, want := rowOverlap(), -theme.InnerPadding(); got != want {
|
||||
t.Errorf("rowOverlap() = %v, want %v", got, want)
|
||||
}
|
||||
if rowOverlap() >= 0 {
|
||||
t.Errorf("rowOverlap() = %v, want a negative value", rowOverlap())
|
||||
}
|
||||
|
||||
testApp.Settings().SetTheme(test.NewTheme())
|
||||
if got, want := rowOverlap(), -theme.InnerPadding(); got != want {
|
||||
t.Errorf("under a different theme, rowOverlap() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelRowOverlapAddsBackOneInnerPadding is the regression guard for the
|
||||
// Settings tab's Theme row sitting flush against the Notifications checkbox:
|
||||
// the wrapper must add exactly the padding rowOverlap removes, on the top edge
|
||||
// only, so the row below is unaffected and the width does not change.
|
||||
func TestCancelRowOverlapAddsBackOneInnerPadding(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
|
||||
child := widget.NewSelect([]string{"System"}, nil)
|
||||
wrapped := cancelRowOverlap(child)
|
||||
|
||||
childMin, wrappedMin := child.MinSize(), wrapped.MinSize()
|
||||
if got, want := wrappedMin.Height, childMin.Height-rowOverlap(); got != want {
|
||||
t.Errorf("wrapped height = %v, want %v (child %v plus one inner padding)", got, want, childMin.Height)
|
||||
}
|
||||
if got, want := wrappedMin.Width, childMin.Width; got != want {
|
||||
t.Errorf("wrapped width = %v, want the child's %v", got, want)
|
||||
}
|
||||
|
||||
wrapped.Resize(wrappedMin)
|
||||
if got, want := child.Position().Y, -rowOverlap(); got != want {
|
||||
t.Errorf("child Y = %v, want %v", got, want)
|
||||
}
|
||||
if got := child.Position().X; got != 0 {
|
||||
t.Errorf("child X = %v, want 0", got)
|
||||
}
|
||||
if got, want := child.Size().Height, childMin.Height; got != want {
|
||||
t.Errorf("child height = %v, want %v: the padding must not be taken out of the row", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptionColumnWidth covers the shapes F10's shared helper has to handle:
|
||||
// no captions, one, and several of varying length at two text sizes.
|
||||
func TestCaptionColumnWidth(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
|
||||
if got := captionColumnWidth(); got != 0 {
|
||||
t.Errorf("no captions: got %v, want 0", got)
|
||||
}
|
||||
solo := captionColumnWidth("Solo")
|
||||
if solo <= 0 {
|
||||
t.Errorf("one caption: got %v, want > 0", solo)
|
||||
}
|
||||
widest := captionColumnWidth("Short", "A Much Longer Caption")
|
||||
if widest <= solo {
|
||||
t.Errorf("widest of several captions = %v, want it wider than a single short one (%v)", widest, solo)
|
||||
}
|
||||
|
||||
testApp.Settings().SetTheme(test.NewTheme())
|
||||
if got := captionColumnWidth("Short", "A Much Longer Caption"); got <= 0 {
|
||||
t.Errorf("under a different theme: got %v, want > 0", got)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/assets"
|
||||
@@ -64,6 +65,7 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.
|
||||
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 = append(events, recorded.Record)
|
||||
@@ -80,6 +82,12 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.
|
||||
if isError {
|
||||
events = append(events, newEvent(0, "Service", "Error", errOccurred.Err.Error()))
|
||||
}
|
||||
if isJobsLoaded {
|
||||
// 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
|
||||
events = append(events, newEvent(0, "Service", "Jobs loaded", detail))
|
||||
}
|
||||
refresh()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -27,7 +27,7 @@ func newTestStore(t *testing.T) *storage.Store {
|
||||
LogsDir: filepath.Join(dir, "logs"),
|
||||
},
|
||||
Config: domain.Config{
|
||||
JobsDir: ".",
|
||||
JobsFile: "jobs.json",
|
||||
LogsDir: "logs",
|
||||
MaxLogFiles: 100,
|
||||
MaxLogAgeDays: 30,
|
||||
@@ -45,6 +45,30 @@ func newTestService(t *testing.T) *app.Service {
|
||||
return app.NewService(newTestStore(t), nil)
|
||||
}
|
||||
|
||||
// TestMainViewFitsTheDefaultWindowSize is the regression guard for F1: the
|
||||
// assembled content must fit within the window size the app asks for, so Fyne
|
||||
// never silently widens the window past it. The store's ConfigPath is
|
||||
// deliberately long so the test also covers F3 — the config path label must
|
||||
// not grow the Settings tab's minimum width with it.
|
||||
func TestMainViewFitsTheDefaultWindowSize(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
|
||||
w := testApp.NewWindow("test")
|
||||
defer w.Close()
|
||||
|
||||
store := newTestStore(t)
|
||||
store.Paths.ConfigPath = filepath.Join(t.TempDir(), "a-deliberately-long-directory-name-to-stress-the-config-path-label", "gosentry.json")
|
||||
svc := app.NewService(store, nil)
|
||||
defer svc.Stop()
|
||||
|
||||
content, _ := newMainView(w, svc)
|
||||
min := content.MinSize()
|
||||
if min.Width > defaultWindowWidth || min.Height > defaultWindowHeight {
|
||||
t.Errorf("content.MinSize() = %v, want within %vx%v", min, defaultWindowWidth, defaultWindowHeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainViewBuilds(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
|
||||
+9
-2
@@ -15,6 +15,13 @@ import (
|
||||
|
||||
const appID = "ru.mixeme.gosentry.desktop"
|
||||
|
||||
// defaultWindowWidth and defaultWindowHeight are the size the window opens at
|
||||
// on first launch (later launches restore the last size from preferences).
|
||||
// Fyne enforces the assembled content's MinSize as a hard floor over these, so
|
||||
// they only take effect if the content actually fits within them.
|
||||
const defaultWindowWidth = 1024
|
||||
const defaultWindowHeight = 660
|
||||
|
||||
// Run is the application entry point. It owns the process lifecycle — single
|
||||
// instance arbitration, Fyne app + window construction, tray wiring, and the
|
||||
// startup-timing record — and delegates all view construction to newMainView in
|
||||
@@ -51,8 +58,8 @@ func Run(startInTray bool) {
|
||||
w := a.NewWindow("GoSentry " + app.Version)
|
||||
configureSystemTray(a, w)
|
||||
prefs := a.Preferences()
|
||||
winW := float32(prefs.FloatWithFallback("window.width", 1024))
|
||||
winH := float32(prefs.FloatWithFallback("window.height", 660))
|
||||
winW := float32(prefs.FloatWithFallback("window.width", defaultWindowWidth))
|
||||
winH := float32(prefs.FloatWithFallback("window.height", defaultWindowHeight))
|
||||
w.Resize(fyne.NewSize(winW, winH))
|
||||
svc, err := app.Open()
|
||||
if err != nil {
|
||||
|
||||
+53
-165
@@ -1,10 +1,6 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -12,24 +8,23 @@ import (
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
// settingsLabelWidth is wide enough to show the longest caption ("Default
|
||||
// overlap policy") in full; the captions truncate, so a narrower width would
|
||||
// clip it. All rows share this width so their value controls stay aligned.
|
||||
const settingsLabelWidth float32 = 180
|
||||
const settingsControlWidth float32 = 330
|
||||
const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry"
|
||||
|
||||
// settingsRowSpacing is the (negative) gap between rows of the settings form,
|
||||
// overlapping each control's built-in vertical padding so the column is tighter
|
||||
// and more compact, matching the condensed job details panel.
|
||||
const settingsRowSpacing float32 = -6
|
||||
// settingsCaptions lists every settingsRow caption in the tab, in no
|
||||
// particular order. settingsView measures this once with captionColumnWidth
|
||||
// so every row's value column starts at the same x; a caption added to a row
|
||||
// below without being added here is the one way a row would silently fall out
|
||||
// of alignment.
|
||||
var settingsCaptions = []string{
|
||||
"Autostart", "Tray", "Notifications", "Theme",
|
||||
"Execution mode", "Default overlap policy", "Default timeout (s)",
|
||||
"Config JSON", "Jobs file", "Logs directory", "Max log files", "Max log age days",
|
||||
"GoSentry", "Go", "Fyne", "Repository",
|
||||
}
|
||||
|
||||
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
store := svc.Store()
|
||||
@@ -91,11 +86,13 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
defaultTimeout.SetPlaceHolder("0 = no timeout")
|
||||
defaultTimeout.SetText(strconv.Itoa(store.Config.DefaultTimeoutSeconds))
|
||||
defaultTimeout.OnChanged = func(string) { updateSaveState() }
|
||||
jobsDir := widget.NewEntry()
|
||||
jobsDir.SetText(store.Config.JobsDir)
|
||||
jobsDir.OnChanged = func(string) { updateSaveState() }
|
||||
jobsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||
chooseFolder(w, jobsDir)
|
||||
jobsFile := widget.NewEntry()
|
||||
jobsFile.SetText(store.Config.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.
|
||||
jobsFileBrowse := widget.NewButtonWithIcon("Browse", theme.FileIcon(), func() {
|
||||
chooseJSONFile(w, jobsFile)
|
||||
})
|
||||
logsDir := widget.NewEntry()
|
||||
logsDir.SetText(store.Config.LogsDir)
|
||||
@@ -103,6 +100,13 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||
chooseFolder(w, logsDir)
|
||||
})
|
||||
// Log files are read outside the app, so the folder gets a direct shortcut
|
||||
// beside its path instead of making the user copy the path into a file
|
||||
// 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))
|
||||
})
|
||||
maxLogFiles := widget.NewEntry()
|
||||
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
|
||||
maxLogFiles.OnChanged = func(string) { updateSaveState() }
|
||||
@@ -112,7 +116,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
// Autostart status sits on its own row beneath the checkbox (rather than
|
||||
// beside it) so the Application section fits within a half-width column.
|
||||
// Truncating keeps a long status message from forcing the column wider.
|
||||
autostartStatus.Wrapping = fyne.TextTruncate
|
||||
autostartStatus.Truncation = fyne.TextTruncateClip
|
||||
settingsStatus := widget.NewLabel("")
|
||||
|
||||
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
||||
@@ -126,8 +130,8 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
settingsStatus.SetText("Max log age days must be a positive number")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(jobsDir.Text) == "" {
|
||||
settingsStatus.SetText("Jobs directory is required")
|
||||
if strings.TrimSpace(jobsFile.Text) == "" {
|
||||
settingsStatus.SetText("Jobs file is required")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(logsDir.Text) == "" {
|
||||
@@ -143,7 +147,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
// validates it, persists config and jobs to the (possibly new) directory,
|
||||
// and runs log cleanup so tightened retention limits take effect at once.
|
||||
config := store.Config
|
||||
config.JobsDir = strings.TrimSpace(jobsDir.Text)
|
||||
config.JobsFile = strings.TrimSpace(jobsFile.Text)
|
||||
config.LogsDir = strings.TrimSpace(logsDir.Text)
|
||||
config.MaxLogFiles = files
|
||||
config.MaxLogAgeDays = days
|
||||
@@ -181,7 +185,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
executionModeSelect.Selected != string(c.ExecutionMode) ||
|
||||
overlapPolicySelect.Selected != string(c.OverlapPolicy) ||
|
||||
strings.TrimSpace(defaultTimeout.Text) != strconv.Itoa(c.DefaultTimeoutSeconds) ||
|
||||
strings.TrimSpace(jobsDir.Text) != c.JobsDir ||
|
||||
strings.TrimSpace(jobsFile.Text) != c.JobsFile ||
|
||||
strings.TrimSpace(logsDir.Text) != c.LogsDir ||
|
||||
strings.TrimSpace(maxLogFiles.Text) != strconv.Itoa(c.MaxLogFiles) ||
|
||||
strings.TrimSpace(maxLogAgeDays.Text) != strconv.Itoa(c.MaxLogAgeDays) ||
|
||||
@@ -207,7 +211,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
executionModeSelect.SetSelected(string(c.ExecutionMode))
|
||||
overlapPolicySelect.SetSelected(string(c.OverlapPolicy))
|
||||
defaultTimeout.SetText(strconv.Itoa(c.DefaultTimeoutSeconds))
|
||||
jobsDir.SetText(c.JobsDir)
|
||||
jobsFile.SetText(c.JobsFile)
|
||||
logsDir.SetText(c.LogsDir)
|
||||
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
|
||||
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
|
||||
@@ -226,137 +230,28 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
loadFields(domain.DefaultConfig())
|
||||
})
|
||||
|
||||
// The form is split into two columns so a wide window uses its horizontal
|
||||
// space instead of stretching into one tall strip. The left column holds the
|
||||
// toggles (Application, Queue); the right holds the editable Storage fields and
|
||||
// the read-only About block. Save spans the full width below both columns.
|
||||
leftColumn := container.NewVBox(
|
||||
settingsSection("Application",
|
||||
settingsRow("Autostart", container.New(minWidthLayout{width: settingsControlWidth}, startOnLogin)),
|
||||
// Autostart status sits on its own row, aligned under the checkbox via an
|
||||
// empty caption, so the Application section fits in a half-width column.
|
||||
settingsRow("", autostartStatus),
|
||||
settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)),
|
||||
settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)),
|
||||
settingsRow("Theme", container.New(minWidthLayout{width: settingsControlWidth}, themeSelect)),
|
||||
),
|
||||
widget.NewSeparator(),
|
||||
// Queue holds the execution mode and overlap policy comboboxes. Like
|
||||
// Storage, it uses the default VBox spacing (not the condensed section
|
||||
// layout) so the comboboxes keep a visible gap between them.
|
||||
container.NewVBox(
|
||||
widget.NewLabelWithStyle("Queue", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
settingsRow("Execution mode", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)),
|
||||
settingsRow("Default overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)),
|
||||
settingsRow("Default timeout (s)", container.New(minWidthLayout{width: settingsControlWidth}, defaultTimeout)),
|
||||
),
|
||||
)
|
||||
rightColumn := container.NewVBox(
|
||||
// Storage holds editable entry fields. It uses the default VBox spacing
|
||||
// (not the condensed section layout) so the entry boxes keep a visible
|
||||
// gap between them instead of merging into one block.
|
||||
container.NewVBox(
|
||||
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
settingsRow("Config JSON", widget.NewLabel(store.Paths.ConfigPath)),
|
||||
settingsRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)),
|
||||
settingsRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)),
|
||||
settingsRow("Max log files", maxLogFiles),
|
||||
settingsRow("Max log age days", maxLogAgeDays),
|
||||
),
|
||||
widget.NewSeparator(),
|
||||
settingsSection("About",
|
||||
settingsRow("GoSentry", widget.NewLabel(app.Version)),
|
||||
settingsRow("Go", widget.NewLabel(runtime.Version())),
|
||||
settingsRow("Fyne", widget.NewLabel(fyneVersion())),
|
||||
settingsRow("Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))),
|
||||
),
|
||||
)
|
||||
|
||||
// The two columns sit in a top-aligned grid; Save spans the full width below.
|
||||
// Wrapping the whole thing in a vertical scroll keeps its minimum height small
|
||||
// so it does not dictate the window's minimum height (AppTabs sizes to the
|
||||
// tallest tab) and it scrolls on short 720p screens.
|
||||
// The button row sits right below the separator's hairline, which reads as
|
||||
// tighter than the other vertical gaps in the tab (those separate whole
|
||||
// sections, not a single thin line from a row of buttons). A spacer the
|
||||
// height of the default padding closes that gap up to match, and a matching
|
||||
// width spacer indents the buttons from the left edge the same amount.
|
||||
buttonRowSpacer := canvas.NewRectangle(color.Transparent)
|
||||
buttonRowSpacer.SetMinSize(fyne.NewSize(0, theme.Padding()))
|
||||
buttonRowLeftInset := canvas.NewRectangle(color.Transparent)
|
||||
buttonRowLeftInset.SetMinSize(fyne.NewSize(theme.Padding(), 0))
|
||||
|
||||
return container.NewVScroll(container.NewPadded(container.NewVBox(
|
||||
container.NewGridWithColumns(2, leftColumn, rightColumn),
|
||||
widget.NewSeparator(),
|
||||
buttonRowSpacer,
|
||||
// Save/Cancel/Defaults share one row with the status so an empty status
|
||||
// (the common case) does not leave a blank line above the separator. The
|
||||
// status appears beside the buttons once a save reports a result.
|
||||
container.NewHBox(buttonRowLeftInset, saveSettings, cancelSettings, restoreDefaults, settingsStatus),
|
||||
)))
|
||||
}
|
||||
|
||||
// settingsSection groups a bold header above its rows using the tight
|
||||
// settingsRowSpacing so a block of label rows reads as one compact unit. The
|
||||
// caller keeps separators and entry-heavy sections in the surrounding VBox so
|
||||
// they retain the theme's normal spacing.
|
||||
func settingsSection(title string, rows ...fyne.CanvasObject) fyne.CanvasObject {
|
||||
children := make([]fyne.CanvasObject, 0, len(rows)+1)
|
||||
children = append(children, widget.NewLabelWithStyle(title, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}))
|
||||
children = append(children, rows...)
|
||||
return container.New(compactVBoxLayout{spacing: settingsRowSpacing}, children...)
|
||||
}
|
||||
|
||||
func fyneVersion() string {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return "unknown"
|
||||
}
|
||||
for _, dependency := range info.Deps {
|
||||
if dependency.Path == "fyne.io/fyne/v2" {
|
||||
if dependency.Replace != nil && dependency.Replace.Version != "" {
|
||||
return dependency.Replace.Version
|
||||
}
|
||||
if dependency.Version != "" {
|
||||
return dependency.Version
|
||||
}
|
||||
return "local"
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func mustParseURL(raw string) *url.URL {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return &url.URL{}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func chooseFile(w fyne.Window, target *widget.Entry) {
|
||||
fileDialog := dialog.NewFileOpen(func(uri fyne.URIReadCloser, err error) {
|
||||
if err != nil || uri == nil {
|
||||
return
|
||||
}
|
||||
target.SetText(uri.URI().Path())
|
||||
}, w)
|
||||
fileDialog.Resize(fyne.NewSize(900, 640))
|
||||
fileDialog.Show()
|
||||
}
|
||||
|
||||
func chooseFolder(w fyne.Window, target *widget.Entry) {
|
||||
folderDialog := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) {
|
||||
if err != nil || uri == nil {
|
||||
return
|
||||
}
|
||||
target.SetText(uri.Path())
|
||||
}, w)
|
||||
// The default folder picker can be cramped on Windows. A larger size makes
|
||||
// long paths readable and avoids forcing the user to resize it every time.
|
||||
folderDialog.Resize(fyne.NewSize(900, 640))
|
||||
folderDialog.Show()
|
||||
return newSettingsLayout(settingsFormFields{
|
||||
startOnLogin: startOnLogin,
|
||||
autostartStatus: autostartStatus,
|
||||
minimizeToTray: minimizeToTray,
|
||||
notifications: notifications,
|
||||
themeSelect: themeSelect,
|
||||
executionModeSelect: executionModeSelect,
|
||||
overlapPolicySelect: overlapPolicySelect,
|
||||
defaultTimeout: defaultTimeout,
|
||||
configPath: store.Paths.ConfigPath,
|
||||
jobsFile: jobsFile,
|
||||
jobsFileBrowse: jobsFileBrowse,
|
||||
logsDir: logsDir,
|
||||
logsDirOpen: logsDirOpen,
|
||||
logsDirBrowse: logsDirBrowse,
|
||||
maxLogFiles: maxLogFiles,
|
||||
maxLogAgeDays: maxLogAgeDays,
|
||||
saveSettings: saveSettings,
|
||||
cancelSettings: cancelSettings,
|
||||
restoreDefaults: restoreDefaults,
|
||||
settingsStatus: settingsStatus,
|
||||
})
|
||||
}
|
||||
|
||||
// Theme dropdown labels. These are the human-facing captions; themeLabel and
|
||||
@@ -380,10 +275,3 @@ func themeFromLabel(label string) domain.Theme {
|
||||
}
|
||||
return domain.ThemeDefault
|
||||
}
|
||||
|
||||
func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
|
||||
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||
caption.Wrapping = fyne.TextTruncate
|
||||
captionBox := container.New(minWidthLayout{width: settingsLabelWidth}, caption)
|
||||
return container.NewBorder(nil, nil, captionBox, nil, value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/platform/filemanager"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
fynestorage "fyne.io/fyne/v2/storage"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
func fyneVersion() string {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return "unknown"
|
||||
}
|
||||
for _, dependency := range info.Deps {
|
||||
if dependency.Path == "fyne.io/fyne/v2" {
|
||||
if dependency.Replace != nil && dependency.Replace.Version != "" {
|
||||
return dependency.Replace.Version
|
||||
}
|
||||
if dependency.Version != "" {
|
||||
return dependency.Version
|
||||
}
|
||||
return "local"
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func mustParseURL(raw string) *url.URL {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return &url.URL{}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// chooseFile opens a file picker that writes the chosen path into target. A
|
||||
// nil filter offers every file, as job_dialog.go's command browser wants;
|
||||
// chooseJSONFile passed an extension filter here before the two were merged,
|
||||
// since they differed only by that one call.
|
||||
func chooseFile(w fyne.Window, target *widget.Entry, filter fynestorage.FileFilter) {
|
||||
fileDialog := dialog.NewFileOpen(func(uri fyne.URIReadCloser, err error) {
|
||||
if err != nil || uri == nil {
|
||||
return
|
||||
}
|
||||
target.SetText(uri.URI().Path())
|
||||
}, w)
|
||||
if filter != nil {
|
||||
fileDialog.SetFilter(filter)
|
||||
}
|
||||
fileDialog.Resize(fyne.NewSize(900, 640))
|
||||
fileDialog.Show()
|
||||
}
|
||||
|
||||
// chooseJSONFile is chooseFile restricted to .json files, used for the jobs
|
||||
// file so the picker does not list every file in the folder. The entry stays
|
||||
// editable, which is how a path to a file that does not exist yet is entered.
|
||||
func chooseJSONFile(w fyne.Window, target *widget.Entry) {
|
||||
chooseFile(w, target, fynestorage.NewExtensionFileFilter([]string{".json"}))
|
||||
}
|
||||
|
||||
func chooseFolder(w fyne.Window, target *widget.Entry) {
|
||||
folderDialog := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) {
|
||||
if err != nil || uri == nil {
|
||||
return
|
||||
}
|
||||
target.SetText(uri.Path())
|
||||
}, w)
|
||||
// The default folder picker can be cramped on Windows. A larger size makes
|
||||
// long paths readable and avoids forcing the user to resize it every time.
|
||||
folderDialog.Resize(fyne.NewSize(900, 640))
|
||||
folderDialog.Show()
|
||||
}
|
||||
|
||||
// settingsFolderPath resolves what a directory field currently points at,
|
||||
// applying the same relative-path rule the store uses when it loads the config
|
||||
// so the folder that opens is the one the setting would use. Blank text has no
|
||||
// folder to open and yields an empty path.
|
||||
func settingsFolderPath(appDir string, text string) string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
return storage.ResolveConfiguredPath(appDir, trimmed)
|
||||
}
|
||||
|
||||
// openFolder reveals dir in the desktop file manager. A folder that is not set
|
||||
// or cannot be opened (most often: it does not exist yet, because the logs
|
||||
// directory is created on the first run) is reported in a dialog rather than
|
||||
// leaving the button looking dead.
|
||||
func openFolder(w fyne.Window, dir string) {
|
||||
if dir == "" {
|
||||
dialog.ShowError(errors.New("no folder is set"), w)
|
||||
return
|
||||
}
|
||||
if err := filemanager.Open(dir); err != nil {
|
||||
dialog.ShowError(err, w)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
// settingsFormFields groups every widget the Settings tab's two-column layout
|
||||
// arranges. It exists so newSettingsLayout takes one argument instead of
|
||||
// twenty, and so a widget added in settingsView is added to exactly one
|
||||
// struct literal rather than threaded through a long parameter list.
|
||||
type settingsFormFields struct {
|
||||
startOnLogin *widget.Check
|
||||
autostartStatus *widget.Label
|
||||
minimizeToTray *widget.Check
|
||||
notifications *widget.Check
|
||||
themeSelect *widget.Select
|
||||
executionModeSelect *widget.Select
|
||||
overlapPolicySelect *widget.Select
|
||||
defaultTimeout *widget.Entry
|
||||
configPath string
|
||||
jobsFile *widget.Entry
|
||||
jobsFileBrowse *widget.Button
|
||||
logsDir *widget.Entry
|
||||
logsDirOpen *widget.Button
|
||||
logsDirBrowse *widget.Button
|
||||
maxLogFiles *widget.Entry
|
||||
maxLogAgeDays *widget.Entry
|
||||
saveSettings *widget.Button
|
||||
cancelSettings *widget.Button
|
||||
restoreDefaults *widget.Button
|
||||
settingsStatus *widget.Label
|
||||
}
|
||||
|
||||
// newSettingsLayout assembles the Settings tab from its fields: a two-column
|
||||
// grid (Application/Queue on the left, Storage/About on the right) with the
|
||||
// Save/Cancel/Defaults row below.
|
||||
func newSettingsLayout(f settingsFormFields) fyne.CanvasObject {
|
||||
// capW is measured once from every caption in the tab so all rows —
|
||||
// Application, Queue, Storage, About — share one value column start,
|
||||
// instead of each settingsRow call re-measuring its own single caption.
|
||||
capW := captionColumnWidth(settingsCaptions...)
|
||||
|
||||
// The form is split into two columns so a wide window uses its horizontal
|
||||
// space instead of stretching into one tall strip. The left column holds the
|
||||
// toggles (Application, Queue); the right holds the editable Storage fields and
|
||||
// the read-only About block. Save spans the full width below both columns.
|
||||
leftColumn := container.NewVBox(
|
||||
settingsSection("Application", rowOverlap(),
|
||||
settingsRow(capW, "Autostart", f.startOnLogin),
|
||||
// Autostart status sits on its own row, aligned under the checkbox via an
|
||||
// empty caption, so the Application section fits in a half-width column.
|
||||
settingsRow(capW, "", f.autostartStatus),
|
||||
settingsRow(capW, "Tray", f.minimizeToTray),
|
||||
settingsRow(capW, "Notifications", f.notifications),
|
||||
// Theme is the one row here whose value is not text: the Select paints
|
||||
// a box out to the row's edge, so the section's overlap would leave it
|
||||
// flush against the Notifications checkbox. Cancelling the overlap for
|
||||
// this row alone restores the gap the checkbox rows have.
|
||||
cancelRowOverlap(settingsRow(capW, "Theme", f.themeSelect)),
|
||||
),
|
||||
widget.NewSeparator(),
|
||||
// Queue used to inline its own container.NewVBox at the theme's default
|
||||
// spacing; settingsSection now takes that spacing explicitly so both
|
||||
// idioms for "a titled block of rows" collapse into one constructor.
|
||||
settingsSection("Queue", theme.Padding(),
|
||||
settingsRow(capW, "Execution mode", f.executionModeSelect),
|
||||
settingsRow(capW, "Default overlap policy", f.overlapPolicySelect),
|
||||
settingsRow(capW, "Default timeout (s)", f.defaultTimeout),
|
||||
),
|
||||
)
|
||||
// Truncating keeps a long config path from forcing the Settings tab's
|
||||
// minimum width to track the path length instead of the layout itself.
|
||||
configPathLabel := widget.NewLabel(f.configPath)
|
||||
configPathLabel.Truncation = fyne.TextTruncateClip
|
||||
rightColumn := container.NewVBox(
|
||||
settingsSection("Storage", theme.Padding(),
|
||||
settingsRow(capW, "Config JSON", configPathLabel),
|
||||
settingsRow(capW, "Jobs file", container.NewBorder(nil, nil, nil, f.jobsFileBrowse, f.jobsFile)),
|
||||
// Browse stays rightmost so it lines up with the Jobs file row
|
||||
// above it; Open sits between it and the path it opens.
|
||||
settingsRow(capW, "Logs directory", container.NewBorder(nil, nil, nil, container.NewHBox(f.logsDirOpen, f.logsDirBrowse), f.logsDir)),
|
||||
settingsRow(capW, "Max log files", f.maxLogFiles),
|
||||
settingsRow(capW, "Max log age days", f.maxLogAgeDays),
|
||||
),
|
||||
widget.NewSeparator(),
|
||||
settingsSection("About", rowOverlap(),
|
||||
settingsRow(capW, "GoSentry", widget.NewLabel(app.Version)),
|
||||
settingsRow(capW, "Go", widget.NewLabel(runtime.Version())),
|
||||
settingsRow(capW, "Fyne", widget.NewLabel(fyneVersion())),
|
||||
settingsRow(capW, "Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))),
|
||||
),
|
||||
)
|
||||
|
||||
// The two columns sit in a top-aligned grid; Save spans the full width below.
|
||||
// Wrapping the whole thing in a vertical scroll keeps its minimum height small
|
||||
// so it does not dictate the window's minimum height (AppTabs sizes to the
|
||||
// tallest tab) and it scrolls on short 720p screens.
|
||||
// The button row sits right below the separator's hairline, which reads as
|
||||
// tighter than the other vertical gaps in the tab (those separate whole
|
||||
// sections, not a single thin line from a row of buttons). A top pad the
|
||||
// height of the default padding closes that gap up to match, and a left pad
|
||||
// indents the buttons 4px from the edge the layout promises.
|
||||
return container.NewVScroll(container.NewPadded(container.NewVBox(
|
||||
container.NewGridWithColumns(2, leftColumn, rightColumn),
|
||||
widget.NewSeparator(),
|
||||
container.New(
|
||||
layout.NewCustomPaddedLayout(2*theme.Padding(), 0, theme.Padding(), 0),
|
||||
// Save/Cancel/Defaults share one row with the status so an empty status
|
||||
// (the common case) does not leave a blank line above the separator. The
|
||||
// status appears beside the buttons once a save reports a result.
|
||||
container.NewHBox(f.saveSettings, f.cancelSettings, f.restoreDefaults, f.settingsStatus),
|
||||
),
|
||||
)))
|
||||
}
|
||||
|
||||
// settingsSection groups a bold header above its rows, using the given
|
||||
// vertical spacing between them. Application and About pass rowOverlap() so
|
||||
// the block reads as one compact unit; Queue and Storage pass theme.Padding()
|
||||
// (the same spacing container.NewVBox would use) so their entry-heavy rows
|
||||
// keep a visible gap. One constructor for "a titled block of rows" rather
|
||||
// than two spellings of it.
|
||||
func settingsSection(title string, spacing float32, rows ...fyne.CanvasObject) fyne.CanvasObject {
|
||||
children := make([]fyne.CanvasObject, 0, len(rows)+1)
|
||||
children = append(children, widget.NewLabelWithStyle(title, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}))
|
||||
children = append(children, rows...)
|
||||
return container.New(layout.NewCustomPaddedVBoxLayout(spacing), children...)
|
||||
}
|
||||
|
||||
func settingsRow(captionWidth float32, label string, value fyne.CanvasObject) fyne.CanvasObject {
|
||||
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||
caption.Truncation = fyne.TextTruncateClip
|
||||
captionBox := container.New(minWidthLayout{width: captionWidth}, caption)
|
||||
return container.NewBorder(nil, nil, captionBox, nil, value)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
fynestorage "fyne.io/fyne/v2/storage"
|
||||
"fyne.io/fyne/v2/test"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
// TestSettingsFolderPath covers the path the "Open" button beside the logs
|
||||
// directory hands to the file manager: blank means nothing to open, a relative
|
||||
// directory resolves against the application directory (as the store does),
|
||||
// and an absolute directory is used as typed. Both directories come from
|
||||
// t.TempDir so the absolute case is genuinely absolute on Windows too.
|
||||
func TestSettingsFolderPath(t *testing.T) {
|
||||
appDir := t.TempDir()
|
||||
absolute := filepath.Join(t.TempDir(), "logs")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want string
|
||||
}{
|
||||
{name: "empty", text: "", want: ""},
|
||||
{name: "whitespace only", text: " ", want: ""},
|
||||
{name: "relative", text: "logs", want: filepath.Join(appDir, "logs")},
|
||||
{name: "relative with spaces around it", text: " logs ", want: filepath.Join(appDir, "logs")},
|
||||
{name: "absolute", text: absolute, want: absolute},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := settingsFolderPath(appDir, testCase.text); got != testCase.want {
|
||||
t.Errorf("settingsFolderPath(%q, %q) = %q, want %q", appDir, testCase.text, got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsRowStretchesItsControl is the property that makes F2's removal
|
||||
// of settingsControlWidth invisible: settingsRow puts the value in a Border
|
||||
// centre slot, which already stretches it to the column width on its own, so
|
||||
// wrapping it in a fixed-width layout was redundant.
|
||||
func TestSettingsRowStretchesItsControl(t *testing.T) {
|
||||
entry := widget.NewEntry()
|
||||
row := settingsRow(captionColumnWidth("Label"), "Label", entry)
|
||||
|
||||
baseWidth := entry.Size().Width
|
||||
wide := fyne.NewSize(row.MinSize().Width+200, row.MinSize().Height)
|
||||
row.Resize(wide)
|
||||
|
||||
// Only the caption column and one inter-column padding come out of the
|
||||
// extra width; the rest must reach the control. Requiring most of the
|
||||
// 200px growth to show up on the entry is what a reinstated fixed-width
|
||||
// wrapper around it would break.
|
||||
if got := entry.Size().Width; got < baseWidth+150 {
|
||||
t.Errorf("control did not stretch to fill the row: entry width = %v, want at least %v", got, baseWidth+150)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChooseFileAppliesFilter is the coverage for the deduplicated file picker
|
||||
// (chooseFile absorbed chooseJSONFile's SetFilter call behind a nil-means-none
|
||||
// filter argument): both a nil filter (job_dialog.go's command browser) and a
|
||||
// concrete one (chooseJSONFile) must open a dialog without panicking, and the
|
||||
// dialog must actually appear as a canvas overlay either way.
|
||||
func TestChooseFileAppliesFilter(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
w := testApp.NewWindow("test")
|
||||
defer w.Close()
|
||||
|
||||
target := widget.NewEntry()
|
||||
|
||||
chooseFile(w, target, nil)
|
||||
if w.Canvas().Overlays().Top() == nil {
|
||||
t.Fatal("chooseFile(nil filter) did not open a dialog")
|
||||
}
|
||||
w.Canvas().Overlays().Top().Hide()
|
||||
|
||||
chooseJSONFile(w, target)
|
||||
if w.Canvas().Overlays().Top() == nil {
|
||||
t.Fatal("chooseJSONFile did not open a dialog")
|
||||
}
|
||||
w.Canvas().Overlays().Top().Hide()
|
||||
|
||||
// Exercising a concrete filter directly through chooseFile as well, so the
|
||||
// filter parameter itself (not just chooseJSONFile's use of it) is covered.
|
||||
chooseFile(w, target, fynestorage.NewExtensionFileFilter([]string{".json"}))
|
||||
if w.Canvas().Overlays().Top() == nil {
|
||||
t.Fatal("chooseFile(non-nil filter) did not open a dialog")
|
||||
}
|
||||
w.Canvas().Overlays().Top().Hide()
|
||||
}
|
||||
|
||||
// TestSettingsCaptionsCoverEveryRow is the settings-tab analog of F10's
|
||||
// jobs-details guard: every caption settingsView actually uses in a row must
|
||||
// be present in settingsCaptions and measure no wider than
|
||||
// captionColumnWidth's result for that list, or a caption added to a row
|
||||
// without adding it to settingsCaptions would silently misalign that column.
|
||||
func TestSettingsCaptionsCoverEveryRow(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
|
||||
capW := captionColumnWidth(settingsCaptions...)
|
||||
for _, c := range settingsCaptions {
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
if w := widget.NewLabelWithStyle(c, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}).MinSize().Width; w > capW {
|
||||
t.Errorf("caption %q measures %v, wider than captionColumnWidth's %v", c, w, capW)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user