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
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ _Avoid_: Import, index, crawl, rebuild
The interactive screen that lists Projects and lets the user choose one to Jump to.
_Avoid_: Menu, list, finder, selector

**Layout**:
One of the arrangements in which the Picker draws Projects: the Grouped Layout puts them under Kind headers, the List Layout in one flat run.
_Avoid_: View, mode, style, theme, skin

**Wrapper**:
The shell function installed into the user's shell that turns a Project chosen in the Picker into a Jump.
_Avoid_: Hook, integration, plugin, shim, alias
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,22 @@ Vim key map (`keys.vim = true`): the list is focused on open.
| `esc` / `q` | cancel |
| `enter` | Jump to the selected Project |

### Layout

The Picker ships two Layouts, selected with `picker.layout`.

The **Grouped Layout** (`grouped`, the default) groups Projects under Kind
headers, marks the selected row with a caret, and puts the filter line
above the list.

The **List Layout** (`list`) is a flat fzf-style run: no Kind headers,
Projects in History order with never-visited ones last, `kind/` muted
before each Project name, the filter prompt below the list, and a `▌` bar
plus a background highlight on the selected row.

Both draw the same preview pane, use the same keys, status glyphs and
colours, and degrade the same way on a narrow terminal.

## Config reference

`cdd` reads `config.toml` from `$XDG_CONFIG_HOME/cdd/config.toml`, falling
Expand All @@ -107,6 +123,8 @@ include_hidden = false
max_visits = 1000 # must be >= 1
[keys]
vim = false
[picker]
layout = "grouped" # or "list" for the flat fzf-style layout
```

- `root` (required): the top-level directory whose Kinds are searched for
Expand All @@ -121,6 +139,9 @@ vim = false
- `[keys].vim`: when `true`, the Picker opens with the list focused and
uses the vim key map described above. Defaults to `false`, the default
key map.
- `[picker].layout`: which Layout the Picker draws, `"grouped"` (the
default) or `"list"`, as described above. Any other value is a config
error.

History is stored at `$XDG_DATA_HOME/cdd/history`, falling back to
`~/.local/share/cdd/history` when `XDG_DATA_HOME` is unset.
Expand Down
30 changes: 28 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
// Package config loads cdd's configuration: the Root to search for Kinds
// and Projects, Exclude globs, hidden-directory handling, and the History
// and Picker key settings.
// and Picker settings.
package config

import (
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"

toml "github.com/pelletier/go-toml/v2"
Expand All @@ -33,6 +34,9 @@ type Config struct {

// Keys configures the Picker's key map.
Keys Keys `toml:"keys"`

// Picker configures the Picker's appearance.
Picker Picker `toml:"picker"`
}

// History configures cdd's History of Visits.
Expand All @@ -50,6 +54,20 @@ type Keys struct {
Vim bool `toml:"vim"`
}

// Picker configures the Picker's appearance.
type Picker struct {
// Layout selects which layout the Picker draws: "grouped" (the
// default) groups rows under Kind headers with a caret on the
// selected row; "list" is a flat fzf-style list with the filter
// prompt below it and a background-highlighted selected row. Both
// draw the same preview pane.
Layout string `toml:"layout"`
}

// pickerLayouts are the values Picker.Layout accepts, in the order the
// error message lists them.
var pickerLayouts = []string{"grouped", "list"}

// defaultConfig returns a Config with every default applied, before a
// config.toml's fields are decoded on top of it.
func defaultConfig() Config {
Expand All @@ -58,6 +76,7 @@ func defaultConfig() Config {
IncludeHidden: false,
History: History{MaxVisits: 1000},
Keys: Keys{Vim: false},
Picker: Picker{Layout: "grouped"},
}
}

Expand All @@ -70,6 +89,8 @@ include_hidden = false
max_visits = 1000 # must be >= 1
[keys]
vim = false
[picker]
layout = "grouped" # or "list" for the flat fzf-style layout
`

