diff --git a/README.md b/README.md index bf2de97..838589e 100644 --- a/README.md +++ b/README.md @@ -410,6 +410,7 @@ Query the session store from scripts without opening the TUI. `dispatch search` dispatch search auth dispatch search --query "fix login" --repo jongio/dispatch dispatch search --branch main --since 2026-01-01 --limit 20 +dispatch search --sort turns --order desc --limit 10 dispatch search --deep refactor --json dispatch search auth --format ids dispatch search auth --ids @@ -421,6 +422,7 @@ The query can be passed as a positional argument or with `--query`. Filters mirr - `--repo`, `--branch`, `--folder`, `--host` narrow by session metadata. - `--since` / `--until` accept `YYYY-MM-DD` or full RFC3339 timestamps. - `--deep` also searches turns, checkpoints, touched files, and refs. +- `--sort updated|created|turns|name|folder` and `--order asc|desc` control result order. - `--limit ` caps the result count (default 50, `0` for no limit). - `--format json|ids|table` chooses JSON output, one session ID per line, or a readable table. `--ids` and `--table` are shortcuts. diff --git a/cmd/dispatch/search.go b/cmd/dispatch/search.go index 5bb39d5..e7f9914 100644 --- a/cmd/dispatch/search.go +++ b/cmd/dispatch/search.go @@ -36,6 +36,7 @@ const ( // searchOptions holds the parsed flags for the search command. type searchOptions struct { filter data.FilterOptions + sort data.SortOptions limit int format searchOutputFormat } @@ -72,7 +73,7 @@ func runSearch(w io.Writer, args []string) error { limit = searchAllLimit } - sessions, err := searchListSessionsFn(opts.filter, limit) + sessions, err := searchListSessionsFn(opts.filter, opts.sort, limit) if err != nil { return err } @@ -165,7 +166,11 @@ func searchTableCell(v string) string { // "search". A single leading token that does not start with "-" is treated as // the search query, matching how the TUI seeds its search box. func parseSearchArgs(args []string) (searchOptions, error) { - opts := searchOptions{limit: searchDefaultLimit, format: searchFormatJSON} + opts := searchOptions{ + sort: defaultSearchSort(), + limit: searchDefaultLimit, + format: searchFormatJSON, + } rest := args if len(rest) > 0 { @@ -264,6 +269,28 @@ func parseSearchArgs(args []string) (searchOptions, error) { } opts.filter.Until = &t i = ni + case name == "--sort": + v, ni, err := takeValue(i, "--sort", inlineOrEmpty(inline, hasInline)) + if err != nil { + return searchOptions{}, err + } + field, err := parseSearchSortField(v) + if err != nil { + return searchOptions{}, err + } + opts.sort.Field = field + i = ni + case name == "--order": + v, ni, err := takeValue(i, "--order", inlineOrEmpty(inline, hasInline)) + if err != nil { + return searchOptions{}, err + } + order, err := parseSearchSortOrder(v) + if err != nil { + return searchOptions{}, err + } + opts.sort.Order = order + i = ni case name == "--limit" || name == "-n": v, ni, err := takeValue(i, "--limit", inlineOrEmpty(inline, hasInline)) if err != nil { @@ -289,6 +316,38 @@ func parseSearchArgs(args []string) (searchOptions, error) { return opts, nil } +func defaultSearchSort() data.SortOptions { + return data.SortOptions{Field: data.SortByUpdated, Order: data.Descending} +} + +func parseSearchSortField(v string) (data.SortField, error) { + switch strings.ToLower(strings.TrimSpace(v)) { + case "updated": + return data.SortByUpdated, nil + case "created": + return data.SortByCreated, nil + case "turns": + return data.SortByTurns, nil + case "name": + return data.SortByName, nil + case "folder": + return data.SortByFolder, nil + default: + return "", fmt.Errorf("invalid --sort value %q (want updated, created, turns, name, or folder)", v) + } +} + +func parseSearchSortOrder(v string) (data.SortOrder, error) { + switch strings.ToLower(strings.TrimSpace(v)) { + case "asc": + return data.Ascending, nil + case "desc": + return data.Descending, nil + default: + return "", fmt.Errorf("invalid --order value %q (want asc or desc)", v) + } +} + func parseSearchFormat(v string) (searchOutputFormat, error) { switch strings.ToLower(strings.TrimSpace(v)) { case string(searchFormatJSON): @@ -313,15 +372,17 @@ func parseSearchLimit(v string) (int, error) { } // defaultSearchListSessions loads sessions matching the filter from the default -// session store, ordered by most recent activity first. -func defaultSearchListSessions(filter data.FilterOptions, limit int) ([]data.Session, error) { +// session store. +func defaultSearchListSessions(filter data.FilterOptions, sortOpts data.SortOptions, limit int) ([]data.Session, error) { store, err := data.Open() if err != nil { return nil, fmt.Errorf("opening session store: %w", err) } defer store.Close() //nolint:errcheck // read-only, best-effort close - sortOpts := data.SortOptions{Field: data.SortByUpdated, Order: data.Descending} + if sortOpts.Field == "" { + sortOpts = defaultSearchSort() + } sessions, err := store.ListSessions(context.Background(), filter, sortOpts, limit) if err != nil { return nil, fmt.Errorf("listing sessions: %w", err) diff --git a/cmd/dispatch/search_test.go b/cmd/dispatch/search_test.go index f3dc491..7fcb8e1 100644 --- a/cmd/dispatch/search_test.go +++ b/cmd/dispatch/search_test.go @@ -13,7 +13,7 @@ import ( // withSearchList swaps the search command's session loader for a test double // and restores it afterward, matching the seam helper used by the stats tests. -func withSearchList(t *testing.T, fn func(data.FilterOptions, int) ([]data.Session, error)) { +func withSearchList(t *testing.T, fn func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error)) { t.Helper() prev := searchListSessionsFn searchListSessionsFn = fn @@ -29,6 +29,8 @@ func TestParseSearchArgsQueryAndFilters(t *testing.T) { "--host", "cli", "--deep", "--limit", "10", + "--sort", "turns", + "--order=asc", "--since", "2026-01-01", "--until", "2026-12-31", }) @@ -56,6 +58,12 @@ func TestParseSearchArgsQueryAndFilters(t *testing.T) { if opts.limit != 10 { t.Errorf("limit = %d, want 10", opts.limit) } + if opts.sort.Field != data.SortByTurns { + t.Errorf("sort field = %q, want %q", opts.sort.Field, data.SortByTurns) + } + if opts.sort.Order != data.Ascending { + t.Errorf("sort order = %q, want %q", opts.sort.Order, data.Ascending) + } if opts.format != searchFormatJSON { t.Errorf("format = %q, want json", opts.format) } @@ -75,6 +83,9 @@ func TestParseSearchArgsDefaultLimit(t *testing.T) { if opts.limit != searchDefaultLimit { t.Errorf("limit = %d, want default %d", opts.limit, searchDefaultLimit) } + if opts.sort != defaultSearchSort() { + t.Errorf("sort = %+v, want %+v", opts.sort, defaultSearchSort()) + } if opts.filter.Query != "" { t.Errorf("Query = %q, want empty", opts.filter.Query) } @@ -121,6 +132,9 @@ func TestParseSearchArgsErrors(t *testing.T) { {"search", "--repo"}, {"search", "--format"}, {"search", "--format", "yaml"}, + {"search", "--sort"}, + {"search", "--sort", "attention"}, + {"search", "--order", "sideways"}, } for _, args := range cases { if _, err := parseSearchArgs(args); err == nil { @@ -144,15 +158,17 @@ func TestRunSearchJSONOutput(t *testing.T) { }, } var gotFilter data.FilterOptions + var gotSort data.SortOptions var gotLimit int - withSearchList(t, func(f data.FilterOptions, limit int) ([]data.Session, error) { + withSearchList(t, func(f data.FilterOptions, sort data.SortOptions, limit int) ([]data.Session, error) { gotFilter = f + gotSort = sort gotLimit = limit return sessions, nil }) var buf bytes.Buffer - if err := runSearch(&buf, []string{"search", "auth", "--limit", "5"}); err != nil { + if err := runSearch(&buf, []string{"search", "auth", "--limit", "5", "--sort", "name", "--order", "asc"}); err != nil { t.Fatalf("runSearch returned error: %v", err) } @@ -162,6 +178,9 @@ func TestRunSearchJSONOutput(t *testing.T) { if gotLimit != 5 { t.Errorf("limit = %d, want 5", gotLimit) } + if gotSort.Field != data.SortByName || gotSort.Order != data.Ascending { + t.Errorf("sort = %+v, want name asc", gotSort) + } var out []searchSession if err := json.Unmarshal(buf.Bytes(), &out); err != nil { @@ -180,7 +199,7 @@ func TestRunSearchIDsOutput(t *testing.T) { {ID: "session-a"}, {ID: "session-b"}, } - withSearchList(t, func(data.FilterOptions, int) ([]data.Session, error) { + withSearchList(t, func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error) { return sessions, nil }) @@ -195,7 +214,7 @@ func TestRunSearchIDsOutput(t *testing.T) { } func TestRunSearchIDsNoMatchesIsEmpty(t *testing.T) { - withSearchList(t, func(data.FilterOptions, int) ([]data.Session, error) { + withSearchList(t, func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error) { return nil, nil }) @@ -225,7 +244,7 @@ func TestRunSearchTableOutput(t *testing.T) { UpdatedAt: "2026-01-05T09:00:00Z", }, } - withSearchList(t, func(data.FilterOptions, int) ([]data.Session, error) { + withSearchList(t, func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error) { return sessions, nil }) @@ -247,7 +266,7 @@ func TestRunSearchTableOutput(t *testing.T) { } func TestRunSearchTableEmptyPrintsHeader(t *testing.T) { - withSearchList(t, func(data.FilterOptions, int) ([]data.Session, error) { + withSearchList(t, func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error) { return nil, nil }) @@ -261,7 +280,7 @@ func TestRunSearchTableEmptyPrintsHeader(t *testing.T) { } func TestRunSearchEmptyIsEmptyArray(t *testing.T) { - withSearchList(t, func(data.FilterOptions, int) ([]data.Session, error) { + withSearchList(t, func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error) { return nil, nil }) @@ -276,7 +295,7 @@ func TestRunSearchEmptyIsEmptyArray(t *testing.T) { func TestRunSearchNoLimitUsesCeiling(t *testing.T) { var gotLimit int - withSearchList(t, func(_ data.FilterOptions, limit int) ([]data.Session, error) { + withSearchList(t, func(_ data.FilterOptions, _ data.SortOptions, limit int) ([]data.Session, error) { gotLimit = limit return nil, nil }) @@ -291,7 +310,7 @@ func TestRunSearchNoLimitUsesCeiling(t *testing.T) { } func TestRunSearchPropagatesStoreError(t *testing.T) { - withSearchList(t, func(data.FilterOptions, int) ([]data.Session, error) { + withSearchList(t, func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error) { return nil, errors.New("store boom") })