From 6da5bb62f722d780b0a2b7601826e3dd84adcd04 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Thu, 17 Sep 2026 01:05:13 +0500 Subject: [PATCH 1/2] feat(jump): add ordering rule for the Picker's rows order is a pure function: Projects with a Visit come first (newest first, ties alphabetical by Rel), never-visited Projects follow alphabetically, and a Stale Visit contributes no row. Co-Authored-By: Claude Fable 5.1 --- internal/jump/order.go | 55 +++++++++++++++++ internal/jump/order_test.go | 115 ++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 internal/jump/order.go create mode 100644 internal/jump/order_test.go diff --git a/internal/jump/order.go b/internal/jump/order.go new file mode 100644 index 0000000..b55a619 --- /dev/null +++ b/internal/jump/order.go @@ -0,0 +1,55 @@ +// Package jump resolves the query a Jump starts from into the chosen +// Project's absolute path: it discovers Projects, orders them by History, +// takes the exact-match shortcut, and otherwise runs the Picker. +package jump + +import ( + "sort" + "time" + + "github.com/kryft-dev/cdd/internal/history" + "github.com/kryft-dev/cdd/internal/picker" + "github.com/kryft-dev/cdd/internal/project" +) + +// order applies the ordering rule to projects, given History's latest +// Visits: Projects with a Visit come first, newest Visit first, ties break +// alphabetically by Rel; never-visited Projects follow, alphabetically. A +// Visit for a Project not present in projects (a Stale Visit) contributes +// no row. order is a pure function: it does not touch the filesystem or +// History itself. +func order(projects []project.Project, latest []history.Visit, root string) []picker.Row { + lastVisit := make(map[string]time.Time, len(latest)) + for _, v := range latest { + lastVisit[v.Project] = v.At + } + + ranked := make([]project.Project, len(projects)) + copy(ranked, projects) + + sort.SliceStable(ranked, func(i, j int) bool { + a, b := ranked[i], ranked[j] + aAt, aVisited := lastVisit[a.Rel()] + bAt, bVisited := lastVisit[b.Rel()] + if aVisited != bVisited { + return aVisited + } + if aVisited && !aAt.Equal(bAt) { + return aAt.After(bAt) + } + return a.Rel() < b.Rel() + }) + + rows := make([]picker.Row, len(ranked)) + for i, p := range ranked { + rows[i] = picker.Row{ + Project: picker.Project{ + Kind: p.Kind, + Name: p.Name, + Path: p.Abs(root), + }, + LastVisit: lastVisit[p.Rel()], + } + } + return rows +} diff --git a/internal/jump/order_test.go b/internal/jump/order_test.go new file mode 100644 index 0000000..b37172c --- /dev/null +++ b/internal/jump/order_test.go @@ -0,0 +1,115 @@ +package jump + +import ( + "testing" + "time" + + "github.com/kryft-dev/cdd/internal/history" + "github.com/kryft-dev/cdd/internal/picker" + "github.com/kryft-dev/cdd/internal/project" +) + +// TestOrder_VisitedFirstNewestThenNeverVisited checks the ordering rule: +// Projects with a Visit come first, newest Visit first, ties break +// alphabetically by Rel, and never-visited Projects follow, alphabetically. +func TestOrder_VisitedFirstNewestThenNeverVisited(t *testing.T) { + projects := []project.Project{ + {Kind: "tools", Name: "cdd"}, + {Kind: "tools", Name: "dotfiles"}, + {Kind: "oss", Name: "lib"}, + {Kind: "work", Name: "api"}, + {Kind: "archive", Name: "old"}, + } + + latest := []history.Visit{ + {Project: "tools/cdd", At: time.Unix(3000, 0)}, + {Project: "work/api", At: time.Unix(5000, 0)}, + {Project: "oss/lib", At: time.Unix(1000, 0)}, + } + + got := order(projects, latest, "/root") + + want := []string{ + "work/api", // newest Visit + "tools/cdd", // next newest Visit + "oss/lib", // oldest Visit + "archive/old", // never visited, alphabetical + "tools/dotfiles", // never visited, alphabetical + } + + assertRowOrder(t, got, want) +} + +// TestOrder_TiesBreakAlphabetically checks that Projects sharing the exact +// same Visit timestamp sort alphabetically by Rel among themselves. +func TestOrder_TiesBreakAlphabetically(t *testing.T) { + projects := []project.Project{ + {Kind: "tools", Name: "zeta"}, + {Kind: "tools", Name: "alpha"}, + } + + tie := time.Unix(1000, 0) + latest := []history.Visit{ + {Project: "tools/zeta", At: tie}, + {Project: "tools/alpha", At: tie}, + } + + got := order(projects, latest, "/root") + + want := []string{"tools/alpha", "tools/zeta"} + assertRowOrder(t, got, want) +} + +// TestOrder_NeverVisitedAlphabetical checks that, with no Visits at all, +// order falls back to plain alphabetical by Rel. +func TestOrder_NeverVisitedAlphabetical(t *testing.T) { + projects := []project.Project{ + {Kind: "tools", Name: "zeta"}, + {Kind: "archive", Name: "old"}, + {Kind: "tools", Name: "alpha"}, + } + + got := order(projects, nil, "/root") + + want := []string{"archive/old", "tools/alpha", "tools/zeta"} + assertRowOrder(t, got, want) +} + +// TestOrder_StaleVisitContributesNoRow checks that a Visit for a Project no +// longer discovered (a Stale Visit) does not appear as a row, and that the +// remaining rows order as if it never existed. +func TestOrder_StaleVisitContributesNoRow(t *testing.T) { + projects := []project.Project{ + {Kind: "tools", Name: "cdd"}, + } + + latest := []history.Visit{ + {Project: "tools/cdd", At: time.Unix(1000, 0)}, + {Project: "gone/vanished", At: time.Unix(9000, 0)}, + } + + got := order(projects, latest, "/root") + + if len(got) != 1 { + t.Fatalf("order: got %d rows, want 1", len(got)) + } + if got[0].Project.Kind != "tools" || got[0].Project.Name != "cdd" { + t.Errorf("row = %+v, want tools/cdd", got[0]) + } +} + +// assertRowOrder checks got's rows, in order, have Rel (Kind/Name) matching +// want. +func assertRowOrder(t *testing.T, got []picker.Row, want []string) { + t.Helper() + + if len(got) != len(want) { + t.Fatalf("order: got %d rows, want %d", len(got), len(want)) + } + for i, w := range want { + rel := got[i].Project.Kind + "/" + got[i].Project.Name + if rel != w { + t.Errorf("row %d = %q, want %q", i, rel, w) + } + } +} From 7caa15882a13bc6904e9c5c2e1357f584cd6ca30 Mon Sep 17 00:00:00 2001 From: Hammad Majid Date: Thu, 17 Sep 2026 01:07:05 +0500 Subject: [PATCH 2/2] feat(jump): implement Resolve, the pick flow entry point Resolve discovers Projects, reads History's latest Visits, takes the exact-match shortcut on a Project's Name or Rel, otherwise runs the Picker (via the injectable PickFunc) with the query prefilled, confirms the chosen directory still exists, Records the Visit (warning to stderr on failure, still returning the path), and returns the absolute path. Cancel is the ErrCancelled sentinel. Tests cover the exact Name and Kind/Name shortcuts, an ambiguous name opening the Picker prefilled, no match opening the Picker, cancel, a vanished chosen directory, and a failing History write still returning the path. Closes #27 Co-Authored-By: Claude Fable 5.1 --- internal/jump/jump.go | 103 +++++++++++++++++ internal/jump/resolve_test.go | 212 ++++++++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 internal/jump/jump.go create mode 100644 internal/jump/resolve_test.go diff --git a/internal/jump/jump.go b/internal/jump/jump.go new file mode 100644 index 0000000..8434851 --- /dev/null +++ b/internal/jump/jump.go @@ -0,0 +1,103 @@ +package jump + +import ( + "context" + "errors" + "fmt" + "os" + "path" + + "github.com/kryft-dev/cdd/internal/config" + "github.com/kryft-dev/cdd/internal/git" + "github.com/kryft-dev/cdd/internal/history" + "github.com/kryft-dev/cdd/internal/picker" + "github.com/kryft-dev/cdd/internal/project" +) + +// ErrCancelled is returned by Resolve when the Picker is cancelled (Esc, +// Ctrl-C, or q on an empty filter). +var ErrCancelled = errors.New("jump: cancelled") + +// PickFunc runs the Picker over rows and returns the chosen Row, mirroring +// picker.Run's signature so tests can inject a fake Picker; production +// passes picker.Run itself. +type PickFunc func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Row, bool, error) + +// Resolve runs the pick flow that turns query into the absolute path of +// the Project to Jump to: discover Projects, read History's latest Visits, +// take the exact-match shortcut on a Project's Name or Rel, otherwise run +// the Picker (via pick) with query prefilled, confirm the chosen +// directory still exists, Record the Visit, and return the absolute path. +// +// A cancelled Picker yields ErrCancelled. A Visit that fails to Record +// only prints a warning to stderr; Resolve still returns the path. +func Resolve(ctx context.Context, cfg config.Config, hist *history.History, query string, pick PickFunc) (string, error) { + projects, err := project.Discover(cfg.Root, cfg.Exclude, cfg.IncludeHidden) + if err != nil { + return "", fmt.Errorf("jump: discover projects: %w", err) + } + + latest, err := hist.Latest() + if err != nil { + return "", fmt.Errorf("jump: %w", err) + } + + rel, abs, err := choose(cfg, projects, latest, query, pick) + if err != nil { + return "", err + } + + if _, err := os.Stat(abs); err != nil { + return "", fmt.Errorf("jump: %q no longer exists: %w", abs, err) + } + + if err := hist.Record(rel); err != nil { + fmt.Fprintf(os.Stderr, "cdd: warning: recording Visit for %q: %v\n", rel, err) + } + + return abs, nil +} + +// choose picks a Project either via the exact-match shortcut or by running +// the Picker, and returns its Rel and absolute path. +func choose(cfg config.Config, projects []project.Project, latest []history.Visit, query string, pick PickFunc) (rel, abs string, err error) { + if p, ok := exactMatch(projects, query); ok { + return p.Rel(), p.Abs(cfg.Root), nil + } + + rows := order(projects, latest, cfg.Root) + status := func(c context.Context, dir string) git.Status { + s, _ := git.GetStatus(c, dir) + return s + } + + row, ok, err := pick(rows, status, picker.Options{Vim: cfg.Keys.Vim, Query: query}) + if err != nil { + return "", "", fmt.Errorf("jump: %w", err) + } + if !ok { + return "", "", ErrCancelled + } + + rel = path.Join(row.Project.Kind, row.Project.Name) + return rel, row.Project.Path, nil +} + +// exactMatch reports whether query is exactly one Project's Name or Rel. +// A query matching two or more Projects (e.g. the same Name in different +// Kinds), or none, is not an exact match. +func exactMatch(projects []project.Project, query string) (project.Project, bool) { + if query == "" { + return project.Project{}, false + } + + var found project.Project + count := 0 + for _, p := range projects { + if p.Name == query || p.Rel() == query { + found = p + count++ + } + } + return found, count == 1 +} diff --git a/internal/jump/resolve_test.go b/internal/jump/resolve_test.go new file mode 100644 index 0000000..a948968 --- /dev/null +++ b/internal/jump/resolve_test.go @@ -0,0 +1,212 @@ +package jump_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/kryft-dev/cdd/internal/config" + "github.com/kryft-dev/cdd/internal/history" + "github.com/kryft-dev/cdd/internal/jump" + "github.com/kryft-dev/cdd/internal/picker" +) + +// mkProjects creates each rel path as a Kind/Name directory tree under +// root, and returns a Config pointing at root. +func mkProjects(t *testing.T, rels ...string) (config.Config, string) { + t.Helper() + + root := t.TempDir() + for _, rel := range rels { + if err := os.MkdirAll(filepath.Join(root, rel), 0o755); err != nil { + t.Fatalf("MkdirAll(%q): %v", rel, err) + } + } + + return config.Config{Root: root, History: config.History{MaxVisits: 1000}}, root +} + +// newHistory opens a fresh History in a temp directory. +func newHistory(t *testing.T) *history.History { + t.Helper() + + path := filepath.Join(t.TempDir(), "history") + h, err := history.Open(path, 1000) + if err != nil { + t.Fatalf("history.Open: %v", err) + } + return h +} + +// failPick fails the test if the Picker is ever run; it is used by tests +// that expect the exact-match shortcut to skip it. +func failPick(t *testing.T) jump.PickFunc { + t.Helper() + return func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Row, bool, error) { + t.Fatal("pick: Picker was run, want the exact-match shortcut to skip it") + return picker.Row{}, false, nil + } +} + +func TestResolve_ExactNameShortcutSkipsPicker(t *testing.T) { + cfg, root := mkProjects(t, "tools/cdd") + hist := newHistory(t) + + got, err := jump.Resolve(context.Background(), cfg, hist, "cdd", failPick(t)) + if err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + + want := filepath.Join(root, "tools", "cdd") + if got != want { + t.Errorf("Resolve = %q, want %q", got, want) + } + + assertRecorded(t, hist, "tools/cdd") +} + +func TestResolve_ExactKindNameShortcutSkipsPicker(t *testing.T) { + cfg, root := mkProjects(t, "tools/cdd", "archive/cdd") + hist := newHistory(t) + + got, err := jump.Resolve(context.Background(), cfg, hist, "tools/cdd", failPick(t)) + if err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + + want := filepath.Join(root, "tools", "cdd") + if got != want { + t.Errorf("Resolve = %q, want %q", got, want) + } + + assertRecorded(t, hist, "tools/cdd") +} + +func TestResolve_AmbiguousNameOpensPickerPrefilled(t *testing.T) { + cfg, root := mkProjects(t, "tools/cdd", "archive/cdd") + hist := newHistory(t) + + var gotQuery string + var gotRows int + pick := func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Row, bool, error) { + gotQuery = opts.Query + gotRows = len(rows) + return picker.Row{Project: picker.Project{ + Kind: "tools", Name: "cdd", Path: filepath.Join(root, "tools", "cdd"), + }}, true, nil + } + + got, err := jump.Resolve(context.Background(), cfg, hist, "cdd", pick) + if err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + + if gotQuery != "cdd" { + t.Errorf("Picker Query = %q, want %q", gotQuery, "cdd") + } + if gotRows != 2 { + t.Errorf("Picker got %d rows, want 2 (both Projects named cdd)", gotRows) + } + + want := filepath.Join(root, "tools", "cdd") + if got != want { + t.Errorf("Resolve = %q, want %q", got, want) + } + + assertRecorded(t, hist, "tools/cdd") +} + +func TestResolve_NoMatchOpensPickerPrefilled(t *testing.T) { + cfg, root := mkProjects(t, "tools/cdd") + hist := newHistory(t) + + var gotQuery string + pick := func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Row, bool, error) { + gotQuery = opts.Query + return picker.Row{Project: picker.Project{ + Kind: "tools", Name: "cdd", Path: filepath.Join(root, "tools", "cdd"), + }}, true, nil + } + + if _, err := jump.Resolve(context.Background(), cfg, hist, "nope", pick); err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + if gotQuery != "nope" { + t.Errorf("Picker Query = %q, want %q", gotQuery, "nope") + } +} + +func TestResolve_CancelReturnsErrCancelled(t *testing.T) { + cfg, _ := mkProjects(t, "tools/cdd") + hist := newHistory(t) + + pick := func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Row, bool, error) { + return picker.Row{}, false, nil + } + + _, err := jump.Resolve(context.Background(), cfg, hist, "anything", pick) + if !errors.Is(err, jump.ErrCancelled) { + t.Fatalf("Resolve error = %v, want ErrCancelled", err) + } +} + +func TestResolve_VanishedDirectoryErrors(t *testing.T) { + cfg, root := mkProjects(t, "tools/cdd") + hist := newHistory(t) + + gone := filepath.Join(root, "tools", "vanished") + pick := func(rows []picker.Row, status picker.StatusFunc, opts picker.Options) (picker.Row, bool, error) { + return picker.Row{Project: picker.Project{Kind: "tools", Name: "vanished", Path: gone}}, true, nil + } + + _, err := jump.Resolve(context.Background(), cfg, hist, "anything", pick) + if err == nil { + t.Fatal("Resolve: want error for a chosen directory that no longer exists, got nil") + } +} + +func TestResolve_FailingHistoryWriteStillReturnsPath(t *testing.T) { + cfg, root := mkProjects(t, "tools/cdd") + + historyDir := t.TempDir() + historyPath := filepath.Join(historyDir, "history") + hist, err := history.Open(historyPath, 1000) + if err != nil { + t.Fatalf("history.Open: %v", err) + } + + // Force Record to fail by making its directory unwritable, without + // touching the Project directories under root. + if err := os.Chmod(historyDir, 0o500); err != nil { + t.Fatalf("Chmod: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(historyDir, 0o700) }) + + got, err := jump.Resolve(context.Background(), cfg, hist, "cdd", failPick(t)) + if err != nil { + t.Fatalf("Resolve: unexpected error despite a failing History write: %v", err) + } + + want := filepath.Join(root, "tools", "cdd") + if got != want { + t.Errorf("Resolve = %q, want %q", got, want) + } +} + +// assertRecorded checks History's latest Visits contain rel. +func assertRecorded(t *testing.T, hist *history.History, rel string) { + t.Helper() + + latest, err := hist.Latest() + if err != nil { + t.Fatalf("Latest: %v", err) + } + for _, v := range latest { + if v.Project == rel { + return + } + } + t.Errorf("History has no Visit for %q after Resolve", rel) +}