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:
@@ -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 {
|
||||
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) {
|
||||
return dir
|
||||
}
|
||||
@@ -177,9 +181,9 @@ func resolveConfiguredDir(appDir string, dir string) string {
|
||||
}
|
||||
|
||||
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.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 {
|
||||
|
||||
+39
-1
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"image/color"
|
||||
"net/url"
|
||||
"runtime"
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||
"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/canvas"
|
||||
@@ -103,6 +106,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() }
|
||||
@@ -259,7 +269,9 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
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)),
|
||||
// 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 age days", maxLogAgeDays),
|
||||
),
|
||||
@@ -359,6 +371,32 @@ func chooseFolder(w fyne.Window, target *widget.Entry) {
|
||||
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
|
||||
// themeFromLabel translate between them and the stored domain.Theme values so the
|
||||
// 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