Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,19 @@ dispatch tags --json

Tags come from sessions you have tagged in the TUI. Counts are taken against the current session store, so tags left on sessions that no longer exist are not counted. Use `--json` for scripting.

### Named Views

List named views and switch the active view from scripts:

```sh
dispatch views
dispatch views --json
dispatch views use Work
dispatch views use default
```

Named views come from the `views` array in `config.json`. `dispatch views use <name>` sets `active_view`, and `dispatch views use default` clears it so the TUI opens with the normal filters.

### Export

Save a full session (metadata and the complete conversation) to a file with `dispatch export <id>`:
Expand Down Expand Up @@ -487,6 +500,8 @@ dispatch config path # print the config file path
| `keybindings` | object | `{}` | Remap keyboard shortcuts. Keys are action names, values are comma-separated key lists (see [Customizing Keybindings](#customizing-keybindings)) |
| `sessionTags` | object | `{}` | Map of session ID to a list of user-defined tags |
| `sessionAliases` | object | `{}` | Map of session ID to a unique short alias for `dispatch open <alias>` |
| `views` | array | `[]` | Named search, sort, pivot, and filter presets |
| `active_view` | string | `""` | Name of the named view to apply on startup. Empty means default filters |
| `hidden_columns` | array | `[]` | Optional session-list columns to hide (`repo`, `folder`, `turns`, `host`); empty shows all |

#### Pane Direction Semantics
Expand Down
7 changes: 7 additions & 0 deletions cmd/dispatch/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,13 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd
}
return true, cleanup, startupOptions{}, nil

case "views":
if vErr := runViews(os.Stdout, args); vErr != nil {
fmt.Fprintf(os.Stderr, "views: %v\n", vErr)
return true, cleanup, startupOptions{}, vErr
}
return true, cleanup, startupOptions{}, nil

case "config":
if cErr := runConfig(os.Stdout, args); cErr != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", cErr)
Expand Down
6 changes: 6 additions & 0 deletions cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ Commands:
stats [flags] Print session totals and breakdowns
search [query] [flags] Print matching sessions as JSON (no TUI)
tags [--json] List tags in use with per-tag session counts
views [command] List named views or set the active view
config [get|set|list|edit|path]
Read or change preferences (see Config commands)
export <id> [flags] Export a session as Markdown or JSON
Expand Down Expand Up @@ -159,6 +160,11 @@ Search flags:
--until <date> Only include sessions active on or before a date
--limit <n> Cap the number of results (default 50, 0 for no limit)

Views commands:
views [list] [--json] List configured named views
views use <name> Set the active named view
views use default Clear the active named view

Config commands:
config list [--json] Print every setting and its value
config get <key> Print one setting value
Expand Down
160 changes: 160 additions & 0 deletions cmd/dispatch/views.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package main

import (
"encoding/json"
"fmt"
"io"
"strings"

"github.com/jongio/dispatch/internal/config"
)

type viewsReport struct {
ActiveView string `json:"active_view"`
Views []config.NamedView `json:"views"`
}

func runViews(w io.Writer, args []string) error {
if w == nil {
w = io.Discard
}

rest := args
if len(rest) > 0 {
rest = rest[1:]
}
if len(rest) == 0 || rest[0] == "list" || rest[0] == "--json" {
return runViewsList(w, rest)
}

switch rest[0] {
case "use":
return runViewsUse(w, rest[1:])
default:
return fmt.Errorf("unknown views subcommand %q (want list or use)", rest[0])
}
}

func runViewsList(w io.Writer, args []string) error {
jsonOut := false
if len(args) > 0 && args[0] == "list" {
args = args[1:]
}
for _, arg := range args {
switch arg {
case "--json":
jsonOut = true
default:
return fmt.Errorf("views list does not take arguments, got %q", arg)
}
}

cfg, err := configLoadFn()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
report := buildViewsReport(cfg)
if jsonOut {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(report)
}
writeViewsText(w, report)
return nil
}

func runViewsUse(w io.Writer, args []string) error {
if len(args) == 0 {
return fmt.Errorf("views use requires a view name or default")
}
name := strings.TrimSpace(strings.Join(args, " "))
if name == "" {
return fmt.Errorf("views use requires a view name or default")
}

cfg, err := configLoadFn()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
if strings.EqualFold(name, "default") {
cfg.ActiveView = ""
if err := configSaveFn(cfg); err != nil {
return err
}
fmt.Fprintln(w, "Active view: Default")
return nil
}
view := cfg.FindView(name)
if view == nil || view.Validate() != nil {
return fmt.Errorf("view %q not found", name)
}
cfg.ActiveView = name
if err := configSaveFn(cfg); err != nil {
return err
}
fmt.Fprintf(w, "Active view: %s\n", name)
return nil
}

func buildViewsReport(cfg *config.Config) viewsReport {
active := "Default"
if cfg != nil && cfg.ActiveView != "" {
active = cfg.ActiveView
}
report := viewsReport{ActiveView: active, Views: []config.NamedView{}}
if cfg == nil {
return report
}
report.Views = cfg.ValidViews()
return report
}

func writeViewsText(w io.Writer, report viewsReport) {
fmt.Fprintln(w, "Dispatch views")
fmt.Fprintln(w)
fmt.Fprintf(w, "Active: %s\n\n", report.ActiveView)
if len(report.Views) == 0 {
fmt.Fprintln(w, "No named views found.")
return
}
for _, v := range report.Views {
marker := " "
if v.Name == report.ActiveView {
marker = "*"
}
fmt.Fprintf(w, "%s %s\n", marker, v.Name)
if summary := describeView(v); summary != "" {
fmt.Fprintf(w, " %s\n", summary)
}
}
}

func describeView(v config.NamedView) string {
parts := make([]string, 0, 8)
if v.Search != "" {
parts = append(parts, "search="+v.Search)
}
if v.TimeRange != "" {
parts = append(parts, "time="+v.TimeRange)
}
if v.Sort != "" {
sortPart := "sort=" + v.Sort
if v.SortOrder != "" {
sortPart += ":" + v.SortOrder
}
parts = append(parts, sortPart)
}
if v.Pivot != "" {
parts = append(parts, "pivot="+v.Pivot)
}
if v.FavoritesOnly {
parts = append(parts, "favorites")
}
if v.ShowHidden {
parts = append(parts, "show_hidden")
}
if len(v.ExcludedDirs) > 0 {
parts = append(parts, fmt.Sprintf("excluded_dirs=%d", len(v.ExcludedDirs)))
}
return strings.Join(parts, ", ")
}
127 changes: 127 additions & 0 deletions cmd/dispatch/views_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package main

import (
"bytes"
"encoding/json"
"strings"
"testing"

"github.com/jongio/dispatch/internal/config"
)

func viewsConfig() *config.Config {
cfg := config.Default()
cfg.ActiveView = "Work"
cfg.Views = []config.NamedView{
{Name: "Broken", TimeRange: "never"},
{Name: "Work", Search: "repo:jongio/dispatch", TimeRange: config.TimeRange7d, Sort: config.SortFieldUpdated, SortOrder: config.SortOrderDesc, Pivot: config.PivotRepo, FavoritesOnly: true},
{Name: "Personal", Pivot: config.PivotFolder, ShowHidden: true, ExcludedDirs: []string{"archive"}},
}
return cfg
}

func TestBuildViewsReportFiltersInvalidViews(t *testing.T) {
report := buildViewsReport(viewsConfig())
if report.ActiveView != "Work" {
t.Fatalf("ActiveView = %q, want Work", report.ActiveView)
}
if len(report.Views) != 2 {
t.Fatalf("views len = %d, want 2 valid views: %+v", len(report.Views), report.Views)
}
for _, v := range report.Views {
if v.Name == "Broken" {
t.Fatal("invalid view should be filtered")
}
}
}

func TestRunViewsListText(t *testing.T) {
withConfigSeams(t, viewsConfig())
var buf bytes.Buffer
if err := runViews(&buf, []string{"views"}); err != nil {
t.Fatalf("runViews: %v", err)
}
out := buf.String()
for _, want := range []string{"Dispatch views", "Active: Work", "* Work", "repo:jongio/dispatch", "Personal"} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "Broken") {
t.Fatalf("invalid view should not appear:\n%s", out)
}
}

