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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ body:
id: what-happened
attributes:
label: What happened?
description: A clear description of the bug, including which Project or Root was involved.
description: A clear description of the bug, including which Project was involved.
validations:
required: true
- type: textarea
Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ body:
id: solution
attributes:
label: Proposed solution
description: What you would like cdd to do, using the project's vocabulary (Root, Kind, Project, Jump, Visit, History, Scan, Picker, Wrapper) where relevant.
description: What you would like cdd to do, using the project's vocabulary (Project, Jump, Visit, History, Scan, Picker, Wrapper) where relevant.
validations:
required: true
- type: textarea
Expand Down
16 changes: 4 additions & 12 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,8 @@ A terminal UI for jumping to recently used projects. It remembers where you have

## Language

**Root**:
The top-level directory whose Kinds are searched for Projects.
_Avoid_: Base, home, workspace, projects dir

**Kind**:
A first-level directory under Root that groups Projects by purpose.
_Avoid_: Category, group, type, namespace

**Project**:
A directory exactly one level below a Kind, identified by its path relative to Root. It need not be a repository or contain anything in particular.
A directory holding a git repository (a `.git` directory or file), identified by its absolute path. It may sit at any depth, and a directory that is not a repository is never a Project.
_Avoid_: Repo, workspace, folder, directory

**Jump**:
Expand All @@ -25,23 +17,23 @@ A single recorded Jump to a Project, or an entry seeded for a Project by a Scan.
_Avoid_: Access, hit, entry, usage

**Stale Visit**:
A Visit whose Project is no longer found under Root, whatever the cause. It contributes nothing to the Picker and is never pruned; it ages out of History.
A Visit whose Project no longer has a `.git` at its path, whatever the cause. It contributes nothing to the Picker and is never pruned; it ages out of History.
_Avoid_: Orphan, dead entry, dangling, missing project

**History**:
The ordered record of Visits from which recent Projects are derived.
_Avoid_: Cache, log, database, store

**Scan**:
A one-off sweep of Root that seeds History with a Visit per Project, dated from evidence found inside the Project itself.
A one-off sweep of the directories given to it (the home directory by default) that seeds History with a Visit per Project found at any depth below them, dated from evidence found inside the Project itself. Nothing about which directories were swept is remembered.
_Avoid_: Import, index, crawl, rebuild

**Picker**:
The interactive screen that lists Projects and lets the user choose one to Jump to.
_Avoid_: Menu, list, finder, selector

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

**Wrapper**:
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ recently used Projects.
## Vocabulary

Before writing code or docs, read [`CONTEXT.md`](CONTEXT.md). It is the
glossary for this project: terms like Root, Kind, Project, Jump, Visit, Stale
glossary for this project: terms like Project, Jump, Visit, Stale
Visit, History, Scan, Picker, and Wrapper have precise, agreed meanings.
Use those terms verbatim in code, comments, commit messages, and pull
requests instead of synonyms, so the codebase and its discussions share one
Expand Down
96 changes: 56 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# cdd

A TUI that lets you jump to recent Projects.
A TUI that lets you jump to the git repositories you work in, most recent
first. There is nothing to lay out or configure: `cdd scan` finds every
repository below the directories you give it, at any depth.

## Demo

<!-- Recorded with VHS against a throwaway Root: demo/setup.sh && vhs demo/demo.tape -->
<!-- Recorded with VHS against a throwaway home: demo/setup.sh && vhs demo/demo.tape -->
![cdd demo](demo/demo.gif)

## Install
Expand Down Expand Up @@ -54,21 +56,35 @@ eval "$(cdd init zsh)"

## First run

