Compare commits

..

3 Commits

Author SHA1 Message Date
mixeme 5a018d03cb docs: add cron import/export and GUI layout review to the roadmap
Two planned items larger than a single fix:

- Import/export jobs as a cron table, with the open questions that must be
  settled first: the job fields crontab has no slot for, "@every" not being
  valid crontab, splitting Command/Arguments per platform, which lines to
  skip on import, and merge semantics.
- A focused pass over the ui package's custom layouts and tuned constants —
  negative spacings that cancel widget padding, pixel sizes that ignore theme
  metrics, a layout with one call site, and settings_view.go past the size
  guideline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:20:11 +03:00
mixeme e5f8c7a812 docs: changelog and version bump for 0.14.0
Close the Unreleased section as 0.14.0 and fill the gaps in it: the Folder
caption moving onto the filter row, the padding around the Settings button
row, the Truncation-field refactor, the Docker build cache mount, and the
review/standards documents added since 0.13.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:11:31 +03:00
mixeme 5e09ba1d58 feat: open the logs folder from the Settings tab
Reading a log file meant copying the configured path out of Settings and
pasting it into a file manager. The Logs directory row now carries an Open
button beside Browse that reveals the folder directly.

The new src/platform/filemanager package holds the platform split — explorer
on Windows, xdg-open on Linux, an "unsupported" error elsewhere — and starts
the handler without waiting on it, since Explorer exits non-zero even after it
opens the window and blocking would stall the UI thread. A missing path, a
path that is a file, and a handler that will not start are all reported to the
user; the logs directory does not exist until the first run, so that case is
reachable.

