Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions internal/jump/jump.go
Original file line number Diff line number Diff line change
@@ -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
}
55 changes: 55 additions & 0 deletions internal/jump/order.go
Original file line number Diff line number Diff line change
@@ -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
}
115 changes: 115 additions & 0 deletions internal/jump/order_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading