diff --git a/README.md b/README.md index f1f55a2..99301e7 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 `: @@ -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 ` | +| `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 diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index 3617a45..9ec1aee 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -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) diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index 775448f..b248b55 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -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 [flags] Export a session as Markdown or JSON @@ -159,6 +160,11 @@ Search flags: --until Only include sessions active on or before a date --limit Cap the number of results (default 50, 0 for no limit) +Views commands: + views [list] [--json] List configured named views + views use 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 Print one setting value diff --git a/cmd/dispatch/views.go b/cmd/dispatch/views.go new file mode 100644 index 0000000..cda3b31 --- /dev/null +++ b/cmd/dispatch/views.go @@ -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, ", ") +} diff --git a/cmd/dispatch/views_test.go b/cmd/dispatch/views_test.go new file mode 100644 index 0000000..f26cda1 --- /dev/null +++ b/cmd/dispatch/views_test.go @@ -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") + } +}