diff --git a/README.md b/README.md index 6a664d6..6312973 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Dispatch reads your local Copilot CLI session store and presents every past sess - **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 - **Copy resume command** (`Y`) — copy the selected session's full resume command to the system clipboard. With a multi-select active, copies one resume command per selected session, one per line - **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) +- **Open linked reference** (`b`) — open the selected session's linked pull request, issue, or commit on github.com in your browser. Picks the pull request first, then the issue, then the commit - **Four launch modes** (`Enter` / `t` / `w` / `e`) — in-place, new tab, new window, split pane (Windows Terminal, or tmux when running inside a tmux session) 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. With a selection active, `h` (hide), `*` (favorite), and `Y` (copy resume command) apply to every selected session at once - **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 `!` @@ -312,6 +313,7 @@ Dates accept `YYYY-MM-DD` or full RFC3339 timestamps (e.g. `after:2024-01-15` or | `o` | Toggle conversation sort order (oldest/newest first) | | `c` | Copy session ID to clipboard | | `O` | Open session working directory in file manager | +| `g` | Open linked PR, issue, or commit in browser | | `Y` | Copy resume command to clipboard | | `PgUp` / `PgDn` | Scroll preview | | `r` | Refresh session store | diff --git a/docs/keybindings.md b/docs/keybindings.md index e493cc0..b576219 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -224,6 +224,13 @@ - Behavior: Expands the preview to fill the entire content area and hides the session list so a long conversation is easier to read. Press z again or Esc to restore the split layout. PgUp/PgDn scrolling still works, and toggling on shows the preview even when the split preview is hidden. - Condition: In session list view +20g. **b** → Open Linked Reference in Browser + - File: internal\tui\keys.go + - Code: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "open PR/issue/commit")) + - Handler: internal\tui\handlers.go (handleOpenRef) + - Behavior: Opens the selected session's linked reference on github.com in the default browser. Chooses the pull request first, then the issue, then the commit. Only http/https URLs are opened. + - Condition: Only when the selected session has a repository and at least one PR, issue, or commit reference + 21. **PgUp (Page Up)** → Preview Panel Scroll Up - File: internal\tui\keys.go (line 85) - Code: key.NewBinding(key.WithKeys("pgup")) diff --git a/internal/data/refurl.go b/internal/data/refurl.go new file mode 100644 index 0000000..a341ed6 --- /dev/null +++ b/internal/data/refurl.go @@ -0,0 +1,144 @@ +package data + +import ( + "strings" +) + +// refTypePriority ranks reference types when picking the single best ref to +// open for a session. A pull request is the most useful landing page, then the +// issue, then the commit. +var refTypePriority = map[string]int{ + "pr": 3, + "issue": 2, + "commit": 1, +} + +// BestRef returns the most useful reference to open for a session, preferring a +// pull request over an issue over a commit. The second return value is false +// when the slice has no usable references. +func BestRef(refs []SessionRef) (SessionRef, bool) { + best := SessionRef{} + bestRank := 0 + for _, r := range refs { + if strings.TrimSpace(r.RefValue) == "" { + continue + } + rank := refTypePriority[strings.ToLower(r.RefType)] + if rank == 0 { + continue + } + if rank > bestRank { + best = r + bestRank = rank + } + } + return best, bestRank > 0 +} + +// RefURL builds a github.com URL for a session reference. The repository may be +// an "owner/repo" slug or a git remote URL (https or ssh form); anything else +// yields ok=false. Pull request and issue values are reduced to their digits so +// stored forms like "#42" or "PR42" still resolve. Commit values are kept as-is +// after a basic hex-character check. It returns ok=false when a valid URL +// cannot be built. +func RefURL(repository, refType, refValue string) (url string, ok bool) { + slug := normalizeRepoSlug(repository) + if slug == "" { + return "", false + } + value := strings.TrimSpace(refValue) + if value == "" { + return "", false + } + base := "https://github.com/" + slug + switch strings.ToLower(refType) { + case "pr": + n := digitsOnly(value) + if n == "" { + return "", false + } + return base + "/pull/" + n, true + case "issue": + n := digitsOnly(value) + if n == "" { + return "", false + } + return base + "/issues/" + n, true + case "commit": + if !isHex(value) { + return "", false + } + return base + "/commit/" + value, true + default: + return "", false + } +} + +// normalizeRepoSlug reduces a repository identifier to its "owner/repo" form. +// It accepts a bare slug, an https URL, a scp-style ssh remote, or a +// github.com-prefixed path, and returns "" when it cannot extract owner/repo. +func normalizeRepoSlug(repository string) string { + s := strings.TrimSpace(repository) + if s == "" { + return "" + } + s = strings.TrimSuffix(s, ".git") + + // scp-style: git@github.com:owner/repo + if i := strings.Index(s, "@"); i >= 0 { + if j := strings.Index(s[i:], ":"); j >= 0 { + s = s[i+j+1:] + } + } + + // Strip a scheme like https:// or ssh://. + if i := strings.Index(s, "://"); i >= 0 { + s = s[i+3:] + } + + // Drop a leading host segment (github.com, github.com:443, etc.). + if i := strings.Index(s, "github.com"); i >= 0 { + s = s[i+len("github.com"):] + } + s = strings.TrimLeft(s, ":/") + + parts := strings.Split(s, "/") + if len(parts) < 2 { + return "" + } + owner := strings.TrimSpace(parts[0]) + repo := strings.TrimSpace(parts[1]) + if owner == "" || repo == "" { + return "" + } + return owner + "/" + repo +} + +// digitsOnly returns the digit runes of s, or "" if s has none. +func digitsOnly(s string) string { + var b strings.Builder + for _, r := range s { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + return b.String() +} + +// isHex reports whether s is non-empty and made up only of hex digits, which is +// the shape of a git commit SHA. +func isHex(s string) bool { + if s == "" { + return false + } + for _, r := range s { + switch { + case r >= '0' && r <= '9': + case r >= 'a' && r <= 'f': + case r >= 'A' && r <= 'F': + default: + return false + } + } + return true +} diff --git a/internal/data/refurl_test.go b/internal/data/refurl_test.go new file mode 100644 index 0000000..2ba610f --- /dev/null +++ b/internal/data/refurl_test.go @@ -0,0 +1,108 @@ +package data + +import "testing" + +func TestRefURL(t *testing.T) { + t.Parallel() + tests := []struct { + name string + repo string + refType string + refValue string + want string + wantOK bool + }{ + {"pr slug", "owner/repo", "pr", "42", "https://github.com/owner/repo/pull/42", true}, + {"issue slug", "owner/repo", "issue", "41", "https://github.com/owner/repo/issues/41", true}, + {"commit slug", "owner/repo", "commit", "a1b2c3d", "https://github.com/owner/repo/commit/a1b2c3d", true}, + {"pr with hash", "owner/repo", "pr", "#42", "https://github.com/owner/repo/pull/42", true}, + {"pr prefixed", "owner/repo", "PR", "PR42", "https://github.com/owner/repo/pull/42", true}, + {"https remote", "https://github.com/owner/repo.git", "pr", "7", "https://github.com/owner/repo/pull/7", true}, + {"ssh remote", "git@github.com:owner/repo.git", "issue", "9", "https://github.com/owner/repo/issues/9", true}, + {"github path", "github.com/owner/repo", "commit", "cafebabe", "https://github.com/owner/repo/commit/cafebabe", true}, + {"empty repo", "", "pr", "42", "", false}, + {"repo no slash", "justowner", "pr", "42", "", false}, + {"empty value", "owner/repo", "pr", "", "", false}, + {"pr non-numeric", "owner/repo", "pr", "abc", "", false}, + {"commit non-hex", "owner/repo", "commit", "zzzz", "", false}, + {"unknown type", "owner/repo", "branch", "main", "", false}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, ok := RefURL(tt.repo, tt.refType, tt.refValue) + if ok != tt.wantOK { + t.Fatalf("RefURL(%q,%q,%q) ok = %v, want %v", tt.repo, tt.refType, tt.refValue, ok, tt.wantOK) + } + if got != tt.want { + t.Errorf("RefURL(%q,%q,%q) = %q, want %q", tt.repo, tt.refType, tt.refValue, got, tt.want) + } + }) + } +} + +func TestBestRef(t *testing.T) { + t.Parallel() + tests := []struct { + name string + refs []SessionRef + wantType string + wantValue string + wantOK bool + }{ + { + name: "prefers pr over issue and commit", + refs: []SessionRef{ + {RefType: "commit", RefValue: "abc123"}, + {RefType: "issue", RefValue: "10"}, + {RefType: "pr", RefValue: "42"}, + }, + wantType: "pr", wantValue: "42", wantOK: true, + }, + { + name: "prefers issue over commit", + refs: []SessionRef{ + {RefType: "commit", RefValue: "abc123"}, + {RefType: "issue", RefValue: "10"}, + }, + wantType: "issue", wantValue: "10", wantOK: true, + }, + { + name: "commit only", + refs: []SessionRef{{RefType: "commit", RefValue: "abc123"}}, + wantType: "commit", wantValue: "abc123", wantOK: true, + }, + { + name: "empty", + refs: nil, + wantOK: false, + }, + { + name: "blank values ignored", + refs: []SessionRef{{RefType: "pr", RefValue: " "}}, + wantOK: false, + }, + { + name: "unknown types ignored", + refs: []SessionRef{{RefType: "branch", RefValue: "main"}}, + wantOK: false, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, ok := BestRef(tt.refs) + if ok != tt.wantOK { + t.Fatalf("BestRef() ok = %v, want %v", ok, tt.wantOK) + } + if !ok { + return + } + if got.RefType != tt.wantType || got.RefValue != tt.wantValue { + t.Errorf("BestRef() = {%q,%q}, want {%q,%q}", got.RefType, got.RefValue, tt.wantType, tt.wantValue) + } + }) + } +} diff --git a/internal/platform/open.go b/internal/platform/open.go index 34537ae..5fbe943 100644 --- a/internal/platform/open.go +++ b/internal/platform/open.go @@ -3,6 +3,7 @@ package platform import ( "context" "fmt" + "net/url" "os" "os/exec" "runtime" @@ -45,3 +46,23 @@ func OpenDir(path string) error { } return openCommand(context.Background(), path).Start() } + +// OpenURL opens the given URL in the platform default browser. Only absolute +// http and https URLs are allowed, so a malformed or non-web value cannot be +// handed to the OS opener (which could otherwise launch an unexpected handler). +func OpenURL(rawURL string) error { + if rawURL == "" { + return fmt.Errorf("no URL to open") + } + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL: %s", rawURL) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("refusing to open non-http URL: %s", rawURL) + } + if u.Host == "" { + return fmt.Errorf("invalid URL: %s", rawURL) + } + return openCommand(context.Background(), rawURL).Start() +} diff --git a/internal/platform/open_test.go b/internal/platform/open_test.go index cc8fa61..bf4de74 100644 --- a/internal/platform/open_test.go +++ b/internal/platform/open_test.go @@ -68,3 +68,26 @@ func TestOpenDir_FileNotDir(t *testing.T) { t.Error("expected error when path is a file, not a directory") } } + +func TestOpenURL_RejectsInvalid(t *testing.T) { + t.Parallel() + tests := []struct { + name string + url string + }{ + {"empty", ""}, + {"no scheme", "github.com/owner/repo"}, + {"file scheme", "file:///etc/passwd"}, + {"javascript scheme", "javascript:alert(1)"}, + {"no host", "https://"}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := OpenURL(tt.url); err == nil { + t.Errorf("OpenURL(%q) = nil, want error", tt.url) + } + }) + } +} diff --git a/internal/tui/cmdpalette.go b/internal/tui/cmdpalette.go index ac8b1c0..6afd028 100644 --- a/internal/tui/cmdpalette.go +++ b/internal/tui/cmdpalette.go @@ -7,6 +7,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/data" "github.com/jongio/dispatch/internal/tui/components" ) @@ -20,6 +21,13 @@ func (m *Model) openCmdPalette() { hasPreview := func() bool { return m.showPreview && m.detail != nil } + hasRef := func() bool { + if m.detail == nil { + return false + } + _, ok := data.BestRef(m.detail.Refs) + return ok + } hasPath := func() bool { if _, ok := m.sessionList.Selected(); ok { return true @@ -43,6 +51,7 @@ func (m *Model) openCmdPalette() { {Name: "Toggle Favorite", Shortcut: "*", Description: "star session", Action: "star", Enabled: hasSelection}, {Name: "Hide Session", Shortcut: "h", Description: "hide from list", Action: "hide", Enabled: hasSelection}, {Name: "Export Markdown", Shortcut: "X", Description: "export session", Action: "export", Enabled: hasSelection}, + {Name: "Open Reference", Shortcut: "b", Description: "open PR/issue/commit", Action: "open-ref", Enabled: hasRef}, {Name: "Rebuild Index", Shortcut: "r", Description: "reindex sessions", Action: "reindex", Enabled: func() bool { return !m.reindexing }}, {Name: "Settings", Shortcut: ",", Description: "open config", Action: "settings", Enabled: alwaysEnabled}, {Name: "Help", Shortcut: "?", Description: "keyboard shortcuts", Action: "help", Enabled: alwaysEnabled}, @@ -123,6 +132,9 @@ func (m Model) handleCmdPaletteAction(msg cmdPaletteActionMsg) (tea.Model, tea.C case "export": return m.handleExport() + case "open-ref": + return m.handleOpenRef() + case "reindex": if !m.reindexing { m.reindexing = true diff --git a/internal/tui/handlers.go b/internal/tui/handlers.go index cebe9c7..c618341 100644 --- a/internal/tui/handlers.go +++ b/internal/tui/handlers.go @@ -165,6 +165,43 @@ func (m Model) handleDirOpened(msg dirOpenedMsg) (Model, tea.Cmd) { return m, clearStatusAfter(2 * time.Second) } +// ----- Reference opened result --------------------------------------------- + +// handleOpenRef opens the current session's most relevant linked reference (a +// pull request, then an issue, then a commit) in the browser. +func (m Model) handleOpenRef() (Model, tea.Cmd) { + if m.detail == nil { + m.statusErr = "No session selected" + return m, clearStatusAfter(2 * time.Second) + } + ref, ok := data.BestRef(m.detail.Refs) + if !ok { + m.statusErr = "No linked PR, issue, or commit for this session" + return m, clearStatusAfter(2 * time.Second) + } + repo := m.detail.Session.Repository + if repo == "" { + m.statusErr = "No repository recorded for this session" + return m, clearStatusAfter(2 * time.Second) + } + url, ok := data.RefURL(repo, ref.RefType, ref.RefValue) + if !ok { + m.statusErr = "Cannot build a URL for " + ref.RefType + " " + ref.RefValue + return m, clearStatusAfter(2 * time.Second) + } + label := ref.RefType + " " + ref.RefValue + return m, m.openRefCmd(url, label) +} + +func (m Model) handleRefOpened(msg refOpenedMsg) (Model, tea.Cmd) { + if msg.err != nil { + m.statusErr = msg.err.Error() + return m, clearStatusAfter(2 * time.Second) + } + m.statusInfo = "Opened " + msg.label + 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 8cbf428..0033a6a 100644 --- a/internal/tui/handlers_test.go +++ b/internal/tui/handlers_test.go @@ -46,6 +46,91 @@ func TestHandleDirOpened_Error(t *testing.T) { } } +// --------------------------------------------------------------------------- +// handleOpenRef / handleRefOpened +// --------------------------------------------------------------------------- + +func TestHandleOpenRef_NoDetail(t *testing.T) { + m := newTestModelWithSize(120, 30) + m.detail = nil + m2, cmd := m.handleOpenRef() + if m2.statusErr == "" { + t.Error("expected statusErr when no session is selected") + } + if cmd == nil { + t.Error("expected a clear-status command") + } +} + +func TestHandleOpenRef_NoRefs(t *testing.T) { + m := newTestModelWithSize(120, 30) + m.detail = &data.SessionDetail{ + Session: data.Session{ID: "s1", Repository: "owner/repo"}, + } + m2, _ := m.handleOpenRef() + if m2.statusErr == "" { + t.Error("expected statusErr when session has no linked refs") + } +} + +func TestHandleOpenRef_NoRepository(t *testing.T) { + m := newTestModelWithSize(120, 30) + m.detail = &data.SessionDetail{ + Session: data.Session{ID: "s1"}, + Refs: []data.SessionRef{{RefType: "pr", RefValue: "42"}}, + } + m2, _ := m.handleOpenRef() + if m2.statusErr == "" { + t.Error("expected statusErr when session has no repository") + } +} + +func TestHandleOpenRef_Success(t *testing.T) { + m := newTestModelWithSize(120, 30) + m.detail = &data.SessionDetail{ + Session: data.Session{ID: "s1", Repository: "owner/repo"}, + Refs: []data.SessionRef{ + {RefType: "commit", RefValue: "abc123"}, + {RefType: "pr", RefValue: "42"}, + }, + } + m2, cmd := m.handleOpenRef() + if m2.statusErr != "" { + t.Errorf("statusErr should be empty on success, got %q", m2.statusErr) + } + if cmd == nil { + t.Error("expected an open-ref command") + } +} + +func TestHandleRefOpened_Success(t *testing.T) { + m := newTestModelWithSize(120, 30) + m2, cmd := m.handleRefOpened(refOpenedMsg{label: "pr 42"}) + if m2.statusErr != "" { + t.Errorf("statusErr should be empty on success, got %q", m2.statusErr) + } + if m2.statusInfo != "Opened pr 42" { + t.Errorf("statusInfo = %q, want %q", m2.statusInfo, "Opened pr 42") + } + if cmd == nil { + t.Error("expected a clear-status command") + } +} + +func TestHandleRefOpened_Error(t *testing.T) { + m := newTestModelWithSize(120, 30) + m2, cmd := m.handleRefOpened(refOpenedMsg{label: "pr 42", 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 2246724..230936a 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -69,6 +69,7 @@ type keyMap struct { ViewSwitch key.Binding OpenFile key.Binding OpenDir key.Binding + OpenRef key.Binding Timeline key.Binding Compare key.Binding CmdPalette key.Binding @@ -76,7 +77,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.PreviewFullscreen, k.ViewPlan, k.Timeline, k.Compare, k.Hide, k.Star, k.Note, k.Tags, k.Alias, k.CopyID, k.CopyPath, k.CopyResumeCommand, 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} + return []key.Binding{k.Enter, k.LaunchWindow, k.LaunchTab, k.LaunchPane, k.LaunchAll, k.Search, k.Filter, k.Sort, k.Preview, k.PreviewFullscreen, k.ViewPlan, k.Timeline, k.Compare, k.Hide, k.Star, k.Note, k.Tags, k.Alias, k.CopyID, k.CopyPath, k.CopyResumeCommand, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.OpenRef, 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. @@ -86,7 +87,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.PreviewFullscreen, k.PreviewPosition, k.PreviewScrollUp, k.PreviewScrollDown, k.ConversationSort, k.ViewPlan, k.Timeline, k.Compare, k.CopyID, k.CopyPath, k.CopyResumeCommand, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.Reindex, k.ScanWorkStatus, k.ViewSwitch, k.Config}, + {k.Preview, k.PreviewFullscreen, k.PreviewPosition, k.PreviewScrollUp, k.PreviewScrollDown, k.ConversationSort, k.ViewPlan, k.Timeline, k.Compare, k.CopyID, k.CopyPath, k.CopyResumeCommand, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.OpenRef, k.Reindex, k.ScanWorkStatus, k.ViewSwitch, k.Config}, {k.Hide, k.ToggleHidden, k.Star, k.Note, k.Tags, k.Alias, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted}, {k.TimeRange1, k.TimeRange2, k.TimeRange3, k.TimeRange4}, {k.Help, k.CmdPalette, k.Quit}, @@ -157,6 +158,7 @@ func defaultKeyMap() keyMap { 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")), + OpenRef: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "open PR/issue/commit")), 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")), @@ -233,6 +235,7 @@ func keybindingEntries(km *keyMap) []keybindingEntry { {"view_switch", &km.ViewSwitch}, {"open_file", &km.OpenFile}, {"open_dir", &km.OpenDir}, + {"open_ref", &km.OpenRef}, {"timeline", &km.Timeline}, {"compare", &km.Compare}, {"cmd_palette", &km.CmdPalette}, diff --git a/internal/tui/messages.go b/internal/tui/messages.go index ee6f4dd..e6b49eb 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -193,6 +193,13 @@ type dirOpenedMsg struct { err error } +// refOpenedMsg reports the result of opening a session's linked PR, issue, or +// commit in the browser. +type refOpenedMsg struct { + label 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 2f5426c..aba56a1 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -663,6 +663,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case dirOpenedMsg: return m.handleDirOpened(msg) + case refOpenedMsg: + return m.handleRefOpened(msg) + case compareDetailMsg: return m.handleCompareDetail(msg) @@ -1602,6 +1605,9 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } return m, m.openDirCmd(cwd) + case key.Matches(msg, keys.OpenRef): + return m.handleOpenRef() + case key.Matches(msg, keys.Space): m.sessionList.ToggleSelected() m.updateSelectionStatus() @@ -4502,6 +4508,15 @@ func (m Model) openDirCmd(path string) tea.Cmd { } } +// openRefCmd opens a session's linked reference URL in the browser. The label +// is carried through so the status line can name what was opened. +func (m Model) openRefCmd(url, label string) tea.Cmd { + return func() tea.Msg { + err := platform.OpenURL(url) + return refOpenedMsg{label: label, err: err} + } +} + // --------------------------------------------------------------------------- // Group sorting helpers // ---------------------------------------------------------------------------