Skip to content

Commit 1e41b4f

Browse files
jongioCopilot
andcommitted
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 <[email protected]>
1 parent f177c6d commit 1e41b4f

9 files changed

Lines changed: 171 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
66

7+
## [Unreleased]
8+
9+
### Added
10+
- **Open working directory** (`O`) — open the selected session's working directory in the system file manager (Explorer, Finder, or the Linux file manager)
11+
712
## [v0.13.0] — 2026-06-30
813

914
### Added

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Dispatch reads your local Copilot CLI session store and presents every past sess
2626
- **Time range filtering** (`1``4`) — 1 hour, 1 day, 7 days, all
2727
- **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
2828
- **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
29+
- **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)
2930
- **Four launch modes** (`Enter` / `t` / `w` / `e`) — in-place, new tab, new window, split pane (Windows Terminal) with per-session overrides
3031
- **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
3132
- **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 `!`
@@ -206,6 +207,7 @@ Run `dispatch doctor` to print setup checks for the config file, session store,
206207
| `v` | View plan in preview pane |
207208
| `o` | Toggle conversation sort order (oldest/newest first) |
208209
| `c` | Copy session ID to clipboard |
210+
| `O` | Open session working directory in file manager |
209211
| `PgUp` / `PgDn` | Scroll preview |
210212
| `r` | Refresh session store |
211213
| `,` | Open settings panel |

internal/platform/open.go

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,46 @@ package platform
22

33
import (
44
"context"
5+
"fmt"
6+
"os"
57
"os/exec"
68
"runtime"
79
)
810

9-
// OpenFile opens the given file path using the platform default application.
10-
// On Windows it uses explorer.exe (avoids cmd.exe metacharacter injection),
11-
// on macOS "open", and on Linux "xdg-open".
12-
func OpenFile(path string) error {
13-
ctx := context.Background()
14-
var cmd *exec.Cmd
11+
// openCommand builds the platform command used to open a path with the
12+
// default handler: explorer.exe on Windows (which avoids cmd.exe
13+
// metacharacter injection), "open" on macOS, and "xdg-open" elsewhere.
14+
func openCommand(ctx context.Context, path string) *exec.Cmd {
1515
switch runtime.GOOS {
1616
case "windows":
17-
cmd = exec.CommandContext(ctx, "explorer", path)
17+
return exec.CommandContext(ctx, "explorer", path)
1818
case "darwin":
19-
cmd = exec.CommandContext(ctx, "open", path)
19+
return exec.CommandContext(ctx, "open", path)
2020
default:
21-
cmd = exec.CommandContext(ctx, "xdg-open", path)
21+
return exec.CommandContext(ctx, "xdg-open", path)
22+
}
23+
}
24+
25+
// OpenFile opens the given file path using the platform default application.
26+
// On Windows it uses explorer.exe (avoids cmd.exe metacharacter injection),
27+
// on macOS "open", and on Linux "xdg-open".
28+
func OpenFile(path string) error {
29+
return openCommand(context.Background(), path).Start()
30+
}
31+
32+
// OpenDir opens the given directory in the platform file manager. It returns
33+
// an error if the path is empty or is not an existing directory, so callers
34+
// can surface a clear message instead of spawning against a bad path.
35+
func OpenDir(path string) error {
36+
if path == "" {
37+
return fmt.Errorf("no directory to open")
38+
}
39+
info, err := os.Stat(path)
40+
if err != nil {
41+
return fmt.Errorf("directory not found: %s", path)
42+
}
43+
if !info.IsDir() {
44+
return fmt.Errorf("not a directory: %s", path)
2245
}
23-
return cmd.Start()
46+
return openCommand(context.Background(), path).Start()
2447
}

internal/platform/open_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package platform
22

33
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"runtime"
48
"testing"
59
)
610

@@ -15,3 +19,52 @@ func TestOpenFile_NonExistentPath(t *testing.T) {
1519
// We just verify no panic occurred.
1620
_ = err
1721
}
22+
23+
func TestOpenCommand_PerOS(t *testing.T) {
24+
t.Parallel()
25+
cmd := openCommand(context.Background(), "/some/path")
26+
if len(cmd.Args) == 0 {
27+
t.Fatal("expected command args")
28+
}
29+
var want string
30+
switch runtime.GOOS {
31+
case "windows":
32+
want = "explorer"
33+
case "darwin":
34+
want = "open"
35+
default:
36+
want = "xdg-open"
37+
}
38+
if got := filepath.Base(cmd.Args[0]); got != want {
39+
t.Errorf("openCommand on %s = %q, want %q", runtime.GOOS, got, want)
40+
}
41+
last := cmd.Args[len(cmd.Args)-1]
42+
if last != "/some/path" {
43+
t.Errorf("openCommand path arg = %q, want %q", last, "/some/path")
44+
}
45+
}
46+
47+
func TestOpenDir_EmptyPath(t *testing.T) {
48+
t.Parallel()
49+
if err := OpenDir(""); err == nil {
50+
t.Error("expected error for empty path")
51+
}
52+
}
53+
54+
func TestOpenDir_MissingPath(t *testing.T) {
55+
t.Parallel()
56+
if err := OpenDir(filepath.Join(t.TempDir(), "does-not-exist")); err == nil {
57+
t.Error("expected error for missing path")
58+
}
59+
}
60+
61+
func TestOpenDir_FileNotDir(t *testing.T) {
62+
t.Parallel()
63+
f := filepath.Join(t.TempDir(), "file.txt")
64+
if err := os.WriteFile(f, []byte("x"), 0o600); err != nil {
65+
t.Fatalf("write temp file: %v", err)
66+
}
67+
if err := OpenDir(f); err == nil {
68+
t.Error("expected error when path is a file, not a directory")
69+
}
70+
}

internal/tui/handlers.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,17 @@ func (m Model) handleFileOpened(msg fileOpenedMsg) (Model, tea.Cmd) {
145145
return m, clearStatusAfter(2 * time.Second)
146146
}
147147

148+
// ----- Directory opened result ---------------------------------------------
149+
150+
func (m Model) handleDirOpened(msg dirOpenedMsg) (Model, tea.Cmd) {
151+
if msg.err != nil {
152+
m.statusErr = msg.err.Error()
153+
return m, clearStatusAfter(2 * time.Second)
154+
}
155+
m.statusInfo = "Opened " + msg.path
156+
return m, clearStatusAfter(2 * time.Second)
157+
}
158+
148159
// ----- Pending click fire (single-click debounce) --------------------------
149160

150161
func (m Model) handlePendingClickFire(msg pendingClickFireMsg) (Model, tea.Cmd) {

internal/tui/handlers_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package tui
22

33
import (
4+
"errors"
45
"testing"
56

67
tea "charm.land/bubbletea/v2"
@@ -9,6 +10,42 @@ import (
910
"github.com/jongio/dispatch/internal/tui/components"
1011
)
1112

13+
var errTestOpenDir = errors.New("directory not found: /tmp/work")
14+
15+
// ---------------------------------------------------------------------------
16+
// handleDirOpened
17+
// ---------------------------------------------------------------------------
18+
19+
func TestHandleDirOpened_Success(t *testing.T) {
20+
m := newTestModelWithSize(120, 30)
21+
m2, cmd := m.handleDirOpened(dirOpenedMsg{path: "/tmp/work"})
22+
23+
if m2.statusErr != "" {
24+
t.Errorf("statusErr should be empty on success, got %q", m2.statusErr)
25+
}
26+
if m2.statusInfo != "Opened /tmp/work" {
27+
t.Errorf("statusInfo = %q, want %q", m2.statusInfo, "Opened /tmp/work")
28+
}
29+
if cmd == nil {
30+
t.Error("expected a clear-status command")
31+
}
32+
}
33+
34+
func TestHandleDirOpened_Error(t *testing.T) {
35+
m := newTestModelWithSize(120, 30)
36+
m2, cmd := m.handleDirOpened(dirOpenedMsg{path: "/tmp/work", err: errTestOpenDir})
37+
38+
if m2.statusInfo != "" {
39+
t.Errorf("statusInfo should be empty on error, got %q", m2.statusInfo)
40+
}
41+
if m2.statusErr != errTestOpenDir.Error() {
42+
t.Errorf("statusErr = %q, want %q", m2.statusErr, errTestOpenDir.Error())
43+
}
44+
if cmd == nil {
45+
t.Error("expected a clear-status command")
46+
}
47+
}
48+
1249
// ---------------------------------------------------------------------------
1350
// handleBackgroundColor
1451
// ---------------------------------------------------------------------------

internal/tui/keys.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,15 @@ type keyMap struct {
5555
ShiftDown key.Binding
5656
ViewSwitch key.Binding
5757
OpenFile key.Binding
58+
OpenDir key.Binding
5859
Timeline key.Binding
5960
Compare key.Binding
6061
CmdPalette key.Binding
6162
}
6263

6364
// ShortHelp returns a compact set of key bindings for the mini help bar.
6465
func (k keyMap) ShortHelp() []key.Binding {
65-
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.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}
66+
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.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}
6667
}
6768

