Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions internal/keybindings/keybindings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
25 changes: 25 additions & 0 deletions internal/panels/filetree/fileops.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions internal/panels/filetree/filetree.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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":
Expand Down
36 changes: 36 additions & 0 deletions internal/panels/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
Expand Down Expand Up @@ -35,7 +36,7 @@
"javascript": true,
"data": true,
"vbscript": true,
"file": true,

Check failure on line 39 in internal/panels/open.go

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest)

string `file` has 3 occurrences, make it a constant (goconst)
}

// ValidateBrowserURL validates a URL before opening it in the default browser.
Expand Down Expand Up @@ -187,3 +188,38 @@
}
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,<path>. 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)))
}
}
81 changes: 81 additions & 0 deletions internal/panels/open_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package panels

import (
"context"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"

Expand Down Expand Up @@ -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])
}
}
}
Loading