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
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ herdr plugin install jwarykowski/shepherd
| `S` | add a subtask to the selected item |
| `u` | edit item (or subtask) text |
| `d` | open detail view (shows every field) |
| `v` | cycle view: category / priority / tag / table |
| `v` | cycle view: category / priority / tag / table / lane |
| `←`/`→` | switch the active column in the [lane view](#lane-view) |
| `F` | hide / show the footer help grid (the `jwarykowski/shepherd` · version line stays); `hidefooter` config sets the default |
| `A` | toggle the [global view](#global-view) across all boards |
| `b` | open the board picker — every board with done/total counts; `enter` jumps, `a` creates a board, `r` renames, `A` archives, `x` deletes (confirmed), `d` shows detail (name, dir, paths, counts) for the selected board (rename/archive/delete don't apply to the default board); `e` toggles the archived-boards view where `u` unarchives the selected board |
Expand Down Expand Up @@ -135,6 +136,17 @@ idle pause (`autosave` seconds, default 60; `0` disables), or on demand with
automatically when you have no unsaved edits, so external edits (or a dotfile
sync) show up on their own.

## lane view

A kanban board: one column per configured `statuses` entry, cycled to with
`v`. `←`/`→` switch the active column; `j`/`k` move within it. `tab` cycles the
selected card's status same as everywhere else — in lane view that also moves
it into the next column, cursor and all. Every other key still acts on the
card under the cursor (`h`/`m`/`l` priority, `g` category, `T` tags, `t` due,
`s` defer, `L` link, `o` open, `y` copy, `u` edit, `d` detail, `space` toggle,
`x` delete). Not available in the [global view](#global-view), since boards
can each configure different statuses.

## subtasks

Any item can hold **one level** of subtasks — the steps that make up a task.
Expand Down Expand Up @@ -399,7 +411,7 @@ Optional `config.toml` at `$XDG_CONFIG_HOME/shepherd/config.toml` (defaults to
`SHEPHERD_CONFIG`):

```toml
view = "category" # category (default) | priority | tag | table
view = "category" # category (default) | priority | tag | table | lane
density = "compact" # compact (default) | comfort
autosave = 60 # seconds idle before writing; 0 disables
categories = ["work", "home", "personal"] # tab-cycles in the category prompt
Expand Down
2 changes: 1 addition & 1 deletion herdr-plugin.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
id = "jwarykowski.herdr-shepherd"
name = "Shepherd"
version = "0.20.0"
version = "0.21.0"
min_herdr_version = "0.7.0"
description = "Shepherd — your todos herded. Interactive todo board in a split, tab, overlay, or zoomed pane. Category/priority/due ordering, overdue pinning, notes, due dates, undo/redo, filter, archive. Backed by a markdown file."
platforms = ["linux", "macos"]
Expand Down
28 changes: 20 additions & 8 deletions internal/todo/statuses.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ func SetStatus(it *Item, name string) {
}
}

// StatusOf is an item's effective status name against the configured order:
// "done" (last after normalization) when Done, the first status when Status is
// left implicit (empty), else Status itself.
func StatusOf(it Item, statuses []string) string {
if len(statuses) == 0 {
return ""
}
if it.Done {
return statuses[len(statuses)-1]
}
if it.Status == "" {
return statuses[0]
}
return it.Status
}

// CycleStatus advances an item to the next status in the configured order,
// wrapping around. statuses is the ordered list from config with "done" last
// (e.g. ["open", "in-progress", "done"]). The terminal "done" state is owned by
Expand All @@ -27,14 +43,10 @@ func CycleStatus(it *Item, statuses []string) {
return
}
cur := 0
if it.Done {
cur = len(statuses) - 1 // "done" is last after config normalization
} else if it.Status != "" {
for i, s := range statuses {
if s == it.Status {
cur = i
break
}
for i, s := range statuses {
if s == StatusOf(*it, statuses) {
cur = i
break
}
}
next := (cur + 1) % len(statuses)
Expand Down
6 changes: 5 additions & 1 deletion internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ func loadConfig(path string) config {
c.view = viewTag
case "table":
c.view = viewTable
case "lane":
c.view = viewLane
default:
c.view = viewCategory
}
Expand Down Expand Up @@ -173,6 +175,7 @@ const (
viewPriority // grouped under priority headers
viewTag // grouped under the item's first tag
viewTable // flat bubbles/table
viewLane // kanban columns, one per configured status
viewBoard // grouped by source board (global view only)
)

Expand All @@ -184,7 +187,7 @@ const (
viewCountGlobal = viewCount + 1
)

var viewName = map[viewMode]string{viewCategory: "category", viewPriority: "priority", viewTag: "tag", viewTable: "table", viewBoard: "board"}
var viewName = map[viewMode]string{viewCategory: "category", viewPriority: "priority", viewTag: "tag", viewTable: "table", viewLane: "lane", viewBoard: "board"}

type model struct {
path string
Expand All @@ -193,6 +196,7 @@ type model struct {
arcRows []todo.Item // archive browse set (modeArchive); all boards' when global
arcCur int // cursor into arcRows
cursor int // index into the VISIBLE subset, not items
lane int // active column in viewLane (index into statuses)
filter string
mode mode
view viewMode
Expand Down
114 changes: 110 additions & 4 deletions internal/tui/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ func key(s string) tea.KeyMsg {
return tea.KeyMsg{Type: tea.KeyCtrlS}
case "ctrl+c":
return tea.KeyMsg{Type: tea.KeyCtrlC}
case "up":
return tea.KeyMsg{Type: tea.KeyUp}
case "down":
return tea.KeyMsg{Type: tea.KeyDown}
case "left":
return tea.KeyMsg{Type: tea.KeyLeft}
case "right":
return tea.KeyMsg{Type: tea.KeyRight}
}
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
}
Expand Down Expand Up @@ -792,7 +800,10 @@ func TestDetailNoteWraps(t *testing.T) {
}

func TestView(t *testing.T) {
m := model{input: textinput.New(), w: 50, height: 20, items: []todo.Item{
// height 21: one more than the two-item body needs, so the footer's help
// grid (now one row taller for the lane view's ←/→ hint) doesn't window out
// the uncategorized group.
m := model{input: textinput.New(), w: 50, height: 21, items: []todo.Item{
{Text: "ship release", Prio: 'H', Category: "work"},
{Text: "buy milk"},
}}
Expand All @@ -810,7 +821,7 @@ func TestView(t *testing.T) {
t.Fatalf("view missing %q", want)
}
}
if got := strings.Count(v, "\n") + 1; got != 20 {
if got := strings.Count(v, "\n") + 1; got != 21 {
t.Fatalf("frame not pinned to height: %d rows", got)
}
}
Expand Down Expand Up @@ -970,7 +981,15 @@ func TestViewToggle(t *testing.T) {
if m.view != viewTag {
t.Fatalf("tag view expected after priority: %d", m.view)
}
m = drive(m, "v", "v")
m = drive(m, "v")
if m.view != viewTable {
t.Fatalf("table view expected after tag: %d", m.view)
}
m = drive(m, "v")
if m.view != viewLane {
t.Fatalf("lane view expected after table: %d", m.view)
}
m = drive(m, "v")
if m.view != viewCategory {
t.Fatalf("view did not cycle back: %d", m.view)
}
Expand Down Expand Up @@ -1169,9 +1188,10 @@ func TestRenderAllViews(t *testing.T) {
{Text: "ship release", Prio: 'H', Category: "work", Due: "2026-07-01"},
{Text: "buy milk"},
}}
for _, v := range []viewMode{viewCategory, viewPriority, viewTable} {
for _, v := range []viewMode{viewCategory, viewPriority, viewTable, viewLane} {
m := base
m.view = v
m.statuses = []string{"open", "in-progress", "done"}
if !strings.Contains(m.View(), appName) {
t.Errorf("view %d missing brand", v)
}
Expand Down Expand Up @@ -1267,6 +1287,92 @@ func TestStatusCycleAndDoneToggle(t *testing.T) {
}
}

// TestLaneView drives the kanban view end to end: cycling in via v, left/right
// switching the active column (clamped at both ends), j/k staying inside that
// column, tab moving a card to the next lane with the cursor following it, and
// a mutation key (h, priority) acting on the lane-selected card rather than
// whatever the flat cursor would have pointed at.
func TestLaneView(t *testing.T) {
newModel := func() model {
return model{input: textinput.New(), note: textarea.New(), w: 60, height: 24,
statuses: []string{"open", "in-progress", "done"},
items: []todo.Item{
{Text: "a"}, {Text: "b"}, {Text: "c", Status: "in-progress"},
}}
}

// v cycles category -> priority -> tag -> table -> lane
m := drive(newModel(), "v", "v", "v", "v")
if m.view != viewLane {
t.Fatalf("v did not cycle into lane view: %v", m.view)
}
if !strings.Contains(m.View(), "in-progress") {
t.Fatalf("lane view missing a configured status column:\n%s", m.View())
}

// left is a no-op at the first lane
if got := drive(m, "left"); got.lane != 0 {
t.Fatalf("left should clamp at lane 0: %d", got.lane)
}
// right moves into the in-progress lane, whose only row is "c"
m = drive(m, "right")
if m.lane != 1 {
t.Fatalf("right did not move to lane 1: %d", m.lane)
}
if ref := m.selRef(); m.rowItem(ref).Text != "c" {
t.Fatalf("lane cursor did not land on the in-progress card: %+v", m.rowItem(ref))
}
// j/k don't leak into other lanes: only one row here, so both are no-ops
if got := drive(m, "j"); got.cursor != 0 {
t.Fatalf("j moved past the active lane's only row: cursor=%d", got.cursor)
}

// tab moves the lane-selected card ("c") to done, and the cursor follows it
m = drive(m, "tab")
if m.lane != 2 {
t.Fatalf("tab-in-lane did not follow the card to lane 2: %d", m.lane)
}
if ref := m.selRef(); m.rowItem(ref).Text != "c" || !m.rowItem(ref).Done {
t.Fatalf("cursor did not follow the moved card: %+v", m.rowItem(ref))
}

// h (priority) acts on the lane-selected card, not the flat-list cursor
m = drive(m, "h")
if got := m.rowItem(m.selRef()); got.Text != "c" || got.Prio != 'H' {
t.Fatalf("h did not set priority on the lane-selected card: %+v", got)
}
}

// TestLaneViewScrolls checks a lane with more cards than fit the pane scrolls
// as the cursor moves, rather than silently truncating.
func TestLaneViewScrolls(t *testing.T) {
items := make([]todo.Item, 8)
for i := range items {
items[i] = todo.Item{Text: fmt.Sprintf("task %d", i)}
}
m := model{input: textinput.New(), w: 60, height: 20, view: viewLane,
statuses: []string{"open", "done"}, items: items}

first := ansi.Strip(m.View())
if !strings.Contains(first, "task 0") {
t.Fatalf("first render should show the top of the lane:\n%s", first)
}

// walk the cursor to the bottom of the lane
downs := make([]string, len(items)-1)
for i := range downs {
downs[i] = "j"
}
m = drive(m, downs...)
last := ansi.Strip(m.View())
if !strings.Contains(last, "task 7") {
t.Fatalf("scrolled view should reach the last card:\n%s", last)
}
if strings.Contains(last, "task 0") {
t.Fatalf("scrolled view should no longer show the first card:\n%s", last)
}
}

func TestGlobalReadOnly(t *testing.T) {
m := model{
input: textinput.New(),
Expand Down
Loading
Loading