From 91d048b90cb32f13e4262f622ddb55152f079118 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Thu, 17 Sep 2026 00:48:28 +0500 Subject: [PATCH 01/11] chore: pin Bubble Tea v2 stack for the Picker charm.land/bubbletea/v2 v2.0.9, charm.land/lipgloss/v2 v2.0.6, charm.land/bubbles/v2 v2.2.1, github.com/sahilm/fuzzy v0.1.3, per the bubbletea-stack research (the github.com/charmbracelet import paths fail with a module mismatch on the v2 line). Co-Authored-By: Claude Fable 5.1 --- go.mod | 25 ++++++++++++++++++++++++- go.sum | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index d070cb4..a6d3485 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,27 @@ module github.com/kryft-dev/cdd go 1.26.3 -require github.com/pelletier/go-toml/v2 v2.4.3 // indirect +require ( + charm.land/bubbletea/v2 v2.0.9 + charm.land/lipgloss/v2 v2.0.6 + github.com/pelletier/go-toml/v2 v2.4.3 + github.com/sahilm/fuzzy v0.1.3 +) + +require ( + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-runewidth v0.0.27 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/go.sum b/go.sum index ddaf7d6..38ce4b3 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,46 @@ +charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8= +charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= +github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= From 66b7020d03a1ed366f3c7c98f84aea112dfd9d5e Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Thu, 17 Sep 2026 00:48:34 +0500 Subject: [PATCH 02/11] feat(picker): add Row, Options and the Bubble Tea Model skeleton Row carries a Project (Kind, Name, absolute Path), LastVisit and Visits. Options carries Vim and an initial Query. Run's rows arrive already in History order; the Picker never computes that order itself. Init fires one tea.Cmd per Row through tea.Batch, bounded by a semaphore of 8, keyed by Project path so results land correctly even as fuzzy filtering reorders the visible rows. Rows are grouped by Kind, ordered by first appearance among the currently visible rows. Co-Authored-By: Claude Fable 5.1 --- internal/picker/model.go | 178 ++++++++++++++++++++++++++++++++++++++ internal/picker/picker.go | 111 ++++++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 internal/picker/model.go create mode 100644 internal/picker/picker.go diff --git a/internal/picker/model.go b/internal/picker/model.go new file mode 100644 index 0000000..83c60a3 --- /dev/null +++ b/internal/picker/model.go @@ -0,0 +1,178 @@ +package picker + +import ( + "context" + + tea "charm.land/bubbletea/v2" + "github.com/sahilm/fuzzy" + + "github.com/kryft-dev/cdd/internal/git" +) + +// focus tracks which part of the Picker receives key presses. Only the vim +// key map ever leaves the list unfocused (it starts on the list itself, but +// f or / can move focus to the filter). +type focus int + +const ( + focusList focus = iota + focusFilter +) + +// match is one row along with where, if anywhere, the current query matched +// its Project's path, for highlighting and ordering within its Kind group. +type match struct { + row Row + matches []int // rune indexes into the matched string, for highlighting +} + +// Model is the Picker's Bubble Tea Model. It never mutates rows: filtering +// and grouping are recomputed from it as the query and window size change. +type Model struct { + rows []Row + status StatusFunc + vim bool + + query string + focus focus + cursor int // index into the current visible/filtered+grouped rows + + statuses map[string]git.Status // keyed by Project.Path + + width, height int + dark bool + themeSet bool + + chosen bool + chosenRow Row + quitting bool +} + +// NewModel builds the Picker's initial Model from rows already in History +// order. +func NewModel(rows []Row, status StatusFunc, opts Options) Model { + f := focusFilter + if opts.Vim { + f = focusList + } + return Model{ + rows: rows, + status: status, + vim: opts.Vim, + query: opts.Query, + focus: f, + statuses: make(map[string]git.Status, len(rows)), + } +} + +// Chosen returns the Row an "enter" press has chosen, and whether one has +// been chosen yet. It lets a caller (or a test) read the outcome without +// waiting for the Bubble Tea runtime to hand back the final Model. +func (m Model) Chosen() (Row, bool) { + return m.chosenRow, m.chosen +} + +// statusResultMsg is the result of one StatusFunc call, keyed by the +// Project's path rather than its row index: the fuzzy filter reorders +// visible rows while calls are still in flight. +type statusResultMsg struct { + path string + status git.Status +} + +// Init fires one command per row that fetches its git status, fanned out +// through tea.Batch and bounded by a semaphore so a large History does not +// spawn unbounded concurrent git processes. It also requests the terminal +// background colour, used to pick the light or dark palette. +func (m Model) Init() tea.Cmd { + sem := make(chan struct{}, concurrency) + cmds := make([]tea.Cmd, 0, len(m.rows)+1) + cmds = append(cmds, tea.RequestBackgroundColor) + for _, r := range m.rows { + cmds = append(cmds, statusCmd(m.status, r.Project.Path, sem)) + } + return tea.Batch(cmds...) +} + +// statusCmd builds the tea.Cmd for one row's status check. The outer +// function captures the path; only the inner func() tea.Msg runs on its own +// goroutine, where the semaphore is acquired and released. +func statusCmd(status StatusFunc, path string, sem chan struct{}) tea.Cmd { + return func() tea.Msg { + sem <- struct{}{} + defer func() { <-sem }() + + return statusResultMsg{path: path, status: status(context.Background(), path)} + } +} + +// visible returns the current query's matches over rows, in row order +// (which preserves History order within a match set, since fuzzy.Find is +// stable relative to its input order for equal scores is not guaranteed, +// but grouping below only depends on first-appearance order of Kind, not on +// score order). +func (m Model) visibleMatches() []match { + if m.query == "" { + out := make([]match, len(m.rows)) + for i, r := range m.rows { + out[i] = match{row: r} + } + return out + } + + paths := make([]string, len(m.rows)) + for i, r := range m.rows { + paths[i] = r.Project.Path + } + results := fuzzy.Find(m.query, paths) + + out := make([]match, len(results)) + for i, res := range results { + out[i] = match{row: m.rows[res.Index], matches: res.MatchedIndexes} + } + return out +} + +// kindGroup is one Kind's header plus the matches that fall under it, in +// the order Kinds first appear among the visible matches. +type kindGroup struct { + kind string + matches []match +} + +// groupByKind groups matches under their Project's Kind, ordering Kinds by +// first appearance in matches (which is History order, or fuzzy-ranked +// order when a query is active) and keeping each Kind's own rows in that +// same order. +func groupByKind(matches []match) []kindGroup { + var groups []kindGroup + index := make(map[string]int) + for _, mt := range matches { + kind := mt.row.Project.Kind + i, ok := index[kind] + if !ok { + i = len(groups) + index[kind] = i + groups = append(groups, kindGroup{kind: kind}) + } + groups[i].matches = append(groups[i].matches, mt) + } + return groups +} + +// visibleGroups returns the current query's matches grouped by Kind, in the +// order rows are actually displayed: the order the Picker's cursor and +// filtering operate on. +func (m Model) visibleGroups() []kindGroup { + return groupByKind(m.visibleMatches()) +} + +// 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 { + var out []match + for _, g := range groups { + out = append(out, g.matches...) + } + return out +} diff --git a/internal/picker/picker.go b/internal/picker/picker.go new file mode 100644 index 0000000..d13b51d --- /dev/null +++ b/internal/picker/picker.go @@ -0,0 +1,111 @@ +// Package picker is the interactive screen that lists Projects and lets the +// user choose one to Jump to. +// +// The Picker never computes History order itself: Run receives rows already +// ordered by the caller (History order, most recent Visit first, then +// never-visited Projects A-Z) and only filters and displays them. +package picker + +import ( + "context" + "errors" + "os" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/kryft-dev/cdd/internal/git" +) + +// Project is the identity of one Picker row: its Kind, its Name, and its +// absolute path on disk. +type Project struct { + Kind string + Name string + Path string +} + +// Row is one line the Picker can show and choose: a Project plus what +// History knows about it. +type Row struct { + Project Project + + // LastVisit is the time of the Project's most recent Visit, or the zero + // time when the Project has never been visited. + LastVisit time.Time + + // Visits is the count of Visits History holds for the Project. + Visits int +} + +// StatusFunc reports a directory's git status. The Picker calls it once per +// row, concurrently, to fill in the status column and the preview pane +// without blocking the screen. +type StatusFunc func(ctx context.Context, dir string) git.Status + +// 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, + // g/G jump to the ends, f or / focuses the filter, esc in the filter + // returns to the list keeping the query, esc or q on the list cancels. + // + // When false, the default key map applies: typing filters, arrow keys + // (and ctrl+p/ctrl+n) move, esc cancels, ctrl+u clears the filter. + Vim bool + + // Query seeds the filter line. + Query string +} + +// concurrency bounds how many StatusFunc calls run at once, so a large +// History does not spawn one git process per Project. +const concurrency = 8 + +// Run draws the Picker over rows, which must already be in History order, +// and lets the user filter and choose one. It returns the chosen Row and +// true, or the zero Row and false when the user cancels. +// +// The Picker draws on /dev/tty via tea.OpenTTY, falling back to stderr when +// no TTY is available. It never writes to stdout. +func Run(rows []Row, status StatusFunc, opts Options) (Row, bool, error) { + m := NewModel(rows, status, opts) + + ttyOpts, cleanup, err := ttyProgramOptions() + if err != nil { + return Row{}, false, err + } + if cleanup != nil { + defer cleanup() + } + + p := tea.NewProgram(m, ttyOpts...) + final, err := p.Run() + if err != nil { + return Row{}, false, err + } + + fm, ok := final.(Model) + if !ok { + return Row{}, false, errors.New("picker: unexpected Model type from Bubble Tea") + } + if !fm.chosen { + return Row{}, false, nil + } + return fm.chosenRow, true, nil +} + +// ttyProgramOptions builds the tea.ProgramOptions that make the Picker draw +// on /dev/tty, falling back to stderr when no TTY can be opened. stdout is +// never used, since a caller may pipe it (the Wrapper reads the chosen path +// from Run's return value, not from the program's own output). +func ttyProgramOptions() ([]tea.ProgramOption, func(), error) { + in, out, err := tea.OpenTTY() + if err != nil { + return []tea.ProgramOption{tea.WithOutput(os.Stderr)}, nil, nil + } + cleanup := func() { + _ = in.Close() + _ = out.Close() + } + return []tea.ProgramOption{tea.WithInput(in), tea.WithOutput(out)}, cleanup, nil +} From 18160ee6b0f85f5ee0a5c2178796b24c1f37fef9 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Thu, 17 Sep 2026 00:48:40 +0500 Subject: [PATCH 03/11] feat(picker): handle key presses and status results in Update Default key map: typing filters, up/down and ctrl+p/ctrl+n move, enter chooses, esc cancels, ctrl+u clears. Vim key map: the list is focused on open, j/k move, g/G jump to the ends, f or / focuses the filter, esc in the filter returns to the list keeping the query, esc or q on the list cancels, enter chooses from either mode. Tests drive Update with tea.KeyPressMsg and a fake StatusFunc, and check Kind-group ordering and fuzzy filtering through the exported Model. Co-Authored-By: Claude Fable 5.1 --- internal/picker/model_test.go | 107 ++++++++++++++++++++ internal/picker/update.go | 172 +++++++++++++++++++++++++++++++++ internal/picker/update_test.go | 112 +++++++++++++++++++++ 3 files changed, 391 insertions(+) create mode 100644 internal/picker/model_test.go create mode 100644 internal/picker/update.go create mode 100644 internal/picker/update_test.go diff --git a/internal/picker/model_test.go b/internal/picker/model_test.go new file mode 100644 index 0000000..03b2ff1 --- /dev/null +++ b/internal/picker/model_test.go @@ -0,0 +1,107 @@ +package picker_test + +import ( + "context" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/kryft-dev/cdd/internal/git" + "github.com/kryft-dev/cdd/internal/picker" +) + +// fakeStatus is a StatusFunc that reports a fixed clean-repo status for +// every row, for tests that only care about ordering or filtering. +func fakeStatus(context.Context, string) git.Status { + return git.Status{Kind: git.Found} +} + +// noopStatus never resolves anything useful; it is used when a test does +// not care what status lands. +func noopStatus(context.Context, string) git.Status { + return git.Status{Kind: git.NotRepo} +} + +func rowsForKindOrder() []picker.Row { + // History order: "work" holds the most recent Visit, so it must lead + // the Kind groups even though "tools" and "oss" rows also exist. + return []picker.Row{ + {Project: picker.Project{Kind: "tools", Name: "cdd", Path: "/root/tools/cdd"}, LastVisit: time.Unix(3000, 0)}, + {Project: picker.Project{Kind: "work", Name: "api", Path: "/root/work/api"}, LastVisit: time.Unix(5000, 0)}, + {Project: picker.Project{Kind: "oss", Name: "lib", Path: "/root/oss/lib"}, LastVisit: time.Unix(1000, 0)}, + {Project: picker.Project{Kind: "tools", Name: "dotfiles", Path: "/root/tools/dotfiles"}, LastVisit: time.Unix(2000, 0)}, + } +} + +// TestModel_KindOrder verifies that moving the cursor through the grouped +// list visits Kinds in the order they first appear among rows (History +// order), not alphabetically, and that each Kind's own rows stay in the +// order they were given. +func TestModel_KindOrder(t *testing.T) { + rows := rowsForKindOrder() + m := picker.NewModel(rows, fakeStatus, picker.Options{}) + + // Rows come in as: tools/cdd, work/api, oss/lib, tools/dotfiles. + // Grouped by first appearance: tools{cdd, dotfiles}, work{api}, oss{lib}. + want := []string{ + "/root/tools/cdd", + "/root/tools/dotfiles", + "/root/work/api", + "/root/oss/lib", + } + + for i, wantPath := range want { + got := chosenAt(t, m, i) + if got.Project.Path != wantPath { + t.Errorf("row %d = %q, want %q", i, got.Project.Path, wantPath) + } + } +} + +// chosenAt moves the cursor to index i from the top and returns the Row +// enter would choose there, using the default key map. +func chosenAt(t *testing.T, m picker.Model, i int) picker.Row { + t.Helper() + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m = next.(picker.Model) + for n := 0; n < i; n++ { + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + m = next.(picker.Model) + } + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + final := next.(picker.Model) + row, ok := final.Chosen() + if !ok { + t.Fatalf("row %d: enter did not choose a Row", i) + } + return row +} + +// TestModel_FuzzyFilter verifies that typing a query narrows the visible +// rows to those whose Project path fuzzy-matches it. +func TestModel_FuzzyFilter(t *testing.T) { + rows := []picker.Row{ + {Project: picker.Project{Kind: "work", Name: "api-gateway", Path: "/root/work/api-gateway"}}, + {Project: picker.Project{Kind: "work", Name: "billing", Path: "/root/work/billing"}}, + {Project: picker.Project{Kind: "oss", Name: "bubbletea", Path: "/root/oss/bubbletea"}}, + } + m := picker.NewModel(rows, noopStatus, picker.Options{}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m = next.(picker.Model) + + for _, r := range []rune("billing") { + next, _ = m.Update(tea.KeyPressMsg{Text: string(r)}) + m = next.(picker.Model) + } + + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + final := next.(picker.Model) + row, ok := final.Chosen() + if !ok { + t.Fatalf("enter did not choose a Row after filtering") + } + if row.Project.Name != "billing" { + t.Errorf("chosen Project = %q, want %q", row.Project.Name, "billing") + } +} diff --git a/internal/picker/update.go b/internal/picker/update.go new file mode 100644 index 0000000..6d06046 --- /dev/null +++ b/internal/picker/update.go @@ -0,0 +1,172 @@ +package picker + +import ( + tea "charm.land/bubbletea/v2" +) + +// Update handles one message: a key press, a status result landing, a +// terminal resize, or the background colour report. It never blocks and +// never loses state (query, cursor, loaded statuses) across a resize. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case statusResultMsg: + m.statuses[msg.path] = msg.status + return m, nil + + case tea.BackgroundColorMsg: + m.dark = msg.IsDark() + m.themeSet = true + return m, nil + + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.clampCursor() + return m, nil + + case tea.KeyPressMsg: + return m.updateKey(msg) + } + + return m, nil +} + +// updateKey dispatches a key press by the active key map and focus. +func (m Model) updateKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if m.vim { + return m.updateKeyVim(msg) + } + return m.updateKeyDefault(msg) +} + +// updateKeyDefault implements the default key map: typing filters, arrow +// keys (and ctrl+p/ctrl+n) move, enter chooses, esc cancels, ctrl+u clears. +func (m Model) updateKeyDefault(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "ctrl+p": + m.moveCursor(-1) + return m, nil + case "down", "ctrl+n": + m.moveCursor(1) + return m, nil + case "enter": + return m.choose() + case "esc", "ctrl+c": + return m.cancel() + case "ctrl+u": + m.query = "" + m.cursor = 0 + return m, nil + case "backspace": + m.backspace() + return m, nil + default: + if msg.Text != "" { + m.query += msg.Text + m.cursor = 0 + } + return m, nil + } +} + +// updateKeyVim implements the vim key map. The list is focused on open; +// j/k move, g/G jump to the ends, f or / focuses the filter, esc in the +// filter returns to the list keeping the query, esc or q on the list +// cancels, enter chooses from either mode. +func (m Model) updateKeyVim(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if m.focus == focusFilter { + switch msg.String() { + case "esc": + m.focus = focusList + return m, nil + case "enter": + return m.choose() + case "backspace": + m.backspace() + return m, nil + default: + if msg.Text != "" { + m.query += msg.Text + m.cursor = 0 + } + return m, nil + } + } + + switch msg.String() { + case "j": + m.moveCursor(1) + return m, nil + case "k": + m.moveCursor(-1) + return m, nil + case "g": + m.cursor = 0 + return m, nil + case "G": + m.cursor = m.lastIndex() + return m, nil + case "f", "/": + m.focus = focusFilter + return m, nil + case "enter": + return m.choose() + case "esc", "q", "ctrl+c": + return m.cancel() + } + return m, nil +} + +// moveCursor shifts the cursor by delta rows, clamped to the visible range. +func (m *Model) moveCursor(delta int) { + m.cursor += delta + m.clampCursor() +} + +// clampCursor keeps the cursor within the currently visible rows, which can +// shrink after a filter change or a resize. +func (m *Model) clampCursor() { + last := m.lastIndex() + if m.cursor > last { + m.cursor = last + } + if m.cursor < 0 { + m.cursor = 0 + } +} + +// lastIndex is the index of the last visible row, or 0 when there are none. +func (m Model) lastIndex() int { + n := len(flatten(m.visibleGroups())) + if n == 0 { + return 0 + } + return n - 1 +} + +// backspace removes the last rune from the filter query. +func (m *Model) backspace() { + if m.query == "" { + return + } + r := []rune(m.query) + m.query = string(r[:len(r)-1]) + m.cursor = 0 +} + +// choose selects the row under the cursor, when there is one, and quits. +func (m Model) choose() (tea.Model, tea.Cmd) { + rows := flatten(m.visibleGroups()) + if m.cursor >= 0 && m.cursor < len(rows) { + m.chosen = true + m.chosenRow = rows[m.cursor].row + } + m.quitting = true + return m, tea.Quit +} + +// cancel quits without a choice. +func (m Model) cancel() (tea.Model, tea.Cmd) { + m.chosen = false + m.quitting = true + return m, tea.Quit +} diff --git a/internal/picker/update_test.go b/internal/picker/update_test.go new file mode 100644 index 0000000..773bbc8 --- /dev/null +++ b/internal/picker/update_test.go @@ -0,0 +1,112 @@ +package picker_test + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/kryft-dev/cdd/internal/git" + "github.com/kryft-dev/cdd/internal/picker" +) + +func twoRowModel(opts picker.Options) picker.Model { + rows := []picker.Row{ + {Project: picker.Project{Kind: "work", Name: "alpha", Path: "/root/work/alpha"}}, + {Project: picker.Project{Kind: "work", Name: "beta", Path: "/root/work/beta"}}, + } + return picker.NewModel(rows, noopStatus, opts) +} + +func TestModel_Update_EscCancels(t *testing.T) { + m := twoRowModel(picker.Options{}) + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + final := next.(picker.Model) + + if _, ok := final.Chosen(); ok { + t.Fatalf("Chosen() ok = true after esc, want false") + } + if cmd == nil { + t.Fatalf("esc should return tea.Quit, got nil Cmd") + } +} + +func TestModel_Update_CtrlUClearsFilter(t *testing.T) { + m := twoRowModel(picker.Options{Query: "alpha"}) + next, _ := m.Update(tea.KeyPressMsg{Code: 'u', Mod: tea.ModCtrl}) + final := next.(picker.Model) + + next, _ = final.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + chosen := next.(picker.Model) + row, ok := chosen.Chosen() + if !ok { + t.Fatalf("enter after ctrl+u did not choose a Row") + } + // With the query cleared, the first row (alpha, History order) is + // chosen rather than a filtered single match. + if row.Project.Name != "alpha" { + t.Errorf("chosen Project = %q, want %q", row.Project.Name, "alpha") + } +} + +// TestModel_Update_StatusResult verifies that a status result lands keyed +// by the row's Project path, independent of row index, via a fake +// StatusFunc as the ticket asks. +func TestModel_Update_StatusResult(t *testing.T) { + m := twoRowModel(picker.Options{}) + + next, cmd := m.Update(struct{}{}) // unrelated message: no-op + final := next.(picker.Model) + if cmd != nil { + t.Fatalf("unrelated message should not return a Cmd") + } + + // Drive Init to obtain the batch of per-row status commands, then run + // one to get a real message shaped like the runtime would deliver it. + batch := final.Init() + if batch == nil { + t.Fatalf("Init() returned a nil Cmd") + } + msg := batch() + bmsg, ok := msg.(tea.BatchMsg) + if !ok || len(bmsg) == 0 { + t.Fatalf("Init() Cmd did not produce a tea.BatchMsg") + } + + // Feed every command's result through Update; each should be accepted + // without error regardless of arrival order. + for _, c := range bmsg { + next, _ = final.Update(c()) + final = next.(picker.Model) + } + _ = git.Status{} // status is opaque here; arrival without panic is the assertion +} + +func TestModel_Update_VimKeys(t *testing.T) { + m := twoRowModel(picker.Options{Vim: true}) + + next, _ := m.Update(tea.KeyPressMsg{Text: "j"}) + m = next.(picker.Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + final := next.(picker.Model) + + row, ok := final.Chosen() + if !ok { + t.Fatalf("enter did not choose a Row in vim mode") + } + if row.Project.Name != "beta" { + t.Errorf("chosen Project = %q, want %q (j should move down)", row.Project.Name, "beta") + } +} + +func TestModel_Update_VimQuitsOnQ(t *testing.T) { + m := twoRowModel(picker.Options{Vim: true}) + next, cmd := m.Update(tea.KeyPressMsg{Text: "q"}) + final := next.(picker.Model) + + if _, ok := final.Chosen(); ok { + t.Fatalf("Chosen() ok = true after q, want false") + } + if cmd == nil { + t.Fatalf("q on the list should return tea.Quit, got nil Cmd") + } +} From f51136cf0da7aa4111bdc171da8c27eb4cd199c0 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Thu, 17 Sep 2026 00:48:45 +0500 Subject: [PATCH 04/11] feat(picker): add relative last-visit time formatting RelativeTime renders the long form used in the row and the preview pane ("2 weeks ago", "yesterday"). RelativeTimeShort renders the compressed form narrow terminals fall back to (now, 5m, 2h, 3d, 2w, 3mo, 1y). Both return "" for a zero time (never visited). Co-Authored-By: Claude Fable 5.1 --- internal/picker/relative.go | 82 ++++++++++++++++++++++++++++++++ internal/picker/relative_test.go | 62 ++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 internal/picker/relative.go create mode 100644 internal/picker/relative_test.go diff --git a/internal/picker/relative.go b/internal/picker/relative.go new file mode 100644 index 0000000..e1ea3c3 --- /dev/null +++ b/internal/picker/relative.go @@ -0,0 +1,82 @@ +package picker + +import ( + "fmt" + "time" +) + +// RelativeTime renders t relative to now for the list row and the preview +// pane's relative column. A zero t (never visited) renders as "". +func RelativeTime(t, now time.Time) string { + if t.IsZero() { + return "" + } + d := now.Sub(t) + if d < 0 { + d = 0 + } + + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + n := int(d / time.Minute) + return plural(n, "minute") + " ago" + case d < 24*time.Hour: + n := int(d / time.Hour) + return plural(n, "hour") + " ago" + case d < 7*24*time.Hour: + n := int(d / (24 * time.Hour)) + if n == 1 { + return "yesterday" + } + return plural(n, "day") + " ago" + case d < 30*24*time.Hour: + n := int(d / (7 * 24 * time.Hour)) + return plural(n, "week") + " ago" + case d < 365*24*time.Hour: + n := int(d / (30 * 24 * time.Hour)) + return plural(n, "month") + " ago" + default: + n := int(d / (365 * 24 * time.Hour)) + return plural(n, "year") + " ago" + } +} + +// plural renders "n unit" or "n units". +func plural(n int, unit string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, unit) + } + return fmt.Sprintf("%d %ss", n, unit) +} + +// RelativeTimeShort renders t relative to now in the compressed form narrow +// terminals fall back to: now, 5m, 2h, 3d, 2w, 3mo, 1y. A zero t (never +// visited) renders as "". +func RelativeTimeShort(t, now time.Time) string { + if t.IsZero() { + return "" + } + d := now.Sub(t) + if d < 0 { + d = 0 + } + + switch { + case d < time.Minute: + return "now" + case d < time.Hour: + return fmt.Sprintf("%dm", int(d/time.Minute)) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d/time.Hour)) + case d < 7*24*time.Hour: + return fmt.Sprintf("%dd", int(d/(24*time.Hour))) + case d < 30*24*time.Hour: + return fmt.Sprintf("%dw", int(d/(7*24*time.Hour))) + case d < 365*24*time.Hour: + return fmt.Sprintf("%dmo", int(d/(30*24*time.Hour))) + default: + return fmt.Sprintf("%dy", int(d/(365*24*time.Hour))) + } +} diff --git a/internal/picker/relative_test.go b/internal/picker/relative_test.go new file mode 100644 index 0000000..f70e293 --- /dev/null +++ b/internal/picker/relative_test.go @@ -0,0 +1,62 @@ +package picker_test + +import ( + "testing" + "time" + + "github.com/kryft-dev/cdd/internal/picker" +) + +func TestRelativeTime(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + when time.Time + want string + }{ + {"zero (never visited)", time.Time{}, ""}, + {"seconds", now.Add(-30 * time.Second), "just now"}, + {"minutes", now.Add(-5 * time.Minute), "5 minutes ago"}, + {"one hour", now.Add(-1 * time.Hour), "1 hour ago"}, + {"hours", now.Add(-2 * time.Hour), "2 hours ago"}, + {"yesterday", now.Add(-24 * time.Hour), "yesterday"}, + {"days", now.Add(-3 * 24 * time.Hour), "3 days ago"}, + {"weeks", now.Add(-14 * 24 * time.Hour), "2 weeks ago"}, + {"months", now.Add(-90 * 24 * time.Hour), "3 months ago"}, + {"years", now.Add(-400 * 24 * time.Hour), "1 year ago"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := picker.RelativeTime(tt.when, now); got != tt.want { + t.Errorf("RelativeTime(%v) = %q, want %q", tt.when, got, tt.want) + } + }) + } +} + +func TestRelativeTimeShort(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + when time.Time + want string + }{ + {"zero (never visited)", time.Time{}, ""}, + {"seconds", now.Add(-30 * time.Second), "now"}, + {"minutes", now.Add(-5 * time.Minute), "5m"}, + {"hours", now.Add(-2 * time.Hour), "2h"}, + {"days", now.Add(-3 * 24 * time.Hour), "3d"}, + {"weeks", now.Add(-14 * 24 * time.Hour), "2w"}, + {"months", now.Add(-90 * 24 * time.Hour), "3mo"}, + {"years", now.Add(-400 * 24 * time.Hour), "1y"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := picker.RelativeTimeShort(tt.when, now); got != tt.want { + t.Errorf("RelativeTimeShort(%v) = %q, want %q", tt.when, got, tt.want) + } + }) + } +} From b15f2c003912eda24a2613d3a80cc8d4ffbde0e2 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Thu, 17 Sep 2026 00:48:50 +0500 Subject: [PATCH 05/11] feat(picker): compute width/height degradation for narrow terminals ComputeLayout sizes the list to its natural content width (caret, longest name, status cluster, relative time), capped at 55% of the terminal, and drops the preview pane once the remainder falls under 30 columns. Below the list's natural width, relative time compresses to its short form first, then names truncate with "..." to a floor of 8; the status cluster never shrinks. Under 15 rows the footer's legend line is dropped, under 10 the keys line too. Co-Authored-By: Claude Fable 5.1 --- internal/picker/layout.go | 135 +++++++++++++++++++++++++++++++++ internal/picker/layout_test.go | 97 +++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 internal/picker/layout.go create mode 100644 internal/picker/layout_test.go diff --git a/internal/picker/layout.go b/internal/picker/layout.go new file mode 100644 index 0000000..470b333 --- /dev/null +++ b/internal/picker/layout.go @@ -0,0 +1,135 @@ +package picker + +import "time" + +// nameFloor is the smallest a truncated Project name is ever shrunk to. +const nameFloor = 8 + +// previewFloor is the least remaining width the preview pane needs; below +// it the preview is not drawn at all. +const previewFloor = 30 + +// caretWidth is the fixed-width caret/gutter column (" " or " › "). +const caretWidth = 3 + +// Layout is the Picker's per-frame sizing, recomputed from every +// tea.WindowSizeMsg without losing any Model state. +type Layout struct { + // NameWidth is the column width for a Project name, after any + // truncation. + NameWidth int + // StatusWidth is the column width for the status glyph cluster. It + // never shrinks. + StatusWidth int + // TimeWidth is the column width for the relative last-visit time. + TimeWidth int + // ShortTime reports whether the relative time uses its compressed + // form (now, 5m, 2h, 3d, 2w, 3mo, 1y). + ShortTime bool + + // ListWidth is the total width given to the grouped list pane. + ListWidth int + // PreviewWidth is the width given to the preview pane, when ShowPreview + // is true. + PreviewWidth int + // ShowPreview reports whether there is room to draw the preview pane. + ShowPreview bool + + // ListHeight is the number of rows the list body may draw. + ListHeight int + // ShowLegend reports whether the footer's glyph legend line is drawn. + ShowLegend bool + // ShowKeys reports whether the footer's keys line is drawn. + ShowKeys bool +} + +// ComputeLayout derives a Layout from the terminal size and the content +// that must fit in it: the longest Project name, the widest status glyph +// cluster, and the last-visit times of the currently visible rows. +// +// Degradation order (narrowest terminal wins): the list is sized to its +// natural content width capped at 55% of the terminal; if that leaves under +// previewFloor columns for the preview, the preview is dropped and the list +// takes the full width. Within the list's own budget, relative time +// compresses to its short form first, then names truncate with "…" down to +// nameFloor; the status cluster never shrinks and there is no hard minimum. +func ComputeLayout(longestName, widestStatus int, times []time.Time, now time.Time, width, height int) Layout { + l := Layout{ + NameWidth: longestName, + StatusWidth: widestStatus, + TimeWidth: widestTime(times, now, false), + } + + cap55 := width * 55 / 100 + natural := caretWidth + l.NameWidth + 2 + l.StatusWidth + 2 + l.TimeWidth + 1 + listBudget := min(natural, max(cap55, 1)) + + remainder := width - listBudget - 1 + l.ShowPreview = remainder >= previewFloor + if l.ShowPreview { + l.PreviewWidth = remainder + } + + // The width actually available to the list: the full terminal when the + // preview is not drawn, otherwise the capped budget. + available := width + if l.ShowPreview { + available = listBudget + } + + if natural > available { + l.ShortTime = true + l.TimeWidth = widestTime(times, now, true) + natural = caretWidth + l.NameWidth + 2 + l.StatusWidth + 2 + l.TimeWidth + 1 + } + if natural > available { + overflow := natural - available + l.NameWidth = max(l.NameWidth-overflow, nameFloor) + } + + l.ListWidth = available + + l.ShowLegend = height >= 15 + l.ShowKeys = height >= 10 + fixed := 1 // filter line + if l.ShowLegend { + fixed++ // rule + legend counted as one reserved line pairing with keys below + } + if l.ShowKeys { + fixed++ + } + l.ListHeight = max(height-fixed-1, 1) // -1 for the footer rule + + return l +} + +// 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 { + w := 0 + for _, t := range times { + var s string + if short { + s = RelativeTimeShort(t, now) + } else { + s = RelativeTime(t, now) + } + if len(s) > w { + w = len(s) + } + } + return w +} + +// truncateName shortens name to width columns, ending in "…" when it does +// not fit, and never going below nameFloor unless name itself is shorter. +func truncateName(name string, width int) string { + r := []rune(name) + if len(r) <= width { + return name + } + if width <= 1 { + return string(r[:max(width, 0)]) + } + return string(r[:width-1]) + "…" +} diff --git a/internal/picker/layout_test.go b/internal/picker/layout_test.go new file mode 100644 index 0000000..81d8651 --- /dev/null +++ b/internal/picker/layout_test.go @@ -0,0 +1,97 @@ +package picker_test + +import ( + "testing" + "time" + + "github.com/kryft-dev/cdd/internal/picker" +) + +func TestComputeLayout_PreviewVisibility(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + times := []time.Time{now.Add(-2 * time.Hour)} + + tests := []struct { + name string + width int + wantPreview bool + }{ + {"wide terminal shows preview", 100, true}, + {"narrow terminal hides preview", 40, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lay := picker.ComputeLayout(8, 1, times, now, tt.width, 24) + if lay.ShowPreview != tt.wantPreview { + t.Errorf("ShowPreview = %v, want %v (width %d)", lay.ShowPreview, tt.wantPreview, tt.width) + } + }) + } +} + +func TestComputeLayout_TimeCompressesBeforeNameTruncates(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + // "2 weeks ago" (11 cols) is long enough to force compression at a + // tight width, while the Project name should still fit uncompressed. + times := []time.Time{now.Add(-14 * 24 * time.Hour)} + + lay := picker.ComputeLayout(12, 1, times, now, 30, 24) + + if !lay.ShortTime { + t.Fatalf("ShortTime = false, want true at a tight width") + } + if lay.NameWidth != 12 { + t.Errorf("NameWidth = %d, want 12 (unchanged once time compression is enough)", lay.NameWidth) + } +} + +func TestComputeLayout_NameTruncatesToFloorWhenStillTooWide(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + times := []time.Time{now.Add(-14 * 24 * time.Hour)} + + lay := picker.ComputeLayout(40, 1, times, now, 20, 24) + + if lay.NameWidth < 8 { + t.Errorf("NameWidth = %d, must never go below the floor of 8", lay.NameWidth) + } + if lay.NameWidth >= 40 { + t.Errorf("NameWidth = %d, want it truncated from 40 at a 20-column width", lay.NameWidth) + } +} + +func TestComputeLayout_StatusNeverShrinks(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + times := []time.Time{now.Add(-14 * 24 * time.Hour)} + + lay := picker.ComputeLayout(40, 9, times, now, 15, 24) + + if lay.StatusWidth != 9 { + t.Errorf("StatusWidth = %d, want 9 (status never shrinks)", lay.StatusWidth) + } +} + +func TestComputeLayout_FooterLines(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + height int + wantLegend bool + wantKeys bool + }{ + {"tall terminal shows both", 30, true, true}, + {"under 15 rows drops the legend", 14, false, true}, + {"under 10 rows drops the keys line too", 9, false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lay := picker.ComputeLayout(8, 1, nil, now, 100, tt.height) + if lay.ShowLegend != tt.wantLegend { + t.Errorf("ShowLegend = %v, want %v", lay.ShowLegend, tt.wantLegend) + } + if lay.ShowKeys != tt.wantKeys { + t.Errorf("ShowKeys = %v, want %v", lay.ShowKeys, tt.wantKeys) + } + }) + } +} From ed510e009b9990177f43447a6d87e7f68b4bf719 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Thu, 17 Sep 2026 00:48:56 +0500 Subject: [PATCH 06/11] feat(picker): add the accepted look's palette and status glyphs lipgloss.LightDark palette (muted, green, yellow, blue, red, accent, rule) selected from tea.RequestBackgroundColor. Status glyph cluster (clean/modified, untracked, ahead/behind, unknown, not-a-repo, loading), its plain-text width for column alignment, the legend line, the keys line, and fuzzy-match highlighting (accent bold underlined). Co-Authored-By: Claude Fable 5.1 --- internal/picker/styles.go | 218 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 internal/picker/styles.go diff --git a/internal/picker/styles.go b/internal/picker/styles.go new file mode 100644 index 0000000..b27a206 --- /dev/null +++ b/internal/picker/styles.go @@ -0,0 +1,218 @@ +package picker + +import ( + "image/color" + "strconv" + "strings" + + "charm.land/lipgloss/v2" + + "github.com/kryft-dev/cdd/internal/git" +) + +// theme holds the accent colours the Picker needs, chosen for the +// terminal's background via lipgloss.LightDark. Body text keeps the +// terminal's own foreground. +type theme struct { + muted, green, yellow, blue, red, accent, rule color.Color +} + +// newTheme builds the theme for a light or dark background, using the +// colours settled on for the accepted look. +func newTheme(dark bool) theme { + ld := lipgloss.LightDark(dark) + c := lipgloss.Color + return theme{ + muted: ld(c("#6E7781"), c("#8B949E")), + green: ld(c("#1A7F37"), c("#3FB950")), + yellow: ld(c("#9A6700"), c("#D29922")), + blue: ld(c("#0969DA"), c("#58A6FF")), + red: ld(c("#CF222E"), c("#F85149")), + accent: ld(c("#8250DF"), c("#A371F7")), + rule: ld(c("#D0D7DE"), c("#30363D")), + } +} + +func (t theme) fg(c color.Color) lipgloss.Style { return lipgloss.NewStyle().Foreground(c) } +func (t theme) muted_() lipgloss.Style { return t.fg(t.muted) } +func (t theme) accentBold() lipgloss.Style { return t.fg(t.accent).Bold(true) } +func (t theme) match() lipgloss.Style { return t.fg(t.accent).Bold(true).Underline(true) } +func (t theme) rule_(w int) string { + if w < 0 { + w = 0 + } + return t.fg(t.rule).Render(strings.Repeat("─", w)) +} + +// Status glyphs, shared by the row cluster, the preview pane and the +// legend. +const ( + glyphClean = "✓" + glyphModified = "●" + glyphUntracked = "?" + glyphAhead = "↑" + glyphBehind = "↓" + glyphUnknown = "!" + glyphNotRepo = "—" + glyphLoading = "…" +) + +// statusCluster renders the compact coloured glyph cluster for one row's +// status: "✓", "● ?", "● ↑3↓2", "—", "…", "!". +func (t theme) statusCluster(st git.Status, loaded bool) string { + if !loaded { + return t.muted_().Render(glyphLoading) + } + switch st.Kind { + case git.NotRepo: + return t.muted_().Render(glyphNotRepo) + case git.Unknown: + return t.fg(t.red).Render(glyphUnknown) + } + + var parts []string + if st.State == git.Dirty { + parts = append(parts, t.fg(t.yellow).Render(glyphModified)) + } else { + parts = append(parts, t.fg(t.green).Render(glyphClean)) + } + if st.Untracked { + parts = append(parts, t.fg(t.blue).Render(glyphUntracked)) + } + sync := "" + if st.Ahead > 0 { + sync += glyphAhead + strconv.Itoa(st.Ahead) + } + if st.Behind > 0 { + sync += glyphBehind + strconv.Itoa(st.Behind) + } + if sync != "" { + parts = append(parts, t.fg(t.accent).Render(sync)) + } + return strings.Join(parts, " ") +} + +// statusClusterWidth is the plain (uncoloured) width of statusCluster's +// output, for column alignment. +func statusClusterWidth(st git.Status, loaded bool) int { + if !loaded || st.Kind != git.Found { + return 1 + } + w := 1 + if st.Untracked { + w += 2 + } + sync := 0 + if st.Ahead > 0 { + sync += 1 + len(strconv.Itoa(st.Ahead)) + } + if st.Behind > 0 { + sync += 1 + len(strconv.Itoa(st.Behind)) + } + if sync > 0 { + w += 1 + sync + } + return w +} + +// legend is the plain-word footer key for the status glyphs. +func (t theme) legend() string { + f := func(c color.Color, glyph, word string) string { + return t.fg(c).Render(glyph) + t.muted_().Render(" "+word) + } + return strings.Join([]string{ + f(t.green, glyphClean, "clean"), + f(t.yellow, glyphModified, "modified"), + f(t.blue, glyphUntracked, "untracked"), + f(t.accent, glyphAhead, "ahead"), + f(t.accent, glyphBehind, "behind"), + f(t.red, glyphUnknown, "unknown"), + f(t.muted, glyphNotRepo, "not a repo"), + f(t.muted, glyphLoading, "loading"), + }, " ") +} + +// keysLine renders a key/description legend, e.g. "↑↓ move · enter jump". +func (t theme) keysLine(items ...string) string { + var parts []string + for i := 0; i+1 < len(items); i += 2 { + parts = append(parts, t.fg(t.muted).Bold(true).Render(items[i])+t.muted_().Render(" "+items[i+1])) + } + return strings.Join(parts, t.muted_().Render(" · ")) +} + +// previewStatusWords spells the working-tree status out in words, for the +// preview pane's "status" line. +func previewStatusWords(t theme, st git.Status, loaded bool) string { + if !loaded { + return t.muted_().Render(glyphLoading + " loading") + } + switch st.Kind { + case git.NotRepo: + return t.muted_().Render(glyphNotRepo + " not a repository") + case git.Unknown: + return t.fg(t.red).Render(glyphUnknown + " git timed out") + } + + var parts []string + if st.State == git.Dirty { + parts = append(parts, t.fg(t.yellow).Render(glyphModified+" modified")) + } else { + parts = append(parts, t.fg(t.green).Render(glyphClean+" clean")) + } + if st.Untracked { + parts = append(parts, t.fg(t.blue).Render(glyphUntracked+" untracked files")) + } + return strings.Join(parts, " ") +} + +// previewSync spells the ahead/behind/upstream state out, for the preview +// pane's "sync" line. It is "" when there is nothing to report (not a +// repository, unknown, or still loading). +func previewSync(t theme, st git.Status, loaded bool) string { + if !loaded || st.Kind != git.Found { + return "" + } + var parts []string + if st.Ahead > 0 { + parts = append(parts, t.fg(t.accent).Render(strconv.Itoa(st.Ahead)+" ahead")) + } + if st.Behind > 0 { + parts = append(parts, t.fg(t.accent).Render(strconv.Itoa(st.Behind)+" behind")) + } + if !st.HasUpstream { + return t.muted_().Render("no upstream") + } + if len(parts) == 0 { + return t.muted_().Render("in sync") + } + return strings.Join(parts, " ") +} + +// highlightMatches renders s with the rune positions in matched (indexes +// into s, shifted by offset) rendered in the accent match style. +func highlightMatches(s string, matched []int, offset int, base lipgloss.Style, t theme) string { + if len(matched) == 0 { + return base.Render(s) + } + set := make(map[int]bool, len(matched)) + for _, i := range matched { + set[i-offset] = true + } + runes := []rune(s) + var b strings.Builder + for i := 0; i < len(runes); { + j := i + for j < len(runes) && set[j] == set[i] { + j++ + } + chunk := string(runes[i:j]) + if set[i] { + b.WriteString(t.match().Render(chunk)) + } else { + b.WriteString(base.Render(chunk)) + } + i = j + } + return b.String() +} From d2d6c5c41202e2973de1f2e61a02c26873d1eb90 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Thu, 17 Sep 2026 00:49:01 +0500 Subject: [PATCH 07/11] feat(picker): draw variant D, grouped list with a preview pane Filter line with the accent prompt; left pane rows grouped under bold Kind headers with a rule, selected row gets a caret and accent name, no background highlight; right pane preview box (kind/name, path, branch, status words, sync, last visit absolute plus relative, visits); footer rule, legend and keys line with the match count. Empty History renders the "run cdd scan to seed History" hint instead of the list. Co-Authored-By: Claude Fable 5.1 --- internal/picker/view.go | 208 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 internal/picker/view.go diff --git a/internal/picker/view.go b/internal/picker/view.go new file mode 100644 index 0000000..ae4109f --- /dev/null +++ b/internal/picker/view.go @@ -0,0 +1,208 @@ +package picker + +import ( + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/kryft-dev/cdd/internal/git" +) + +// View renders the current frame: the filter line, the grouped list (with a +// preview pane beside it when there is room), and the footer. +func (m Model) View() tea.View { + if m.quitting { + return tea.NewView("") + } + if len(m.rows) == 0 { + return tea.NewView(m.emptyHistoryView()) + } + + t := newTheme(m.dark) + now := time.Now() + groups := m.visibleGroups() + rows := flatten(groups) + + longestName := 0 + for _, mt := range rows { + if n := len([]rune(mt.row.Project.Name)); n > longestName { + longestName = n + } + } + widestStatus := 1 + times := make([]time.Time, 0, len(rows)) + for _, mt := range rows { + st, loaded := m.statuses[mt.row.Project.Path] + if w := statusClusterWidth(st, loaded); w > widestStatus { + widestStatus = w + } + times = append(times, mt.row.LastVisit) + } + + width, height := m.width, m.height + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } + lay := ComputeLayout(longestName, widestStatus, times, now, width, height) + + var b strings.Builder + b.WriteString(m.filterLine(t)) + b.WriteString("\n") + b.WriteString(m.listView(t, groups, rows, lay, now)) + + if lay.ShowPreview { + preview := m.previewView(t, rows, lay, now) + list := b.String() + b.Reset() + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, list, " ", preview)) + } + + b.WriteString("\n") + b.WriteString(m.footerView(t, width, len(rows), lay)) + + return tea.NewView(b.String()) +} + +// emptyHistoryView is shown when there are no rows at all: an empty +// History has nothing for the Picker to list. +func (m Model) emptyHistoryView() string { + t := newTheme(m.dark) + return t.muted_().Render("run cdd scan to seed History") +} + +// filterLine renders the "❯ " prompt, the query, and a muted placeholder +// when the query is empty. +func (m Model) filterLine(t theme) string { + prompt := t.fg(t.accent).Bold(true).Render("❯ ") + if m.query == "" { + return prompt + t.muted_().Render("type to filter") + } + return prompt + m.query +} + +// listView renders the grouped list body: a header line per Kind, then its +// rows, with the cursor's row carrying the caret and accent name. +func (m Model) listView(t theme, groups []kindGroup, rows []match, lay Layout, now time.Time) string { + var lines []string + i := 0 + for _, g := range groups { + rule := t.rule_(max(lay.ListWidth-len([]rune(g.kind))-1, 0)) + lines = append(lines, t.accentBold().Render(g.kind)+" "+rule) + for _, mt := range g.matches { + lines = append(lines, m.rowView(t, mt, i == m.cursor, lay, now)) + i++ + } + } + if len(rows) == 0 { + lines = append(lines, t.muted_().Render("no projects match")) + } + return strings.Join(lines, "\n") +} + +// rowView renders one Project row: NAME STATUS LAST VISIT, with a caret +// and accent name when selected. +func (m Model) rowView(t theme, mt match, selected bool, lay Layout, now time.Time) string { + caret := " " + nameStyle := lipgloss.NewStyle().Bold(true) + if selected { + caret = t.fg(t.accent).Bold(true).Render("›") + nameStyle = nameStyle.Foreground(t.accent) + } + + name := mt.row.Project.Name + if len([]rune(name)) > lay.NameWidth { + name = truncateName(name, lay.NameWidth) + } + offset := len([]rune(mt.row.Project.Path)) - len([]rune(mt.row.Project.Name)) + name = padRight(highlightMatches(name, mt.matches, offset, nameStyle, t), lay.NameWidth) + + st, loaded := m.statuses[mt.row.Project.Path] + status := padRight(t.statusCluster(st, loaded), lay.StatusWidth) + + rel := RelativeTime(mt.row.LastVisit, now) + if lay.ShortTime { + rel = RelativeTimeShort(mt.row.LastVisit, now) + } + rel = padLeft(t.muted_().Render(rel), lay.TimeWidth) + + return caret + " " + name + " " + status + " " + rel +} + +// padRight/padLeft pad plain or styled strings to a display width. +func padRight(s string, w int) string { + if d := w - lipgloss.Width(s); d > 0 { + return s + strings.Repeat(" ", d) + } + return s +} + +func padLeft(s string, w int) string { + if d := w - lipgloss.Width(s); d > 0 { + return strings.Repeat(" ", d) + s + } + return s +} + +// previewView renders the right-hand preview box for the selected row. +func (m Model) previewView(t theme, rows []match, lay Layout, now time.Time) string { + var body strings.Builder + if m.cursor >= 0 && m.cursor < len(rows) { + p := rows[m.cursor].row.Project + st, loaded := m.statuses[p.Path] + + label := func(s string) string { return t.muted_().Render(padRight(s, 11)) } + body.WriteString(t.muted_().Render(p.Kind+"/") + t.accentBold().Render(p.Name) + "\n\n") + body.WriteString(label("path") + p.Path + "\n") + if loaded && st.Kind == git.Found { + body.WriteString(label("branch") + st.Branch + "\n") + } + body.WriteString(label("status") + previewStatusWords(t, st, loaded) + "\n") + if sync := previewSync(t, st, loaded); sync != "" { + body.WriteString(label("sync") + sync + "\n") + } + last := rows[m.cursor].row.LastVisit + if last.IsZero() { + body.WriteString(label("last visit") + t.muted_().Render("never")) + } else { + body.WriteString(label("last visit") + last.Format("2006-01-02 15:04")) + body.WriteString(t.muted_().Render(" " + RelativeTime(last, now))) + } + body.WriteString("\n" + label("visits") + fmt.Sprintf("%d", rows[m.cursor].row.Visits)) + } else { + body.WriteString(t.muted_().Render("nothing selected")) + } + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.rule). + Padding(0, 1). + Width(lay.PreviewWidth). + Height(lay.ListHeight) + return box.Render(body.String()) +} + +// footerView renders the rule, legend line (when there is room), keys line +// (when there is room) and the match count. +func (m Model) footerView(t theme, width, matched int, lay Layout) string { + var lines []string + lines = append(lines, t.rule_(width)) + if lay.ShowLegend { + lines = append(lines, t.legend()) + } + if lay.ShowKeys { + keys := t.keysLine("↑↓", "move", "enter", "jump", "esc", "cancel", "ctrl+u", "clear") + if m.vim { + keys = t.keysLine("j/k", "move", "f", "filter", "esc", "back/cancel", "enter", "jump", "q", "quit") + } + count := t.muted_().Render(fmt.Sprintf("%d/%d", matched, len(m.rows))) + gap := max(width-lipgloss.Width(keys)-lipgloss.Width(count), 1) + lines = append(lines, keys+strings.Repeat(" ", gap)+count) + } + return strings.Join(lines, "\n") +} From c24c89d714e97d9d6e58a03b88791b5e611fe2a3 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Thu, 17 Sep 2026 00:57:42 +0500 Subject: [PATCH 08/11] fix(picker): render the preview pane under the filter line, not on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview column was joined against a block whose first line was the filter line, so the box's top border landed on the "❯ type to filter" row instead of aligning with the list body. Prepend a leading newline to the preview column, matching the accepted prototype's `"\n" + box.Render(...)`, so it renders under a matching blank line. Co-Authored-By: Claude Sonnet 5 --- internal/picker/view.go | 13 ++++++++----- internal/picker/view_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 internal/picker/view_test.go diff --git a/internal/picker/view.go b/internal/picker/view.go index ae4109f..f27fb06 100644 --- a/internal/picker/view.go +++ b/internal/picker/view.go @@ -54,13 +54,16 @@ func (m Model) View() tea.View { var b strings.Builder b.WriteString(m.filterLine(t)) b.WriteString("\n") - b.WriteString(m.listView(t, groups, rows, lay, now)) + list := m.listView(t, groups, rows, lay, now) if lay.ShowPreview { - preview := m.previewView(t, rows, lay, now) - list := b.String() - b.Reset() - b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, list, " ", preview)) + // The preview column is joined under a matching blank line so its + // box's top border lands on the list's first row, not on the + // filter line above. + right := "\n" + m.previewView(t, rows, lay, now) + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, list, " ", right)) + } else { + b.WriteString(list) } b.WriteString("\n") diff --git a/internal/picker/view_test.go b/internal/picker/view_test.go new file mode 100644 index 0000000..9869fda --- /dev/null +++ b/internal/picker/view_test.go @@ -0,0 +1,35 @@ +package picker_test + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/kryft-dev/cdd/internal/picker" +) + +// TestModel_View_PreviewUnderFilterLine verifies that the preview box does +// not land on the same row as the filter line: the first rendered line +// must be the filter prompt, with no preview box border on it. +func TestModel_View_PreviewUnderFilterLine(t *testing.T) { + rows := []picker.Row{ + {Project: picker.Project{Kind: "work", Name: "alpha", Path: "/root/work/alpha"}}, + } + m := picker.NewModel(rows, noopStatus, picker.Options{}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m = next.(picker.Model) + + out := m.View().Content + lines := strings.Split(out, "\n") + if len(lines) == 0 { + t.Fatalf("View() produced no lines") + } + first := lines[0] + if !strings.Contains(first, "type to filter") { + t.Errorf("first line = %q, want it to contain the filter prompt", first) + } + if strings.ContainsAny(first, "╭╮╰╯│─") { + t.Errorf("first line = %q, should not contain any preview box border", first) + } +} From 7e3b59d3bf8f62aa8195b924268523247681b921 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Thu, 17 Sep 2026 00:58:11 +0500 Subject: [PATCH 09/11] fix(picker): window the list body to Layout.ListHeight Layout.ListHeight was only consumed by the preview box's Height; the list itself drew every row, pushing the footer off screen once rows exceeded the terminal height and leaving a cursor past the fold invisible. Port the prototype's windowing: track the cursor's line index (counting Kind header lines), scroll so it stays on screen, and emit exactly ListHeight lines. Co-Authored-By: Claude Sonnet 5 --- internal/picker/view.go | 22 +++++++++++-- internal/picker/view_test.go | 61 ++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/internal/picker/view.go b/internal/picker/view.go index f27fb06..0a2dd7f 100644 --- a/internal/picker/view.go +++ b/internal/picker/view.go @@ -90,14 +90,20 @@ func (m Model) filterLine(t theme) string { } // listView renders the grouped list body: a header line per Kind, then its -// rows, with the cursor's row carrying the caret and accent name. +// rows, with the cursor's row carrying the caret and accent name. The body +// is windowed to exactly Layout.ListHeight lines, scrolled so the cursor's +// line (counting Kind header lines) stays on screen. func (m Model) listView(t theme, groups []kindGroup, rows []match, lay Layout, now time.Time) string { var lines []string i := 0 + cursorLine := 0 for _, g := range groups { rule := t.rule_(max(lay.ListWidth-len([]rune(g.kind))-1, 0)) lines = append(lines, t.accentBold().Render(g.kind)+" "+rule) for _, mt := range g.matches { + if i == m.cursor { + cursorLine = len(lines) + } lines = append(lines, m.rowView(t, mt, i == m.cursor, lay, now)) i++ } @@ -105,7 +111,19 @@ func (m Model) listView(t theme, groups []kindGroup, rows []match, lay Layout, n if len(rows) == 0 { lines = append(lines, t.muted_().Render("no projects match")) } - return strings.Join(lines, "\n") + + listH := max(lay.ListHeight, 1) + start := 0 + if cursorLine >= listH { + start = cursorLine - listH + 1 + } + windowed := make([]string, listH) + for i := range windowed { + if idx := start + i; idx < len(lines) { + windowed[i] = lines[idx] + } + } + return strings.Join(windowed, "\n") } // rowView renders one Project row: NAME STATUS LAST VISIT, with a caret diff --git a/internal/picker/view_test.go b/internal/picker/view_test.go index 9869fda..bba7c37 100644 --- a/internal/picker/view_test.go +++ b/internal/picker/view_test.go @@ -3,12 +3,28 @@ package picker_test import ( "strings" "testing" + "time" tea "charm.land/bubbletea/v2" "github.com/kryft-dev/cdd/internal/picker" ) +// manyRows builds n rows all under the same Kind, so the list body is long +// enough to need scrolling at a modest terminal height. +func manyRows(n int) []picker.Row { + rows := make([]picker.Row, n) + for i := range rows { + name := "project-" + string(rune('a'+i%26)) + string(rune('0'+i/26)) + rows[i] = picker.Row{Project: picker.Project{ + Kind: "work", + Name: name, + Path: "/root/work/" + name, + }} + } + return rows +} + // TestModel_View_PreviewUnderFilterLine verifies that the preview box does // not land on the same row as the filter line: the first rendered line // must be the filter prompt, with no preview box border on it. @@ -33,3 +49,48 @@ func TestModel_View_PreviewUnderFilterLine(t *testing.T) { t.Errorf("first line = %q, should not contain any preview box border", first) } } + +// TestModel_View_ListWindowedToHeight verifies that a list far taller than +// the terminal is clipped to Layout.ListHeight rows rather than pushing the +// footer off screen, and that scrolling the cursor to the last row keeps it +// on screen. +func TestModel_View_ListWindowedToHeight(t *testing.T) { + rows := manyRows(40) + m := picker.NewModel(rows, noopStatus, picker.Options{}) + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 20}) + m = next.(picker.Model) + + lay := picker.ComputeLayout(10, 1, make([]time.Time, len(rows)), time.Now(), 120, 20) + + out := m.View().Content + lines := strings.Split(out, "\n") + + // Without windowing, every one of the 40 rows plus its Kind header + // would be drawn, pushing the total well past Height; with windowing + // the whole frame stays close to the terminal height. + if len(lines) > lay.ListHeight+6 { + t.Errorf("View() produced %d lines at Height 20 (ListHeight=%d); footer likely pushed off screen:\n%s", len(lines), lay.ListHeight, out) + } + + sawKeys := false + for _, l := range lines { + if strings.Contains(l, "move") && strings.Contains(l, "jump") { + sawKeys = true + } + } + if !sawKeys { + t.Errorf("View() output is missing the footer keys line:\n%s", out) + } + + // Move the cursor to the last row and confirm its name still appears + // in the rendered output (i.e. it scrolled into the window). + for i := 0; i < len(rows)-1; i++ { + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + m = next.(picker.Model) + } + out = m.View().Content + last := rows[len(rows)-1].Project.Name + if !strings.Contains(out, last) { + t.Errorf("after moving the cursor to the last row, View() output does not contain %q (row scrolled out of the window)", last) + } +} From 9a52ec6699c53877c563b1802029761804e0dcfd Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Thu, 17 Sep 2026 00:58:48 +0500 Subject: [PATCH 10/11] fix(picker): make the caret gutter a fixed 3 columns on every row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unselected caret was " " + " " (3 columns) but the selected caret was "›" + " " (2 columns), so every column on the selected row shifted one place left as the cursor moved. Use a fixed 3-column gutter (" " / " › ") on both branches, matching the spec and the accepted prototype. Co-Authored-By: Claude Sonnet 5 --- internal/picker/view.go | 6 +++--- internal/picker/view_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/internal/picker/view.go b/internal/picker/view.go index 0a2dd7f..15ca2fe 100644 --- a/internal/picker/view.go +++ b/internal/picker/view.go @@ -129,10 +129,10 @@ func (m Model) listView(t theme, groups []kindGroup, rows []match, lay Layout, n // rowView renders one Project row: NAME STATUS LAST VISIT, with a caret // and accent name when selected. func (m Model) rowView(t theme, mt match, selected bool, lay Layout, now time.Time) string { - caret := " " + caret := " " nameStyle := lipgloss.NewStyle().Bold(true) if selected { - caret = t.fg(t.accent).Bold(true).Render("›") + caret = t.fg(t.accent).Bold(true).Render(" › ") nameStyle = nameStyle.Foreground(t.accent) } @@ -152,7 +152,7 @@ func (m Model) rowView(t theme, mt match, selected bool, lay Layout, now time.Ti } rel = padLeft(t.muted_().Render(rel), lay.TimeWidth) - return caret + " " + name + " " + status + " " + rel + return caret + name + " " + status + " " + rel } // padRight/padLeft pad plain or styled strings to a display width. diff --git a/internal/picker/view_test.go b/internal/picker/view_test.go index bba7c37..617344a 100644 --- a/internal/picker/view_test.go +++ b/internal/picker/view_test.go @@ -6,6 +6,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/kryft-dev/cdd/internal/picker" ) @@ -94,3 +95,38 @@ func TestModel_View_ListWindowedToHeight(t *testing.T) { t.Errorf("after moving the cursor to the last row, View() output does not contain %q (row scrolled out of the window)", last) } } + +// TestModel_View_RowsShareEqualWidth verifies that every list row renders +// to the same display width, selected or not: a fixed-width caret gutter +// on every row, not a caret that shrinks the selected row by a column. +func TestModel_View_RowsShareEqualWidth(t *testing.T) { + rows := []picker.Row{ + {Project: picker.Project{Kind: "work", Name: "alpha", Path: "/root/work/alpha"}}, + {Project: picker.Project{Kind: "work", Name: "beta", Path: "/root/work/beta"}}, + {Project: picker.Project{Kind: "work", Name: "gamma", Path: "/root/work/gamma"}}, + } + m := picker.NewModel(rows, noopStatus, picker.Options{}) + // A narrow terminal keeps the preview pane from being drawn, so each + // Project name appears exactly once, in its list row. + next, _ := m.Update(tea.WindowSizeMsg{Width: 30, Height: 40}) + m = next.(picker.Model) + + names := []string{"alpha", "beta", "gamma"} + var widths []int + out := m.View().Content + for _, l := range strings.Split(out, "\n") { + for _, n := range names { + if strings.Contains(l, n) { + widths = append(widths, lipgloss.Width(l)) + } + } + } + if len(widths) != len(names) { + t.Fatalf("found %d row lines, want %d", len(widths), len(names)) + } + for i := 1; i < len(widths); i++ { + if widths[i] != widths[0] { + t.Errorf("row %d width = %d, want %d (same as row 0, selected or not)", i, widths[i], widths[0]) + } + } +} From 76daec55c6fb34bfc4cc8adfc08c894c1d2471da Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Thu, 17 Sep 2026 00:59:40 +0500 Subject: [PATCH 11/11] test(picker): cover status results keyed by path, not arrival index TestModel_Update_StatusResult asserted nothing about the result, ending with a comment claiming "arrival without panic is the assertion". Rewrite it with a fake StatusFunc that returns a distinct git.Status per row's path, feed the resulting messages through Update in reverse order, and assert the rendered View shows the right glyph on the right row, proving the keying is by Project path and not by arrival index. Co-Authored-By: Claude Sonnet 5 --- internal/picker/update_test.go | 73 ++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/internal/picker/update_test.go b/internal/picker/update_test.go index 773bbc8..43f9c60 100644 --- a/internal/picker/update_test.go +++ b/internal/picker/update_test.go @@ -1,6 +1,8 @@ package picker_test import ( + "context" + "strings" "testing" tea "charm.land/bubbletea/v2" @@ -48,20 +50,40 @@ func TestModel_Update_CtrlUClearsFilter(t *testing.T) { } } +// keyedStatus is a StatusFunc that returns a distinct git.Status per path, +// so a test can tell whether a result landed on the row it was meant for. +func keyedStatus(statuses map[string]git.Status) picker.StatusFunc { + return func(_ context.Context, path string) git.Status { + return statuses[path] + } +} + // TestModel_Update_StatusResult verifies that a status result lands keyed // by the row's Project path, independent of row index, via a fake -// StatusFunc as the ticket asks. +// StatusFunc as the ticket asks: each row gets a distinct git.Status, the +// resulting messages are fed through Update in reverse arrival order, and +// the rendered View still shows the right glyph on the right row. func TestModel_Update_StatusResult(t *testing.T) { - m := twoRowModel(picker.Options{}) - - next, cmd := m.Update(struct{}{}) // unrelated message: no-op - final := next.(picker.Model) - if cmd != nil { - t.Fatalf("unrelated message should not return a Cmd") + statuses := map[string]git.Status{ + "/root/work/alpha": {Kind: git.Found, State: git.Dirty}, + "/root/work/beta": {Kind: git.NotRepo}, } + m := picker.NewModel( + []picker.Row{ + {Project: picker.Project{Kind: "work", Name: "alpha", Path: "/root/work/alpha"}}, + {Project: picker.Project{Kind: "work", Name: "beta", Path: "/root/work/beta"}}, + }, + keyedStatus(statuses), + picker.Options{}, + ) + // A narrow terminal keeps the preview pane from being drawn, so each + // Project path appears exactly once, in its list row. + next, _ := m.Update(tea.WindowSizeMsg{Width: 30, Height: 40}) + final := next.(picker.Model) // Drive Init to obtain the batch of per-row status commands, then run - // one to get a real message shaped like the runtime would deliver it. + // each to get real messages shaped like the runtime would deliver + // them. batch := final.Init() if batch == nil { t.Fatalf("Init() returned a nil Cmd") @@ -71,14 +93,39 @@ func TestModel_Update_StatusResult(t *testing.T) { if !ok || len(bmsg) == 0 { t.Fatalf("Init() Cmd did not produce a tea.BatchMsg") } - - // Feed every command's result through Update; each should be accepted - // without error regardless of arrival order. + results := make([]tea.Msg, 0, len(bmsg)) for _, c := range bmsg { - next, _ = final.Update(c()) + results = append(results, c()) + } + + // Feed the results through Update in reverse order: if results were + // keyed by arrival index rather than by Project path, the last row's + // status (beta, NotRepo) would land on the first row instead. + for i := len(results) - 1; i >= 0; i-- { + next, _ = final.Update(results[i]) final = next.(picker.Model) } - _ = git.Status{} // status is opaque here; arrival without panic is the assertion + + out := final.View().Content + lines := strings.Split(out, "\n") + var alphaLine, betaLine string + for _, l := range lines { + if strings.Contains(l, "alpha") { + alphaLine = l + } + if strings.Contains(l, "beta") { + betaLine = l + } + } + if alphaLine == "" || betaLine == "" { + t.Fatalf("could not find both row lines in View():\n%s", out) + } + if !strings.Contains(alphaLine, "●") { + t.Errorf("alpha row = %q, want it to contain the dirty glyph ●", alphaLine) + } + if !strings.Contains(betaLine, "—") { + t.Errorf("beta row = %q, want it to contain the not-a-repo glyph —", betaLine) + } } func TestModel_Update_VimKeys(t *testing.T) {