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:
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
}
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user