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
12 changes: 10 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,7 @@ go test -run TestTheCommandsAnswerTheSameInBothModes . # the commands, in both m
- **A wait after a create names the row, not the hints.** A status flash holds the hints back for
`statusFlashDuration`, and it lands before the refresh that draws the row.
- **A script masks each value that changes between runs.** A date left in a golden fails the next day.
- **Search results sort by a creation time the fixture cannot hold still**, so the search flow's query
matches one item.
A mask rewrites frames and not waits, so a wait on a masked value names it with a pattern.

To look at something while developing, write the keys to a script and replay it:

Expand Down Expand Up @@ -170,6 +169,15 @@ Bare `projects` prints help. The project verbs sit at the root, and the item ver
`projects items`, with `tasks`, `dependencies` and `memberships` as nouns under it. `cli/root.go`
builds the tree. Help renders through `gotermstyle/cobrahelp`.

- **Every key the board takes is a row in `tui/action_table.go`, in every mode.** The key handler, the
hints in the status bar and in each prompt, picker and overlay, and the `?` overlay all read it. So a
key added there shows in each, and a row's `applies` keeps it out of each where it does not act. A
mode with a text box binds only keys that type nothing, and `typesText` hands the rest to the box.
The hosted reader takes its own keys while it shows.
- **The board hosts gotermboard's `Reader` and not its `Model`.** Its own panes, glyphs and footer are
drawn in `tui`, so `App` forwards keys, pastes and window sizes to the reader while one shows, and
`Reader.ClosesWith` hands the screen back.

