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= 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) + } + }) + } +} 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/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/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 +} 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) + } + }) + } +} 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() +} 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..43f9c60 --- /dev/null +++ b/internal/picker/update_test.go @@ -0,0 +1,159 @@ +package picker_test + +import ( + "context" + "strings" + "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") + } +} + +// 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: 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) { + 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 + // 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") + } + msg := batch() + bmsg, ok := msg.(tea.BatchMsg) + if !ok || len(bmsg) == 0 { + t.Fatalf("Init() Cmd did not produce a tea.BatchMsg") + } + results := make([]tea.Msg, 0, len(bmsg)) + for _, c := range bmsg { + 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) + } + + 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) { + 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") + } +} diff --git a/internal/picker/view.go b/internal/picker/view.go new file mode 100644 index 0000000..15ca2fe --- /dev/null +++ b/internal/picker/view.go @@ -0,0 +1,229 @@ +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") + + list := m.listView(t, groups, rows, lay, now) + if lay.ShowPreview { + // 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") + 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. 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++ + } + } + if len(rows) == 0 { + lines = append(lines, t.muted_().Render("no projects match")) + } + + 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 +// 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") +} diff --git a/internal/picker/view_test.go b/internal/picker/view_test.go new file mode 100644 index 0000000..617344a --- /dev/null +++ b/internal/picker/view_test.go @@ -0,0 +1,132 @@ +package picker_test + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/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. +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) + } +} + +// 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) + } +} + +// 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]) + } + } +}