From cea169fd085fc6cc83d55a6136ee59ce065d7a2b Mon Sep 17 00:00:00 2001 From: Jon Gallant Date: Fri, 17 Jul 2026 11:38:58 -0700 Subject: [PATCH] feat(cli): add compare, watch, tag, prune, aliases, scoped resume, stats --csv, batch export Add eight session-management CLI commands (integrated from parallel swarm work, rebased onto current main): - dispatch compare side-by-side session comparison as text/JSON (#264) - dispatch export --filter batch-export filtered sessions (#265) - dispatch watch monitor session attention outside the TUI (#271) - dispatch tag add/remove/set/list session tags from the CLI (#273) - dispatch aliases list session aliases (#275) - dispatch open --current resume the most recent session scoped to the current repo/branch/folder (#276) - dispatch prune clean stale session config entries (#277) - dispatch stats --csv CSV output for stats (#279) Closes #264 Closes #265 Closes #271 Closes #273 Closes #275 Closes #276 Closes #277 Closes #279 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 524cf702-8922-4cfd-b23f-4069b734e09b --- README.md | 97 ++++++- cmd/dispatch/aliases.go | 173 ++++++++++++ cmd/dispatch/aliases_test.go | 182 +++++++++++++ cmd/dispatch/cli.go | 43 ++- cmd/dispatch/compare.go | 282 ++++++++++++++++++++ cmd/dispatch/compare_test.go | 356 +++++++++++++++++++++++++ cmd/dispatch/export.go | 137 +++++++++- cmd/dispatch/export_test.go | 70 +++++ cmd/dispatch/launch_overrides_test.go | 2 +- cmd/dispatch/main.go | 48 +++- cmd/dispatch/open.go | 101 ++++++- cmd/dispatch/open_test.go | 90 ++++++- cmd/dispatch/prune.go | 264 ++++++++++++++++++ cmd/dispatch/prune_test.go | 178 +++++++++++++ cmd/dispatch/stats.go | 56 ++++ cmd/dispatch/stats_test.go | 41 +++ cmd/dispatch/tag.go | 261 ++++++++++++++++++ cmd/dispatch/tag_test.go | 263 ++++++++++++++++++ cmd/dispatch/watch.go | 370 ++++++++++++++++++++++++++ cmd/dispatch/watch_test.go | 193 ++++++++++++++ docs/keybindings.md | 20 +- internal/tui/model.go | 122 +++------ internal/tui/model_coverage_test.go | 84 ------ internal/tui/model_helpers_test.go | 162 ++--------- internal/tui/model_launch_test.go | 19 -- internal/tui/screenshot.go | 10 +- 26 files changed, 3254 insertions(+), 370 deletions(-) create mode 100644 cmd/dispatch/aliases.go create mode 100644 cmd/dispatch/aliases_test.go create mode 100644 cmd/dispatch/compare.go create mode 100644 cmd/dispatch/compare_test.go create mode 100644 cmd/dispatch/prune.go create mode 100644 cmd/dispatch/prune_test.go create mode 100644 cmd/dispatch/tag.go create mode 100644 cmd/dispatch/tag_test.go create mode 100644 cmd/dispatch/watch.go create mode 100644 cmd/dispatch/watch_test.go diff --git a/README.md b/README.md index 77d60a4..f5ce0a1 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ Dispatch reads your local Copilot CLI session store and presents every past sess - **Full-text search** (`/`) — FTS5 full-text search with BM25 ranking when available, falling back to LIKE for older CLI versions. Two-tier: quick search (summaries, branches, repos, directories) returns results instantly; deep search (turns, checkpoints, files, refs) kicks in after 300ms. Searching a number (e.g. "42", "#42", "PR42") also matches session refs (PRs, issues, commits) - **Directory filtering** (`f`) — hierarchical tree panel for toggling directory exclusion, persisted to config - **Word filtering** (Settings panel) — comma-separated list of words to exclude sessions by content. Sessions whose name or conversation turns contain any excluded word (case-insensitive) are hidden from the list -- **Sorting** (`s` / `S`) — 5 fields (updated, folder, name, created, turns) with toggleable direction -- **Grouping (pivot) modes** (`Tab`) — flat, folder, repo, branch, date — displayed as collapsible trees with session counts +- **Sorting** (`s` / `S`) — 7 fields (updated, created, turns, name, folder, attention, frecency) with toggleable direction. Sort applies to both sessions and group ordering +- **Grouping modes** (`Tab`) — list, folder, repo, branch, date, host. Displayed as collapsible trees with session counts - **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`. Press `z` to view the preview fullscreen. 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 @@ -193,6 +193,20 @@ dispatch open --yolo=false # force off even if config enables it These flags apply to every launch mode and to `--print`. When omitted, the saved config values are used. +#### Scoped Resume + +Resume the most recent session matching a repository, branch, or folder instead of specifying an ID: + +```sh +dispatch open --repo jongio/dispatch +dispatch open --branch feature/auth +dispatch open --repo jongio/dispatch --branch main +dispatch open --folder ~/projects/api +dispatch open --current # auto-detect repo and branch from cwd +``` + +Scope flags cannot be combined with `--last`, `--stdin`, or a session ID. + ### Start a New Session Start a brand-new Copilot session from the command line without opening the TUI: @@ -234,6 +248,7 @@ Run `dispatch stats` to print session totals and breakdowns by repository, branc ```sh dispatch stats dispatch stats --json +dispatch stats --csv dispatch stats --calendar dispatch stats --repo jongio/dispatch --since 2026-01-01 dispatch stats --top 5 @@ -299,6 +314,82 @@ dispatch export 0a1b2c3d --out ./exports By default the session is written as Markdown to the exports directory. Use `--format json` for machine-readable output or `--format html` for a self-contained web page you can open in a browser (styles are inlined, so there are no external files to manage). Use `--stdout` to print to the terminal instead of writing a file, `--out ` to choose the destination directory, and `--redact` to mask common secret patterns before writing. `--stdout` and `--out` cannot be combined. +#### Batch Export + +Export all sessions matching a scope filter at once: + +```sh +dispatch export --repo jongio/dispatch +dispatch export --repo jongio/dispatch --format json --out ./backup +dispatch export --branch main --since 2026-01-01 --until 2026-07-01 +dispatch export --query "auth fix" --redact +``` + +Filter flags (`--query`, `--repo`, `--branch`, `--folder`, `--since`, `--until`) replace the session ID. Each matching session is exported to its own file in the output directory. `--stdout` is not supported in batch mode. + +### Compare + +Compare two sessions side by side with `dispatch compare`: + +```sh +dispatch compare 0a1b2c3d 9f8e7d6c +dispatch compare 0a1b2c3d 9f8e7d6c --json +``` + +The output shows metadata differences (summary, branch, turn count), files that appear in only one session, ref differences, and checkpoint title lists. Use `--json` for machine-readable output. Session IDs accept the same prefix shorthand as `open` and `export`. + +### Aliases + +List every session alias with `dispatch aliases`: + +```sh +dispatch aliases +dispatch aliases --json +``` + +Each entry shows the alias, the target session ID, summary, and repository. Aliases whose target session is no longer in the store are marked as orphaned. Use `--json` for scripting. + +### Tag + +Manage tags on a single session from the command line: + +```sh +dispatch tag # list current tags +dispatch tag --add work,api # add tags +dispatch tag --remove api # remove tags +dispatch tag --set a,b # replace all tags +dispatch tag --json # print result as JSON +``` + +A session alias can be used in place of the ID. Tags are deduplicated and stored in sorted order. + +### Prune + +Clean up config entries that reference sessions no longer in the store: + +```sh +dispatch prune # dry run: report what would be removed +dispatch prune --apply # remove stale entries +dispatch prune --json # machine-readable summary +dispatch prune --apply --json # remove and print JSON summary +``` + +Scans aliases, tags, notes, favorites, hidden sessions, and launch stats. By default it only reports; `--apply` performs the cleanup. + +### Watch + +Monitor session attention state without opening the TUI: + +```sh +dispatch watch --once # one-shot snapshot +dispatch watch --once --json # JSON snapshot +dispatch watch # stream transitions (Ctrl-C to stop) +dispatch watch --interval 10s # custom poll interval +dispatch watch --once --repo jongio/dispatch # filter by repo +``` + +The terminal bell rings when a session transitions to waiting or interrupted. Use `--repo`, `--branch`, or `--folder` to limit which sessions are monitored. + ### Search (JSON) Query the session store from scripts without opening the TUI. `dispatch search` prints matching sessions as a JSON array by default: @@ -617,7 +708,7 @@ named keys (`up`, `down`, `left`, `right`, `enter`, `esc`, `tab`, `space`, Available action names: `up`, `down`, `left`, `right`, `enter`, `space`, `quit`, `force_quit`, -`search`, `escape`, `filter`, `sort`, `sort_order`, `pivot`, `pivot_order`, +`search`, `escape`, `filter`, `sort`, `sort_order`, `pivot`, `preview`, `reindex`, `help`, `config`, `time_range_1`, `time_range_2`, `time_range_3`, `time_range_4`, `hide`, `toggle_hidden`, `star`, `launch_window`, `launch_tab`, `launch_pane`, `preview_scroll_up`, diff --git a/cmd/dispatch/aliases.go b/cmd/dispatch/aliases.go new file mode 100644 index 0000000..b059bda --- /dev/null +++ b/cmd/dispatch/aliases.go @@ -0,0 +1,173 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strings" + + "github.com/jongio/dispatch/internal/data" +) + +// aliasesListSessionsFn loads sessions for the aliases command. It is a +// package variable so tests can substitute a fixed set of sessions. +var aliasesListSessionsFn = defaultStatsListSessions + +// aliasEntry describes one alias mapping. +type aliasEntry struct { + Alias string `json:"alias"` + ID string `json:"id"` + Summary string `json:"summary,omitempty"` + Repo string `json:"repo,omitempty"` + Orphaned bool `json:"orphaned"` +} + +// aliasesReport is the aggregate output for the aliases command. +type aliasesReport struct { + TotalAliases int `json:"total_aliases"` + Orphaned int `json:"orphaned"` + Aliases []aliasEntry `json:"aliases"` +} + +// runAliases lists every session alias with its target session. args[0] is +// expected to be "aliases". +func runAliases(w io.Writer, args []string) error { + if w == nil { + w = io.Discard + } + + opts, err := parseAliasesArgs(args) + if err != nil { + return err + } + + cfg, err := configLoadFn() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + sessions, err := aliasesListSessionsFn(data.FilterOptions{}) + if err != nil { + return err + } + + report := buildAliasesReport(cfg.SessionAliases, sessions) + if opts.json { + return writeAliasesJSON(w, report) + } + writeAliasesText(w, report) + return nil +} + +// aliasesOptions holds parsed flags for the aliases command. +type aliasesOptions struct { + json bool +} + +// parseAliasesArgs reads the aliases subcommand flags. +func parseAliasesArgs(args []string) (aliasesOptions, error) { + var opts aliasesOptions + + rest := args + if len(rest) > 0 { + rest = rest[1:] + } + + for _, arg := range rest { + switch { + case arg == "--json": + opts.json = true + case strings.HasPrefix(arg, "-"): + return aliasesOptions{}, fmt.Errorf("unknown flag: %s", arg) + default: + return aliasesOptions{}, fmt.Errorf("aliases does not take positional arguments, got %q", arg) + } + } + + return opts, nil +} + +// buildAliasesReport builds the alias listing. SessionAliases maps session +// ID to alias string. +func buildAliasesReport(aliases map[string]string, sessions []data.Session) aliasesReport { + report := aliasesReport{Aliases: []aliasEntry{}} + + liveIDs := make(map[string]*data.Session, len(sessions)) + for i := range sessions { + liveIDs[sessions[i].ID] = &sessions[i] + } + + for id, alias := range aliases { + if alias == "" { + continue + } + entry := aliasEntry{Alias: alias, ID: id} + if s, ok := liveIDs[id]; ok { + entry.Summary = s.Summary + entry.Repo = s.Repository + } else { + entry.Orphaned = true + report.Orphaned++ + } + report.Aliases = append(report.Aliases, entry) + } + + sort.Slice(report.Aliases, func(i, j int) bool { + return report.Aliases[i].Alias < report.Aliases[j].Alias + }) + report.TotalAliases = len(report.Aliases) + return report +} + +// writeAliasesJSON prints the report as a single JSON object. +func writeAliasesJSON(w io.Writer, report aliasesReport) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(report) +} + +// writeAliasesText prints the report in a plain, human-readable layout. +func writeAliasesText(w io.Writer, report aliasesReport) { + fmt.Fprintln(w, "Dispatch aliases") + fmt.Fprintln(w) + + if report.TotalAliases == 0 { + fmt.Fprintln(w, "No aliases configured.") + return + } + + fmt.Fprintf(w, "Aliases: %d", report.TotalAliases) + if report.Orphaned > 0 { + fmt.Fprintf(w, " (%d orphaned)", report.Orphaned) + } + fmt.Fprintln(w) + fmt.Fprintln(w) + + aliasWidth := 0 + for _, e := range report.Aliases { + if len(e.Alias) > aliasWidth { + aliasWidth = len(e.Alias) + } + } + + for _, e := range report.Aliases { + if e.Orphaned { + fmt.Fprintf(w, " %-*s %s [orphaned]\n", aliasWidth, e.Alias, shortID(e.ID)) + } else { + label := e.Summary + if e.Repo != "" { + label = e.Repo + ": " + label + } + fmt.Fprintf(w, " %-*s %s %s\n", aliasWidth, e.Alias, shortID(e.ID), label) + } + } +} + +// shortID returns a truncated session ID for display. +func shortID(id string) string { + if len(id) > 12 { + return id[:12] + } + return id +} diff --git a/cmd/dispatch/aliases_test.go b/cmd/dispatch/aliases_test.go new file mode 100644 index 0000000..1629040 --- /dev/null +++ b/cmd/dispatch/aliases_test.go @@ -0,0 +1,182 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "testing" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/data" + "github.com/jongio/dispatch/internal/update" +) + +// withAliasesSeams swaps the config loader and session lister for the duration +// of a test. +func withAliasesSeams(t *testing.T, cfg *config.Config, sessions []data.Session) { + t.Helper() + + prevLoad := configLoadFn + configLoadFn = func() (*config.Config, error) { return cfg, nil } + t.Cleanup(func() { configLoadFn = prevLoad }) + + prevList := aliasesListSessionsFn + aliasesListSessionsFn = func(data.FilterOptions) ([]data.Session, error) { + return sessions, nil + } + t.Cleanup(func() { aliasesListSessionsFn = prevList }) +} + +func TestRunAliases_Text(t *testing.T) { + cfg := &config.Config{ + SessionAliases: map[string]string{ + "ses-1": "auth", + "ses-2": "deploy", + }, + } + sessions := []data.Session{ + {ID: "ses-1", Summary: "Add auth", Repository: "jongio/dispatch"}, + {ID: "ses-2", Summary: "Fix deploy", Repository: "jongio/life"}, + } + withAliasesSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runAliases(&buf, []string{"aliases"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + for _, want := range []string{"auth", "deploy", "ses-1", "ses-2", "Add auth", "Fix deploy"} { + if !bytes.Contains([]byte(out), []byte(want)) { + t.Errorf("output missing %q, got:\n%s", want, out) + } + } +} + +func TestRunAliases_JSON(t *testing.T) { + cfg := &config.Config{ + SessionAliases: map[string]string{ + "ses-1": "auth", + }, + } + sessions := []data.Session{ + {ID: "ses-1", Summary: "Add auth", Repository: "jongio/dispatch"}, + } + withAliasesSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runAliases(&buf, []string{"aliases", "--json"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got aliasesReport + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if got.TotalAliases != 1 { + t.Errorf("total_aliases = %d, want 1", got.TotalAliases) + } + if got.Aliases[0].Alias != "auth" { + t.Errorf("alias = %q, want auth", got.Aliases[0].Alias) + } +} + +func TestRunAliases_Empty(t *testing.T) { + cfg := &config.Config{} + withAliasesSeams(t, cfg, nil) + + var buf bytes.Buffer + if err := runAliases(&buf, []string{"aliases"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + if !bytes.Contains([]byte(out), []byte("No aliases configured")) { + t.Errorf("expected empty-state message, got:\n%s", out) + } +} + +func TestRunAliases_Orphaned(t *testing.T) { + cfg := &config.Config{ + SessionAliases: map[string]string{ + "ses-gone": "old", + "ses-1": "auth", + }, + } + sessions := []data.Session{ + {ID: "ses-1", Summary: "Add auth"}, + } + withAliasesSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runAliases(&buf, []string{"aliases"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + if !bytes.Contains([]byte(out), []byte("orphaned")) { + t.Errorf("expected orphaned marker, got:\n%s", out) + } +} + +func TestRunAliases_OrphanedJSON(t *testing.T) { + cfg := &config.Config{ + SessionAliases: map[string]string{ + "ses-gone": "old", + }, + } + withAliasesSeams(t, cfg, nil) + + var buf bytes.Buffer + if err := runAliases(&buf, []string{"aliases", "--json"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var got aliasesReport + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if got.Orphaned != 1 { + t.Errorf("orphaned = %d, want 1", got.Orphaned) + } + if !got.Aliases[0].Orphaned { + t.Error("expected alias to be marked orphaned") + } +} + +func TestParseAliasesArgs_UnknownFlag(t *testing.T) { + _, err := parseAliasesArgs([]string{"aliases", "--nope"}) + if err == nil { + t.Fatal("expected error for unknown flag") + } +} + +func TestParseAliasesArgs_Positional(t *testing.T) { + _, err := parseAliasesArgs([]string{"aliases", "extra"}) + if err == nil { + t.Fatal("expected error for positional argument") + } +} + +func TestRunAliases_ConfigLoadError(t *testing.T) { + prev := configLoadFn + configLoadFn = func() (*config.Config, error) { return nil, errors.New("boom") } + t.Cleanup(func() { configLoadFn = prev }) + + err := runAliases(&bytes.Buffer{}, []string{"aliases"}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestHandleArgs_Aliases(t *testing.T) { + cfg := &config.Config{} + withAliasesSeams(t, cfg, nil) + + ch := make(chan *update.UpdateInfo, 1) + ch <- nil + + done, _, _, err := handleArgs([]string{"aliases"}, &bytes.Buffer{}, ch) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !done { + t.Error("expected done=true") + } +} diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index 1004baf..a40a730 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -198,6 +198,41 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd } return true, cleanup, startupOptions{}, nil + case "compare": + if cErr := runCompare(os.Stdout, args); cErr != nil { + fmt.Fprintf(os.Stderr, "compare: %v\n", cErr) + return true, cleanup, startupOptions{}, cErr + } + return true, cleanup, startupOptions{}, nil + + case "aliases": + if aErr := runAliases(os.Stdout, args); aErr != nil { + fmt.Fprintf(os.Stderr, "aliases: %v\n", aErr) + return true, cleanup, startupOptions{}, aErr + } + return true, cleanup, startupOptions{}, nil + + case "prune": + if pErr := runPrune(os.Stdout, args); pErr != nil { + fmt.Fprintf(os.Stderr, "prune: %v\n", pErr) + return true, cleanup, startupOptions{}, pErr + } + return true, cleanup, startupOptions{}, nil + + case "tag": + if tErr := runTag(os.Stdout, args); tErr != nil { + fmt.Fprintf(os.Stderr, "tag: %v\n", tErr) + return true, cleanup, startupOptions{}, tErr + } + return true, cleanup, startupOptions{}, nil + + case "watch": + if wErr := runWatch(os.Stdout, args); wErr != nil { + fmt.Fprintf(os.Stderr, "watch: %v\n", wErr) + return true, cleanup, startupOptions{}, wErr + } + return true, cleanup, startupOptions{}, nil + case "man": if mErr := runMan(os.Stdout); mErr != nil { fmt.Fprintf(os.Stderr, "man: %v\n", mErr) @@ -358,7 +393,7 @@ const bashCompletionScript = `# bash completion for dispatch _dispatch_completion() { local cur="${COMP_WORDS[COMP_CWORD]}" local bin="${COMP_WORDS[0]}" - local commands="help version open new doctor update completion stats search tags config export info man" + local commands="help version open new doctor update completion stats search tags aliases compare prune tag watch config export info man" local flags="-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query" if [[ "${COMP_CWORD}" -eq 1 ]]; then @@ -396,7 +431,7 @@ const zshCompletionScript = `#compdef dispatch disp _dispatch_completion() { local -a commands flags configsubs shells aliases configkeys openflags newflags local bin=${words[1]} - commands=(help version open new doctor update completion stats search tags config export info man) + commands=(help version open new doctor update completion stats search tags aliases compare prune tag watch config export info man) configsubs=(list get set unset edit path) openflags=(--mode --last --print --agent --model --yolo) newflags=(--mode --agent --model --yolo) @@ -465,7 +500,7 @@ end for bin in dispatch disp complete -c $bin -f - complete -c $bin -n '__dispatch_needs_command' -a 'help version open new doctor update completion stats search tags config export info man' + complete -c $bin -n '__dispatch_needs_command' -a 'help version open new doctor update completion stats search tags aliases compare prune tag watch config export info man' complete -c $bin -n '__dispatch_needs_command' -a '-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query' complete -c $bin -n '__dispatch_after completion' -a "($bin __complete shells)" complete -c $bin -n '__dispatch_after open' -a "($bin __complete aliases)" @@ -476,7 +511,7 @@ end ` const powershellCompletionScript = `# PowerShell completion for dispatch -$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'config', 'export', 'info', 'man') +$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'aliases', 'compare', 'prune', 'tag', 'watch', 'config', 'export', 'info', 'man') $script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex', '--current', '--cwd', '--repo', '--branch', '--query') $script:DispatchConfigSubcommands = @('list', 'get', 'set', 'unset', 'edit', 'path') $script:DispatchOpenFlags = @('--mode', '--last', '--print', '--agent', '--model', '--yolo') diff --git a/cmd/dispatch/compare.go b/cmd/dispatch/compare.go new file mode 100644 index 0000000..085ca07 --- /dev/null +++ b/cmd/dispatch/compare.go @@ -0,0 +1,282 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "sort" + "strings" + + "github.com/jongio/dispatch/internal/data" +) + +// compareGetDetailFn loads a session's aggregated detail by ID. It is a seam +// so tests can substitute the store lookup without touching the environment. +var compareGetDetailFn = defaultExportGetDetail + +// sessionComparison is the structured result of comparing two sessions, used +// for both the text and JSON outputs. +type sessionComparison struct { + Left sessionSide `json:"left"` + Right sessionSide `json:"right"` + + MetadataDiffs []metadataDiff `json:"metadata_diffs"` + FilesOnlyLeft []string `json:"files_only_left"` + FilesOnlyRight []string `json:"files_only_right"` + RefsOnlyLeft []string `json:"refs_only_left"` + RefsOnlyRight []string `json:"refs_only_right"` +} + +// sessionSide summarises one side of the comparison. +type sessionSide struct { + ID string `json:"id"` + Summary string `json:"summary,omitempty"` + Repository string `json:"repository,omitempty"` + Branch string `json:"branch,omitempty"` + Directory string `json:"directory,omitempty"` + HostType string `json:"host_type,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Turns int `json:"turns"` + Files int `json:"files"` + Checkpoints int `json:"checkpoints"` + CheckpointTitles []string `json:"checkpoint_titles,omitempty"` +} + +// metadataDiff records a single field that differs between the two sessions. +type metadataDiff struct { + Field string `json:"field"` + Left string `json:"left"` + Right string `json:"right"` +} + +// runCompare prints a comparison of two sessions as text, or as JSON with +// --json. args is the full argument slice with args[0] == "compare". +func runCompare(w io.Writer, args []string) error { + if w == nil { + w = io.Discard + } + + leftID, rightID, asJSON, err := parseCompareArgs(args) + if err != nil { + return err + } + + leftDetail, err := compareGetDetailFn(leftID) + if err != nil { + return err + } + if leftDetail == nil { + return fmt.Errorf("session %q not found", leftID) + } + + rightDetail, err := compareGetDetailFn(rightID) + if err != nil { + return err + } + if rightDetail == nil { + return fmt.Errorf("session %q not found", rightID) + } + + cmp := buildComparison(leftDetail, rightDetail) + if asJSON { + return writeCompareJSON(w, cmp) + } + return writeCompareText(w, cmp) +} + +// parseCompareArgs extracts the two session IDs and the --json flag from the +// compare subcommand arguments. args[0] is expected to be "compare". +func parseCompareArgs(args []string) (leftID, rightID string, asJSON bool, err error) { + rest := args + if len(rest) > 0 { + rest = rest[1:] // drop the "compare" token + } + + var positionals []string + for _, arg := range rest { + switch { + case arg == "--json": + asJSON = true + case strings.HasPrefix(arg, "-"): + return "", "", false, fmt.Errorf("unknown flag: %s", arg) + default: + positionals = append(positionals, arg) + } + } + + switch len(positionals) { + case 0: + return "", "", false, errors.New("compare requires two session IDs") + case 1: + return "", "", false, errors.New("compare requires two session IDs") + case 2: + return positionals[0], positionals[1], asJSON, nil + default: + return "", "", false, fmt.Errorf("compare accepts exactly two session IDs, got %d", len(positionals)) + } +} + +// buildComparison produces a sessionComparison from two loaded session details. +func buildComparison(left, right *data.SessionDetail) sessionComparison { + cmp := sessionComparison{ + Left: buildSide(left), + Right: buildSide(right), + } + + // Metadata diffs. + addDiff := func(field, lv, rv string) { + if lv != rv { + cmp.MetadataDiffs = append(cmp.MetadataDiffs, metadataDiff{Field: field, Left: lv, Right: rv}) + } + } + addDiff("summary", left.Session.Summary, right.Session.Summary) + addDiff("repository", left.Session.Repository, right.Session.Repository) + addDiff("branch", left.Session.Branch, right.Session.Branch) + addDiff("directory", left.Session.Cwd, right.Session.Cwd) + addDiff("host_type", left.Session.HostType, right.Session.HostType) + addDiff("created_at", left.Session.CreatedAt, right.Session.CreatedAt) + addDiff("updated_at", left.Session.UpdatedAt, right.Session.UpdatedAt) + + addCountDiff := func(field string, lv, rv int) { + if lv != rv { + cmp.MetadataDiffs = append(cmp.MetadataDiffs, metadataDiff{ + Field: field, + Left: fmt.Sprintf("%d", lv), + Right: fmt.Sprintf("%d", rv), + }) + } + } + addCountDiff("turns", left.Session.TurnCount, right.Session.TurnCount) + addCountDiff("files", left.Session.FileCount, right.Session.FileCount) + addCountDiff("checkpoints", len(left.Checkpoints), len(right.Checkpoints)) + + // File diffs. + leftFiles := filePathSet(left.Files) + rightFiles := filePathSet(right.Files) + cmp.FilesOnlyLeft = setDiff(leftFiles, rightFiles) + cmp.FilesOnlyRight = setDiff(rightFiles, leftFiles) + + // Ref diffs. + leftRefs := refStringSet(left.Refs) + rightRefs := refStringSet(right.Refs) + cmp.RefsOnlyLeft = setDiff(leftRefs, rightRefs) + cmp.RefsOnlyRight = setDiff(rightRefs, leftRefs) + + return cmp +} + +// buildSide creates the summary view for one side of the comparison. +func buildSide(detail *data.SessionDetail) sessionSide { + s := detail.Session + side := sessionSide{ + ID: s.ID, + Summary: s.Summary, + Repository: s.Repository, + Branch: s.Branch, + Directory: s.Cwd, + HostType: s.HostType, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + Turns: s.TurnCount, + Files: s.FileCount, + Checkpoints: len(detail.Checkpoints), + } + for _, cp := range detail.Checkpoints { + if cp.Title != "" { + side.CheckpointTitles = append(side.CheckpointTitles, cp.Title) + } + } + return side +} + +// filePathSet returns the unique file paths as a set (map keys). +func filePathSet(files []data.SessionFile) map[string]struct{} { + result := make(map[string]struct{}, len(files)) + for _, f := range files { + result[f.FilePath] = struct{}{} + } + return result +} + +// refStringSet returns "type:value" strings as a set for comparison. +func refStringSet(refs []data.SessionRef) map[string]struct{} { + result := make(map[string]struct{}, len(refs)) + for _, r := range refs { + key := strings.ToLower(r.RefType) + ":" + r.RefValue + result[key] = struct{}{} + } + return result +} + +// setDiff returns the sorted keys present in a but absent from b. +func setDiff(a, b map[string]struct{}) []string { + var diff []string + for k := range a { + if _, ok := b[k]; !ok { + diff = append(diff, k) + } + } + sort.Strings(diff) + return diff +} + +// writeCompareJSON encodes the comparison as indented JSON. +func writeCompareJSON(w io.Writer, cmp sessionComparison) error { + b, err := json.MarshalIndent(cmp, "", " ") + if err != nil { + return fmt.Errorf("encoding comparison as JSON: %w", err) + } + _, err = fmt.Fprintln(w, string(b)) + return err +} + +// writeCompareText prints a human-readable diff summary. +func writeCompareText(w io.Writer, cmp sessionComparison) error { + var b strings.Builder + + fmt.Fprintf(&b, "Comparing sessions\n") + fmt.Fprintf(&b, " Left: %s\n", cmp.Left.ID) + fmt.Fprintf(&b, " Right: %s\n", cmp.Right.ID) + b.WriteString("\n") + + // Metadata diffs. + if len(cmp.MetadataDiffs) == 0 { + b.WriteString("Metadata: identical\n") + } else { + b.WriteString("Metadata differences:\n") + for _, d := range cmp.MetadataDiffs { + fmt.Fprintf(&b, " %-14s %s -> %s\n", d.Field+":", d.Left, d.Right) + } + } + b.WriteString("\n") + + // Checkpoint titles. + writeCompareList(&b, "Checkpoint titles (left):", cmp.Left.CheckpointTitles) + writeCompareList(&b, "Checkpoint titles (right):", cmp.Right.CheckpointTitles) + + // File diffs. + writeCompareList(&b, "Files only in left:", cmp.FilesOnlyLeft) + writeCompareList(&b, "Files only in right:", cmp.FilesOnlyRight) + + // Ref diffs. + writeCompareList(&b, "Refs only in left:", cmp.RefsOnlyLeft) + writeCompareList(&b, "Refs only in right:", cmp.RefsOnlyRight) + + _, err := io.WriteString(w, b.String()) + return err +} + +// writeCompareList appends a labeled list to the builder, or "(none)" when +// the slice is empty. +func writeCompareList(b *strings.Builder, label string, items []string) { + fmt.Fprintf(b, "%s\n", label) + if len(items) == 0 { + b.WriteString(" (none)\n") + } else { + for _, item := range items { + fmt.Fprintf(b, " %s\n", item) + } + } +} diff --git a/cmd/dispatch/compare_test.go b/cmd/dispatch/compare_test.go new file mode 100644 index 0000000..7b6cc05 --- /dev/null +++ b/cmd/dispatch/compare_test.go @@ -0,0 +1,356 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/jongio/dispatch/internal/data" + "github.com/jongio/dispatch/internal/update" +) + +// withCompareDetail swaps the compare detail loader seam for the duration of a +// test, restoring the original on cleanup. +func withCompareDetail(t *testing.T, fn func(string) (*data.SessionDetail, error)) { + t.Helper() + prev := compareGetDetailFn + compareGetDetailFn = fn + t.Cleanup(func() { compareGetDetailFn = prev }) +} + +// compareSampleLeft returns a session detail fixture for the "left" side. +func compareSampleLeft() *data.SessionDetail { + return &data.SessionDetail{ + Session: data.Session{ + ID: "ses-left", + Summary: "Add auth", + Cwd: "/tmp/project", + Repository: "jongio/dispatch", + Branch: "main", + HostType: "github", + CreatedAt: "2026-01-01T10:00:00Z", + UpdatedAt: "2026-01-02T11:00:00Z", + TurnCount: 4, + FileCount: 2, + }, + Checkpoints: []data.Checkpoint{ + {CheckpointNumber: 1, Title: "Setup auth"}, + {CheckpointNumber: 2, Title: "Write tests"}, + }, + Files: []data.SessionFile{ + {FilePath: "src/auth.go"}, + {FilePath: "src/main.go"}, + }, + Refs: []data.SessionRef{ + {RefType: "commit", RefValue: "abc123"}, + {RefType: "pr", RefValue: "10"}, + }, + } +} + +// compareSampleRight returns a session detail fixture for the "right" side. +func compareSampleRight() *data.SessionDetail { + return &data.SessionDetail{ + Session: data.Session{ + ID: "ses-right", + Summary: "Fix login", + Cwd: "/tmp/project", + Repository: "jongio/dispatch", + Branch: "feature", + HostType: "github", + CreatedAt: "2026-02-01T10:00:00Z", + UpdatedAt: "2026-02-02T11:00:00Z", + TurnCount: 7, + FileCount: 3, + }, + Checkpoints: []data.Checkpoint{ + {CheckpointNumber: 1, Title: "Reproduce bug"}, + }, + Files: []data.SessionFile{ + {FilePath: "src/main.go"}, + {FilePath: "src/login.go"}, + }, + Refs: []data.SessionRef{ + {RefType: "commit", RefValue: "def456"}, + {RefType: "issue", RefValue: "5"}, + }, + } +} + +// compareLoader returns a loader function that maps session IDs to the left +// and right fixtures. Unknown IDs return (nil, nil). +func compareLoader() func(string) (*data.SessionDetail, error) { + return func(id string) (*data.SessionDetail, error) { + switch id { + case "ses-left": + return compareSampleLeft(), nil + case "ses-right": + return compareSampleRight(), nil + default: + return nil, nil + } + } +} + +// --------------------------------------------------------------------------- +// Argument parsing +// --------------------------------------------------------------------------- + +func TestParseCompareArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantLeft string + wantRight string + wantJSON bool + wantErr bool + }{ + {name: "two ids", args: []string{"compare", "a", "b"}, wantLeft: "a", wantRight: "b"}, + {name: "two ids with json", args: []string{"compare", "a", "b", "--json"}, wantLeft: "a", wantRight: "b", wantJSON: true}, + {name: "json between ids", args: []string{"compare", "a", "--json", "b"}, wantLeft: "a", wantRight: "b", wantJSON: true}, + {name: "json before ids", args: []string{"compare", "--json", "a", "b"}, wantLeft: "a", wantRight: "b", wantJSON: true}, + {name: "no args", args: []string{"compare"}, wantErr: true}, + {name: "one arg", args: []string{"compare", "a"}, wantErr: true}, + {name: "three args", args: []string{"compare", "a", "b", "c"}, wantErr: true}, + {name: "unknown flag", args: []string{"compare", "a", "b", "--nope"}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + leftID, rightID, asJSON, err := parseCompareArgs(tt.args) + if tt.wantErr { + if err == nil { + t.Fatal("expected an error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if leftID != tt.wantLeft { + t.Errorf("leftID = %q, want %q", leftID, tt.wantLeft) + } + if rightID != tt.wantRight { + t.Errorf("rightID = %q, want %q", rightID, tt.wantRight) + } + if asJSON != tt.wantJSON { + t.Errorf("asJSON = %v, want %v", asJSON, tt.wantJSON) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Text output +// --------------------------------------------------------------------------- + +func TestRunCompare_Text(t *testing.T) { + withCompareDetail(t, compareLoader()) + + var buf bytes.Buffer + if err := runCompare(&buf, []string{"compare", "ses-left", "ses-right"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + + for _, want := range []string{ + "ses-left", + "ses-right", + "Metadata differences:", + "summary:", + "Add auth", + "Fix login", + "branch:", + "main", + "feature", + "turns:", + "Files only in left:", + "src/auth.go", + "Files only in right:", + "src/login.go", + "Refs only in left:", + "commit:abc123", + "Refs only in right:", + "commit:def456", + "issue:5", + "Checkpoint titles (left):", + "Setup auth", + "Write tests", + "Checkpoint titles (right):", + "Reproduce bug", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q, got:\n%s", want, out) + } + } +} + +// --------------------------------------------------------------------------- +// JSON output +// --------------------------------------------------------------------------- + +func TestRunCompare_JSON(t *testing.T) { + withCompareDetail(t, compareLoader()) + + var buf bytes.Buffer + if err := runCompare(&buf, []string{"compare", "ses-left", "ses-right", "--json"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got sessionComparison + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String()) + } + + if got.Left.ID != "ses-left" || got.Right.ID != "ses-right" { + t.Errorf("IDs = %q / %q, want ses-left / ses-right", got.Left.ID, got.Right.ID) + } + if got.Left.Turns != 4 || got.Right.Turns != 7 { + t.Errorf("turns = %d / %d, want 4 / 7", got.Left.Turns, got.Right.Turns) + } + if len(got.MetadataDiffs) == 0 { + t.Error("expected at least one metadata diff") + } + if len(got.FilesOnlyLeft) != 1 || got.FilesOnlyLeft[0] != "src/auth.go" { + t.Errorf("files_only_left = %v, want [src/auth.go]", got.FilesOnlyLeft) + } + if len(got.FilesOnlyRight) != 1 || got.FilesOnlyRight[0] != "src/login.go" { + t.Errorf("files_only_right = %v, want [src/login.go]", got.FilesOnlyRight) + } + if len(got.Left.CheckpointTitles) != 2 { + t.Errorf("left checkpoint_titles = %v, want 2 entries", got.Left.CheckpointTitles) + } + if len(got.Right.CheckpointTitles) != 1 { + t.Errorf("right checkpoint_titles = %v, want 1 entry", got.Right.CheckpointTitles) + } +} + +// --------------------------------------------------------------------------- +// Missing session IDs +// --------------------------------------------------------------------------- + +func TestRunCompare_LeftNotFound(t *testing.T) { + withCompareDetail(t, func(id string) (*data.SessionDetail, error) { + return nil, nil // every ID is unknown + }) + + err := runCompare(&bytes.Buffer{}, []string{"compare", "missing", "ses-right"}) + if err == nil { + t.Fatal("expected a not-found error for the left session") + } + if !strings.Contains(err.Error(), "missing") { + t.Errorf("error should mention the missing ID, got: %v", err) + } +} + +func TestRunCompare_RightNotFound(t *testing.T) { + withCompareDetail(t, func(id string) (*data.SessionDetail, error) { + if id == "ses-left" { + return compareSampleLeft(), nil + } + return nil, nil + }) + + err := runCompare(&bytes.Buffer{}, []string{"compare", "ses-left", "ghost"}) + if err == nil { + t.Fatal("expected a not-found error for the right session") + } + if !strings.Contains(err.Error(), "ghost") { + t.Errorf("error should mention the missing ID, got: %v", err) + } +} + +func TestRunCompare_MissingIDs(t *testing.T) { + err := runCompare(&bytes.Buffer{}, []string{"compare"}) + if err == nil { + t.Fatal("expected an error when no session IDs are given") + } +} + +func TestRunCompare_SingleID(t *testing.T) { + err := runCompare(&bytes.Buffer{}, []string{"compare", "only-one"}) + if err == nil { + t.Fatal("expected an error when only one session ID is given") + } +} + +// --------------------------------------------------------------------------- +// Loader error propagation +// --------------------------------------------------------------------------- + +func TestRunCompare_LoaderError(t *testing.T) { + withCompareDetail(t, func(string) (*data.SessionDetail, error) { + return nil, errors.New("store boom") + }) + + err := runCompare(&bytes.Buffer{}, []string{"compare", "a", "b"}) + if err == nil { + t.Fatal("expected the loader error to propagate") + } +} + +// --------------------------------------------------------------------------- +// Identical sessions +// --------------------------------------------------------------------------- + +func TestRunCompare_Identical(t *testing.T) { + withCompareDetail(t, func(string) (*data.SessionDetail, error) { + return compareSampleLeft(), nil + }) + + var buf bytes.Buffer + if err := runCompare(&buf, []string{"compare", "ses-left", "ses-left"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + + if !strings.Contains(out, "Metadata: identical") { + t.Errorf("expected identical metadata label, got:\n%s", out) + } + // Files and refs should show "(none)" for both sides. + if cnt := strings.Count(out, "(none)"); cnt < 4 { + t.Errorf("expected at least 4 '(none)' markers for identical sessions, got %d:\n%s", cnt, out) + } +} + +// --------------------------------------------------------------------------- +// handleArgs integration +// --------------------------------------------------------------------------- + +func TestHandleArgs_Compare(t *testing.T) { + withCompareDetail(t, compareLoader()) + + ch := make(chan *update.UpdateInfo, 1) + ch <- nil + + done, cleanup, _, err := handleArgs([]string{"compare", "ses-left", "ses-right"}, io.Discard, ch) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !done { + t.Error("expected done=true for compare") + } + if cleanup != nil { + t.Error("expected cleanup=nil for compare") + } +} + +func TestHandleArgs_CompareError(t *testing.T) { + withCompareDetail(t, func(string) (*data.SessionDetail, error) { + return nil, nil // unknown ID + }) + + ch := make(chan *update.UpdateInfo, 1) + ch <- nil + + done, _, _, err := handleArgs([]string{"compare", "missing-a", "missing-b"}, io.Discard, ch) + if !done { + t.Error("expected done=true for compare") + } + if err == nil { + t.Error("expected an error for unknown session IDs") + } +} diff --git a/cmd/dispatch/export.go b/cmd/dispatch/export.go index ea8fa65..48b6fdd 100644 --- a/cmd/dispatch/export.go +++ b/cmd/dispatch/export.go @@ -18,8 +18,9 @@ import ( // Function variables allow test substitution of external calls, matching the // pattern used elsewhere in this package (see cli.go and open.go). var ( - exportGetDetailFn = defaultExportGetDetail - exportDirFn = data.ExportDir + exportGetDetailFn = defaultExportGetDetail + exportDirFn = data.ExportDir + exportListSessionsFn = defaultStatsListSessions ) // exportOptions holds the parsed flags for the export command. @@ -29,6 +30,7 @@ type exportOptions struct { stdout bool outDir string redact bool + filter *data.FilterOptions // non-nil for batch mode } // runExport writes a session's full content as Markdown or JSON. It writes to @@ -44,6 +46,10 @@ func runExport(w io.Writer, args []string) error { return err } + if opts.filter != nil { + return runExportBatch(w, opts) + } + detail, err := exportGetDetailFn(opts.id) if err != nil { return err @@ -103,6 +109,8 @@ func parseExportArgs(args []string) (exportOptions, error) { } var positionals []string + var filter data.FilterOptions + hasFilter := false for i := 0; i < len(rest); i++ { arg := rest[i] name, inline, hasInline := splitFlag(arg) @@ -130,6 +138,62 @@ func parseExportArgs(args []string) (exportOptions, error) { opts.stdout = true case name == "--redact": opts.redact = true + case name == "--query" || name == "-q": + v, ni, err := takeValue(i, "--query", inlineOrEmpty(inline, hasInline)) + if err != nil { + return exportOptions{}, err + } + filter.Query = v + hasFilter = true + i = ni + case name == "--repo": + v, ni, err := takeValue(i, "--repo", inlineOrEmpty(inline, hasInline)) + if err != nil { + return exportOptions{}, err + } + filter.Repository = v + hasFilter = true + i = ni + case name == "--branch": + v, ni, err := takeValue(i, "--branch", inlineOrEmpty(inline, hasInline)) + if err != nil { + return exportOptions{}, err + } + filter.Branch = v + hasFilter = true + i = ni + case name == "--folder": + v, ni, err := takeValue(i, "--folder", inlineOrEmpty(inline, hasInline)) + if err != nil { + return exportOptions{}, err + } + filter.Folder = v + hasFilter = true + i = ni + case name == "--since": + v, ni, err := takeValue(i, "--since", inlineOrEmpty(inline, hasInline)) + if err != nil { + return exportOptions{}, err + } + t, ok := parseStatsTime(v) + if !ok { + return exportOptions{}, fmt.Errorf("invalid --since value %q (want YYYY-MM-DD or RFC3339)", v) + } + filter.Since = &t + hasFilter = true + i = ni + case name == "--until": + v, ni, err := takeValue(i, "--until", inlineOrEmpty(inline, hasInline)) + if err != nil { + return exportOptions{}, err + } + t, ok := parseStatsTime(v) + if !ok { + return exportOptions{}, fmt.Errorf("invalid --until value %q (want YYYY-MM-DD or RFC3339)", v) + } + filter.Until = &t + hasFilter = true + i = ni case strings.HasPrefix(arg, "-"): return exportOptions{}, fmt.Errorf("unknown flag: %s", arg) default: @@ -137,11 +201,15 @@ func parseExportArgs(args []string) (exportOptions, error) { } } - switch len(positionals) { - case 0: - return exportOptions{}, errors.New("export requires a session ID") - case 1: + switch { + case len(positionals) == 0 && hasFilter: + opts.filter = &filter + case len(positionals) == 0 && !hasFilter: + return exportOptions{}, errors.New("export requires a session ID or filter flags (--query, --repo, --branch, --folder, --since, --until)") + case len(positionals) == 1 && !hasFilter: opts.id = positionals[0] + case len(positionals) == 1 && hasFilter: + return exportOptions{}, errors.New("cannot combine a session ID with filter flags; use one or the other") default: return exportOptions{}, fmt.Errorf("export accepts a single session ID, got %d arguments", len(positionals)) } @@ -278,3 +346,60 @@ func defaultExportGetDetail(id string) (*data.SessionDetail, error) { } return detail, nil } + +// runExportBatch exports all sessions matching the filter flags. Each session +// is exported to a separate file in the output directory. +func runExportBatch(w io.Writer, opts exportOptions) error { + if opts.stdout { + return errors.New("--stdout is not supported in batch mode; use --out to set the output directory") + } + + sessions, err := exportListSessionsFn(*opts.filter) + if err != nil { + return err + } + if len(sessions) == 0 { + fmt.Fprintln(w, "No sessions match the given filters.") + return nil + } + + outDir := opts.outDir + if outDir == "" { + d, dirErr := exportDirFn() + if dirErr != nil { + return dirErr + } + outDir = d + } + + var exported int + for _, s := range sessions { + detail, detailErr := exportGetDetailFn(s.ID) + if detailErr != nil { + fmt.Fprintf(w, "Skipping %s: %v\n", shortID(s.ID), detailErr) + continue + } + if detail == nil { + fmt.Fprintf(w, "Skipping %s: session not found\n", shortID(s.ID)) + continue + } + if opts.redact { + detail = redactedSessionDetail(detail) + } + content, renderErr := renderExport(detail, opts.format) + if renderErr != nil { + fmt.Fprintf(w, "Skipping %s: %v\n", shortID(s.ID), renderErr) + continue + } + path, writeErr := writeExportFile(outDir, s.ID, opts.format, content) + if writeErr != nil { + fmt.Fprintf(w, "Skipping %s: %v\n", shortID(s.ID), writeErr) + continue + } + fmt.Fprintf(w, "Exported %s to %s\n", shortID(s.ID), path) + exported++ + } + + fmt.Fprintf(w, "\nExported %d of %d sessions to %s\n", exported, len(sessions), outDir) + return nil +} diff --git a/cmd/dispatch/export_test.go b/cmd/dispatch/export_test.go index ff8e06e..8dbdfbd 100644 --- a/cmd/dispatch/export_test.go +++ b/cmd/dispatch/export_test.go @@ -49,6 +49,7 @@ func TestParseExportArgs(t *testing.T) { wantStdout bool wantOut string wantRedact bool + wantFilter bool wantErr bool }{ {name: "id only", args: []string{"export", "abc"}, wantID: "abc", wantFormat: "md"}, @@ -65,6 +66,11 @@ func TestParseExportArgs(t *testing.T) { {name: "invalid format", args: []string{"export", "a", "--format", "yaml"}, wantErr: true}, {name: "format without value", args: []string{"export", "a", "--format"}, wantErr: true}, {name: "stdout and out", args: []string{"export", "a", "--stdout", "--out", "/tmp/x"}, wantErr: true}, + {name: "id with filter", args: []string{"export", "a", "--repo", "x/y"}, wantErr: true}, + {name: "batch query only", args: []string{"export", "--query", "fix"}, wantFormat: "md", wantFilter: true}, + {name: "batch repo", args: []string{"export", "--repo", "x/y"}, wantFormat: "md", wantFilter: true}, + {name: "batch since until", args: []string{"export", "--since", "2026-01-01", "--until", "2026-02-01"}, wantFormat: "md", wantFilter: true}, + {name: "batch with format", args: []string{"export", "--branch", "main", "--format", "json"}, wantFormat: "json", wantFilter: true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -93,6 +99,12 @@ func TestParseExportArgs(t *testing.T) { if opts.redact != tc.wantRedact { t.Errorf("redact = %v, want %v", opts.redact, tc.wantRedact) } + if tc.wantFilter && opts.filter == nil { + t.Errorf("expected filter to be set") + } + if !tc.wantFilter && opts.filter != nil { + t.Errorf("expected filter to be nil, got %+v", opts.filter) + } }) } } @@ -255,3 +267,61 @@ func TestRunExport_LoadError(t *testing.T) { t.Fatalf("err = %v, want %v", err, sentinel) } } + +func withExportList(t *testing.T, fn func(data.FilterOptions) ([]data.Session, error)) { + t.Helper() + prev := exportListSessionsFn + exportListSessionsFn = fn + t.Cleanup(func() { exportListSessionsFn = prev }) +} + +func TestRunExportBatch_WriteFiles(t *testing.T) { + sessions := []data.Session{ + {ID: "ses-001", Summary: "First"}, + {ID: "ses-002", Summary: "Second"}, + } + withExportList(t, func(data.FilterOptions) ([]data.Session, error) { return sessions, nil }) + withExportDetail(t, func(id string) (*data.SessionDetail, error) { + return &data.SessionDetail{ + Session: data.Session{ID: id, Summary: "S " + id}, + }, nil + }) + + dir := t.TempDir() + var buf bytes.Buffer + if err := runExport(&buf, []string{"export", "--repo", "x/y", "--out", dir}); err != nil { + t.Fatalf("runExport batch: %v", err) + } + out := buf.String() + if !strings.Contains(out, "Exported 2 of 2 sessions") { + t.Errorf("summary missing, got:\n%s", out) + } + for _, id := range []string{"ses-001", "ses-002"} { + path := filepath.Join(dir, id+".md") + if _, err := os.Stat(path); err != nil { + t.Errorf("expected file %s: %v", path, err) + } + } +} + +func TestRunExportBatch_NoMatches(t *testing.T) { + withExportList(t, func(data.FilterOptions) ([]data.Session, error) { return nil, nil }) + + var buf bytes.Buffer + if err := runExport(&buf, []string{"export", "--query", "nomatch"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(buf.String(), "No sessions match") { + t.Errorf("expected no-match message, got: %s", buf.String()) + } +} + +func TestRunExportBatch_StdoutForbidden(t *testing.T) { + withExportList(t, func(data.FilterOptions) ([]data.Session, error) { + return []data.Session{{ID: "a"}}, nil + }) + err := runExport(&bytes.Buffer{}, []string{"export", "--repo", "x/y", "--stdout"}) + if err == nil || !strings.Contains(err.Error(), "--stdout is not supported in batch mode") { + t.Fatalf("expected stdout-batch error, got: %v", err) + } +} diff --git a/cmd/dispatch/launch_overrides_test.go b/cmd/dispatch/launch_overrides_test.go index 2dd55e5..aa563ba 100644 --- a/cmd/dispatch/launch_overrides_test.go +++ b/cmd/dispatch/launch_overrides_test.go @@ -40,7 +40,7 @@ func TestParseOpenArgs_Overrides(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - id, mode, _, _, _, ov, err := parseOpenArgs(tc.args) + id, mode, _, _, _, ov, _, err := parseOpenArgs(tc.args) if tc.wantErr { if err == nil { t.Fatalf("expected error, got id=%q mode=%q", id, mode) diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index 3216c78..8931a16 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -120,18 +120,28 @@ Commands: open --last [--mode M] Resume the most recently active session open --stdin [--mode M] Resume every session ID read from standard input (one per line; pairs with search --ids) + open --repo R [--mode M] + Resume the most recent session matching a scope filter new [dir] [--mode M] Start a new session in a directory (default: current) completion Print shell completion (bash, zsh, fish, powershell) doctor [--json] Print environment diagnostics (--json for machine-readable output) stats [flags] Print session totals and breakdowns search [query] [flags] Print matching sessions as JSON, IDs, or a table tags [--json] List tags in use with per-tag session counts + aliases [--json] List session aliases with orphan detection notes [command] List, get, set, or clear session notes views [command] List named views or set the active view config [get|set|list|edit|path] Read or change preferences (see Config commands) - export [flags] Export a session as Markdown or JSON + export [flags] Export a session as Markdown, JSON, or HTML + export --repo R [flags] Export all sessions matching a scope filter (batch mode) info [--json] Print a concise session summary (--json for machine-readable output) + compare [--json] + Compare two sessions side by side + tag [flags] Add, remove, set, or list tags on a session + prune [--apply] [--json] + Report (or remove) config entries for missing sessions + watch [--once] [flags] Monitor session attention state man Write the man page (roff) to standard output update Update dispatch to the latest release @@ -142,14 +152,19 @@ Open and new flags: --yolo[=true|false] Override yolo mode for this run only (bare form implies true) --last (open only) Resume the most recently active session --print (open only) Print the resume command instead of launching + --repo (open only) Resume the most recent session for a repository + --branch (open only) Resume the most recent session on a branch + --folder (open only) Resume the most recent session under a folder + --current (open only) Auto-detect repo and branch from the current directory Session IDs: - Commands that take (open, export) accept a full session ID or any - unique prefix of one, like a short git SHA. An ambiguous prefix lists the - matching sessions so you can add more characters. + Commands that take (open, export, info, compare, tag) accept a full session + ID or any unique prefix of one, like a short git SHA. An ambiguous prefix + lists the matching sessions so you can add more characters. Stats flags: --json Print the summary as JSON + --csv Print the summary as CSV --calendar Add a per-day activity heatmap --repo Only count sessions for a repository --branch Only count sessions on a branch @@ -197,6 +212,31 @@ Export flags: --out Write to a directory instead of the exports folder --stdout Print to stdout instead of writing a file --redact Mask common secret patterns in the export + --query (batch) Text filter for session matching + --repo (batch) Only export sessions for a repository + --branch (batch) Only export sessions on a branch + --folder (batch) Only export sessions under a folder + --since (batch) Only export sessions created on or after a date + --until (batch) Only export sessions created on or before a date + +Tag commands: + tag List tags for one session + tag --add a,b Add tags (comma-separated) + tag --remove a Remove tags + tag --set a,b Replace all tags + tag --json Print the result as JSON + +Prune flags: + --apply Remove stale entries (default is dry run) + --json Print the summary as JSON + +Watch flags: + --once Print a snapshot and exit (default: stream transitions) + --json Print as JSON + --interval Poll interval (default 5s, minimum 1s) + --repo Only watch sessions for a repository + --branch Only watch sessions on a branch + --folder Only watch sessions under a folder Flags: -h, --help Show this help message diff --git a/cmd/dispatch/open.go b/cmd/dispatch/open.go index 5343d07..aecd791 100644 --- a/cmd/dispatch/open.go +++ b/cmd/dispatch/open.go @@ -23,6 +23,7 @@ var ( openGetLastSessionFn = defaultOpenGetLastSession openLaunchFn = defaultOpenLaunch openResumeCmdFn = platform.BuildResumeCommandString + openListSessionsFn = defaultStatsListSessions // openStdin is the reader used by --stdin batch resume. It is a package // variable so tests can feed it a fixed list of IDs. @@ -40,7 +41,7 @@ func runOpen(w io.Writer, args []string) error { w = io.Discard } - id, modeFlag, last, printCmd, stdin, ov, err := parseOpenArgs(args) + id, modeFlag, last, printCmd, stdin, ov, scopeFilter, err := parseOpenArgs(args) if err != nil { return err } @@ -64,6 +65,16 @@ func runOpen(w io.Writer, args []string) error { if sess == nil { return errors.New("no sessions to resume") } + } else if scopeFilter != nil { + // Scoped resume: find the newest session matching the filter. + sessions, lErr := openListSessionsFn(*scopeFilter) + if lErr != nil { + return lErr + } + if len(sessions) == 0 { + return fmt.Errorf("no sessions found matching the filter") + } + sess = &sessions[0] } else { // Resolve the argument as an alias first; fall back to treating it as a // session ID when no alias matches. @@ -211,18 +222,20 @@ func openResumeConfig(cfg *config.Config, sess *data.Session) platform.ResumeCon // parseOpenArgs extracts the session ID, optional launch mode, the --last // flag, the --print flag, the --stdin flag, and any per-launch agent/model/yolo // overrides from the "open" subcommand arguments. args[0] is expected to be "open". -func parseOpenArgs(args []string) (id, mode string, last, printCmd, stdin bool, ov launchOverrides, err error) { +func parseOpenArgs(args []string) (id, mode string, last, printCmd, stdin bool, ov launchOverrides, scopeFilter *data.FilterOptions, err error) { rest := args if len(rest) > 0 { rest = rest[1:] // drop the "open" token } var positionals []string + var repo, branch, folder string + var current bool for i := 0; i < len(rest); i++ { arg := rest[i] if matched, next, mErr := matchLaunchOverride(rest, i, &ov); matched { if mErr != nil { - return "", "", false, false, false, launchOverrides{}, mErr + return "", "", false, false, false, launchOverrides{}, nil, mErr } i = next continue @@ -232,52 +245,104 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd, stdin bool, last = true case arg == "--stdin": stdin = true + case arg == "--current": + current = true case arg == "--mode" || arg == "-m": if i+1 >= len(rest) { - return "", "", false, false, false, launchOverrides{}, errors.New("--mode requires a value: inplace, tab, window, or pane") + return "", "", false, false, false, launchOverrides{}, nil, errors.New("--mode requires a value: inplace, tab, window, or pane") } mode = rest[i+1] i++ case strings.HasPrefix(arg, "--mode="): mode = strings.TrimPrefix(arg, "--mode=") + case arg == "--repo": + if i+1 >= len(rest) { + return "", "", false, false, false, launchOverrides{}, nil, errors.New("--repo requires a value") + } + repo = rest[i+1] + i++ + case strings.HasPrefix(arg, "--repo="): + repo = strings.TrimPrefix(arg, "--repo=") + case arg == "--branch": + if i+1 >= len(rest) { + return "", "", false, false, false, launchOverrides{}, nil, errors.New("--branch requires a value") + } + branch = rest[i+1] + i++ + case strings.HasPrefix(arg, "--branch="): + branch = strings.TrimPrefix(arg, "--branch=") + case arg == "--folder": + if i+1 >= len(rest) { + return "", "", false, false, false, launchOverrides{}, nil, errors.New("--folder requires a value") + } + folder = rest[i+1] + i++ + case strings.HasPrefix(arg, "--folder="): + folder = strings.TrimPrefix(arg, "--folder=") case arg == "--print": printCmd = true case strings.HasPrefix(arg, "-"): - return "", "", false, false, false, launchOverrides{}, fmt.Errorf("unknown flag: %s", arg) + return "", "", false, false, false, launchOverrides{}, nil, fmt.Errorf("unknown flag: %s", arg) default: positionals = append(positionals, arg) } } + hasScope := repo != "" || branch != "" || folder != "" || current switch { case stdin: - if last { - return "", "", false, false, false, launchOverrides{}, errors.New("open --stdin cannot be combined with --last") + if last || hasScope { + return "", "", false, false, false, launchOverrides{}, nil, errors.New("open --stdin cannot be combined with --last or scope filters") } if len(positionals) > 0 { - return "", "", false, false, false, launchOverrides{}, errors.New("open --stdin reads session IDs from standard input; do not also pass an ID") + return "", "", false, false, false, launchOverrides{}, nil, errors.New("open --stdin reads session IDs from standard input; do not also pass an ID") } case last: + if hasScope { + return "", "", false, false, false, launchOverrides{}, nil, errors.New("open --last cannot be combined with --repo, --branch, --folder, or --current") + } if len(positionals) > 0 { - return "", "", false, false, false, launchOverrides{}, errors.New("open --last does not take a session ID") + return "", "", false, false, false, launchOverrides{}, nil, errors.New("open --last does not take a session ID") } + case hasScope: + if len(positionals) > 0 { + return "", "", false, false, false, launchOverrides{}, nil, errors.New("open with scope filters does not take a session ID") + } + filter := data.FilterOptions{ + Repository: repo, + Branch: branch, + Folder: folder, + } + if current { + detectedRepo, detectedBranch, cErr := detectGitContext() + if cErr != nil { + return "", "", false, false, false, launchOverrides{}, nil, fmt.Errorf("--current: %w", cErr) + } + if filter.Repository == "" { + filter.Repository = detectedRepo + } + if filter.Branch == "" { + filter.Branch = detectedBranch + } + } + scopeFilter = &filter default: switch len(positionals) { case 0: - return "", "", false, false, false, launchOverrides{}, errors.New("open requires a session ID (or use --last or --stdin)") + return "", "", false, false, false, launchOverrides{}, nil, errors.New("open requires a session ID (or use --last, --current, --repo, --branch, or --stdin)") case 1: id = positionals[0] default: - return "", "", false, false, false, launchOverrides{}, fmt.Errorf("open accepts a single session ID, got %d arguments", len(positionals)) + return "", "", false, false, false, launchOverrides{}, nil, fmt.Errorf("open accepts a single session ID, got %d arguments", len(positionals)) } } if mode != "" { if _, mErr := normalizeLaunchMode(mode); mErr != nil { - return "", "", false, false, false, launchOverrides{}, mErr + return "", "", false, false, false, launchOverrides{}, nil, mErr } } - return id, mode, last, printCmd, stdin, ov, nil + return id, mode, last, printCmd, stdin, ov, scopeFilter, nil } // normalizeLaunchMode maps a user-facing mode string to a config launch mode. @@ -421,3 +486,13 @@ func launchStyleForOpenMode(mode string) string { return platform.LaunchStyleTab } } + +// detectGitContext resolves the repo and branch for the current working +// directory, reusing the same seam the startup filter uses. +func detectGitContext() (string, string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", "", err + } + return detectGitRepoFn(cwd) +} diff --git a/cmd/dispatch/open_test.go b/cmd/dispatch/open_test.go index 6d23153..0c66aa9 100644 --- a/cmd/dispatch/open_test.go +++ b/cmd/dispatch/open_test.go @@ -50,7 +50,7 @@ func TestParseOpenArgs(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - id, mode, last, printCmd, stdin, _, err := parseOpenArgs(tc.args) + id, mode, last, printCmd, stdin, _, _, err := parseOpenArgs(tc.args) if tc.wantErr { if err == nil { t.Fatalf("expected error, got id=%q mode=%q last=%v", id, mode, last) @@ -522,3 +522,91 @@ func TestRunOpen_StdinPrint(t *testing.T) { t.Errorf("output = %q, want resume commands for both sessions", out) } } + +func TestParseOpenArgs_ScopeFlags(t *testing.T) { + // --repo flag produces a non-nil scopeFilter. + _, _, _, _, _, _, scope, err := parseOpenArgs([]string{"open", "--repo", "jongio/dispatch"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if scope == nil { + t.Fatal("expected scope filter to be non-nil") + } + if scope.Repository != "jongio/dispatch" { + t.Errorf("Repository = %q, want jongio/dispatch", scope.Repository) + } +} + +func TestParseOpenArgs_CurrentFlag(t *testing.T) { + origGit := detectGitRepoFn + detectGitRepoFn = func(string) (string, string, error) { + return "test/repo", "test-branch", nil + } + t.Cleanup(func() { detectGitRepoFn = origGit }) + + _, _, _, _, _, _, scope, err := parseOpenArgs([]string{"open", "--current"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if scope == nil { + t.Fatal("expected scope filter to be non-nil for --current") + } + if scope.Repository != "test/repo" { + t.Errorf("Repository = %q, want test/repo", scope.Repository) + } +} + +func TestParseOpenArgs_ScopeWithLastConflict(t *testing.T) { + _, _, _, _, _, _, _, err := parseOpenArgs([]string{"open", "--last", "--repo", "x/y"}) + if err == nil { + t.Fatal("expected error for --last + --repo conflict") + } +} + +func TestParseOpenArgs_ScopeWithIDConflict(t *testing.T) { + _, _, _, _, _, _, _, err := parseOpenArgs([]string{"open", "abc", "--repo", "x/y"}) + if err == nil { + t.Fatal("expected error for ID + --repo conflict") + } +} + +func TestRunOpen_ScopedResume(t *testing.T) { + cfg := config.Default() + cfg.LaunchMode = config.LaunchModeTab + sess := &data.Session{ID: "scope-1", Cwd: "/tmp/p", Repository: "x/y", Branch: "main"} + capture := withOpenStubs(t, cfg, sess, nil) + + origList := openListSessionsFn + openListSessionsFn = func(f data.FilterOptions) ([]data.Session, error) { + if f.Repository != "x/y" { + t.Errorf("filter.Repository = %q, want x/y", f.Repository) + } + return []data.Session{*sess}, nil + } + t.Cleanup(func() { openListSessionsFn = origList }) + + if err := runOpen(io.Discard, []string{"open", "--repo", "x/y"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !capture.launched { + t.Fatal("expected launch to be invoked") + } + if capture.session == nil || capture.session.ID != "scope-1" { + t.Errorf("launched session = %+v, want scope-1", capture.session) + } +} + +func TestRunOpen_ScopedResumeNoMatch(t *testing.T) { + withOpenStubs(t, config.Default(), nil, nil) + + origList := openListSessionsFn + openListSessionsFn = func(data.FilterOptions) ([]data.Session, error) { + return nil, nil + } + t.Cleanup(func() { openListSessionsFn = origList }) + + err := runOpen(io.Discard, []string{"open", "--repo", "nonexistent/repo"}) + if err == nil { + t.Fatal("expected error when no sessions match scope filter") + } +} diff --git a/cmd/dispatch/prune.go b/cmd/dispatch/prune.go new file mode 100644 index 0000000..02858a2 --- /dev/null +++ b/cmd/dispatch/prune.go @@ -0,0 +1,264 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strings" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/data" +) + +// pruneListSessionsFn loads sessions for the prune command. It is a package +// variable so tests can substitute a fixed set of sessions. +var pruneListSessionsFn = defaultStatsListSessions + +// pruneCategory describes stale entries found in one config section. +type pruneCategory struct { + Name string `json:"name"` + Stale int `json:"stale"` + Kept int `json:"kept"` + Removed []string `json:"removed,omitempty"` +} + +// pruneReport is the aggregate output for the prune command. +type pruneReport struct { + TotalStale int `json:"total_stale"` + Applied bool `json:"applied"` + Categories []pruneCategory `json:"categories"` +} + +// pruneOptions holds parsed flags. +type pruneOptions struct { + apply bool + json bool +} + +// runPrune scans session-keyed config entries and reports (or removes) entries +// whose session ID is no longer in the store. +func runPrune(w io.Writer, args []string) error { + if w == nil { + w = io.Discard + } + + opts, err := parsePruneArgs(args) + if err != nil { + return err + } + + cfg, err := configLoadFn() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + sessions, err := pruneListSessionsFn(data.FilterOptions{}) + if err != nil { + return err + } + + liveIDs := make(map[string]bool, len(sessions)) + for _, s := range sessions { + liveIDs[s.ID] = true + } + + report := buildPruneReport(cfg, liveIDs) + report.Applied = opts.apply + + if opts.apply && report.TotalStale > 0 { + applyPrune(cfg, liveIDs) + if err := configSaveFn(cfg); err != nil { + return err + } + } + + if opts.json { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(report) + } + writePruneText(w, report) + return nil +} + +// parsePruneArgs reads the prune subcommand flags. +func parsePruneArgs(args []string) (pruneOptions, error) { + var opts pruneOptions + rest := args + if len(rest) > 0 { + rest = rest[1:] + } + for _, arg := range rest { + switch { + case arg == "--apply": + opts.apply = true + case arg == "--json": + opts.json = true + case strings.HasPrefix(arg, "-"): + return pruneOptions{}, fmt.Errorf("unknown flag: %s", arg) + default: + return pruneOptions{}, fmt.Errorf("prune does not take positional arguments, got %q", arg) + } + } + return opts, nil +} + +// buildPruneReport scans each config section for stale entries. +func buildPruneReport(cfg *config.Config, liveIDs map[string]bool) pruneReport { + report := pruneReport{Categories: []pruneCategory{}} + + report.Categories = append( + report.Categories, + pruneMapStringString("aliases", cfg.SessionAliases, liveIDs), + pruneMapStringSlice("tags", cfg.SessionTags, liveIDs), + pruneMapStringString("notes", cfg.SessionNotes, liveIDs), + pruneMapStringLaunch("launches", cfg.SessionLaunches, liveIDs), + pruneStringSlice("favorites", cfg.FavoriteSessions, liveIDs), + pruneStringSlice("hidden", cfg.HiddenSessions, liveIDs), + ) + + for _, cat := range report.Categories { + report.TotalStale += cat.Stale + } + return report +} + +// applyPrune removes stale entries from the config in place. +func applyPrune(cfg *config.Config, liveIDs map[string]bool) { + pruneMapInPlace(cfg.SessionAliases, liveIDs) + pruneMapSliceInPlace(cfg.SessionTags, liveIDs) + pruneMapInPlace(cfg.SessionNotes, liveIDs) + pruneMapLaunchInPlace(cfg.SessionLaunches, liveIDs) + cfg.FavoriteSessions = filterSlice(cfg.FavoriteSessions, liveIDs) + cfg.HiddenSessions = filterSlice(cfg.HiddenSessions, liveIDs) +} + +// pruneMapStringString reports stale entries in a map[string]string keyed by +// session ID. +func pruneMapStringString(name string, m map[string]string, liveIDs map[string]bool) pruneCategory { + cat := pruneCategory{Name: name} + for id := range m { + if liveIDs[id] { + cat.Kept++ + } else { + cat.Stale++ + cat.Removed = append(cat.Removed, id) + } + } + sort.Strings(cat.Removed) + return cat +} + +// pruneMapStringSlice reports stale entries in a map[string][]string keyed by +// session ID. +func pruneMapStringSlice(name string, m map[string][]string, liveIDs map[string]bool) pruneCategory { + cat := pruneCategory{Name: name} + for id := range m { + if liveIDs[id] { + cat.Kept++ + } else { + cat.Stale++ + cat.Removed = append(cat.Removed, id) + } + } + sort.Strings(cat.Removed) + return cat +} + +// pruneMapStringLaunch reports stale entries in the SessionLaunches map. +func pruneMapStringLaunch(name string, m map[string]config.SessionLaunch, liveIDs map[string]bool) pruneCategory { + cat := pruneCategory{Name: name} + for id := range m { + if liveIDs[id] { + cat.Kept++ + } else { + cat.Stale++ + cat.Removed = append(cat.Removed, id) + } + } + sort.Strings(cat.Removed) + return cat +} + +// pruneStringSlice reports stale entries in a string slice of session IDs. +func pruneStringSlice(name string, ids []string, liveIDs map[string]bool) pruneCategory { + cat := pruneCategory{Name: name} + for _, id := range ids { + if liveIDs[id] { + cat.Kept++ + } else { + cat.Stale++ + cat.Removed = append(cat.Removed, id) + } + } + sort.Strings(cat.Removed) + return cat +} + +// pruneMapInPlace deletes keys from a map[string]string that are not in liveIDs. +func pruneMapInPlace(m map[string]string, liveIDs map[string]bool) { + for id := range m { + if !liveIDs[id] { + delete(m, id) + } + } +} + +// pruneMapSliceInPlace deletes keys from a map[string][]string that are not in liveIDs. +func pruneMapSliceInPlace(m map[string][]string, liveIDs map[string]bool) { + for id := range m { + if !liveIDs[id] { + delete(m, id) + } + } +} + +// pruneMapLaunchInPlace deletes keys from the SessionLaunches map that are not in liveIDs. +func pruneMapLaunchInPlace(m map[string]config.SessionLaunch, liveIDs map[string]bool) { + for id := range m { + if !liveIDs[id] { + delete(m, id) + } + } +} + +// filterSlice returns a new slice containing only IDs present in liveIDs. +func filterSlice(ids []string, liveIDs map[string]bool) []string { + result := make([]string, 0, len(ids)) + for _, id := range ids { + if liveIDs[id] { + result = append(result, id) + } + } + return result +} + +// writePruneText prints the prune report in a human-readable layout. +func writePruneText(w io.Writer, report pruneReport) { + if report.TotalStale == 0 { + fmt.Fprintln(w, "Nothing to prune. All config entries reference existing sessions.") + return + } + + if report.Applied { + fmt.Fprintf(w, "Pruned %d stale config entries.\n\n", report.TotalStale) + } else { + fmt.Fprintf(w, "Found %d stale config entries (run with --apply to remove).\n\n", report.TotalStale) + } + + for _, cat := range report.Categories { + if cat.Stale == 0 { + continue + } + fmt.Fprintf(w, " %s: %d stale, %d kept\n", cat.Name, cat.Stale, cat.Kept) + limit := 5 + for i, id := range cat.Removed { + if i >= limit { + fmt.Fprintf(w, " ... and %d more\n", cat.Stale-limit) + break + } + fmt.Fprintf(w, " %s\n", id) + } + } +} diff --git a/cmd/dispatch/prune_test.go b/cmd/dispatch/prune_test.go new file mode 100644 index 0000000..f1b515c --- /dev/null +++ b/cmd/dispatch/prune_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "testing" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/data" + "github.com/jongio/dispatch/internal/update" +) + +func withPruneSeams(t *testing.T, cfg *config.Config, sessions []data.Session) { + t.Helper() + + prevLoad := configLoadFn + prevSave := configSaveFn + configLoadFn = func() (*config.Config, error) { return cfg, nil } + configSaveFn = func(c *config.Config) error { return nil } + t.Cleanup(func() { configLoadFn = prevLoad; configSaveFn = prevSave }) + + prevList := pruneListSessionsFn + pruneListSessionsFn = func(data.FilterOptions) ([]data.Session, error) { + return sessions, nil + } + t.Cleanup(func() { pruneListSessionsFn = prevList }) +} + +func makePruneConfig() *config.Config { + return &config.Config{ + SessionAliases: map[string]string{ + "live-1": "auth", + "gone-1": "old", + }, + SessionTags: map[string][]string{ + "live-1": {"work"}, + "gone-2": {"stale"}, + }, + SessionNotes: map[string]string{ + "live-1": "keep this", + "gone-3": "remove this", + }, + FavoriteSessions: []string{"live-1", "gone-4"}, + HiddenSessions: []string{"gone-5"}, + SessionLaunches: map[string]config.SessionLaunch{ + "live-1": {Count: 5}, + "gone-6": {Count: 1}, + }, + } +} + +func TestRunPrune_DryRun(t *testing.T) { + cfg := makePruneConfig() + sessions := []data.Session{{ID: "live-1"}} + withPruneSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runPrune(&buf, []string{"prune"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + if !bytes.Contains([]byte(out), []byte("--apply")) { + t.Errorf("expected --apply hint, got:\n%s", out) + } + // Verify config was NOT modified (dry run). + if _, ok := cfg.SessionAliases["gone-1"]; !ok { + t.Error("dry run should not remove entries") + } +} + +func TestRunPrune_Apply(t *testing.T) { + cfg := makePruneConfig() + sessions := []data.Session{{ID: "live-1"}} + withPruneSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runPrune(&buf, []string{"prune", "--apply"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + if !bytes.Contains([]byte(out), []byte("Pruned")) { + t.Errorf("expected 'Pruned' message, got:\n%s", out) + } + if _, ok := cfg.SessionAliases["gone-1"]; ok { + t.Error("expected stale alias to be removed after --apply") + } + if _, ok := cfg.SessionAliases["live-1"]; !ok { + t.Error("live alias should be kept") + } + if len(cfg.FavoriteSessions) != 1 || cfg.FavoriteSessions[0] != "live-1" { + t.Errorf("favorites = %v, want [live-1]", cfg.FavoriteSessions) + } +} + +func TestRunPrune_JSON(t *testing.T) { + cfg := makePruneConfig() + sessions := []data.Session{{ID: "live-1"}} + withPruneSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runPrune(&buf, []string{"prune", "--json"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got pruneReport + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if got.TotalStale == 0 { + t.Error("expected stale entries") + } +} + +func TestRunPrune_NothingToPrune(t *testing.T) { + cfg := &config.Config{ + SessionAliases: map[string]string{"live-1": "auth"}, + } + sessions := []data.Session{{ID: "live-1"}} + withPruneSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runPrune(&buf, []string{"prune"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + if !bytes.Contains([]byte(out), []byte("Nothing to prune")) { + t.Errorf("expected nothing-to-prune message, got:\n%s", out) + } +} + +func TestRunPrune_EmptyConfig(t *testing.T) { + cfg := &config.Config{} + withPruneSeams(t, cfg, nil) + + var buf bytes.Buffer + if err := runPrune(&buf, []string{"prune"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + if !bytes.Contains([]byte(out), []byte("Nothing to prune")) { + t.Errorf("expected nothing-to-prune message, got:\n%s", out) + } +} + +func TestRunPrune_ConfigLoadError(t *testing.T) { + prev := configLoadFn + configLoadFn = func() (*config.Config, error) { return nil, errors.New("boom") } + t.Cleanup(func() { configLoadFn = prev }) + + err := runPrune(&bytes.Buffer{}, []string{"prune"}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestParsePruneArgs_UnknownFlag(t *testing.T) { + _, err := parsePruneArgs([]string{"prune", "--nope"}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestHandleArgs_Prune(t *testing.T) { + cfg := &config.Config{} + withPruneSeams(t, cfg, nil) + + ch := make(chan *update.UpdateInfo, 1) + ch <- nil + + done, _, _, err := handleArgs([]string{"prune"}, &bytes.Buffer{}, ch) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !done { + t.Error("expected done=true") + } +} diff --git a/cmd/dispatch/stats.go b/cmd/dispatch/stats.go index 73a6d00..6d736ca 100644 --- a/cmd/dispatch/stats.go +++ b/cmd/dispatch/stats.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/csv" "encoding/json" "fmt" "io" @@ -26,6 +27,7 @@ const statsQueryLimit = 100_000 type statsOptions struct { filter data.FilterOptions json bool + csv bool calendar bool top int } @@ -86,6 +88,9 @@ func runStats(w io.Writer, args []string) error { report.Calendar = &cal } applyStatsTopLimit(&report, opts.top) + if opts.csv { + return writeStatsCSV(w, report) + } if opts.json { return writeStatsJSON(w, report) } @@ -123,6 +128,8 @@ func parseStatsArgs(args []string) (statsOptions, error) { switch { case name == "--json": opts.json = true + case name == "--csv": + opts.csv = true case name == "--calendar": opts.calendar = true case name == "--top": @@ -186,6 +193,10 @@ func parseStatsArgs(args []string) (statsOptions, error) { } } + if opts.json && opts.csv { + return statsOptions{}, fmt.Errorf("--json and --csv cannot be combined") + } + return opts, nil } @@ -337,6 +348,51 @@ func writeStatsJSON(w io.Writer, report statsReport) error { return enc.Encode(report) } +// writeStatsCSV prints the report as RFC 4180 CSV rows. +func writeStatsCSV(w io.Writer, report statsReport) error { + cw := csv.NewWriter(w) + defer cw.Flush() + + if err := cw.Write([]string{"section", "label", "count"}); err != nil { + return err + } + if err := cw.Write([]string{"totals", "sessions", strconv.Itoa(report.TotalSessions)}); err != nil { + return err + } + if err := cw.Write([]string{"totals", "turns", strconv.Itoa(report.TotalTurns)}); err != nil { + return err + } + if err := cw.Write([]string{"totals", "files", strconv.Itoa(report.TotalFiles)}); err != nil { + return err + } + + for _, e := range report.ByRepository { + if err := cw.Write([]string{"repository", e.Label, strconv.Itoa(e.Count)}); err != nil { + return err + } + } + for _, e := range report.ByBranch { + if err := cw.Write([]string{"branch", e.Label, strconv.Itoa(e.Count)}); err != nil { + return err + } + } + for _, e := range report.ByHostType { + if err := cw.Write([]string{"host_type", e.Label, strconv.Itoa(e.Count)}); err != nil { + return err + } + } + + if report.Calendar != nil { + for _, d := range report.Calendar.Days { + if err := cw.Write([]string{"calendar", d.Date, strconv.Itoa(d.Count)}); err != nil { + return err + } + } + } + + return nil +} + // writeStatsText prints the report in a plain, human-readable layout. func writeStatsText(w io.Writer, report statsReport) { fmt.Fprintln(w, "Dispatch stats") diff --git a/cmd/dispatch/stats_test.go b/cmd/dispatch/stats_test.go index cb5613a..bcb5d78 100644 --- a/cmd/dispatch/stats_test.go +++ b/cmd/dispatch/stats_test.go @@ -278,3 +278,44 @@ func TestHandleArgsStats(t *testing.T) { t.Errorf("unexpected error: %v", err) } } + +func TestParseStatsArgs_CSV(t *testing.T) { + opts, err := parseStatsArgs([]string{"stats", "--csv"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !opts.csv { + t.Error("expected csv=true") + } +} + +func TestParseStatsArgs_CSVAndJSONConflict(t *testing.T) { + _, err := parseStatsArgs([]string{"stats", "--csv", "--json"}) + if err == nil { + t.Fatal("expected error for --csv + --json conflict") + } + if !strings.Contains(err.Error(), "cannot be combined") { + t.Errorf("wrong error: %v", err) + } +} + +func TestRunStatsCSV(t *testing.T) { + withStatsList(t, func(data.FilterOptions) ([]data.Session, error) { + return sampleSessions(), nil + }) + + var buf bytes.Buffer + if err := runStats(&buf, []string{"stats", "--csv"}); err != nil { + t.Fatalf("runStats --csv: %v", err) + } + out := buf.String() + if !strings.Contains(out, "section,label,count") { + t.Errorf("CSV missing header, got:\n%s", out) + } + if !strings.Contains(out, "totals,sessions,3") { + t.Errorf("CSV missing totals row, got:\n%s", out) + } + if !strings.Contains(out, "repository,jongio/dispatch,2") { + t.Errorf("CSV missing repo breakdown, got:\n%s", out) + } +} diff --git a/cmd/dispatch/tag.go b/cmd/dispatch/tag.go new file mode 100644 index 0000000..512fcd8 --- /dev/null +++ b/cmd/dispatch/tag.go @@ -0,0 +1,261 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strings" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/data" +) + +// tagListSessionsFn loads sessions for the tag command. It is a package +// variable so tests can substitute a fixed set of sessions. +var tagListSessionsFn = defaultStatsListSessions + +// tagResult is the JSON output after a tag mutation. +type tagResult struct { + ID string `json:"id"` + Tags []string `json:"tags"` +} + +// runTag manages tags on a single session. args[0] is "tag". +// +// dispatch tag list tags for one session +// dispatch tag --add a,b add tags +// dispatch tag --remove a remove tags +// dispatch tag --set a,b replace all tags +// dispatch tag --json print result as JSON +func runTag(w io.Writer, args []string) error { + if w == nil { + w = io.Discard + } + + opts, err := parseTagArgs(args) + if err != nil { + return err + } + + cfg, err := configLoadFn() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + // Resolve alias to session ID. + sessionID := opts.id + if resolved := cfg.SessionIDForAlias(sessionID); resolved != "" { + sessionID = resolved + } + + // Verify the session exists in the store. + sessions, err := tagListSessionsFn(data.FilterOptions{}) + if err != nil { + return err + } + found := false + for _, s := range sessions { + if s.ID == sessionID { + found = true + break + } + } + if !found { + return fmt.Errorf("session %q not found", opts.id) + } + + // Apply mutation if requested. + mutated := false + switch opts.action { + case tagActionNone: + // Display-only: no mutation requested. + case tagActionAdd: + mutated = true + addSessionTags(cfg, sessionID, opts.values) + case tagActionRemove: + mutated = true + removeSessionTags(cfg, sessionID, opts.values) + case tagActionSet: + mutated = true + setSessionTags(cfg, sessionID, opts.values) + } + + if mutated { + if err := configSaveFn(cfg); err != nil { + return err + } + } + + tags := cfg.SessionTags[sessionID] + if opts.json { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(tagResult{ID: sessionID, Tags: tags}) + } + + if len(tags) == 0 { + fmt.Fprintf(w, "Session %s has no tags.\n", sessionID) + } else { + fmt.Fprintf(w, "Tags for %s: %s\n", sessionID, strings.Join(tags, ", ")) + } + return nil +} + +// tagAction describes which mutation the user requested. +type tagAction int + +const ( + tagActionNone tagAction = iota + tagActionAdd // --add + tagActionRemove // --remove + tagActionSet // --set +) + +// tagOptions holds parsed flags for the tag command. +type tagOptions struct { + id string + action tagAction + values []string + json bool +} + +// parseTagArgs reads the tag subcommand flags. +func parseTagArgs(args []string) (tagOptions, error) { + var opts tagOptions + + rest := args + if len(rest) > 0 { + rest = rest[1:] // drop "tag" + } + + i := 0 + for i < len(rest) { + arg := rest[i] + switch { + case arg == "--json": + opts.json = true + case arg == "--add": + if opts.action != tagActionNone { + return tagOptions{}, fmt.Errorf("only one of --add, --remove, --set is allowed") + } + i++ + if i >= len(rest) { + return tagOptions{}, fmt.Errorf("--add requires a comma-separated tag list") + } + opts.action = tagActionAdd + opts.values = splitTags(rest[i]) + case arg == "--remove": + if opts.action != tagActionNone { + return tagOptions{}, fmt.Errorf("only one of --add, --remove, --set is allowed") + } + i++ + if i >= len(rest) { + return tagOptions{}, fmt.Errorf("--remove requires a comma-separated tag list") + } + opts.action = tagActionRemove + opts.values = splitTags(rest[i]) + case arg == "--set": + if opts.action != tagActionNone { + return tagOptions{}, fmt.Errorf("only one of --add, --remove, --set is allowed") + } + i++ + if i >= len(rest) { + return tagOptions{}, fmt.Errorf("--set requires a comma-separated tag list") + } + opts.action = tagActionSet + opts.values = splitTags(rest[i]) + case strings.HasPrefix(arg, "-"): + return tagOptions{}, fmt.Errorf("unknown flag: %s", arg) + default: + if opts.id != "" { + return tagOptions{}, fmt.Errorf("tag takes one session ID, got extra %q", arg) + } + opts.id = arg + } + i++ + } + + if opts.id == "" { + return tagOptions{}, fmt.Errorf("tag requires a session ID") + } + return opts, nil +} + +// splitTags splits a comma-separated tag list, trims whitespace, and +// removes empty entries. +func splitTags(s string) []string { + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} + +// addSessionTags appends tags to a session, deduplicating and sorting. +func addSessionTags(cfg *config.Config, id string, tags []string) { + if cfg.SessionTags == nil { + cfg.SessionTags = make(map[string][]string) + } + existing := cfg.SessionTags[id] + seen := make(map[string]bool, len(existing)) + for _, t := range existing { + seen[t] = true + } + for _, t := range tags { + if !seen[t] { + existing = append(existing, t) + seen[t] = true + } + } + sort.Strings(existing) + cfg.SessionTags[id] = existing +} + +// removeSessionTags removes tags from a session. +func removeSessionTags(cfg *config.Config, id string, tags []string) { + if cfg.SessionTags == nil { + return + } + remove := make(map[string]bool, len(tags)) + for _, t := range tags { + remove[t] = true + } + existing := cfg.SessionTags[id] + result := make([]string, 0, len(existing)) + for _, t := range existing { + if !remove[t] { + result = append(result, t) + } + } + if len(result) == 0 { + delete(cfg.SessionTags, id) + } else { + cfg.SessionTags[id] = result + } +} + +// setSessionTags replaces all tags on a session. +func setSessionTags(cfg *config.Config, id string, tags []string) { + if cfg.SessionTags == nil { + cfg.SessionTags = make(map[string][]string) + } + if len(tags) == 0 { + delete(cfg.SessionTags, id) + return + } + deduped := make([]string, 0, len(tags)) + seen := make(map[string]bool, len(tags)) + for _, t := range tags { + if !seen[t] { + deduped = append(deduped, t) + seen[t] = true + } + } + sort.Strings(deduped) + cfg.SessionTags[id] = deduped +} diff --git a/cmd/dispatch/tag_test.go b/cmd/dispatch/tag_test.go new file mode 100644 index 0000000..b763baa --- /dev/null +++ b/cmd/dispatch/tag_test.go @@ -0,0 +1,263 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "testing" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/data" + "github.com/jongio/dispatch/internal/update" +) + +func withTagSeams(t *testing.T, cfg *config.Config, sessions []data.Session) { + t.Helper() + + prevLoad := configLoadFn + prevSave := configSaveFn + configLoadFn = func() (*config.Config, error) { return cfg, nil } + configSaveFn = func(c *config.Config) error { return nil } + t.Cleanup(func() { configLoadFn = prevLoad; configSaveFn = prevSave }) + + prevList := tagListSessionsFn + tagListSessionsFn = func(data.FilterOptions) ([]data.Session, error) { + return sessions, nil + } + t.Cleanup(func() { tagListSessionsFn = prevList }) +} + +func TestRunTag_ListTags(t *testing.T) { + cfg := &config.Config{ + SessionTags: map[string][]string{ + "ses-1": {"api", "work"}, + }, + } + sessions := []data.Session{{ID: "ses-1"}} + withTagSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runTag(&buf, []string{"tag", "ses-1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + if !bytes.Contains([]byte(out), []byte("api")) { + t.Errorf("expected 'api' in output, got:\n%s", out) + } + if !bytes.Contains([]byte(out), []byte("work")) { + t.Errorf("expected 'work' in output, got:\n%s", out) + } +} + +func TestRunTag_ListEmpty(t *testing.T) { + cfg := &config.Config{} + sessions := []data.Session{{ID: "ses-1"}} + withTagSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runTag(&buf, []string{"tag", "ses-1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + if !bytes.Contains([]byte(out), []byte("no tags")) { + t.Errorf("expected 'no tags' message, got:\n%s", out) + } +} + +func TestRunTag_Add(t *testing.T) { + cfg := &config.Config{ + SessionTags: map[string][]string{ + "ses-1": {"existing"}, + }, + } + sessions := []data.Session{{ID: "ses-1"}} + withTagSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runTag(&buf, []string{"tag", "ses-1", "--add", "new,another"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + tags := cfg.SessionTags["ses-1"] + if len(tags) != 3 { + t.Errorf("expected 3 tags, got %v", tags) + } +} + +func TestRunTag_AddDeduplicates(t *testing.T) { + cfg := &config.Config{ + SessionTags: map[string][]string{ + "ses-1": {"api"}, + }, + } + sessions := []data.Session{{ID: "ses-1"}} + withTagSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runTag(&buf, []string{"tag", "ses-1", "--add", "api,work"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + tags := cfg.SessionTags["ses-1"] + if len(tags) != 2 { + t.Errorf("expected 2 tags (no duplicate), got %v", tags) + } +} + +func TestRunTag_Remove(t *testing.T) { + cfg := &config.Config{ + SessionTags: map[string][]string{ + "ses-1": {"api", "work", "temp"}, + }, + } + sessions := []data.Session{{ID: "ses-1"}} + withTagSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runTag(&buf, []string{"tag", "ses-1", "--remove", "temp"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + tags := cfg.SessionTags["ses-1"] + if len(tags) != 2 { + t.Errorf("expected 2 tags after removal, got %v", tags) + } +} + +func TestRunTag_RemoveAll(t *testing.T) { + cfg := &config.Config{ + SessionTags: map[string][]string{ + "ses-1": {"only"}, + }, + } + sessions := []data.Session{{ID: "ses-1"}} + withTagSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runTag(&buf, []string{"tag", "ses-1", "--remove", "only"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := cfg.SessionTags["ses-1"]; ok { + t.Error("expected session tag entry to be deleted when all tags removed") + } +} + +func TestRunTag_Set(t *testing.T) { + cfg := &config.Config{ + SessionTags: map[string][]string{ + "ses-1": {"old1", "old2"}, + }, + } + sessions := []data.Session{{ID: "ses-1"}} + withTagSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runTag(&buf, []string{"tag", "ses-1", "--set", "new1,new2"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + tags := cfg.SessionTags["ses-1"] + if len(tags) != 2 || tags[0] != "new1" || tags[1] != "new2" { + t.Errorf("expected [new1, new2], got %v", tags) + } +} + +func TestRunTag_JSON(t *testing.T) { + cfg := &config.Config{ + SessionTags: map[string][]string{ + "ses-1": {"api"}, + }, + } + sessions := []data.Session{{ID: "ses-1"}} + withTagSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runTag(&buf, []string{"tag", "ses-1", "--json"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var got tagResult + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if got.ID != "ses-1" { + t.Errorf("id = %q, want ses-1", got.ID) + } +} + +func TestRunTag_AliasResolution(t *testing.T) { + cfg := &config.Config{ + SessionAliases: map[string]string{ + "ses-1": "myalias", + }, + SessionTags: map[string][]string{ + "ses-1": {"existing"}, + }, + } + sessions := []data.Session{{ID: "ses-1"}} + withTagSeams(t, cfg, sessions) + + var buf bytes.Buffer + if err := runTag(&buf, []string{"tag", "myalias"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + if !bytes.Contains([]byte(out), []byte("existing")) { + t.Errorf("expected tags via alias resolution, got:\n%s", out) + } +} + +func TestRunTag_UnknownSession(t *testing.T) { + cfg := &config.Config{} + sessions := []data.Session{{ID: "ses-1"}} + withTagSeams(t, cfg, sessions) + + err := runTag(&bytes.Buffer{}, []string{"tag", "missing"}) + if err == nil { + t.Fatal("expected error for unknown session") + } +} + +func TestRunTag_NoID(t *testing.T) { + err := runTag(&bytes.Buffer{}, []string{"tag"}) + if err == nil { + t.Fatal("expected error when no session ID given") + } +} + +func TestRunTag_UnknownFlag(t *testing.T) { + _, err := parseTagArgs([]string{"tag", "ses-1", "--nope"}) + if err == nil { + t.Fatal("expected error for unknown flag") + } +} + +func TestRunTag_ConflictingActions(t *testing.T) { + _, err := parseTagArgs([]string{"tag", "ses-1", "--add", "a", "--remove", "b"}) + if err == nil { + t.Fatal("expected error for conflicting actions") + } +} + +func TestRunTag_ConfigLoadError(t *testing.T) { + prev := configLoadFn + configLoadFn = func() (*config.Config, error) { return nil, errors.New("boom") } + t.Cleanup(func() { configLoadFn = prev }) + + err := runTag(&bytes.Buffer{}, []string{"tag", "ses-1"}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestHandleArgs_Tag(t *testing.T) { + cfg := &config.Config{} + sessions := []data.Session{{ID: "ses-1"}} + withTagSeams(t, cfg, sessions) + + ch := make(chan *update.UpdateInfo, 1) + ch <- nil + + done, _, _, err := handleArgs([]string{"tag", "ses-1"}, &bytes.Buffer{}, ch) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !done { + t.Error("expected done=true") + } +} diff --git a/cmd/dispatch/watch.go b/cmd/dispatch/watch.go new file mode 100644 index 0000000..df92b7e --- /dev/null +++ b/cmd/dispatch/watch.go @@ -0,0 +1,370 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/signal" + "sort" + "strings" + "time" + + "github.com/jongio/dispatch/internal/data" +) + +// watchScanAttentionFn scans session attention state. It is a package variable +// so tests can substitute a controlled snapshot. +var watchScanAttentionFn = func(threshold time.Duration) map[string]data.AttentionStatus { + return data.ScanAttention(threshold, true) +} + +// watchListSessionsFn loads sessions so watch can apply filters and resolve +// metadata. It is a package variable so tests can substitute a fixed set. +var watchListSessionsFn = defaultStatsListSessions + +// bellFn writes a BEL character. It is a package variable so tests can +// suppress the audible bell. +var bellFn = func() { fmt.Fprint(os.Stderr, "\a") } + +// watchOptions holds parsed flags for the watch command. +type watchOptions struct { + once bool + json bool + interval time.Duration + repo string + branch string + folder string +} + +// watchSnapshot is the JSON representation of attention state at a point in time. +type watchSnapshot struct { + Timestamp string `json:"timestamp"` + Total int `json:"total"` + Waiting int `json:"waiting"` + Working int `json:"working"` + Thinking int `json:"thinking"` + Active int `json:"active"` + Stale int `json:"stale"` + Idle int `json:"idle"` + Compacting int `json:"compacting"` + Sessions []watchSessionEntry `json:"sessions,omitempty"` +} + +// watchSessionEntry describes one session in the watch output. +type watchSessionEntry struct { + ID string `json:"id"` + Status string `json:"status"` + Summary string `json:"summary,omitempty"` +} + +// runWatch monitors attention state. With --once it prints a snapshot and +// exits; otherwise it polls and prints transitions. +func runWatch(w io.Writer, args []string) error { + if w == nil { + w = io.Discard + } + + opts, err := parseWatchArgs(args) + if err != nil { + return err + } + + if opts.once { + return runWatchOnce(w, opts) + } + return runWatchStream(w, opts) +} + +// parseWatchArgs reads the watch subcommand flags. +func parseWatchArgs(args []string) (watchOptions, error) { + opts := watchOptions{interval: 5 * time.Second} + + rest := args + if len(rest) > 0 { + rest = rest[1:] + } + + i := 0 + for i < len(rest) { + arg := rest[i] + switch { + case arg == "--once": + opts.once = true + case arg == "--json": + opts.json = true + case arg == "--interval": + i++ + if i >= len(rest) { + return watchOptions{}, fmt.Errorf("--interval requires a duration (e.g. 5s, 1m)") + } + d, dErr := time.ParseDuration(rest[i]) + if dErr != nil { + return watchOptions{}, fmt.Errorf("invalid interval %q: %w", rest[i], dErr) + } + if d < time.Second { + d = time.Second + } + opts.interval = d + case arg == "--repo": + i++ + if i >= len(rest) { + return watchOptions{}, fmt.Errorf("--repo requires a value") + } + opts.repo = rest[i] + case arg == "--branch": + i++ + if i >= len(rest) { + return watchOptions{}, fmt.Errorf("--branch requires a value") + } + opts.branch = rest[i] + case arg == "--folder": + i++ + if i >= len(rest) { + return watchOptions{}, fmt.Errorf("--folder requires a value") + } + opts.folder = rest[i] + case strings.HasPrefix(arg, "-"): + return watchOptions{}, fmt.Errorf("unknown flag: %s", arg) + default: + return watchOptions{}, fmt.Errorf("watch does not take positional arguments, got %q", arg) + } + i++ + } + + return opts, nil +} + +// runWatchOnce prints a single attention snapshot and exits. +func runWatchOnce(w io.Writer, opts watchOptions) error { + snap, err := buildWatchSnapshot(opts) + if err != nil { + return err + } + if opts.json { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(snap) + } + writeWatchSnapshotText(w, snap) + return nil +} + +// runWatchStream polls attention state and prints transitions until Ctrl-C. +func runWatchStream(w io.Writer, opts watchOptions) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + prev := map[string]data.AttentionStatus{} + ticker := time.NewTicker(opts.interval) + defer ticker.Stop() + + // Print initial snapshot. + snap, err := buildWatchSnapshot(opts) + if err != nil { + return err + } + if opts.json { + enc := json.NewEncoder(w) + if encErr := enc.Encode(snap); encErr != nil { + return encErr + } + } else { + writeWatchSnapshotText(w, snap) + fmt.Fprintln(w) + } + + // Build initial state for transition detection. + attention := scanFiltered(opts) + for id, status := range attention { + prev[id] = status + } + + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + current := scanFiltered(opts) + for id, status := range current { + old, existed := prev[id] + if !existed || old != status { + if status == data.AttentionWaiting || status == data.AttentionInterrupted { + bellFn() + } + ts := time.Now().Format("15:04:05") + if opts.json { + enc := json.NewEncoder(w) + _ = enc.Encode(map[string]string{ + "time": ts, + "id": id, + "status": status.String(), + }) + } else { + fmt.Fprintf(w, "[%s] %s %s\n", ts, shortID(id), status.String()) + } + } + } + // Detect sessions that disappeared. + for id := range prev { + if _, ok := current[id]; !ok { + ts := time.Now().Format("15:04:05") + if opts.json { + enc := json.NewEncoder(w) + _ = enc.Encode(map[string]string{ + "time": ts, + "id": id, + "status": "gone", + }) + } else { + fmt.Fprintf(w, "[%s] %s gone\n", ts, shortID(id)) + } + } + } + prev = current + } + } +} + +// scanFiltered runs the attention scan and filters by session metadata. +func scanFiltered(opts watchOptions) map[string]data.AttentionStatus { + threshold := 15 * time.Minute + attention := watchScanAttentionFn(threshold) + + if opts.repo == "" && opts.branch == "" && opts.folder == "" { + return attention + } + + sessions, err := watchListSessionsFn(data.FilterOptions{ + Repository: opts.repo, + Branch: opts.branch, + Folder: opts.folder, + }) + if err != nil { + return attention + } + + allowed := make(map[string]bool, len(sessions)) + for _, s := range sessions { + allowed[s.ID] = true + } + + filtered := make(map[string]data.AttentionStatus, len(attention)) + for id, status := range attention { + if allowed[id] { + filtered[id] = status + } + } + return filtered +} + +// buildWatchSnapshot creates a snapshot of the current attention state. +func buildWatchSnapshot(opts watchOptions) (watchSnapshot, error) { + attention := scanFiltered(opts) + + sessions, err := watchListSessionsFn(data.FilterOptions{ + Repository: opts.repo, + Branch: opts.branch, + Folder: opts.folder, + }) + if err != nil { + return watchSnapshot{}, err + } + + summaries := make(map[string]string, len(sessions)) + for _, s := range sessions { + summaries[s.ID] = s.Summary + } + + snap := watchSnapshot{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + + entries := make([]watchSessionEntry, 0, len(attention)) + for id, status := range attention { + snap.Total++ + switch status { + case data.AttentionWaiting: + snap.Waiting++ + case data.AttentionWorking: + snap.Working++ + case data.AttentionThinking: + snap.Thinking++ + case data.AttentionActive: + snap.Active++ + case data.AttentionStale: + snap.Stale++ + case data.AttentionCompacting: + snap.Compacting++ + default: + snap.Idle++ + } + + if status == data.AttentionWaiting || status == data.AttentionInterrupted || + status == data.AttentionWorking || status == data.AttentionThinking || + status == data.AttentionActive { + entries = append(entries, watchSessionEntry{ + ID: id, + Status: status.String(), + Summary: summaries[id], + }) + } + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].ID < entries[j].ID + }) + snap.Sessions = entries + return snap, nil +} + +// writeWatchSnapshotText prints a human-readable attention snapshot. +func writeWatchSnapshotText(w io.Writer, snap watchSnapshot) { + fmt.Fprintln(w, "Dispatch session attention") + fmt.Fprintln(w) + + if snap.Total == 0 { + fmt.Fprintln(w, "No sessions found.") + return + } + + fmt.Fprintf(w, "Total: %d", snap.Total) + parts := []string{} + if snap.Waiting > 0 { + parts = append(parts, fmt.Sprintf("%d waiting", snap.Waiting)) + } + if snap.Working > 0 { + parts = append(parts, fmt.Sprintf("%d working", snap.Working)) + } + if snap.Thinking > 0 { + parts = append(parts, fmt.Sprintf("%d thinking", snap.Thinking)) + } + if snap.Active > 0 { + parts = append(parts, fmt.Sprintf("%d active", snap.Active)) + } + if snap.Stale > 0 { + parts = append(parts, fmt.Sprintf("%d stale", snap.Stale)) + } + if snap.Compacting > 0 { + parts = append(parts, fmt.Sprintf("%d compacting", snap.Compacting)) + } + if snap.Idle > 0 { + parts = append(parts, fmt.Sprintf("%d idle", snap.Idle)) + } + if len(parts) > 0 { + fmt.Fprintf(w, " (%s)", strings.Join(parts, ", ")) + } + fmt.Fprintln(w) + + if len(snap.Sessions) > 0 { + fmt.Fprintln(w) + for _, s := range snap.Sessions { + label := s.Summary + if label == "" { + label = "(no summary)" + } + fmt.Fprintf(w, " %-12s %-10s %s\n", shortID(s.ID), s.Status, label) + } + } +} diff --git a/cmd/dispatch/watch_test.go b/cmd/dispatch/watch_test.go new file mode 100644 index 0000000..e5ed760 --- /dev/null +++ b/cmd/dispatch/watch_test.go @@ -0,0 +1,193 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/jongio/dispatch/internal/data" + "github.com/jongio/dispatch/internal/update" +) + +func withWatchSeams(t *testing.T, attention map[string]data.AttentionStatus, sessions []data.Session) { + t.Helper() + + prevScan := watchScanAttentionFn + watchScanAttentionFn = func(time.Duration) map[string]data.AttentionStatus { + return attention + } + t.Cleanup(func() { watchScanAttentionFn = prevScan }) + + prevList := watchListSessionsFn + watchListSessionsFn = func(data.FilterOptions) ([]data.Session, error) { + return sessions, nil + } + t.Cleanup(func() { watchListSessionsFn = prevList }) + + prevBell := bellFn + bellFn = func() {} // suppress bell in tests + t.Cleanup(func() { bellFn = prevBell }) +} + +func TestRunWatch_OnceText(t *testing.T) { + attention := map[string]data.AttentionStatus{ + "ses-1": data.AttentionWaiting, + "ses-2": data.AttentionIdle, + "ses-3": data.AttentionWorking, + } + sessions := []data.Session{ + {ID: "ses-1", Summary: "Auth work"}, + {ID: "ses-2", Summary: "Old session"}, + {ID: "ses-3", Summary: "Building"}, + } + withWatchSeams(t, attention, sessions) + + var buf bytes.Buffer + if err := runWatch(&buf, []string{"watch", "--once"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + for _, want := range []string{"waiting", "working", "Total: 3", "Auth work"} { + if !bytes.Contains([]byte(out), []byte(want)) { + t.Errorf("output missing %q, got:\n%s", want, out) + } + } +} + +func TestRunWatch_OnceJSON(t *testing.T) { + attention := map[string]data.AttentionStatus{ + "ses-1": data.AttentionWaiting, + } + sessions := []data.Session{ + {ID: "ses-1", Summary: "Auth work"}, + } + withWatchSeams(t, attention, sessions) + + var buf bytes.Buffer + if err := runWatch(&buf, []string{"watch", "--once", "--json"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got watchSnapshot + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if got.Waiting != 1 { + t.Errorf("waiting = %d, want 1", got.Waiting) + } + if got.Total != 1 { + t.Errorf("total = %d, want 1", got.Total) + } +} + +func TestRunWatch_OnceEmpty(t *testing.T) { + withWatchSeams(t, map[string]data.AttentionStatus{}, nil) + + var buf bytes.Buffer + if err := runWatch(&buf, []string{"watch", "--once"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + if !bytes.Contains([]byte(out), []byte("No sessions found")) { + t.Errorf("expected empty message, got:\n%s", out) + } +} + +func TestRunWatch_FilteredByRepo(t *testing.T) { + attention := map[string]data.AttentionStatus{ + "ses-1": data.AttentionWaiting, + "ses-2": data.AttentionWaiting, + } + sessions := []data.Session{ + {ID: "ses-1", Summary: "Auth", Repository: "jongio/dispatch"}, + } + withWatchSeams(t, attention, sessions) + + var buf bytes.Buffer + if err := runWatch(&buf, []string{"watch", "--once", "--repo", "jongio/dispatch"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got watchSnapshot + // Use JSON for precise checking. + buf.Reset() + if err := runWatch(&buf, []string{"watch", "--once", "--json", "--repo", "jongio/dispatch"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if got.Total != 1 { + t.Errorf("total = %d, want 1 (filtered)", got.Total) + } +} + +func TestParseWatchArgs_Interval(t *testing.T) { + opts, err := parseWatchArgs([]string{"watch", "--interval", "10s"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.interval != 10*time.Second { + t.Errorf("interval = %v, want 10s", opts.interval) + } +} + +func TestParseWatchArgs_IntervalClamped(t *testing.T) { + opts, err := parseWatchArgs([]string{"watch", "--interval", "100ms"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.interval < time.Second { + t.Errorf("interval = %v, should be clamped to at least 1s", opts.interval) + } +} + +func TestParseWatchArgs_UnknownFlag(t *testing.T) { + _, err := parseWatchArgs([]string{"watch", "--nope"}) + if err == nil { + t.Fatal("expected error for unknown flag") + } +} + +func TestParseWatchArgs_MissingIntervalValue(t *testing.T) { + _, err := parseWatchArgs([]string{"watch", "--interval"}) + if err == nil { + t.Fatal("expected error for missing interval value") + } +} + +func TestRunWatch_ListSessionsError(t *testing.T) { + prevScan := watchScanAttentionFn + watchScanAttentionFn = func(time.Duration) map[string]data.AttentionStatus { + return map[string]data.AttentionStatus{} + } + t.Cleanup(func() { watchScanAttentionFn = prevScan }) + + prevList := watchListSessionsFn + watchListSessionsFn = func(data.FilterOptions) ([]data.Session, error) { + return nil, errors.New("store error") + } + t.Cleanup(func() { watchListSessionsFn = prevList }) + + err := runWatch(&bytes.Buffer{}, []string{"watch", "--once"}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestHandleArgs_Watch(t *testing.T) { + withWatchSeams(t, map[string]data.AttentionStatus{}, nil) + + ch := make(chan *update.UpdateInfo, 1) + ch <- nil + + done, _, _, err := handleArgs([]string{"watch", "--once"}, &bytes.Buffer{}, ch) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !done { + t.Error("expected done=true") + } +} diff --git a/docs/keybindings.md b/docs/keybindings.md index f5afedc..21cf26d 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -160,25 +160,25 @@ - Condition: In session list view ### Sorting and Organization -17. **s** → Cycle Sort Order +17. **s** → Cycle Sort Field - File: internal\tui\keys.go (line 69) - Code: key.NewBinding(key.WithKeys("s")) - Handler: internal\tui\model.go (lines 878-880) - - Behavior: Cycles through sort options (Name, Created, Updated, etc.) + - Behavior: Cycles through sort options (updated, folder, name, attention, frecency). Affects session ordering within groups. Group ordering is fixed per group mode (A-Z for folder/repo/branch/host, newest first for date). - Condition: In session list view 18. **S** (Shift+S) → Toggle Sort Direction - File: internal\tui\keys.go (line 70) - Code: key.NewBinding(key.WithKeys("S")) - Handler: internal\tui\model.go (lines 882-884) - - Behavior: Toggles between ascending and descending sort + - Behavior: Toggles between ascending and descending sort direction for sessions within groups. Group ordering is fixed per group mode. Has no effect in date group mode (sessions are always sorted by most recent first). - Condition: In session list view -19. **Tab** → Cycle Pivot Mode +19. **Tab** → Cycle Group Mode - File: internal\tui\keys.go (line 71) - Code: key.NewBinding(key.WithKeys("tab")) - Handler: internal\tui\model.go (lines 886-888) - - Behavior: Cycles grouping: none → folder → repo → branch → date → none + - Behavior: Cycles grouping: list → folder → repo → branch → date → host → list - Condition: In session list view ### Preview and Display @@ -606,15 +606,15 @@ When help overlay is open (after pressing ?): - Behavior: Sets time range filter to clicked option (1h, 1d, 7d, all) - Condition: Click on Y=1 (badge line), within time range segment -70. **Click Sort Indicator** → Cycle Sort Order +70. **Click Sort Indicator** → Cycle Sort Field - Handler: internal\tui\model.go (lines 1170-1198) - - Behavior: Cycles sort field to next option + - Behavior: Cycles sort field to next option. Click the arrow to toggle direction. - Condition: Click on Y=1, within sort indicator area -71. **Click Pivot Indicator** → Cycle Pivot Mode +71. **Click Group Indicator** → Cycle Group Mode - Handler: internal\tui\model.go (lines 1170-1198) - - Behavior: Cycles pivot grouping mode - - Condition: Click on Y=1, within pivot area + - Behavior: Cycles group mode (list, folder, repo, branch, date, host) + - Condition: Click on Y=1, within group indicator area ### Scroll Wheel (Mouse) diff --git a/internal/tui/model.go b/internal/tui/model.go index 8372adb..944b3c6 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -298,11 +298,10 @@ type Model struct { cfg *config.Config // Query parameters. - filter data.FilterOptions - sort data.SortOptions - timeRange string // "1h", "1d", "7d", "all" - pivot string // "none", "folder", "repo", "branch", "date", "host" - pivotOrder data.SortOrder // group header sort direction + filter data.FilterOptions + sort data.SortOptions + timeRange string // "1h", "1d", "7d", "all" + pivot string // "none", "folder", "repo", "branch", "date", "host" // Loaded data. sessions []data.Session @@ -1457,10 +1456,6 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.cyclePivot() return m, m.loadSessionsCmd() - case key.Matches(msg, keys.PivotOrder): - m.togglePivotOrder() - return m, m.loadSessionsCmd() - case key.Matches(msg, keys.Preview): m.showPreview = !m.showPreview m.cfg.ShowPreview = m.showPreview @@ -2606,9 +2601,6 @@ func (m Model) handleHeaderClick(x, y int) (tea.Model, tea.Cmd) { case "pivot": m.cyclePivot() return m, m.loadSessionsCmd() - case "pivotorder": - m.togglePivotOrder() - return m, m.loadSessionsCmd() case "expandall": if m.sessionList.AllExpanded() { m.sessionList.CollapseAll() @@ -2686,29 +2678,14 @@ func (m Model) badgeClickAction(x int) string { } cursor += w + 2 - // Pivot indicator — split into arrow (order toggle) and label (cycle mode). + // Pivot/group indicator (no direction arrow; sort direction controls both). pivotLabel := m.pivot if pivotLabel == pivotNone { pivotLabel = "list" } - pivotArrow := styles.IconSortDown() - if m.pivotOrder == data.Ascending { - pivotArrow = styles.IconSortUp() - } - pivotLabel = pivotArrow + " " + pivotLabel - pivotKeyRendered := styles.KeyStyle.Render("tab") - pivotKeyW := lipgloss.Width(pivotKeyRendered) - pivotPrefix := styles.DimmedStyle.Render(": ") - pivotPrefixW := lipgloss.Width(pivotPrefix) - pivotRendered := pivotKeyRendered + styles.DimmedStyle.Render(": "+pivotLabel) + pivotRendered := styles.KeyStyle.Render("tab") + styles.DimmedStyle.Render(": "+pivotLabel) pw := lipgloss.Width(pivotRendered) if x >= cursor && x < cursor+pw { - pivotArrowRendered := styles.DimmedStyle.Render(styles.IconSortDown() + " ") - pivotArrowW := lipgloss.Width(pivotArrowRendered) - arrowStart := cursor + pivotKeyW + pivotPrefixW - if x >= arrowStart && x < arrowStart+pivotArrowW { - return "pivotorder" - } return "pivot" } cursor += pw + 2 @@ -2889,16 +2866,11 @@ func (m Model) renderBadges() string { sortLabel := arrow + " " + sortDisplayLabel(m.sort.Field) parts = append(parts, styles.KeyStyle.Render("s")+styles.DimmedStyle.Render(": "+sortLabel)) - // Pivot indicator with shortcut (always shown). + // Pivot/group indicator with shortcut (no direction arrow). pivotLabel := m.pivot if pivotLabel == pivotNone { pivotLabel = "list" } - pivotArrow := styles.IconSortDown() - if m.pivotOrder == data.Ascending { - pivotArrow = styles.IconSortUp() - } - pivotLabel = pivotArrow + " " + pivotLabel parts = append(parts, styles.KeyStyle.Render("tab")+styles.DimmedStyle.Render(": "+pivotLabel)) // Expand/collapse all indicator — only shown in tree mode. @@ -3592,35 +3564,16 @@ func (m *Model) cyclePivot() { for i, p := range pivotModes { if p == m.pivot { m.pivot = pivotModes[(i+1)%len(pivotModes)] - m.pivotOrder = defaultPivotOrder(m.pivot) m.cfg.DefaultPivot = m.pivot m.saveConfig() return } } m.pivot = pivotNone - m.pivotOrder = data.Ascending m.cfg.DefaultPivot = m.pivot m.saveConfig() } -// defaultPivotOrder returns the natural default sort direction for a pivot. -// Date defaults to descending (newest first); others to ascending (A-Z). -func defaultPivotOrder(pivot string) data.SortOrder { - if pivot == pivotDate { - return data.Descending - } - return data.Ascending -} - -func (m *Model) togglePivotOrder() { - if m.pivotOrder == data.Descending { - m.pivotOrder = data.Ascending - } else { - m.pivotOrder = data.Descending - } -} - // --------------------------------------------------------------------------- // Config persistence // --------------------------------------------------------------------------- @@ -4031,7 +3984,6 @@ func (m Model) loadSessionsCmd() tea.Cmd { sortOpts := m.sort limit := m.cfg.MaxSessions pivot := m.pivot - pivotOrd := m.pivotOrder return func() tea.Msg { if store == nil { @@ -4039,17 +3991,26 @@ func (m Model) loadSessionsCmd() tea.Cmd { } if pivot != pivotNone { pf := pivotFieldFromString(pivot) - groups, err := store.GroupSessions(context.Background(), pf, filter, sortOpts, limit) + + // Date grouping always shows most recent sessions first, + // regardless of the user's sort field/direction. + sessionSort := sortOpts + if pivot == pivotDate { + sessionSort = data.SortOptions{Field: data.SortByUpdated, Order: data.Descending} + } + + groups, err := store.GroupSessions(context.Background(), pf, filter, sessionSort, limit) if err != nil { return dataErrorMsg{err: err} } - // When sorting by updated time, reorder groups so the most - // recently active group appears first; otherwise sort groups - // alphabetically by their pivot label. - if sortOpts.Field == data.SortByUpdated { - sortGroupsByLatest(groups, sortOpts.Order) + // Group ordering is fixed per pivot mode (sort direction + // only affects sessions, not groups): + // date → newest date first (descending labels) + // other → A-Z (ascending labels) + if pivot == pivotDate { + sortGroupsByLabel(groups, data.Descending) } else { - sortGroupsByLabel(groups, pivotOrd) + sortGroupsByLabel(groups, data.Ascending) } return groupsLoadedMsg{groups: groups} } @@ -4541,7 +4502,6 @@ func (m Model) deepSearchCmd(version int) tea.Cmd { sortOpts := m.sort limit := m.cfg.MaxSessions pivot := m.pivot - pivotOrd := m.pivotOrder return func() tea.Msg { if store == nil { @@ -4549,14 +4509,20 @@ func (m Model) deepSearchCmd(version int) tea.Cmd { } if pivot != pivotNone { pf := pivotFieldFromString(pivot) - groups, err := store.GroupSessions(context.Background(), pf, filter, sortOpts, limit) + + sessionSort := sortOpts + if pivot == pivotDate { + sessionSort = data.SortOptions{Field: data.SortByUpdated, Order: data.Descending} + } + + groups, err := store.GroupSessions(context.Background(), pf, filter, sessionSort, limit) if err != nil { return dataErrorMsg{err: err} } - if sortOpts.Field == data.SortByUpdated { - sortGroupsByLatest(groups, sortOpts.Order) + if pivot == pivotDate { + sortGroupsByLabel(groups, data.Descending) } else { - sortGroupsByLabel(groups, pivotOrd) + sortGroupsByLabel(groups, data.Ascending) } return deepSearchResultMsg{version: version, groups: groups} } @@ -4715,28 +4681,6 @@ func (m Model) openRefCmd(url, label string) tea.Cmd { // Group sorting helpers // --------------------------------------------------------------------------- -// sortGroupsByLatest reorders groups so that the group containing the most -// recently updated session appears first (or last, for ascending). -func sortGroupsByLatest(groups []data.SessionGroup, order data.SortOrder) { - slices.SortFunc(groups, func(a, b data.SessionGroup) int { - c := cmp.Compare(latestUpdate(a.Sessions), latestUpdate(b.Sessions)) - if order == data.Descending { - return -c - } - return c - }) -} - -func latestUpdate(sessions []data.Session) string { - latest := "" - for _, s := range sessions { - if s.LastActiveAt > latest { - latest = s.LastActiveAt - } - } - return latest -} - // sortGroupsByLabel sorts groups alphabetically by their label. // Descending reverses the order (e.g. newest date first). func sortGroupsByLabel(groups []data.SessionGroup, order data.SortOrder) { diff --git a/internal/tui/model_coverage_test.go b/internal/tui/model_coverage_test.go index c2adce8..53345f7 100644 --- a/internal/tui/model_coverage_test.go +++ b/internal/tui/model_coverage_test.go @@ -387,90 +387,6 @@ func TestCovSortDisplayLabel(t *testing.T) { } } -// --------------------------------------------------------------------------- -// latestUpdate — edge cases -// --------------------------------------------------------------------------- - -func TestCovLatestUpdate_Empty(t *testing.T) { - got := latestUpdate(nil) - if got != "" { - t.Errorf("latestUpdate(nil) = %q, want empty", got) - } -} - -func TestCovLatestUpdate_Single(t *testing.T) { - sessions := []data.Session{{LastActiveAt: "2025-06-01T00:00:00Z"}} - got := latestUpdate(sessions) - if got != "2025-06-01T00:00:00Z" { - t.Errorf("latestUpdate single = %q, want 2025-06-01T00:00:00Z", got) - } -} - -func TestCovLatestUpdate_PicksMax(t *testing.T) { - sessions := []data.Session{ - {LastActiveAt: "2025-01-01T00:00:00Z"}, - {LastActiveAt: "2025-06-15T12:00:00Z"}, - {LastActiveAt: "2025-03-10T00:00:00Z"}, - } - got := latestUpdate(sessions) - if got != "2025-06-15T12:00:00Z" { - t.Errorf("latestUpdate = %q, want 2025-06-15T12:00:00Z", got) - } -} - -// --------------------------------------------------------------------------- -// sortGroupsByLatest — additional cases -// --------------------------------------------------------------------------- - -func TestCovSortGroupsByLatest_Descending(t *testing.T) { - groups := []data.SessionGroup{ - {Label: "A", Sessions: []data.Session{{LastActiveAt: "2025-01-01T00:00:00Z"}}}, - {Label: "C", Sessions: []data.Session{{LastActiveAt: "2025-03-01T00:00:00Z"}}}, - {Label: "B", Sessions: []data.Session{{LastActiveAt: "2025-02-01T00:00:00Z"}}}, - } - sortGroupsByLatest(groups, data.Descending) - order := []string{groups[0].Label, groups[1].Label, groups[2].Label} - want := []string{"C", "B", "A"} - for i := range order { - if order[i] != want[i] { - t.Errorf("position %d: got %q, want %q", i, order[i], want[i]) - } - } -} - -func TestCovSortGroupsByLatest_Ascending(t *testing.T) { - groups := []data.SessionGroup{ - {Label: "C", Sessions: []data.Session{{LastActiveAt: "2025-03-01T00:00:00Z"}}}, - {Label: "A", Sessions: []data.Session{{LastActiveAt: "2025-01-01T00:00:00Z"}}}, - {Label: "B", Sessions: []data.Session{{LastActiveAt: "2025-02-01T00:00:00Z"}}}, - } - sortGroupsByLatest(groups, data.Ascending) - order := []string{groups[0].Label, groups[1].Label, groups[2].Label} - want := []string{"A", "B", "C"} - for i := range order { - if order[i] != want[i] { - t.Errorf("position %d: got %q, want %q", i, order[i], want[i]) - } - } -} - -func TestCovSortGroupsByLatest_MultipleSessionsInGroup(t *testing.T) { - groups := []data.SessionGroup{ - {Label: "old", Sessions: []data.Session{ - {LastActiveAt: "2025-01-01T00:00:00Z"}, - {LastActiveAt: "2025-01-05T00:00:00Z"}, - }}, - {Label: "new", Sessions: []data.Session{ - {LastActiveAt: "2025-06-01T00:00:00Z"}, - {LastActiveAt: "2025-01-01T00:00:00Z"}, - }}, - } - sortGroupsByLatest(groups, data.Descending) - if groups[0].Label != "new" { - t.Errorf("group with latest session should be first, got %q", groups[0].Label) - } -} - // --------------------------------------------------------------------------- // cycleSort — full cycle and wrap // --------------------------------------------------------------------------- diff --git a/internal/tui/model_helpers_test.go b/internal/tui/model_helpers_test.go index 2c03b9c..afd9075 100644 --- a/internal/tui/model_helpers_test.go +++ b/internal/tui/model_helpers_test.go @@ -155,157 +155,53 @@ func TestSortDisplayLabel(t *testing.T) { } // --------------------------------------------------------------------------- -// latestUpdate +// Group sort ordering: groups use fixed order per pivot mode // --------------------------------------------------------------------------- -func TestLatestUpdate(t *testing.T) { - tests := []struct { - name string - sessions []data.Session - want string - }{ - {"empty", nil, ""}, - {"single", []data.Session{{LastActiveAt: "2024-01-01T10:00:00Z"}}, "2024-01-01T10:00:00Z"}, - {"multiple", []data.Session{ - {LastActiveAt: "2024-01-01T10:00:00Z"}, - {LastActiveAt: "2024-01-03T10:00:00Z"}, - {LastActiveAt: "2024-01-02T10:00:00Z"}, - }, "2024-01-03T10:00:00Z"}, - {"all same", []data.Session{ - {LastActiveAt: "2024-01-01T10:00:00Z"}, - {LastActiveAt: "2024-01-01T10:00:00Z"}, - }, "2024-01-01T10:00:00Z"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := latestUpdate(tt.sessions) - if got != tt.want { - t.Errorf("latestUpdate() = %q, want %q", got, tt.want) - } - }) - } -} - -// --------------------------------------------------------------------------- -// sortGroupsByLatest -// --------------------------------------------------------------------------- - -func TestSortGroupsByLatest_Descending(t *testing.T) { - groups := []data.SessionGroup{ - {Label: "old", Sessions: []data.Session{{LastActiveAt: "2024-01-01T00:00:00Z"}}}, - {Label: "new", Sessions: []data.Session{{LastActiveAt: "2024-01-03T00:00:00Z"}}}, - {Label: "mid", Sessions: []data.Session{{LastActiveAt: "2024-01-02T00:00:00Z"}}}, - } - - sortGroupsByLatest(groups, data.Descending) - - if groups[0].Label != "new" { - t.Errorf("first group should be 'new', got %q", groups[0].Label) - } - if groups[1].Label != "mid" { - t.Errorf("second group should be 'mid', got %q", groups[1].Label) - } - if groups[2].Label != "old" { - t.Errorf("third group should be 'old', got %q", groups[2].Label) - } -} - -func TestSortGroupsByLatest_Ascending(t *testing.T) { - groups := []data.SessionGroup{ - {Label: "new", Sessions: []data.Session{{LastActiveAt: "2024-01-03T00:00:00Z"}}}, - {Label: "old", Sessions: []data.Session{{LastActiveAt: "2024-01-01T00:00:00Z"}}}, - {Label: "mid", Sessions: []data.Session{{LastActiveAt: "2024-01-02T00:00:00Z"}}}, - } - - sortGroupsByLatest(groups, data.Ascending) - - if groups[0].Label != "old" { - t.Errorf("first group should be 'old', got %q", groups[0].Label) - } - if groups[2].Label != "new" { - t.Errorf("last group should be 'new', got %q", groups[2].Label) - } -} - -func TestSortGroupsByLatest_Empty(t *testing.T) { - var groups []data.SessionGroup - sortGroupsByLatest(groups, data.Descending) // should not panic -} - -func TestSortGroupsByLatest_SingleGroup(t *testing.T) { - groups := []data.SessionGroup{ - {Label: "only", Sessions: []data.Session{{LastActiveAt: "2024-01-01T00:00:00Z"}}}, - } - sortGroupsByLatest(groups, data.Descending) - if groups[0].Label != "only" { - t.Errorf("single group should remain, got %q", groups[0].Label) - } -} - -// --------------------------------------------------------------------------- -// Group sort ordering: sortGroupsByLatest must not be overridden by label sort -// --------------------------------------------------------------------------- - -// TestSortGroupsByLatest_NotOverriddenByLabel is a regression test ensuring -// that when sort field is SortByUpdated, groups are ordered by recency, NOT -// alphabetically by label. Before the fix, sortGroupsByLabel always ran -// after sortGroupsByLatest, overwriting the recency-based ordering. -func TestSortGroupsByLatest_NotOverriddenByLabel(t *testing.T) { - // Groups whose labels are alphabetical (A < B < Z) but whose sessions - // have recency in the opposite order (Z=newest, A=oldest). +// TestGroupOrder_NonDatePivot_AlwaysAscending verifies that non-date pivot +// modes always sort groups A-Z regardless of the sort field or direction. +func TestGroupOrder_NonDatePivot_AlwaysAscending(t *testing.T) { groups := []data.SessionGroup{ - {Label: "A-folder", Sessions: []data.Session{{LastActiveAt: "2024-01-01T00:00:00Z"}}}, - {Label: "B-folder", Sessions: []data.Session{{LastActiveAt: "2024-01-02T00:00:00Z"}}}, {Label: "Z-folder", Sessions: []data.Session{{LastActiveAt: "2024-01-03T00:00:00Z"}}}, + {Label: "A-folder", Sessions: []data.Session{{LastActiveAt: "2024-01-01T00:00:00Z"}}}, + {Label: "M-folder", Sessions: []data.Session{{LastActiveAt: "2024-01-02T00:00:00Z"}}}, } - // Simulate the fixed loadSessionsCmd logic: SortByUpdated → sortGroupsByLatest only. - sortOpts := data.SortOptions{Field: data.SortByUpdated, Order: data.Descending} - pivotOrd := data.Ascending + // Non-date pivots always use ascending label order, even when sort + // field is SortByUpdated or direction is Descending. + sortGroupsByLabel(groups, data.Ascending) - if sortOpts.Field == data.SortByUpdated { - sortGroupsByLatest(groups, sortOpts.Order) - } else { - sortGroupsByLabel(groups, pivotOrd) + if groups[0].Label != "A-folder" { + t.Errorf("expected first group 'A-folder', got %q", groups[0].Label) } - - // Expect recency order: Z-folder (newest) first, A-folder (oldest) last. - if groups[0].Label != "Z-folder" { - t.Errorf("expected first group 'Z-folder' (newest), got %q", groups[0].Label) + if groups[1].Label != "M-folder" { + t.Errorf("expected second group 'M-folder', got %q", groups[1].Label) } - if groups[2].Label != "A-folder" { - t.Errorf("expected last group 'A-folder' (oldest), got %q", groups[2].Label) + if groups[2].Label != "Z-folder" { + t.Errorf("expected third group 'Z-folder', got %q", groups[2].Label) } } -// TestSortGroupsByLabel_UsedWhenNotSortByUpdated verifies that when sorting -// by a non-updated field, groups are sorted alphabetically by label. -func TestSortGroupsByLabel_UsedWhenNotSortByUpdated(t *testing.T) { +// TestGroupOrder_DatePivot_AlwaysDescending verifies that date pivot mode +// always sorts groups newest-first (descending labels). +func TestGroupOrder_DatePivot_AlwaysDescending(t *testing.T) { groups := []data.SessionGroup{ - {Label: "Z-folder", Sessions: []data.Session{{LastActiveAt: "2024-01-03T00:00:00Z"}}}, - {Label: "A-folder", Sessions: []data.Session{{LastActiveAt: "2024-01-01T00:00:00Z"}}}, - {Label: "M-folder", Sessions: []data.Session{{LastActiveAt: "2024-01-02T00:00:00Z"}}}, + {Label: "2024-01-01", Sessions: []data.Session{{LastActiveAt: "2024-01-01T10:00:00Z"}}}, + {Label: "2024-01-03", Sessions: []data.Session{{LastActiveAt: "2024-01-03T10:00:00Z"}}}, + {Label: "2024-01-02", Sessions: []data.Session{{LastActiveAt: "2024-01-02T10:00:00Z"}}}, } - sortOpts := data.SortOptions{Field: data.SortByName, Order: data.Descending} - pivotOrd := data.Ascending + // Date pivot always uses descending label order (newest date first). + sortGroupsByLabel(groups, data.Descending) - if sortOpts.Field == data.SortByUpdated { - sortGroupsByLatest(groups, sortOpts.Order) - } else { - sortGroupsByLabel(groups, pivotOrd) + if groups[0].Label != "2024-01-03" { + t.Errorf("expected first group '2024-01-03' (newest), got %q", groups[0].Label) } - - // Expect alphabetical ascending: A, M, Z. - if groups[0].Label != "A-folder" { - t.Errorf("expected first group 'A-folder', got %q", groups[0].Label) + if groups[1].Label != "2024-01-02" { + t.Errorf("expected second group '2024-01-02', got %q", groups[1].Label) } - if groups[1].Label != "M-folder" { - t.Errorf("expected second group 'M-folder', got %q", groups[1].Label) - } - if groups[2].Label != "Z-folder" { - t.Errorf("expected third group 'Z-folder', got %q", groups[2].Label) + if groups[2].Label != "2024-01-01" { + t.Errorf("expected third group '2024-01-01' (oldest), got %q", groups[2].Label) } } diff --git a/internal/tui/model_launch_test.go b/internal/tui/model_launch_test.go index 9ab8e5b..9ab3276 100644 --- a/internal/tui/model_launch_test.go +++ b/internal/tui/model_launch_test.go @@ -599,25 +599,6 @@ func TestSortByAttention_Ascending(t *testing.T) { } } -// --------------------------------------------------------------------------- -// togglePivotOrder — 0% coverage -// --------------------------------------------------------------------------- - -func TestTogglePivotOrder(t *testing.T) { - m := newTestModel() - m.pivotOrder = data.Ascending - - m.togglePivotOrder() - if m.pivotOrder != data.Descending { - t.Errorf("after toggle: pivotOrder = %q, want Descending", m.pivotOrder) - } - - m.togglePivotOrder() - if m.pivotOrder != data.Ascending { - t.Errorf("after second toggle: pivotOrder = %q, want Ascending", m.pivotOrder) - } -} - // --------------------------------------------------------------------------- // updateSelectionStatus — 0% coverage // --------------------------------------------------------------------------- diff --git a/internal/tui/screenshot.go b/internal/tui/screenshot.go index 8b18026..bed935c 100644 --- a/internal/tui/screenshot.go +++ b/internal/tui/screenshot.go @@ -147,7 +147,7 @@ func (c *captureCtx) captureFeatures(subDir string) []Screenshot { m.sort.Field = data.SortByFolder m.sort.Order = data.Ascending if sorted, err := c.store.GroupSessions(context.Background(), data.PivotByFolder, c.flatFilter, m.sort, 0); err == nil { - sortGroupsByLatest(sorted, data.Ascending) + sortGroupsByLabel(sorted, data.Ascending) m.groups = sorted m.sessionList.SetPivotField(m.pivot) m.sessionList.SetGroups(sorted) @@ -198,7 +198,7 @@ func (c *captureCtx) captureFeatures(subDir string) []Screenshot { m.timeRange = tc.tr trFilter := data.FilterOptions{Since: timeRangeToSince(tc.tr)} if trGroups, err := c.store.GroupSessions(context.Background(), data.PivotByFolder, trFilter, c.flatSort, 0); err == nil { - sortGroupsByLatest(trGroups, data.Descending) + sortGroupsByLabel(trGroups, data.Ascending) m.groups = trGroups m.sessionList.SetPivotField(m.pivot) m.sessionList.SetGroups(trGroups) @@ -702,7 +702,11 @@ func CaptureScreenshots(dbPath string, width, height int) ([]Screenshot, error) if err != nil { return nil } - sortGroupsByLatest(groups, data.Descending) + if pf == data.PivotByDate { + sortGroupsByLabel(groups, data.Descending) + } else { + sortGroupsByLabel(groups, data.Ascending) + } return groups }