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>
This commit is contained in:
mixeme
2026-07-26 23:09:33 +03:00
parent fe13d1f34e
commit 5e09ba1d58
11 changed files with 258 additions and 5 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
``` ```
+10
View File
@@ -4,6 +4,16 @@ All notable GoSentry changes are recorded in this file.
## Unreleased ## Unreleased
**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.
**Job dialog:** **Job dialog:**
- The **Arguments** placeholder now states the field's rule — one argument per - The **Arguments** placeholder now states the field's rule — one argument per
+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`
+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)
}
})
}
}