diff --git a/docs/keybindings.md b/docs/keybindings.md index 919df3b..84b21c7 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 6c9e704..90a2a9e 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 f9951c5..329ecac 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 21412ec..bb7a391 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 9369d7e..4906db0 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 a657a33..c324788 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]) + } + } +}