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 <krzysztof.zon2001@gmail.com>
This commit is contained in:
Rick Calixte 2024-12-13 05:37:18 -05:00 committed by GitHub
commit d03f4ce54a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 233 additions and 11 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -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)
})
}