Create a `config.toml` (see [Config reference](#config-reference)) with at
least a `root`, then seed History with a Scan of everything already under
Root:
Seed History with a Scan. With no arguments it walks your home directory;
give it one or more directories to walk those instead:

```sh
cdd scan
cdd scan # every git repository below ~
cdd scan ~/Developer ~/work # only below these
```

A Project is any directory holding a `.git` directory or file (so
worktrees and submodules count). The walk goes to any depth but stops at a
repository, so repositories nested inside one are not listed, unless you
name that repository to `cdd scan` itself. It does not follow symlinks,
skips hidden directories and anything matched by `exclude`, and passes over
directories it cannot read. Run it again whenever you clone something new:
it never overrides the date of a real Jump.

## Usage

```sh
cdd # open the Picker over History, ordered by recency
cdd <query> # open the Picker pre-filtered by query
cdd <query> # Jump straight there when exactly one Project matches, else
# open the Picker pre-filtered by query
```

A query matches a Project straight away when it is the Project's name
(`cdd cdd`), a trailing part of its path (`cdd tools/cdd`), or its whole
path. The Picker lists only Projects in History, and drops any whose `.git`
has since gone.

### Keys

Default key map:
Expand Down Expand Up @@ -96,63 +112,63 @@ Vim key map (`keys.vim = true`): the list is focused on open.

### Layout

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

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

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

Both draw the same preview pane, use the same keys, status glyphs and
colours, and degrade the same way on a narrow terminal.
The Picker draws the **List Layout**: a flat fzf-style run of Projects in
History order, each Project's parent directory muted before its name (`~`
standing in for your home directory, and trimmed from the start on a narrow
terminal), the filter prompt below the list, and a `▌` bar plus a
background highlight on the selected row. The filter matches the parent
directory as well as the name.

## Config reference

`cdd` reads `config.toml` from `$XDG_CONFIG_HOME/cdd/config.toml`, falling
back to `~/.config/cdd/config.toml` when `XDG_CONFIG_HOME` is unset.
back to `~/.config/cdd/config.toml` when `XDG_CONFIG_HOME` is unset. The
file is optional, and so is every key in it; these are the defaults:

```toml
root = "~/Developer" # required, no default
exclude = [] # paths relative to Root, glob-matched
exclude = []
include_hidden = false
[history]
max_visits = 1000 # must be >= 1
max_visits = 1000
[keys]
vim = false
[picker]
layout = "grouped" # or "list" for the flat fzf-style layout
layout = "list"
```

- `root` (required): the top-level directory whose Kinds are searched for
Projects. A leading `~` is expanded to the user's home directory;
`$VAR` is left as-is.
- `exclude`: paths relative to Root, matched with `path.Match` semantics,
that are skipped when discovering Kinds and Projects.
- `include_hidden`: when `false` (the default), hidden directories are
excluded at both Kind and Project level.
- `exclude`: glob patterns (`filepath.Match` semantics) for directories
`cdd scan` skips, along with everything below them. A pattern containing
`/` is matched against the directory's absolute path, any other against
its name alone, as in `.gitignore`: `["node_modules", "~/go/pkg/*"]`. A
leading `~` is expanded to your home directory; `$VAR` is left as-is.
- `include_hidden`: when `false` (the default), `cdd scan` skips hidden
directories.
- `[history].max_visits`: the maximum number of Visits kept in History.
Must be at least 1; defaults to `1000`.
- `[keys].vim`: when `true`, the Picker opens with the list focused and
uses the vim key map described above. Defaults to `false`, the default
key map.
- `[picker].layout`: which Layout the Picker draws, `"grouped"` (the
default) or `"list"`, as described above. Any other value is a config
error.
- `[picker].layout`: which Layout the Picker draws. `"list"`, described
above, is the default and, for now, the only one. Any other value is a
config error.

Upgrading from v0.2: `root` and the `"grouped"` layout are gone. Delete
them from `config.toml` (cdd names the offending line), then run `cdd scan`
to rebuild History, since the old entries were stored relative to Root and
are ignored.

History is stored at `$XDG_DATA_HOME/cdd/history`, falling back to
`~/.local/share/cdd/history` when `XDG_DATA_HOME` is unset.

## How it works

A Scan sweeps Root once and seeds History with a Visit per discovered
Project, dated from evidence found inside the Project itself. The Picker
lists Projects drawn from History, most recently visited first, and lets
you choose one; choosing a Project records a new Visit and hands its path
to the Wrapper, which turns it into a Jump in your shell.
A Scan walks the directories it is given once and seeds History with a
Visit per git repository it finds, dated from the repository's last commit
(or the directory's mtime, before the first commit). The Picker lists
Projects drawn from History alone, most recently visited first, so opening
it never walks the disk; choosing a Project records a new Visit and hands
its path to the Wrapper, which turns it into a Jump in your shell. See
[ADR 0001](docs/adr/0001-projects-are-git-repos-found-by-scan.md) for why.

## Contributing

Expand Down
Binary file modified demo/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions demo/demo.tape
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Records demo/demo.gif against the environment built by demo/setup.sh.
# Run `demo/setup.sh` first; it never touches your real Root or History.
# Run `demo/setup.sh` first; it never touches your real home or History.
#
# demo/setup.sh && vhs demo/demo.tape
#
Expand All @@ -19,12 +19,13 @@ Set TypingSpeed 60ms

Env XDG_CONFIG_HOME "/tmp/cdd-demo/xdg/config"
Env XDG_DATA_HOME "/tmp/cdd-demo/xdg/data"
Env HOME "/tmp/cdd-demo/home"

Hide
Type "set -gx PATH /tmp/cdd-demo/bin $PATH" Enter
Type "set -g fish_autosuggestion_enabled 0" Enter
Type "cdd init fish | source" Enter
Type "cd /tmp/cdd-demo/root/lab/notes" Enter
Type "cd ~/notes" Enter
Type "clear" Enter
Show

Expand Down
68 changes: 29 additions & 39 deletions demo/setup.sh
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
#!/usr/bin/env bash
# Builds a self-contained demo environment for recording cdd, so the
# recording never touches your real Root, config, or History.
# recording never touches your real home, config, or History.
#
# demo/setup.sh # builds /tmp/cdd-demo
# DEMO_DIR=~/x demo/setup.sh
#
# Layout it creates:
# $DEMO_DIR/root a fake Root with five Kinds and a few Projects
# $DEMO_DIR/home a fake home with git repositories at several depths
# $DEMO_DIR/xdg/config XDG_CONFIG_HOME holding cdd/config.toml
# $DEMO_DIR/xdg/data XDG_DATA_HOME holding cdd/history
# $DEMO_DIR/bin/cdd the binary built from this checkout
set -euo pipefail

DEMO_DIR="${DEMO_DIR:-/tmp/cdd-demo}"
ROOT="$DEMO_DIR/root"
ROOT="$DEMO_DIR/home"
CONFIG="$DEMO_DIR/xdg/config"
DATA="$DEMO_DIR/xdg/data"
BIN="$DEMO_DIR/bin"
Expand All @@ -27,7 +27,7 @@ mkdir -p "$ROOT" "$CONFIG/cdd" "$DATA/cdd" "$BIN"
# out so `cdd scan` seeds a varied History.
git_demo() { git -c user.name=demo -c user.email=demo@example.com -c commit.gpgsign=false "$@"; }

repo() { # repo <kind/name> <days-ago> <file>
repo() { # repo <path under home> <days-ago> <file>
local dir="$ROOT/$1"; mkdir -p "$dir"
git_demo -C "$dir" init -q -b main
echo "# ${1##*/}" > "$dir/$3"
Expand All @@ -41,54 +41,44 @@ ago() { # ago <days> -> ISO timestamp, GNU or BSD date
|| date -u -v-"$1"d +%Y-%m-%dT%H:%M:%SZ
}

repo domain/kryft.dev 1 README.md
repo domain/wowaitech.com 3 README.md
repo domain/candidex.dev 12 README.md
repo tools/cdd 0 main.go
repo tools/grg 6 main.go
repo tools/dotfiles 40 README.md
repo ops/internal 2 README.md
repo ops/backups 90 README.md
repo lab/bubbletea-play 5 main.go
repo uni/compilers 20 notes.md
repo Developer/domain/kryft.dev 1 README.md
repo Developer/domain/wowaitech.com 3 README.md
repo Developer/tools/cdd 0 main.go
repo Developer/tools/grg 6 main.go
repo Developer/tools/lab/bubbletea-play 5 main.go
repo work/internal 2 README.md
repo work/clients/acme/api 9 README.md
repo dotfiles 40 README.md
repo uni/2025/compilers 20 notes.md

# clean vs. dirty vs. untracked vs. ahead
echo "wip" >> "$ROOT/domain/wowaitech.com/README.md" # modified
touch "$ROOT/tools/grg/scratch.go" # untracked
echo "wip" >> "$ROOT/ops/internal/README.md"; touch "$ROOT/ops/internal/new.txt" # both
echo "wip" >> "$ROOT/Developer/domain/wowaitech.com/README.md" # modified
touch "$ROOT/Developer/tools/grg/scratch.go" # untracked
echo "wip" >> "$ROOT/work/internal/README.md"; touch "$ROOT/work/internal/new.txt" # both

upstream="$DEMO_DIR/upstream.git"
git_demo init -q --bare "$upstream"
git_demo -C "$ROOT/domain/kryft.dev" remote add origin "$upstream"
git_demo -C "$ROOT/domain/kryft.dev" push -q -u origin main
echo "more" >> "$ROOT/domain/kryft.dev/README.md"
git_demo -C "$ROOT/domain/kryft.dev" commit -q -am "second commit" # ahead by 1
git_demo -C "$ROOT/Developer/domain/kryft.dev" remote add origin "$upstream"
git_demo -C "$ROOT/Developer/domain/kryft.dev" push -q -u origin main
echo "more" >> "$ROOT/Developer/domain/kryft.dev/README.md"
git_demo -C "$ROOT/Developer/domain/kryft.dev" commit -q -am "second commit" # ahead by 1

mkdir -p "$ROOT/lab/notes" # not a repository
mkdir -p "$ROOT/.archive/old-thing" "$ROOT/uni/.hidden" # hidden, never listed
mkdir -p "$ROOT/notes/2026" # not a repository, never listed
repo Developer/tools/cdd/vendor/inner 1 README.md # inside a repository, never listed
repo .cache/some-tool 1 README.md # hidden, never listed

# --- Config and History ---------------------------------------------------
cat > "$CONFIG/cdd/config.toml" <<TOML
root = "$ROOT"
exclude = []
include_hidden = false

[history]
max_visits = 1000

[keys]
vim = false
TOML
# No config.toml: cdd needs none.

# --- Binary and seeded History --------------------------------------------
( cd "$REPO_DIR" && go build -o "$BIN/cdd" ./cmd/cdd )
XDG_CONFIG_HOME="$CONFIG" XDG_DATA_HOME="$DATA" "$BIN/cdd" scan
HOME="$ROOT" XDG_CONFIG_HOME="$CONFIG" XDG_DATA_HOME="$DATA" "$BIN/cdd" scan

# A few real Jumps on top of the Scan so the ordering shows both sources.
hist="$DATA/cdd/history"
printf '%s\tjump\t%s\n' "$(ago 0)" tools/cdd >> "$hist"
printf '%s\tjump\t%s\n' "$(ago 1)" domain/kryft.dev >> "$hist"
printf '%s\tjump\t%s\n' "$(ago 2)" ops/internal >> "$hist"
printf '%s\tjump\t%s\n' "$(ago 0)" "$ROOT/Developer/tools/cdd" >> "$hist"
printf '%s\tjump\t%s\n' "$(ago 1)" "$ROOT/Developer/domain/kryft.dev" >> "$hist"
printf '%s\tjump\t%s\n' "$(ago 2)" "$ROOT/work/internal" >> "$hist"

cat <<MSG

Expand All @@ -97,7 +87,7 @@ Demo environment ready in $DEMO_DIR
Try it in a shell:
set -gx XDG_CONFIG_HOME $CONFIG; set -gx XDG_DATA_HOME $DATA # fish
export XDG_CONFIG_HOME=$CONFIG XDG_DATA_HOME=$DATA # bash/zsh
$BIN/cdd
HOME=$ROOT $BIN/cdd

Record it (VHS v0.11.0; v0.12.0 silently writes no GIF):
vhs demo/demo.tape
Expand Down
17 changes: 17 additions & 0 deletions docs/adr/0001-projects-are-git-repos-found-by-scan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Projects are git repositories found by Scan

Until v0.3.0 cdd searched a configured Root with a fixed shape: every directory under Root was a Kind, every directory under a Kind was a Project, and any directory counted. That shape forced one layout onto the disk and listed folders that were never meant to be jumped to, while repositories nested deeper, or kept outside Root, could not be reached at all.

A Project is now any directory holding a `.git` directory or file, at any depth, and there is no Root or Kind to configure. Only `cdd scan [dir...]` walks the disk (the home directory by default); it seeds History with absolute paths, and the Picker lists History alone, dropping any Visit whose `.git` has gone. The walk stops descending at the first repository it finds, except in a directory named to Scan, so a dotfiles repository at the home directory does not hide everything under it.

## Considered options

- **Walk the home directory each time the Picker opens.** Always fresh, but opening cost grows with the whole home directory; opening the Picker must stay instant.
- **Remember the directories Scan was given.** A plain `cdd scan` could re-walk them, but that is a configured Root under another name.
- **Record any `cd` into a repository through the Wrapper.** Useful, but it changes what counts as a Visit; it can be added later without undoing this decision.

## Consequences

- History lines written before v0.3.0 hold Root-relative paths; they are skipped as malformed and age out, and a fresh Scan rebuilds the order.
- The Grouped Layout drew Kind headers, so it is gone; `picker.layout` stays for layouts still to come.
- A config.toml still setting `root`, or `picker.layout = "grouped"`, is a hard error naming the change, so nobody is left wondering why their Root is ignored.
2 changes: 1 addition & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func Run(args []string, stdout, stderr io.Writer) int {
func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "cdd",
Short: "cdd finds Projects under a Root and Jumps to the one you pick",
Short: "cdd Jumps to the git repository you pick from your recent Projects",
}
root.Flags().BoolP("version", "V", false, "print the cdd version and exit")

Expand Down
Loading
Loading