- **The `api` key, or `--api`, chooses the mode, and neither means the database.** `decideMode` in
`cli/data_source.go` reads goclilogin's parse of the key, so the mode and the login read one
setting. It runs in each command's `RunE`, so `help`, `completion` and `version` open nothing.
Expand Down
103 changes: 64 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ projects tui # the board, reading that
```

Those lines are the whole setup for database mode. The right end of the board's status bar reads
`LOCAL` and the database path. In the board, `a` adds a project, `Tab` moves to the item pane, and `a`
`LOCAL` and the database path. In the board, `a` adds a project, `l` moves to the item pane, and `a`
adds an item there. `q` quits.

To work through an API instead:
Expand Down Expand Up @@ -99,7 +99,8 @@ projects items dependencies add 43 --depends-on 42 # 43 waits on 42
- `delete` and every `remove` ask first. `--yes` skips the question. With `--no-input`, or no
terminal, they refuse unless `--yes` is passed.
- `projects tui --once` prints one frame at `COLUMNS` by `LINES` and exits. `projects tui --json`
prints what the board is drawn from. With neither and no terminal, `projects tui` refuses.
prints what the board is drawn from, in whichever view `--view` names. With neither and no
terminal, `projects tui` refuses.

| Exit | Meaning |
| ---- | ------- |
Expand Down Expand Up @@ -162,47 +163,70 @@ because the first default run moves that directory out from under it.
A retired `TODOUI_*` variable set to a value stops every command with exit 2, naming the variable that
replaced it. Ignoring `TODOUI_DB` would open the default database where the variable chose another.

## The TUI — projects on the left, their items on the right, every key one press away
## The TUI — views on Tab, one key table, every key one press away

```text
j / k move through items and their tasks as one list J / K jump item to item
Tab switch pane Enter read it in full
a add a project, or an item to this project space mark done
T dependency browser / search every project
u undo the last change R read the data again now
? every key the pane you are in takes q quit
Tab the next view, in the order the header names them / filter the list in place
j / k move through rows [ / ] jump item to item, past tasks
h / l move between the project and item panes Enter read it full screen
space open the note under its row, or mark a task done c mark done
a add a project, or an item to this project M move the row
n / e write the note, or the description, in $EDITOR r rename
T the dependency map on this item C a Claude session on the row
u undo the last change R read the data again now
? every key the row you are on takes q quit
```

The status bar carries the keys the current row takes, and `?` lists every key for the pane.

The project pane opens on an `All` row, which shows every item grouped by project. `space` on a
project adds it to a selection. The item pane then shows the selected projects' items, grouped the
same way. An item belongs to one project or to several. `A` adds an item to more than one, and `p`
changes which projects hold it. Each item carries a title, notes, a checklist of sub-tasks, and the
items it waits on. The reader also names the repo the item is for. The board has no key that sets
that tag. `projects items edit <item> --repo <name>` sets it.
One table of actions builds the key handling, the hints in the status bar and in each prompt and
picker, and the `?` overlay. A key the row cannot take is in none of them. The header names the view
showing.

**By project** lists the projects on the left and the items of the one under the cursor on the right.
`space` on a project marks it, and the item pane then lists every marked project's items, each under
a header. **All items** lists every project's items under its header, and the header shows its open
and done counts. `Enter` or `space` on a header folds it. An item belongs to one project or to
several. `A` adds an item to more than one, and `P` changes which projects hold it. Each item carries
a title, notes, a checklist of sub-tasks, a repo tag, and the items it waits on. The board has no key
that sets the repo tag. `projects items edit <item> --repo <name>` sets it.

A row takes two lines. The first holds the state, the title, `≡` for a note, `◈` for an item in
several projects and the task count. The second, dim, holds the repo, the other projects holding the
item, and its number at the right edge. A `▸` marks only a row whose note opens. `space` opens the
note in place under the row, however tall, and `space` again closes it. While the cursor is on an
open note, `J` and `K` scroll it a line, and `Ctrl+d` and `Ctrl+u` half a screen. Elsewhere `Ctrl+d`
and `Ctrl+u` move half a screen of the list. Scrolling keeps the item's first line, and its group's
header, at the top of the pane.

`/` filters the list in place as each key lands. It matches titles and notes, ignoring case unless
the filter holds a capital. `Enter` keeps the filter and `Esc` clears it. In the project pane it
filters the projects.

Through an API, the status bar reads `API` and the API's host. The board reads the API again every
`apiRereadInterval` in `tui/app.go`, and `R` reads it at once.

`Enter` opens the reader, a full screen holding one item or one project. An item shows its fields,
what blocks it, what it blocks, its tasks, and its notes rendered as markdown. A project shows its
status, its open and done counts, and its description rendered the same way. `j`/`k` scroll a line,
`Ctrl+d`/`Ctrl+u` half a screen, and `g`/`G` jump to either end. `Esc`, `q` or `h` goes back to
where the reader was opened: the list on the same row, or the dependency browser. The item keys act
on the item shown, so `n` edits the note in `$EDITOR` and the reader shows the result. `u` undoes
the last change, and the reader shows that too. A placeholder written as `<word>` shows as written
rather than being read as an HTML tag. The list draws no note text. A `≡` on the row says the item
has one.

`b` links an item this one is blocked by. A link that would close a cycle is refused. `T` opens the
dependency browser. The left pane is every tree, searchable with `/`. The right pane is the tree you
are standing in. `l` drills in, so the row under the cursor becomes the root. `h` backs out one
drill at a time. `i` flips the tree between what an item waits on and what it unblocks. Finished
trees stay hidden until `a` shows them.
`Enter` opens an item or a project full screen in gotermboard's reader. An item shows its fields,
what blocks it, what it blocks, its tasks, and its note rendered as markdown. A project shows its
status, its open and done counts, and its description rendered the same way. `[` and `]` jump
section to section, `/` searches the text, and `Esc` or `space` goes back to the row it came from.
The note alone opens in place with `space`, so `Enter` goes full screen however short it is.

`n` or `e` writes the note, or a project's description, in `$EDITOR`. `r` renames. `t` adds a task,
and `D` deletes one. `M` grabs a row, `j` and `k` carry it, `Enter` drops it and `Esc` puts it back.

`C` opens a Claude session on the row. Inside tmux it opens in a window of its own, and outside tmux
it takes the terminal until it ends. It runs in the checkout the repo registry names for the item's
repo tag, or for a project's one repo. With no checkout named, it runs in the board's own directory,
and the session's first message and the status bar say so. A checkout named but missing opens no
session.

`b` links an item this one is blocked by. A link that would close a cycle is refused. **Dependencies**
shows the dependency map. The left pane is every tree, filtered with `/`. The right pane is the tree
you are standing in. `l` goes into it, then drills, so the row under the cursor becomes the root. `h`
backs out one drill at a time. `i` flips the tree between what an item waits on and what it unblocks.
Finished trees stay hidden until `.` shows them. `T` on an item opens the map standing on it.

`x` archives an item. `1` filters to blocked items, `2` adds archived ones back, and `0` clears the
filter.
filter. `projects tui --view items` opens on All items, and `--view dependencies` on the map.

## A project is bounded work, and its name is never a repo's

Expand All @@ -216,10 +240,11 @@ in database mode only. Through an API it bans nothing, because the API holds no
registry is a JSON file with a `repos` array, and each entry's `name` is a banned project name:

```json
{ "repos": [{ "name": "projects" }] }
{ "repos": [{ "name": "projects", "path": "~/code/projects" }] }
```

The match ignores case and surrounding spaces. `projects` reads the first registry it finds, in this
The match ignores case and surrounding spaces. An entry's `path`, where it has one, is the checkout
`C` in the board opens a Claude session in. `projects` reads the first registry it finds, in this
order:

1. `$PROJECTS_REPOS_REGISTRY`
Expand All @@ -229,11 +254,11 @@ order:
A missing registry bans nothing. A registry that does not parse fails every create and rename with
the parse error.

A project's status is `active`, `someday`, `completed` or `dropped`. Reading a project, `c`
completes it, `x` drops it, `S` shelves it for someday, and `r` reopens a shelved or closed one. On
the command line those are `projects complete`, `close --reason`, `shelve` and `reopen`.
A project's status is `active`, `someday`, `completed` or `dropped`. On a project's row, `c`
completes it, or reopens a shelved or closed one. `x` drops it, and `S` shelves it for someday. On
the command line those are `projects complete`, `reopen`, `close --reason` and `shelve`.
Completing a project is what takes it out of the list, so there is no separate archive step the way
items have one. A shelved project leaves the list too, but records no closing date. `C` shows
items have one. A shelved project leaves the list too, but records no closing date. `.` shows
shelved and closed projects again, each marked with its status.
Dropping requires a reason, because "deferred" invites the same idea back next month and "dropped,
and here is why" does not.
Expand Down
8 changes: 0 additions & 8 deletions apiclient/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,14 +255,6 @@ func (b *Backend) DeleteTask(itemID, taskID string) error {
return b.client.DeleteTask(b.ctx(), itemID, taskID)
}

func (b *Backend) Search(query string) ([]model.ProjectItem, error) {
items, err := b.items(b.client.SearchItems(b.ctx(), query, nil))
if err != nil {
return nil, err
}
return model.WithoutArchived(items), nil
}

func (b *Backend) ListBlocked() ([]model.ProjectItem, error) {
return b.items(b.client.ListBlockedItems(b.ctx(), nil))
}
Expand Down
3 changes: 0 additions & 3 deletions backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,6 @@ type Backend interface {
UpdateTask(itemID, taskID string, input model.UpdateProjectItemTask) (*model.ProjectItemTask, error)
DeleteTask(itemID, taskID string) error

// Search
Search(query string) ([]model.ProjectItem, error)

// Filters
ListBlocked() ([]model.ProjectItem, error)
ListArchived(projectID string) ([]model.ProjectItemInProject, error)
Expand Down
39 changes: 27 additions & 12 deletions both_modes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (

"github.com/datapointchris/tuidrive"
"github.com/santhosh-tekuri/jsonschema/v6"

"github.com/datapointchris/projects/tui"
)

// Each mode runs the built binary over the same seeded data: a database on this
Expand Down Expand Up @@ -49,10 +51,12 @@ func TestTheCommandsAnswerTheSameInBothModes(t *testing.T) {
{"item-with-tasks-and-blockers", []string{"items", "show", number, "--json"}},
{"focus-queue", []string{"next", "--json"}},
{"dependency-tree", []string{"items", "dependencies", "tree", "--json"}},
{"board-snapshot", []string{"tui", "--json"}},
} {
matchesSchema(t, read.schema, run(0, read.args...))
}
for _, view := range tui.ViewNames() {
matchesSchema(t, "board-snapshot", run(0, "tui", "--json", "--view", view))
}
if mode == "api" {
matchesSchema(t, "auth-status", run(0, "auth", "status", "--json"))
}
Expand Down Expand Up @@ -113,26 +117,37 @@ func commandRunner(t *testing.T, binary, dir string) func(want int, args ...stri
}
}

// matchesSchema checks answer against cli/schemas/<name>.schema.json. Every schema there is
// loaded under its $id, so one can refer to another by it.
func matchesSchema(t *testing.T, name string, answer []byte) {
t.Helper()
path := filepath.Join("cli", "schemas", name+".schema.json")
file, err := os.Open(path)
paths, err := filepath.Glob(filepath.Join("cli", "schemas", "*.schema.json"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = file.Close() }()
document, err := jsonschema.UnmarshalJSON(file)
if err != nil {
t.Fatalf("reading %s: %v", path, err)
}
compiler := jsonschema.NewCompiler()
compiler.AssertFormat()
if err := compiler.AddResource(name, document); err != nil {
t.Fatal(err)
compiled := ""
for _, path := range paths {
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
document, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
if err != nil {
t.Fatalf("reading %s: %v", path, err)
}
id, _ := document.(map[string]any)["$id"].(string)
if err := compiler.AddResource(id, document); err != nil {
t.Fatalf("%s: %v", path, err)
}
if filepath.Base(path) == name+".schema.json" {
compiled = id
}
}
schema, err := compiler.Compile(name)
schema, err := compiler.Compile(compiled)
if err != nil {
t.Fatalf("compiling %s: %v", path, err)
t.Fatalf("compiling %s: %v", name, err)
}
value, err := jsonschema.UnmarshalJSON(bytes.NewReader(answer))
if err != nil {
Expand Down
11 changes: 11 additions & 0 deletions cli/data_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,17 @@ func (s *dataSource) repoRegistry() (*repos.Registry, error) {
return repos.Load(repos.DefaultPath())
}

// checkout is where this machine's registry says repo is checked out, in either mode,
// because the Claude session the board's C opens runs on this machine whatever it reads.
func (s *dataSource) checkout(repo string) (string, bool, error) {
registry, err := repos.Load(repos.DefaultPath())
if err != nil {
return "", false, err
}
path, named := registry.Checkout(repo)
return path, named, nil
}

// apiHost is the API's host, or "" in database mode.
func (s *dataSource) apiHost() string {
if s.deployment == nil {
Expand Down
29 changes: 17 additions & 12 deletions cli/item_dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ func newDependenciesTreeCommand() *cobra.Command {
}
}
if asJSON {
return encodeJSON(cmd.OutOrStdout(), treeShownOf(tree, byID, drawings, invert, maxDepth))
node := func(id string) treeNode { return treeNodeOf(byID[id]) }
return encodeJSON(cmd.OutOrStdout(), treeShownOf(tree, node, drawings, invert, maxDepth))
}
printTrees(cmd.OutOrStdout(), tree, byID, drawings, invert, maxDepth)
if len(args) == 0 && !cmd.Flags().Changed("status") {
Expand Down Expand Up @@ -340,7 +341,9 @@ type treeEdge struct {

const treeShownVersion = 1

func treeShownOf(tree *graph.Tree, byID map[string]wire.ProjectItem, drawings []drawing, invert bool, maxDepth int) treeShown {
// treeShownOf is the drawn part of each drawing, with node saying what each item is. The
// command reads items from the API, and the board's --json from the map it drew.
func treeShownOf(tree *graph.Tree, node func(id string) treeNode, drawings []drawing, invert bool, maxDepth int) treeShown {
shown := treeShown{SchemaVersion: treeShownVersion, Nodes: []treeNode{}, Edges: []treeEdge{}, Roots: []string{}}
for _, d := range drawings {
drawn := make(map[string]bool)
Expand All @@ -351,21 +354,23 @@ func treeShownOf(tree *graph.Tree, byID map[string]wire.ProjectItem, drawings []
if !drawn[id] {
continue
}
item := byID[id]
projects := make([]string, 0, len(item.Projects))
for _, p := range item.Projects {
projects = append(projects, p.Name)
}
shown.Nodes = append(shown.Nodes, treeNode{
ID: item.ID, Number: item.Number, Title: item.Title, Status: item.Status, Repo: item.Repo, Projects: projects,
})
for _, dependency := range item.DependencyIDs {
shown.Nodes = append(shown.Nodes, node(id))
for _, dependency := range tree.Children(id, false) {
if drawn[dependency] {
shown.Edges = append(shown.Edges, treeEdge{Item: item.ID, DependsOn: dependency})
shown.Edges = append(shown.Edges, treeEdge{Item: id, DependsOn: dependency})
}
}
}
shown.Roots = append(shown.Roots, d.roots...)
}
return shown
}

// treeNodeOf is an item the API answered with, as a tree's node.
func treeNodeOf(item wire.ProjectItem) treeNode {
projects := make([]string, 0, len(item.Projects))
for _, p := range item.Projects {
projects = append(projects, p.Name)
}
return treeNode{ID: item.ID, Number: item.Number, Title: item.Title, Status: item.Status, Repo: item.Repo, Projects: projects}
}
Loading
Loading