From 475189c266f274798b38aae5d9714e2d7033a515 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:14:47 -0700 Subject: [PATCH] feat: open session working directory in file manager Add an `O` keybinding that opens the selected session's working directory in the system file manager. Reuses the platform opener (explorer, open, xdg-open) with a new OpenDir helper that validates the path is an existing directory before spawning, and reports the result through the status line. Closes #197 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + README.md | 2 ++ internal/platform/open.go | 43 ++++++++++++++++++++------- internal/platform/open_test.go | 53 ++++++++++++++++++++++++++++++++++ internal/tui/handlers.go | 11 +++++++ internal/tui/handlers_test.go | 37 ++++++++++++++++++++++++ internal/tui/keys.go | 6 ++-- internal/tui/messages.go | 6 ++++ internal/tui/model.go | 20 +++++++++++++ 9 files changed, 167 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdc619f..fa59e72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - **Configurable auto-refresh** — set `auto_refresh_seconds` (also in the settings panel) to tune the session-list poll interval, or set it to `0` to turn polling off and refresh only with `r` or reindex - **Machine-readable doctor output** — `dispatch doctor --json` prints diagnostics as a single JSON object so scripts and CI can parse them instead of scraping text - **Search query argument** — pass a search string on the command line (`dispatch auth` or `dispatch fix auth bug`) to launch the TUI with the search box pre-filled and the list already filtered +- **Open working directory** (`O`) — open the selected session's working directory in the system file manager (Explorer, Finder, or the Linux file manager) ## [v0.13.0] — 2026-06-30 diff --git a/README.md b/README.md index e2ef92a..718b6e2 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Dispatch reads your local Copilot CLI session store and presents every past sess - **Time range filtering** (`1`–`4`) — 1 hour, 1 day, 7 days, all - **Preview panel** (`p`) — metadata, chat-style conversation bubbles, checkpoints (up to 5), files (up to 5), refs (up to 5), scroll indicators. Toggle conversation sort order with `o`. Click the session ID row to copy it to clipboard - **Copy session ID** (`c`) — copy the selected session's ID to the system clipboard. Also available by clicking the ID row in the preview pane +- **Open working directory** (`O`) — open the selected session's working directory in the system file manager (Explorer on Windows, Finder on macOS, the default file manager on Linux) - **Four launch modes** (`Enter` / `t` / `w` / `e`) — in-place, new tab, new window, split pane (Windows Terminal) with per-session overrides - **Multi-session open** (`Space` / `L` / `a` / `d`) — select multiple sessions with Space, launch all at once with L, select/deselect all with a/d. Shift+↑/↓ for range selection, Ctrl+click and Shift+click for mouse selection - **Attention indicators** — colored dots showing real-time session status: working (blue, executing tools), thinking (cyan, generating response), compacting (magenta, context compaction), waiting (purple), active (green), stale (yellow), interrupted (orange ⚡), idle (gray). Jump to next waiting session with `n`, resume interrupted sessions with `R`, filter by status with `!` @@ -215,6 +216,7 @@ Add `--json` (`dispatch doctor --json`) to print the same checks as a single JSO | `v` | View plan in preview pane | | `o` | Toggle conversation sort order (oldest/newest first) | | `c` | Copy session ID to clipboard | +| `O` | Open session working directory in file manager | | `PgUp` / `PgDn` | Scroll preview | | `r` | Refresh session store | | `,` | Open settings panel | diff --git a/internal/platform/open.go b/internal/platform/open.go index 9f80b65..34537ae 100644 --- a/internal/platform/open.go +++ b/internal/platform/open.go @@ -2,23 +2,46 @@ package platform import ( "context" + "fmt" + "os" "os/exec" "runtime" ) -// OpenFile opens the given file path using the platform default application. -// On Windows it uses explorer.exe (avoids cmd.exe metacharacter injection), -// on macOS "open", and on Linux "xdg-open". -func OpenFile(path string) error { - ctx := context.Background() - var cmd *exec.Cmd +// openCommand builds the platform command used to open a path with the +// default handler: explorer.exe on Windows (which avoids cmd.exe +// metacharacter injection), "open" on macOS, and "xdg-open" elsewhere. +func openCommand(ctx context.Context, path string) *exec.Cmd { switch runtime.GOOS { case "windows": - cmd = exec.CommandContext(ctx, "explorer", path) + return exec.CommandContext(ctx, "explorer", path) case "darwin": - cmd = exec.CommandContext(ctx, "open", path) + return exec.CommandContext(ctx, "open", path) default: - cmd = exec.CommandContext(ctx, "xdg-open", path) + return exec.CommandContext(ctx, "xdg-open", path) + } +} + +// OpenFile opens the given file path using the platform default application. +// On Windows it uses explorer.exe (avoids cmd.exe metacharacter injection), +// on macOS "open", and on Linux "xdg-open". +func OpenFile(path string) error { + return openCommand(context.Background(), path).Start() +} + +// OpenDir opens the given directory in the platform file manager. It returns +// an error if the path is empty or is not an existing directory, so callers +// can surface a clear message instead of spawning against a bad path. +func OpenDir(path string) error { + if path == "" { + return fmt.Errorf("no directory to open") + } + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("directory not found: %s", path) + } + if !info.IsDir() { + return fmt.Errorf("not a directory: %s", path) } - return cmd.Start() + return openCommand(context.Background(), path).Start() } diff --git a/internal/platform/open_test.go b/internal/platform/open_test.go index a428465..cc8fa61 100644 --- a/internal/platform/open_test.go +++ b/internal/platform/open_test.go @@ -1,6 +1,10 @@ package platform import ( + "context" + "os" + "path/filepath" + "runtime" "testing" ) @@ -15,3 +19,52 @@ func TestOpenFile_NonExistentPath(t *testing.T) { // We just verify no panic occurred. _ = err } + +func TestOpenCommand_PerOS(t *testing.T) { + t.Parallel() + cmd := openCommand(context.Background(), "/some/path") + if len(cmd.Args) == 0 { + t.Fatal("expected command args") + } + var want string + switch runtime.GOOS { + case "windows": + want = "explorer" + case "darwin": + want = "open" + default: + want = "xdg-open" + } + if got := filepath.Base(cmd.Args[0]); got != want { + t.Errorf("openCommand on %s = %q, want %q", runtime.GOOS, got, want) + } + last := cmd.Args[len(cmd.Args)-1] + if last != "/some/path" { + t.Errorf("openCommand path arg = %q, want %q", last, "/some/path") + } +} + +func TestOpenDir_EmptyPath(t *testing.T) { + t.Parallel() + if err := OpenDir(""); err == nil { + t.Error("expected error for empty path") + } +} + +func TestOpenDir_MissingPath(t *testing.T) { + t.Parallel() + if err := OpenDir(filepath.Join(t.TempDir(), "does-not-exist")); err == nil { + t.Error("expected error for missing path") + } +} + +func TestOpenDir_FileNotDir(t *testing.T) { + t.Parallel() + f := filepath.Join(t.TempDir(), "file.txt") + if err := os.WriteFile(f, []byte("x"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + if err := OpenDir(f); err == nil { + t.Error("expected error when path is a file, not a directory") + } +} diff --git a/internal/tui/handlers.go b/internal/tui/handlers.go index cd39011..71c0c6c 100644 --- a/internal/tui/handlers.go +++ b/internal/tui/handlers.go @@ -153,6 +153,17 @@ func (m Model) handleFileOpened(msg fileOpenedMsg) (Model, tea.Cmd) { return m, clearStatusAfter(2 * time.Second) } +// ----- Directory opened result --------------------------------------------- + +func (m Model) handleDirOpened(msg dirOpenedMsg) (Model, tea.Cmd) { + if msg.err != nil { + m.statusErr = msg.err.Error() + return m, clearStatusAfter(2 * time.Second) + } + m.statusInfo = "Opened " + msg.path + return m, clearStatusAfter(2 * time.Second) +} + // ----- Pending click fire (single-click debounce) -------------------------- func (m Model) handlePendingClickFire(msg pendingClickFireMsg) (Model, tea.Cmd) { diff --git a/internal/tui/handlers_test.go b/internal/tui/handlers_test.go index 16dd69c..8cbf428 100644 --- a/internal/tui/handlers_test.go +++ b/internal/tui/handlers_test.go @@ -1,6 +1,7 @@ package tui import ( + "errors" "testing" tea "charm.land/bubbletea/v2" @@ -9,6 +10,42 @@ import ( "github.com/jongio/dispatch/internal/tui/components" ) +var errTestOpenDir = errors.New("directory not found: /tmp/work") + +// --------------------------------------------------------------------------- +// handleDirOpened +// --------------------------------------------------------------------------- + +func TestHandleDirOpened_Success(t *testing.T) { + m := newTestModelWithSize(120, 30) + m2, cmd := m.handleDirOpened(dirOpenedMsg{path: "/tmp/work"}) + + if m2.statusErr != "" { + t.Errorf("statusErr should be empty on success, got %q", m2.statusErr) + } + if m2.statusInfo != "Opened /tmp/work" { + t.Errorf("statusInfo = %q, want %q", m2.statusInfo, "Opened /tmp/work") + } + if cmd == nil { + t.Error("expected a clear-status command") + } +} + +func TestHandleDirOpened_Error(t *testing.T) { + m := newTestModelWithSize(120, 30) + m2, cmd := m.handleDirOpened(dirOpenedMsg{path: "/tmp/work", err: errTestOpenDir}) + + if m2.statusInfo != "" { + t.Errorf("statusInfo should be empty on error, got %q", m2.statusInfo) + } + if m2.statusErr != errTestOpenDir.Error() { + t.Errorf("statusErr = %q, want %q", m2.statusErr, errTestOpenDir.Error()) + } + if cmd == nil { + t.Error("expected a clear-status command") + } +} + // --------------------------------------------------------------------------- // handleBackgroundColor // --------------------------------------------------------------------------- diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 3068b58..8d6a49e 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -56,6 +56,7 @@ type keyMap struct { ShiftDown key.Binding ViewSwitch key.Binding OpenFile key.Binding + OpenDir key.Binding Timeline key.Binding Compare key.Binding CmdPalette key.Binding @@ -63,7 +64,7 @@ type keyMap struct { // ShortHelp returns a compact set of key bindings for the mini help bar. func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Enter, k.LaunchWindow, k.LaunchTab, k.LaunchPane, k.LaunchAll, k.Search, k.Filter, k.Sort, k.Preview, k.ViewPlan, k.Timeline, k.Compare, k.Hide, k.Star, k.Note, k.CopyID, k.CopyPath, k.CopyPreview, k.Export, k.OpenFile, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted, k.ScanWorkStatus, k.ExpandCollapseAll, k.ViewSwitch, k.CmdPalette, k.Config, k.Help, k.Quit} + return []key.Binding{k.Enter, k.LaunchWindow, k.LaunchTab, k.LaunchPane, k.LaunchAll, k.Search, k.Filter, k.Sort, k.Preview, k.ViewPlan, k.Timeline, k.Compare, k.Hide, k.Star, k.Note, k.CopyID, k.CopyPath, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted, k.ScanWorkStatus, k.ExpandCollapseAll, k.ViewSwitch, k.CmdPalette, k.Config, k.Help, k.Quit} } // FullHelp returns grouped key bindings for the expanded help view. @@ -73,7 +74,7 @@ func (k keyMap) FullHelp() [][]key.Binding { {k.Space, k.LaunchAll, k.SelectAll, k.DeselectAll, k.ShiftUp, k.ShiftDown}, {k.Search, k.Escape, k.Filter}, {k.Sort, k.SortOrder, k.Pivot, k.PivotOrder, k.ExpandCollapseAll}, - {k.Preview, k.PreviewPosition, k.PreviewScrollUp, k.PreviewScrollDown, k.ConversationSort, k.ViewPlan, k.Timeline, k.Compare, k.CopyID, k.CopyPath, k.CopyPreview, k.Export, k.OpenFile, k.Reindex, k.ScanWorkStatus, k.ViewSwitch, k.Config}, + {k.Preview, k.PreviewPosition, k.PreviewScrollUp, k.PreviewScrollDown, k.ConversationSort, k.ViewPlan, k.Timeline, k.Compare, k.CopyID, k.CopyPath, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.Reindex, k.ScanWorkStatus, k.ViewSwitch, k.Config}, {k.Hide, k.ToggleHidden, k.Star, k.Note, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted}, {k.TimeRange1, k.TimeRange2, k.TimeRange3, k.TimeRange4}, {k.Help, k.CmdPalette, k.Quit}, @@ -132,6 +133,7 @@ var keys = keyMap{ ShiftDown: key.NewBinding(key.WithKeys("shift+down"), key.WithHelp("shift+\u2193", "extend select down")), ViewSwitch: key.NewBinding(key.WithKeys("V"), key.WithHelp("V", "switch view")), OpenFile: key.NewBinding(key.WithKeys("F"), key.WithHelp("F", "open file")), + OpenDir: key.NewBinding(key.WithKeys("O"), key.WithHelp("O", "open directory")), Timeline: key.NewBinding(key.WithKeys("T"), key.WithHelp("T", "activity timeline")), Compare: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "compare selected")), CmdPalette: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "command palette")), diff --git a/internal/tui/messages.go b/internal/tui/messages.go index dd3b621..ee6f4dd 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -187,6 +187,12 @@ type fileOpenedMsg struct { err error } +// dirOpenedMsg reports the result of opening a directory in the file manager. +type dirOpenedMsg struct { + path string + err error +} + // compareDetailMsg delivers two session details for side-by-side comparison. type compareDetailMsg struct { left *data.SessionDetail diff --git a/internal/tui/model.go b/internal/tui/model.go index 8c6267f..c4e2758 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -618,6 +618,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case fileOpenedMsg: return m.handleFileOpened(msg) + case dirOpenedMsg: + return m.handleDirOpened(msg) + case compareDetailMsg: return m.handleCompareDetail(msg) @@ -1438,6 +1441,14 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } return m, nil + case key.Matches(msg, keys.OpenDir): + cwd := m.selectedSessionCwd() + if cwd == "" { + m.statusErr = "No working directory for this session" + return m, clearStatusAfter(2 * time.Second) + } + return m, m.openDirCmd(cwd) + case key.Matches(msg, keys.Space): m.sessionList.ToggleSelected() m.updateSelectionStatus() @@ -4067,6 +4078,15 @@ func (m Model) openFileCmd(path string) tea.Cmd { } } +// openDirCmd opens a directory in the platform file manager. Validation of the +// path lives in platform.OpenDir so the failure message is consistent. +func (m Model) openDirCmd(path string) tea.Cmd { + return func() tea.Msg { + err := platform.OpenDir(path) + return dirOpenedMsg{path: path, err: err} + } +} + // --------------------------------------------------------------------------- // Group sorting helpers // ---------------------------------------------------------------------------