From 64b32211b8160e7fbe35704f2ad94fa320b180b7 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:25:05 -0700 Subject: [PATCH] feat(filetree): reveal selected file in OS file manager Add an M keybinding in the file tree that opens the OS file manager with the item under the cursor highlighted inside its parent directory. Works for both files and directories. Platform launchers live in internal/panels/open.go: - Windows: explorer /select, - macOS: open -R - Linux: freedesktop D-Bus FileManager1.ShowItems when dbus-send is present, otherwise xdg-open on the parent directory Paths pass through ValidateEditorPath, so shell metacharacters and null bytes are rejected before any launcher runs. Closes #313 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/keybindings.md | 1 + internal/keybindings/keybindings.json | 1 + internal/panels/filetree/fileops.go | 25 +++++++++ internal/panels/filetree/filetree.go | 3 + internal/panels/open.go | 36 ++++++++++++ internal/panels/open_test.go | 81 +++++++++++++++++++++++++++ 6 files changed, 147 insertions(+) diff --git a/docs/keybindings.md b/docs/keybindings.md index 919df3bf..84b21c70 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -59,6 +59,7 @@ File Tree panel (panel 1). | `h/l` | Collapse/expand directory | | `H/L` | Collapse/expand all directories | | `o` | Open in external editor | +| `M` | Reveal in OS file manager | | `.` | Toggle hidden files | | `f` | Toggle git filter | | `v` | Toggle tree/list view | diff --git a/internal/keybindings/keybindings.json b/internal/keybindings/keybindings.json index 6c9e7045..90a2a9e5 100644 --- a/internal/keybindings/keybindings.json +++ b/internal/keybindings/keybindings.json @@ -45,6 +45,7 @@ { "key": "h/l", "action": "Collapse/expand directory" }, { "key": "H/L", "action": "Collapse/expand all directories" }, { "key": "o", "action": "Open in external editor" }, + { "key": "M", "action": "Reveal in OS file manager" }, { "key": ".", "action": "Toggle hidden files" }, { "key": "f", "action": "Toggle git filter" }, { "key": "v", "action": "Toggle tree/list view" }, diff --git a/internal/panels/filetree/fileops.go b/internal/panels/filetree/fileops.go index f9951c5a..329ecac7 100644 --- a/internal/panels/filetree/fileops.go +++ b/internal/panels/filetree/fileops.go @@ -472,6 +472,31 @@ func (ft *FileTree) openInEditor() (panels.Panel, tea.Cmd) { } } +// revealInFileManager reveals the item under the cursor in the OS file manager, +// highlighting it within its parent directory. Unlike openInDefaultApp, it works +// on both files and directories. +func (ft *FileTree) revealInFileManager() (panels.Panel, tea.Cmd) { + n := ft.cursorNode() + if n == nil { + return ft, nil + } + itemPath := n.path + itemName := n.name + ctx := ft.safeCtx() + return ft, func() (msg tea.Msg) { + defer func() { + if r := recover(); r != nil { + slog.Error("panic in revealInFileManager cmd", "recover", r, "file", itemPath, "stack", string(debug.Stack())) + msg = notify.ShowToastMsg{Message: "Reveal crashed: " + fmt.Sprint(r), Level: notify.Error} + } + }() + if err := panels.RevealInFileManager(ctx, itemPath); err != nil { + return notify.ShowToastMsg{Message: "Reveal failed: " + err.Error(), Level: notify.Error} + } + return notify.ShowToastMsg{Message: "Revealed " + itemName, Level: notify.Info} + } +} + // copyPath copies the path of the item under the cursor to the OS clipboard. func (ft *FileTree) copyPath() (panels.Panel, tea.Cmd) { n := ft.cursorNode() diff --git a/internal/panels/filetree/filetree.go b/internal/panels/filetree/filetree.go index 21412ec9..bb7a391d 100644 --- a/internal/panels/filetree/filetree.go +++ b/internal/panels/filetree/filetree.go @@ -586,6 +586,7 @@ func (ft *FileTree) KeyBindings() []panels.KeyBinding { {Key: "d", Description: "Delete file(s)", Action: "item_delete"}, {Key: "e/F2", Description: "Rename file", Action: "item_edit"}, {Key: "o", Description: "Open in editor", Action: "item_open"}, + {Key: "M", Description: "Reveal in file manager", Action: "reveal_in_file_manager"}, {Key: "y", Description: "Copy file path", Action: "item_copy"}, {Key: "c", Description: "Copy selected to clipboard", Action: "copy"}, {Key: "x", Description: "Cut selected to clipboard", Action: "cut"}, @@ -973,6 +974,8 @@ func (ft *FileTree) handleKey(msg tea.KeyPressMsg) (panels.Panel, tea.Cmd) { return ft.openInEditor() case "O": return ft.openInDefaultApp() + case "M": + return ft.revealInFileManager() case "y": return ft.copyPath() case "c": diff --git a/internal/panels/open.go b/internal/panels/open.go index 9369d7e8..4906db03 100644 --- a/internal/panels/open.go +++ b/internal/panels/open.go @@ -6,6 +6,7 @@ import ( "net/url" "os" "os/exec" + "path/filepath" "runtime" "strings" "time" @@ -187,3 +188,38 @@ func OpenInTerminal(ctx context.Context, dir string) error { } return StartDetachedFn(cmd) } + +// RevealInFileManager opens the OS file manager with the given path selected +// (highlighted) inside its parent directory. On Windows it uses explorer +// /select, on macOS it uses open -R, and on Linux it uses the freedesktop +// D-Bus FileManager1.ShowItems interface when available, otherwise it opens the +// parent directory with xdg-open. +func RevealInFileManager(ctx context.Context, path string) error { + if err := ValidateEditorPath(path); err != nil { + return err + } + switch runtime.GOOS { + case "windows": + // explorer highlights the item when passed /select,. It exits + // with a non-zero status even on success, but StartDetachedFn ignores + // the reaped exit status so that does not surface as an error. + return StartDetachedFn(exec.CommandContext(ctx, "explorer", "/select,"+path)) + case "darwin": + return StartDetachedFn(exec.CommandContext(ctx, "open", "-R", path)) + default: + if _, err := exec.LookPath("dbus-send"); err == nil { + fileURI := (&url.URL{Scheme: "file", Path: path}).String() + return StartDetachedFn(exec.CommandContext( + ctx, "dbus-send", + "--session", + "--dest=org.freedesktop.FileManager1", + "--type=method_call", + "/org/freedesktop/FileManager1", + "org.freedesktop.FileManager1.ShowItems", + "array:string:"+fileURI, + "string:", + )) + } + return StartDetachedFn(exec.CommandContext(ctx, "xdg-open", filepath.Dir(path))) + } +} diff --git a/internal/panels/open_test.go b/internal/panels/open_test.go index a657a330..c3247889 100644 --- a/internal/panels/open_test.go +++ b/internal/panels/open_test.go @@ -2,7 +2,10 @@ package panels import ( "context" + "net/url" "os" + "os/exec" + "path/filepath" "runtime" "testing" @@ -306,3 +309,81 @@ func TestOpenInTerminal_AcceptsValidDirectory(t *testing.T) { assert.NotContains(t, err.Error(), "null byte") } } + +// --------------------------------------------------------------------------- +// Security + command construction: RevealInFileManager +// --------------------------------------------------------------------------- + +func TestRevealInFileManager_RejectsEmpty(t *testing.T) { + err := RevealInFileManager(context.Background(), "") + require.Error(t, err) + assert.Contains(t, err.Error(), "must not be empty") +} + +func TestRevealInFileManager_RejectsNullByte(t *testing.T) { + err := RevealInFileManager(context.Background(), "/tmp/file\x00malicious") + require.Error(t, err) + assert.Contains(t, err.Error(), "null byte") +} + +func TestRevealInFileManager_RejectsShellMetachars(t *testing.T) { + dangerous := []struct { + name string + path string + }{ + {"semicolon", "/tmp/file;rm -rf /"}, + {"pipe", "/tmp/file|cat /etc/passwd"}, + {"backtick", "/tmp/`whoami`"}, + {"dollar", "/tmp/$HOME"}, + } + for _, tt := range dangerous { + t.Run(tt.name, func(t *testing.T) { + err := RevealInFileManager(context.Background(), tt.path) + require.Error(t, err, "path %q should be rejected", tt.path) + assert.Contains(t, err.Error(), "forbidden character") + }) + } +} + +func TestRevealInFileManager_BuildsPlatformCommand(t *testing.T) { + var captured *exec.Cmd + orig := StartDetachedFn + StartDetachedFn = func(cmd *exec.Cmd) error { + captured = cmd + return nil + } + t.Cleanup(func() { StartDetachedFn = orig }) + + f, err := os.CreateTemp(t.TempDir(), "reveal-*") + require.NoError(t, err) + require.NoError(t, f.Close()) + path := f.Name() + + require.NoError(t, RevealInFileManager(context.Background(), path)) + require.NotNil(t, captured, "StartDetachedFn should have been called") + require.NotEmpty(t, captured.Args) + + switch runtime.GOOS { + case "windows": + assert.Equal(t, "explorer", captured.Args[0]) + require.Len(t, captured.Args, 2) + assert.Equal(t, "/select,"+path, captured.Args[1]) + case "darwin": + assert.Equal(t, "open", captured.Args[0]) + require.Len(t, captured.Args, 3) + assert.Equal(t, "-R", captured.Args[1]) + assert.Equal(t, path, captured.Args[2]) + default: + switch captured.Args[0] { + case "dbus-send": + wantURI := "array:string:" + (&url.URL{Scheme: "file", Path: path}).String() + assert.Contains(t, captured.Args, "org.freedesktop.FileManager1.ShowItems") + assert.Contains(t, captured.Args, wantURI) + case "xdg-open": + require.Len(t, captured.Args, 2) + assert.Equal(t, filepath.Dir(path), captured.Args[1]) + default: + t.Fatalf("unexpected linux reveal command: %q", captured.Args[0]) + } + } +}