From d03f4ce54a70561fec5ab9d0e2f7dda800237085 Mon Sep 17 00:00:00 2001 From: Rick Calixte <10281587+rcalixte@users.noreply.github.com> Date: Fri, 13 Dec 2024 05:37:18 -0500 Subject: [PATCH] OpenFileManager for opening with the native file manager and optional file selection support (#3937) OpenFileManager for opening with the native file manager and optional file selection support Closes #3197 Co-authored-by: Krzysztofz01 --- mkdocs-website/docs/en/changelog.md | 2 +- v3/internal/fileexplorer/fileexplorer.go | 159 +++++++++++++++++- v3/internal/fileexplorer/fileexplorer_test.go | 75 +++++++++ v3/pkg/application/application.go | 8 +- 4 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 v3/internal/fileexplorer/fileexplorer_test.go diff --git a/mkdocs-website/docs/en/changelog.md b/mkdocs-website/docs/en/changelog.md index fae69ac36..4e80a9dfa 100644 --- a/mkdocs-website/docs/en/changelog.md +++ b/mkdocs-website/docs/en/changelog.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `app.OpenDirectory(dir string)` to open the system file explorer to the directory `dir` by [@leaanthony](https://github.com/leaanthony) +- `app.OpenFileManager(path string, selectFile bool)` to open the system file manager to the path `path` with optional highlighting via `selectFile` by [@Krzysztofz01](https://github.com/Krzysztofz01) [@rcalixte](https://github.com/rcalixte) ### Fixed diff --git a/v3/internal/fileexplorer/fileexplorer.go b/v3/internal/fileexplorer/fileexplorer.go index 773d6ef18..fb363a201 100644 --- a/v3/internal/fileexplorer/fileexplorer.go +++ b/v3/internal/fileexplorer/fileexplorer.go @@ -1,24 +1,169 @@ package fileexplorer import ( + "bytes" + "context" + "errors" "fmt" + "net/url" + "os" "os/exec" + "path/filepath" "runtime" + "strings" + "time" + + ini "gopkg.in/ini.v1" ) -func Open(path string) error { - var cmd *exec.Cmd +type explorerBinArgs func(path string, selectFile bool) (string, []string, error) + +func OpenFileManager(path string, selectFile bool) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + path = os.ExpandEnv(path) + path = filepath.Clean(path) + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("failed to resolve the absolute path: %w", err) + } + path = absPath + if pathInfo, err := os.Stat(path); err != nil { + return fmt.Errorf("failed to access the specified path: %w", err) + } else { + selectFile = selectFile && !pathInfo.IsDir() + } + + var ( + explorerBinArgs explorerBinArgs + ignoreExitCode bool = false + ) switch runtime.GOOS { case "windows": - cmd = exec.Command("explorer", path) + explorerBinArgs = windowsExplorerBinArgs + // NOTE: Disabling the exit code check on Windows system. Workaround for explorer.exe + // exit code handling (https://github.com/microsoft/WSL/issues/6565) + ignoreExitCode = true case "darwin": - cmd = exec.Command("open", path) + explorerBinArgs = darwinExplorerBinArgs case "linux": - cmd = exec.Command("xdg-open", path) + explorerBinArgs = linuxExplorerBinArgs default: - return fmt.Errorf("unsupported platform") + return errors.New("unsupported platform: " + runtime.GOOS) } - return cmd.Run() + explorerBin, explorerArgs, err := explorerBinArgs(path, selectFile) + if err != nil { + return fmt.Errorf("failed to determine the file explorer binary: %w", err) + } + + cmd := exec.CommandContext(ctx, explorerBin, explorerArgs...) + cmd.Stdout = nil + cmd.Stderr = nil + + if err := cmd.Run(); err != nil { + if !ignoreExitCode { + return fmt.Errorf("failed to open the file explorer: %w", err) + } + } + return nil +} + +var windowsExplorerBinArgs explorerBinArgs = func(path string, selectFile bool) (string, []string, error) { + args := []string{} + if selectFile { + args = append(args, fmt.Sprintf("/select,\"%s\"", path)) + } else { + args = append(args, path) + } + return "explorer.exe", args, nil +} + +var darwinExplorerBinArgs explorerBinArgs = func(path string, selectFile bool) (string, []string, error) { + args := []string{} + if selectFile { + args = append(args, "-R") + } + + args = append(args, path) + return "open", args, nil +} + +var linuxExplorerBinArgs explorerBinArgs = func(path string, selectFile bool) (string, []string, error) { + // Map of field codes to their replacements + var fieldCodes = map[string]string{ + "%d": "", + "%D": "", + "%n": "", + "%N": "", + "%v": "", + "%m": "", + "%f": path, + "%F": path, + "%u": pathToURI(path), + "%U": pathToURI(path), + } + fileManagerQuery := exec.Command("xdg-mime", "query", "default", "inode/directory") + buf := new(bytes.Buffer) + fileManagerQuery.Stdout = buf + fileManagerQuery.Stderr = nil + + if err := fileManagerQuery.Run(); err != nil { + return linuxFallbackExplorerBinArgs(path, selectFile) + } + + desktopFile, err := findDesktopFile(strings.TrimSpace((buf.String()))) + if err != nil { + return linuxFallbackExplorerBinArgs(path, selectFile) + } + + cfg, err := ini.Load(desktopFile) + if err != nil { + // Opting to fallback rather than fail + return linuxFallbackExplorerBinArgs(path, selectFile) + } + + exec := cfg.Section("Desktop Entry").Key("Exec").String() + for fieldCode, replacement := range fieldCodes { + exec = strings.ReplaceAll(exec, fieldCode, replacement) + } + args := strings.Fields(exec) + if !strings.Contains(strings.Join(args, " "), path) { + args = append(args, path) + } + + return args[0], args[1:], nil +} + +var linuxFallbackExplorerBinArgs explorerBinArgs = func(path string, selectFile bool) (string, []string, error) { + // NOTE: The linux fallback explorer opening is not supporting file selection + path = filepath.Dir(path) + return "xdg-open", []string{path}, nil +} + +func pathToURI(path string) string { + absPath, err := filepath.Abs(path) + if err != nil { + return path + } + return "file://" + url.PathEscape(absPath) +} + +func findDesktopFile(xdgFileName string) (string, error) { + paths := []string{ + filepath.Join(os.Getenv("XDG_DATA_HOME"), "applications"), + filepath.Join(os.Getenv("HOME"), ".local", "share", "applications"), + "/usr/share/applications", + } + + for _, path := range paths { + desktopFile := filepath.Join(path, xdgFileName) + if _, err := os.Stat(desktopFile); err == nil { + return desktopFile, nil + } + } + err := fmt.Errorf("desktop file not found: %s", xdgFileName) + return "", err } diff --git a/v3/internal/fileexplorer/fileexplorer_test.go b/v3/internal/fileexplorer/fileexplorer_test.go new file mode 100644 index 000000000..0b7012636 --- /dev/null +++ b/v3/internal/fileexplorer/fileexplorer_test.go @@ -0,0 +1,75 @@ +package fileexplorer_test + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/wailsapp/wails/v3/internal/fileexplorer" +) + +func TestFileExplorer(t *testing.T) { + // TestFileExplorer verifies that the OpenFileManager function correctly handles: + // - Opening files in the native file manager across different platforms + // - Selecting files when the selectFile parameter is true + // - Various error conditions like non-existent paths + tempDir := t.TempDir() // Create a temporary directory for tests + + tests := []struct { + name string + path string + selectFile bool + expectedErr error + }{ + {"Open Existing File", tempDir, false, nil}, + {"Select Existing File", tempDir, true, nil}, + {"Non-Existent Path", "/path/does/not/exist", false, fmt.Errorf("failed to access the specified path: /path/does/not/exist")}, + {"Path with Special Characters", filepath.Join(tempDir, "test space.txt"), true, nil}, + {"No Permission Path", "/root/test.txt", false, fmt.Errorf("failed to open the file explorer: /root/test.txt")}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Run("Windows", func(t *testing.T) { + runPlatformTest(t, "windows") + }) + t.Run("Linux", func(t *testing.T) { + runPlatformTest(t, "linux") + }) + t.Run("Darwin", func(t *testing.T) { + runPlatformTest(t, "darwin") + }) + }) + } +} + +func runPlatformTest(t *testing.T, platform string) { + if runtime.GOOS != platform { + t.Skipf("Skipping test on non-%s platform", strings.ToTitle(platform)) + } + + testFile := filepath.Join(t.TempDir(), "test.txt") + if err := os.WriteFile(testFile, []byte("Test file contents"), 0644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + selectFile bool + }{ + {"OpenFile", false}, + {"SelectFile", true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := fileexplorer.OpenFileManager(testFile, test.selectFile) + if err != nil { + t.Errorf("OpenFileManager(%s, %v) error = %v", testFile, test.selectFile, err) + } + }) + } +} diff --git a/v3/pkg/application/application.go b/v3/pkg/application/application.go index 86480485a..264f8e1fe 100644 --- a/v3/pkg/application/application.go +++ b/v3/pkg/application/application.go @@ -5,7 +5,6 @@ import ( "embed" "encoding/json" "fmt" - "github.com/wailsapp/wails/v3/internal/fileexplorer" "io" "log" "log/slog" @@ -17,6 +16,8 @@ import ( "strings" "sync" + "github.com/wailsapp/wails/v3/internal/fileexplorer" + "github.com/wailsapp/wails/v3/internal/operatingsystem" "github.com/pkg/browser" @@ -1046,8 +1047,9 @@ func (a *App) Paths(selector Paths) []string { return pathdirs[selector] } -func (a *App) OpenDirectory(path string) error { +// OpenFileManager opens the file manager at the specified path, optionally selecting the file. +func (a *App) OpenFileManager(path string, selectFile bool) error { return InvokeSyncWithError(func() error { - return fileexplorer.Open(path) + return fileexplorer.OpenFileManager(path, selectFile) }) }