6869
// FullHelp returns grouped key bindings for the expanded help view.
@@ -72,7 +73,7 @@ func (k keyMap) FullHelp() [][]key.Binding {
7273
{k.Space, k.LaunchAll, k.SelectAll, k.DeselectAll, k.ShiftUp, k.ShiftDown},
7374
{k.Search, k.Escape, k.Filter},
7475
{k.Sort, k.SortOrder, k.Pivot, k.PivotOrder, k.ExpandCollapseAll},
75-
{k.Preview, k.PreviewPosition, k.PreviewScrollUp, k.PreviewScrollDown, k.ConversationSort, k.ViewPlan, k.Timeline, k.Compare, k.CopyID, k.CopyPreview, k.Export, k.OpenFile, k.Reindex, k.ScanWorkStatus, k.ViewSwitch, k.Config},
76+
{k.Preview, k.PreviewPosition, k.PreviewScrollUp, k.PreviewScrollDown, k.ConversationSort, k.ViewPlan, k.Timeline, k.Compare, k.CopyID, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.Reindex, k.ScanWorkStatus, k.ViewSwitch, k.Config},
7677
{k.Hide, k.ToggleHidden, k.Star, k.Note, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted},
7778
{k.TimeRange1, k.TimeRange2, k.TimeRange3, k.TimeRange4},
7879
{k.Help, k.CmdPalette, k.Quit},
@@ -130,6 +131,7 @@ var keys = keyMap{
130131
ShiftDown: key.NewBinding(key.WithKeys("shift+down"), key.WithHelp("shift+\u2193", "extend select down")),
131132
ViewSwitch: key.NewBinding(key.WithKeys("V"), key.WithHelp("V", "switch view")),
132133
OpenFile: key.NewBinding(key.WithKeys("F"), key.WithHelp("F", "open file")),
134+
OpenDir: key.NewBinding(key.WithKeys("O"), key.WithHelp("O", "open directory")),
133135
Timeline: key.NewBinding(key.WithKeys("T"), key.WithHelp("T", "activity timeline")),
134136
Compare: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "compare selected")),
135137
CmdPalette: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "command palette")),

internal/tui/messages.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,12 @@ type fileOpenedMsg struct {
187187
err error
188188
}
189189

190+
// dirOpenedMsg reports the result of opening a directory in the file manager.
191+
type dirOpenedMsg struct {
192+
path string
193+
err error
194+
}
195+
190196
// compareDetailMsg delivers two session details for side-by-side comparison.
191197
type compareDetailMsg struct {
192198
left *data.SessionDetail

internal/tui/model.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
483483
case fileOpenedMsg:
484484
return m.handleFileOpened(msg)
485485

486+
case dirOpenedMsg:
487+
return m.handleDirOpened(msg)
488+
486489
case compareDetailMsg:
487490
return m.handleCompareDetail(msg)
488491

@@ -1300,6 +1303,14 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
13001303
}
13011304
return m, nil
13021305

1306+
case key.Matches(msg, keys.OpenDir):
1307+
cwd := m.selectedSessionCwd()
1308+
if cwd == "" {
1309+
m.statusErr = "No working directory for this session"
1310+
return m, clearStatusAfter(2 * time.Second)
1311+
}
1312+
return m, m.openDirCmd(cwd)
1313+
13031314
case key.Matches(msg, keys.Space):
13041315
m.sessionList.ToggleSelected()
13051316
m.updateSelectionStatus()
@@ -3881,6 +3892,15 @@ func (m Model) openFileCmd(path string) tea.Cmd {
38813892
}
38823893
}
38833894

3895+
// openDirCmd opens a directory in the platform file manager. Validation of the
3896+
// path lives in platform.OpenDir so the failure message is consistent.
3897+
func (m Model) openDirCmd(path string) tea.Cmd {
3898+
return func() tea.Msg {
3899+
err := platform.OpenDir(path)
3900+
return dirOpenedMsg{path: path, err: err}
3901+
}
3902+
}
3903+
38843904
// ---------------------------------------------------------------------------
38853905
// Group sorting helpers
38863906
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)