Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `!`
Expand Down Expand Up @@ -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 |
Expand Down
7 changes: 7 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
144 changes: 144 additions & 0 deletions internal/data/refurl.go
Original file line number Diff line number Diff line change
@@ -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
}
108 changes: 108 additions & 0 deletions internal/data/refurl_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
21 changes: 21 additions & 0 deletions internal/platform/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package platform
import (
"context"
"fmt"
"net/url"
"os"
"os/exec"
"runtime"
Expand Down Expand Up @@ -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()
}
23 changes: 23 additions & 0 deletions internal/platform/open_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
12 changes: 12 additions & 0 deletions internal/tui/cmdpalette.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
Expand All @@ -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},
Expand Down Expand Up @@ -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
Expand Down
Loading