// ExampleConfig returns an example config.toml, for the cli package to print
Expand Down Expand Up @@ -143,7 +164,8 @@ func describeDecodeError(err error) error {

// validate checks the decoded Config against the rules Load and LoadFrom
// enforce: Root is required, expanded, and must be an existing directory;
// History.MaxVisits must be at least 1.
// History.MaxVisits must be at least 1; Picker.Layout must name a known
// layout.
func (c *Config) validate(path string) error {
if c.Root == "" {
return fmt.Errorf("config: %s: root is required\n\nExample config.toml:\n\n%s", path, exampleConfigBody)
Expand All @@ -170,6 +192,10 @@ func (c *Config) validate(path string) error {
return fmt.Errorf("config: %s: history.max_visits must be >= 1, got %d", path, c.History.MaxVisits)
}

if !slices.Contains(pickerLayouts, c.Picker.Layout) {
return fmt.Errorf("config: %s: picker.layout must be %s, got %q", path, strings.Join(pickerLayouts, " or "), c.Picker.Layout)
}

return nil
}

Expand Down
29 changes: 28 additions & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ func TestLoadFromMinimalAppliesDefaults(t *testing.T) {
if cfg.Keys.Vim {
t.Errorf("Keys.Vim = true, want false")
}
if cfg.Picker.Layout != "grouped" {
t.Errorf("Picker.Layout = %q, want %q", cfg.Picker.Layout, "grouped")
}
}

func TestLoadFromEachKey(t *testing.T) {
Expand All @@ -71,6 +74,8 @@ include_hidden = true
max_visits = 42
[keys]
vim = true
[picker]
layout = "list"
`
path := writeConfig(t, body)

Expand Down Expand Up @@ -100,6 +105,9 @@ vim = true
if !cfg.Keys.Vim {
t.Errorf("Keys.Vim = false, want true")
}
if cfg.Picker.Layout != "list" {
t.Errorf("Picker.Layout = %q, want %q", cfg.Picker.Layout, "list")
}
}

func TestLoadFromTildeExpansion(t *testing.T) {
Expand Down Expand Up @@ -154,6 +162,25 @@ max_visits = 0
}
}

func TestLoadFromUnknownPickerLayoutRejected(t *testing.T) {
root := t.TempDir()
body := `root = "` + root + `"
[picker]
layout = "fancy"
`
path := writeConfig(t, body)

_, err := config.LoadFrom(path)
if err == nil {
t.Fatal(`LoadFrom layout = "fancy": got nil error, want error`)
}
for _, want := range []string{"picker.layout", "fancy", "grouped", "list"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error = %q, want it to contain %q", err.Error(), want)
}
}
}

func TestLoadFromRootNotDirectoryRejected(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "root-*")
if err != nil {
Expand Down Expand Up @@ -192,7 +219,7 @@ func TestLoadFromRootMissing(t *testing.T) {

func TestExampleConfig(t *testing.T) {
got := config.ExampleConfig()
for _, want := range []string{"root =", "exclude =", "include_hidden =", "[history]", "max_visits =", "[keys]", "vim ="} {
for _, want := range []string{"root =", "exclude =", "include_hidden =", "[history]", "max_visits =", "[keys]", "vim =", "[picker]", "layout ="} {
if !strings.Contains(got, want) {
t.Errorf("ExampleConfig() = %q, want it to contain %q", got, want)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/jump/jump.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func choose(cfg config.Config, projects []project.Project, latest []history.Visi
return s
}

row, ok, err := pick(rows, status, picker.Options{Vim: cfg.Keys.Vim, Query: query})
row, ok, err := pick(rows, status, picker.Options{Vim: cfg.Keys.Vim, Query: query, Layout: picker.LayoutStyle(cfg.Picker.Layout)})
if err != nil {
return "", "", fmt.Errorf("jump: %w", err)
}
Expand Down
28 changes: 28 additions & 0 deletions internal/jump/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,34 @@ func TestResolve_AmbiguousNameOpensPickerPrefilled(t *testing.T) {
assertRecorded(t, hist, "tools/cdd")
}

// TestResolve_ForwardsPickerOptions verifies that the config keys that
// configure the Picker reach it: the vim key map and the layout.
func TestResolve_ForwardsPickerOptions(t *testing.T) {
cfg, root := mkProjects(t, "tools/cdd")
cfg.Keys.Vim = true
cfg.Picker.Layout = "list"
hist := newHistory(t)

var got picker.Options
pick := func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Row, bool, error) {
got = opts
return picker.Row{Project: picker.Project{
Kind: "tools", Name: "cdd", Path: filepath.Join(root, "tools", "cdd"),
}}, true, nil
}

if _, err := jump.Resolve(context.Background(), cfg, hist, "", pick); err != nil {
t.Fatalf("Resolve: unexpected error: %v", err)
}

if !got.Vim {
t.Errorf("Options.Vim = false, want true")
}
if got.Layout != picker.LayoutList {
t.Errorf("Options.Layout = %q, want %q", got.Layout, picker.LayoutList)
}
}

func TestResolve_NoMatchOpensPickerPrefilled(t *testing.T) {
cfg, root := mkProjects(t, "tools/cdd")
hist := newHistory(t)
Expand Down
61 changes: 60 additions & 1 deletion internal/picker/layout.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package picker

import "time"
import (
"strings"
"time"
)

// nameFloor is the smallest a truncated Project name is ever shrunk to.
const nameFloor = 8
Expand Down Expand Up @@ -103,6 +106,62 @@ func ComputeLayout(longestName, widestStatus int, times []time.Time, now time.Ti
return l
}

// frameSize is the terminal size to draw at, standing in a default for
// each dimension until the first tea.WindowSizeMsg lands.
func (m Model) frameSize() (width, height int) {
width, height = m.width, m.height
if width <= 0 {
width = 80
}
if height <= 0 {
height = 24
}
return width, height
}

// computeLayout sizes one frame from the rows it has to show. The name
// column holds the Project name in the grouped layout and "kind/name" in
// the list layout; the two layouts spend the same total width on their
// other columns, so one budget serves both.
func (m Model) computeLayout(rows []match, now time.Time, width, height int) Layout {
longestName, widestStatus := 0, 1
times := make([]time.Time, 0, len(rows))
for _, mt := range rows {
n := len([]rune(mt.row.Project.Name))
if m.layout == LayoutList {
n += len([]rune(mt.row.Project.Kind)) + 1 // "kind/"
}
if n > longestName {
longestName = n
}
st, loaded := m.statuses[mt.row.Project.Path]
if w := statusClusterWidth(st, loaded); w > widestStatus {
widestStatus = w
}
times = append(times, mt.row.LastVisit)
}
return ComputeLayout(longestName, widestStatus, times, now, width, height)
}

// window clips lines to exactly height lines, scrolled just far enough to
// keep cursorLine on screen and padded with blanks when lines run short.
// Both layouts draw their body through it, so the frame always comes out
// at the terminal height.
func window(lines []string, cursorLine, height int) string {
height = max(height, 1)
start := 0
if cursorLine >= height {
start = cursorLine - height + 1
}
out := make([]string, height)
for i := range out {
if idx := start + i; idx < len(lines) {
out[i] = lines[idx]
}
}
return strings.Join(out, "\n")
}

// widestTime is the widest rendered relative time among times, using the
// short form when short is true.
func widestTime(times []time.Time, now time.Time, short bool) int {
Expand Down
17 changes: 17 additions & 0 deletions internal/picker/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type Model struct {
rows []Row
status StatusFunc
vim bool
layout LayoutStyle

query string
focus focus
Expand All @@ -55,10 +56,15 @@ func NewModel(rows []Row, status StatusFunc, opts Options) Model {
if opts.Vim {
f = focusList
}
layout := opts.Layout
if layout == "" {
layout = LayoutGrouped
}
return Model{
rows: rows,
status: status,
vim: opts.Vim,
layout: layout,
query: opts.Query,
focus: f,
statuses: make(map[string]git.Status, len(rows)),
Expand Down Expand Up @@ -167,6 +173,17 @@ func (m Model) visibleGroups() []kindGroup {
return groupByKind(m.visibleMatches())
}

// visibleRows returns the current query's matches in the order the active
// layout draws them: grouped by Kind for LayoutGrouped, flat History order
// for LayoutList. It is the order the cursor indexes into.
func (m Model) visibleRows() []match {
matches := m.visibleMatches()
if m.layout == LayoutList {
return matches
}
return flatten(groupByKind(matches))
}

// flatten lays a Kind grouping out as a single ordered slice of matches,
// matching the row order the list draws (header lines aside).
func flatten(groups []kindGroup) []match {
Expand Down
20 changes: 20 additions & 0 deletions internal/picker/picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@ type Row struct {
// without blocking the screen.
type StatusFunc func(ctx context.Context, dir string) git.Status

// LayoutStyle names one of the Picker's two layouts. It is a standing
// preference, set once in config.toml, not a per-session toggle.
type LayoutStyle string

const (
// LayoutGrouped is the default layout: rows grouped under Kind
// headers, a caret on the selected row, and the filter line on top.
LayoutGrouped LayoutStyle = "grouped"

// LayoutList is the flat fzf-style layout: no Kind headers, rows in
// History order with "kind/" muted before each Project name, the
// filter prompt below the list, and a bar plus background highlight
// on the selected row.
LayoutList LayoutStyle = "list"
)

// Options configures a Run of the Picker.
type Options struct {
// Vim selects the vim key map: the list is focused on open, j/k move,
Expand All @@ -55,6 +71,10 @@ type Options struct {

// Query seeds the filter line.
Query string

// Layout selects which layout is drawn. The zero value is
// LayoutGrouped.
Layout LayoutStyle
}

// concurrency bounds how many StatusFunc calls run at once, so a large
Expand Down
Loading
Loading