diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8b8bb9b..195867d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,6 +18,7 @@ src/ platform/ autostart/ Manager interface + Windows (shortcut) and Linux (XDG) impls desktop/ display-scale helper (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 ``` diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 243ec32..0d447fa 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,16 @@ All notable GoSentry changes are recorded in this file. ## 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:** - The **Arguments** placeholder now states the field's rule — one argument per diff --git a/docs/TESTS.md b/docs/TESTS.md index 2e097bd..9524c4f 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -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 **Package:** `ui` diff --git a/src/platform/filemanager/filemanager.go b/src/platform/filemanager/filemanager.go new file mode 100644 index 0000000..003f1ff --- /dev/null +++ b/src/platform/filemanager/filemanager.go @@ -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 +} diff --git a/src/platform/filemanager/filemanager_linux.go b/src/platform/filemanager/filemanager_linux.go new file mode 100644 index 0000000..5976b9f --- /dev/null +++ b/src/platform/filemanager/filemanager_linux.go @@ -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} +} diff --git a/src/platform/filemanager/filemanager_other.go b/src/platform/filemanager/filemanager_other.go new file mode 100644 index 0000000..79d6ddc --- /dev/null +++ b/src/platform/filemanager/filemanager_other.go @@ -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 +} diff --git a/src/platform/filemanager/filemanager_test.go b/src/platform/filemanager/filemanager_test.go new file mode 100644 index 0000000..ad224c1 --- /dev/null +++ b/src/platform/filemanager/filemanager_test.go @@ -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)) + } +} diff --git a/src/platform/filemanager/filemanager_windows.go b/src/platform/filemanager/filemanager_windows.go new file mode 100644 index 0000000..06dd13c --- /dev/null +++ b/src/platform/filemanager/filemanager_windows.go @@ -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)} +} diff --git a/src/storage/store.go b/src/storage/store.go index e9b38dd..91a049d 100644 --- a/src/storage/store.go +++ b/src/storage/store.go @@ -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 { diff --git a/src/ui/settings_view.go b/src/ui/settings_view.go index 8c84e98..4a2bf45 100644 --- a/src/ui/settings_view.go +++ b/src/ui/settings_view.go @@ -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. diff --git a/src/ui/settings_view_test.go b/src/ui/settings_view_test.go new file mode 100644 index 0000000..865156d --- /dev/null +++ b/src/ui/settings_view_test.go @@ -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) + } + }) + } +}