The button opens whatever the field currently holds rather than the saved
config, so an edit can be checked before Save. Resolving a relative directory
against the application folder is the store's rule, so resolveConfiguredDir is
now exported as storage.ResolveConfiguredDir instead of being duplicated in
the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 23:09:33 +03:00
13 changed files with 369 additions and 12 deletions
+1
View File
@@ -18,6 +18,7 @@ src/
platform/ platform/
autostart/ Manager interface + Windows (shortcut) and Linux (XDG) impls autostart/ Manager interface + Windows (shortcut) and Linux (XDG) impls
desktop/ display-scale helper (Linux only) desktop/ display-scale helper (Linux only)
filemanager/ open a folder in the desktop file manager
winproc/ hidden-window startup flags (Windows only) winproc/ hidden-window startup flags (Windows only)
ui/ Fyne windows, tabs, and dialogs; reads service via Events ui/ Fyne windows, tabs, and dialogs; reads service via Events
``` ```
+38 -6
View File
@@ -2,13 +2,10 @@
All notable GoSentry changes are recorded in this file. All notable GoSentry changes are recorded in this file.
## Unreleased ## 0.14.0 - 2026-07-26
**Job dialog:** **Compact job list view, "no timeout" at both timeout levels, and an Open
button for the logs folder.**
- 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.
**Compact job list view.** **Compact job list view.**
@@ -23,6 +20,31 @@ All notable GoSentry changes are recorded in this file.
so it survives a restart. Empty/legacy configs and any unrecognised value so it survives a restart. Empty/legacy configs and any unrecognised value
normalize to detailed, so existing installs keep the current look. 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.** **Timeouts: 0 now means "no timeout" at both levels.**
- The global **Default timeout** in Settings now defaults to `0`, meaning jobs - The global **Default timeout** in Settings now defaults to `0`, meaning jobs
@@ -39,6 +61,16 @@ All notable GoSentry changes are recorded in this file.
Existing jobs and configs are unaffected: a job with no `timeout_seconds` still Existing jobs and configs are unaffected: a job with no `timeout_seconds` still
inherits, and a saved global default of 30 stays 30. 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 ## 0.13.0 - 2026-07-26
**Branded GoSentry color theme; Cancel/Defaults buttons in Settings.** **Branded GoSentry color theme; Cancel/Defaults buttons in Settings.**
+82
View File
@@ -37,6 +37,88 @@ Design notes / open questions:
- *No auto-download.* Scope is detection and notification only; installing the - *No auto-download.* Scope is detection and notification only; installing the
update stays a manual click-through to the release page. 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.
### GUI review — custom layouts and composition
The `ui` package has accumulated hand-written layouts and tuned constants that
work but have never been reviewed as a whole:
[`layout.go`](../src/ui/layout.go) holds four custom `fyne.Layout`
implementations (`minWidthLayout`, `compactVBoxLayout`, `fixedHeightLayout`,
`captionValueLayout`), and the views drive them with negative spacings
(`detailRowSpacing = -8`, `jobRowSpacing = -8`, `settingsRowSpacing = -6`) that
cancel out the built-in padding of Fyne widgets.
Do a focused pass over composition only — not a general code review — and
answer, per layout and per constant: is it still needed, is it the smallest
thing that works, and does it hold up at different window sizes and theme
scales.
What to look for:
- *Negative spacing as a workaround.* Pulling rows together to overlap label
padding is a workaround for widget metrics, not a layout decision. Check
whether a `widget.Form`, a grid, or a custom text row would express the same
result without depending on the padding a future Fyne release may change.
- *Hard-coded pixel constants.* Widths and heights expressed in raw pixels
(`logColumnMinWidth`, `minJobsSidebarWidth`, `settingsControlWidth`) do not
follow `theme.Padding()` / text size, so they behave differently under a
scaled UI. `activityRowsHeight` already derives its height from the theme —
decide which of the rest should do the same.
- *Layouts with one call site.* `fixedHeightLayout` is used once. If a stock
container expresses the same intent, deleting the type is a net win — the
complexity rule in [REVIEW.md](REVIEW.md) §2 applies to layouts too.
- *File size.* `settings_view.go` is well past the ~250-line guideline in
[ARCHITECTURE.md](ARCHITECTURE.md) and should be split the way `jobs_view.go`
was.
- *Behaviour at small sizes.* The details pane was condensed to fit 720p; verify
the current composition still degrades sensibly when the window is narrow or
short, instead of clipping.
Findings that are single fixes go straight in; anything larger comes back here.
### Window size persistence *(frozen)* ### Window size persistence *(frozen)*
Window size is currently **not** saved on quit or close. Saving was disabled Window size is currently **not** saved on quit or close. Saving was disabled
+27
View File
@@ -379,6 +379,33 @@ Tests pure History tab helpers (no Fyne widget construction).
--- ---
### 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/settings_view_test.go
**Package:** `ui`
Tests pure Settings tab helpers (no Fyne widget construction).
| 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. |
---
### src/ui/mainwindow_test.go ### src/ui/mainwindow_test.go
**Package:** `ui` **Package:** `ui`
+1 -1
View File
@@ -3,4 +3,4 @@ package app
// Version is the application version shown in the GUI and used by build // Version is the application version shown in the GUI and used by build
// scripts in artifact names. It is a var rather than a const so release builds // scripts in artifact names. It is a var rather than a const so release builds
// can override it with Go ldflags when CI tags a build. // can override it with Go ldflags when CI tags a build.
var Version = "0.13.0" var Version = "0.14.0"
+42
View File
@@ -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)}
}
+8 -4
View File
@@ -163,10 +163,14 @@ func normalizeJobs(jobs []domain.Job) {
} }
func resolveJobsDir(appDir string, jobsDir string) string { func resolveJobsDir(appDir string, jobsDir string) string {
return resolveConfiguredDir(appDir, jobsDir) return ResolveConfiguredDir(appDir, jobsDir)
} }
func resolveConfiguredDir(appDir string, dir string) string { // ResolveConfiguredDir turns a directory 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 ResolveConfiguredDir(appDir string, dir string) string {
if filepath.IsAbs(dir) { if filepath.IsAbs(dir) {
return dir return dir
} }
@@ -177,9 +181,9 @@ func resolveConfiguredDir(appDir string, dir string) string {
} }
func (s *Store) applyConfigPaths() { func (s *Store) applyConfigPaths() {
s.Paths.JobsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir) s.Paths.JobsDir = ResolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir)
s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName) s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName)
s.Paths.LogsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir) s.Paths.LogsDir = ResolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir)
} }
func writeJSON(path string, value any) error { func writeJSON(path string, value any) error {
+39 -1
View File
@@ -1,6 +1,7 @@
package ui package ui
import ( import (
"errors"
"image/color" "image/color"
"net/url" "net/url"
"runtime" "runtime"
@@ -10,6 +11,8 @@ import (
"gitea.mixdep.ru/mix/gosentry/src/app" "gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain" "gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/platform/filemanager"
"gitea.mixdep.ru/mix/gosentry/src/storage"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/canvas"
@@ -103,6 +106,13 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() { logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFolder(w, logsDir) 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 := widget.NewEntry()
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles)) maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
maxLogFiles.OnChanged = func(string) { updateSaveState() } maxLogFiles.OnChanged = func(string) { updateSaveState() }
@@ -259,7 +269,9 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
settingsRow("Config JSON", widget.NewLabel(store.Paths.ConfigPath)), settingsRow("Config JSON", widget.NewLabel(store.Paths.ConfigPath)),
settingsRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)), settingsRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)),
settingsRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)), // Browse stays rightmost so it lines up with the Jobs directory row
// above it; Open sits between it and the path it opens.
settingsRow("Logs directory", container.NewBorder(nil, nil, nil, container.NewHBox(logsDirOpen, logsDirBrowse), logsDir)),
settingsRow("Max log files", maxLogFiles), settingsRow("Max log files", maxLogFiles),
settingsRow("Max log age days", maxLogAgeDays), settingsRow("Max log age days", maxLogAgeDays),
), ),
@@ -359,6 +371,32 @@ func chooseFolder(w fyne.Window, target *widget.Entry) {
folderDialog.Show() 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.ResolveConfiguredDir(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)
}
}
// Theme dropdown labels. These are the human-facing captions; themeLabel and // Theme dropdown labels. These are the human-facing captions; themeLabel and
// themeFromLabel translate between them and the stored domain.Theme values so the // themeFromLabel translate between them and the stored domain.Theme values so the
// select never leaks the on-disk "default"/"gosentry" strings to the user. // select never leaks the on-disk "default"/"gosentry" strings to the user.
+35
View File
@@ -0,0 +1,35 @@
package ui
import (
"path/filepath"
"testing"
)
// 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)
}
})
}
}