func TestRunViewsListJSON(t *testing.T) {
withConfigSeams(t, viewsConfig())
var buf bytes.Buffer
if err := runViews(&buf, []string{"views", "--json"}); err != nil {
t.Fatalf("runViews json: %v", err)
}
var report viewsReport
if err := json.Unmarshal(buf.Bytes(), &report); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, buf.String())
}
if report.ActiveView != "Work" || len(report.Views) != 2 {
t.Fatalf("report = %+v, want active Work and 2 views", report)
}
}

func TestRunViewsUse(t *testing.T) {
cfg := withConfigSeams(t, viewsConfig())
var buf bytes.Buffer
if err := runViews(&buf, []string{"views", "use", "Personal"}); err != nil {
t.Fatalf("views use Personal: %v", err)
}
if cfg.ActiveView != "Personal" {
t.Fatalf("ActiveView = %q, want Personal", cfg.ActiveView)
}
if !strings.Contains(buf.String(), "Active view: Personal") {
t.Fatalf("unexpected output: %q", buf.String())
}

buf.Reset()
if err := runViews(&buf, []string{"views", "use", "default"}); err != nil {
t.Fatalf("views use default: %v", err)
}
if cfg.ActiveView != "" {
t.Fatalf("ActiveView = %q, want cleared", cfg.ActiveView)
}
}

func TestRunViewsUseInvalidDoesNotSave(t *testing.T) {
cfg := withConfigSeams(t, viewsConfig())
if err := runViews(&bytes.Buffer{}, []string{"views", "use", "Missing"}); err == nil {
t.Fatal("expected error for missing view")
}
if err := runViews(&bytes.Buffer{}, []string{"views", "use", "Broken"}); err == nil {
t.Fatal("expected error for invalid view")
}
if cfg.ActiveView != "Work" {
t.Fatalf("ActiveView changed on error: %q", cfg.ActiveView)
}
}

func TestRunViewsErrors(t *testing.T) {
withConfigSeams(t, viewsConfig())
for _, args := range [][]string{
{"views", "bogus"},
{"views", "list", "extra"},
{"views", "use"},
} {
if err := runViews(&bytes.Buffer{}, args); err == nil {
t.Fatalf("expected error for args %v", args)
}
}
}

func TestHandleArgsViews(t *testing.T) {
withConfigSeams(t, viewsConfig())
done, _, _, err := handleArgs([]string{"views"}, &bytes.Buffer{}, nil)
if err != nil {
t.Fatalf("handleArgs views: %v", err)
}
if !done {
t.Fatal("handleArgs should report done for views")
}
}