diff --git a/internal/tui/board_view.go b/internal/tui/board_view.go index e6bde96..e2e88e1 100644 --- a/internal/tui/board_view.go +++ b/internal/tui/board_view.go @@ -39,6 +39,12 @@ type boardColumnClickedMsg struct{ status board.Status } type filterTextClickedMsg struct{} type filterLabelClickedMsg struct{ tag string } type filterClearClickedMsg struct{} +type boardPointerDownMsg struct{ taskID string } +type boardPointerMoveMsg struct { + status board.Status + beforeTaskID string +} +type boardPointerUpMsg struct{} type boardHitKind uint8 @@ -265,15 +271,21 @@ func (m Model) renderBoard() (string, []boardHit) { hits = append(filterHits, hits...) state := "ready" - if m.loading || (m.watcher != nil && !m.haveVersion) { - state = "loading board..." - } - if m.loadErr != nil { + moveActive := m.move.lifted != nil || m.move.saving + movePriority := moveActive || m.move.notice + showMoveStatus := movePriority && m.move.status != "" + if showMoveStatus { + state = sanitizeTerminal(m.move.status) + } else if m.loadErr != nil { state = "error: " + m.loadErr.Error() } else if m.pollErr != nil { state = "error: " + m.pollErr.Error() } else if m.preferenceErr != nil { state = "error: " + m.preferenceErr.Error() + } else if m.move.status != "" { + state = sanitizeTerminal(m.move.status) + } else if m.loading || (m.watcher != nil && !m.haveVersion) { + state = "loading board..." } cancelled := "off" if m.boardView.showCancelled { @@ -283,6 +295,10 @@ func (m Model) renderBoard() (string, []boardHit) { if m.editor.Enabled() { help = "n new | e edit | " + help } + if showMoveStatus || (m.move.status != "" && m.loadErr == nil && m.pollErr == nil && m.preferenceErr == nil) { + footer := fitLine(state, width) + return strings.Join([]string{header, filterLine, body, footer}, "\n"), hits + } if m.settingsNew != nil { footer := settingsBoardFooter(state, cancelled, m.editor.Enabled(), width) return strings.Join([]string{header, filterLine, body, footer}, "\n"), hits @@ -476,6 +492,9 @@ func (m Model) renderTaskLines(tasks []board.Task, status board.Status, width in if m.boardView.column == statusIndex(status) && i == selected { marker = "› " } + if m.move.lifted != nil && task.ID == m.move.lifted.taskID { + marker = "↕ " + } first := cardHeading(task, m.now()) for lineIndex, line := range wrapTokens(first, max(width-2, 1)) { prefix := " " @@ -558,30 +577,56 @@ func joinColumns(columns []renderedColumn) (string, []boardHit) { return strings.Join(lines, "\n"), hits } -func boardMouseHandler(hits []boardHit) func(tea.MouseMsg) tea.Cmd { +func boardMouseHandler(hits []boardHit, active ...bool) func(tea.MouseMsg) tea.Cmd { + pointerActive := len(active) > 0 && active[0] return func(message tea.MouseMsg) tea.Cmd { - click, ok := message.(tea.MouseClickMsg) - if !ok || click.Button != tea.MouseLeft { + mouse := message.Mouse() + if _, release := message.(tea.MouseReleaseMsg); release { + if mouse.Button == tea.MouseLeft || (mouse.Button == tea.MouseNone && pointerActive) { + return func() tea.Msg { return boardPointerUpMsg{} } + } return nil } - mouse := click.Mouse() + if mouse.Button != tea.MouseLeft { + return nil + } + var matched *boardHit + var dragAnchor *boardHit for i := len(hits) - 1; i >= 0; i-- { hit := hits[i] if mouse.X < hit.x0 || mouse.X >= hit.x1 || mouse.Y < hit.y0 || mouse.Y >= hit.y1 { continue } - switch hit.kind { + if matched == nil { + matched = &hit + } + if dragAnchor == nil && hit.kind == boardHitDefault { + dragAnchor = &hit + } + } + switch message.(type) { + case tea.MouseClickMsg: + if matched == nil { + return nil + } + switch matched.kind { case boardHitFilterText: return func() tea.Msg { return filterTextClickedMsg{} } case boardHitFilterLabel: - return func() tea.Msg { return filterLabelClickedMsg{tag: hit.tag} } + return func() tea.Msg { return filterLabelClickedMsg{tag: matched.tag} } case boardHitFilterClear: return func() tea.Msg { return filterClearClickedMsg{} } } - if hit.taskID != "" { - return func() tea.Msg { return boardCardClickedMsg{taskID: hit.taskID} } + if matched.taskID != "" { + return func() tea.Msg { return boardPointerDownMsg{taskID: matched.taskID} } + } + return func() tea.Msg { return boardColumnClickedMsg{status: matched.status} } + case tea.MouseMotionMsg: + if dragAnchor != nil { + return func() tea.Msg { + return boardPointerMoveMsg{status: dragAnchor.status, beforeTaskID: dragAnchor.taskID} + } } - return func() tea.Msg { return boardColumnClickedMsg{status: hit.status} } } return nil } diff --git a/internal/tui/board_view_test.go b/internal/tui/board_view_test.go index a8468f1..a83ce20 100644 --- a/internal/tui/board_view_test.go +++ b/internal/tui/board_view_test.go @@ -263,7 +263,7 @@ func TestBoardRenderResponsiveFullCardsAndMouse(t *testing.T) { break } } - command := boardMouseHandler(hits)(tea.MouseClickMsg{X: cardHit.x0 + 1, Y: cardHit.y0, Button: tea.MouseLeft}) + command := boardMouseHandler(hits, false)(tea.MouseClickMsg{X: cardHit.x0 + 1, Y: cardHit.y0, Button: tea.MouseLeft}) if command == nil { t.Fatal("card click was not hit") } @@ -271,10 +271,10 @@ func TestBoardRenderResponsiveFullCardsAndMouse(t *testing.T) { if selected, ok := m.selectedTask(); !ok || selected.ID != "doing-1" { t.Fatalf("mouse selection = %+v,%v", selected, ok) } - if command := boardMouseHandler(hits)(tea.MouseReleaseMsg{}); command != nil { + if command := boardMouseHandler(hits, false)(tea.MouseReleaseMsg{}); command != nil { t.Fatalf("release produced command %v", command) } - if command := boardMouseHandler(hits)(tea.MouseClickMsg{X: 999, Y: 999, Button: tea.MouseLeft}); command != nil { + if command := boardMouseHandler(hits, false)(tea.MouseClickMsg{X: 999, Y: 999, Button: tea.MouseLeft}); command != nil { t.Fatalf("off-board click produced command %v", command) } diff --git a/internal/tui/integration_test.go b/internal/tui/integration_test.go index 58928b3..3a21f14 100644 --- a/internal/tui/integration_test.go +++ b/internal/tui/integration_test.go @@ -1,6 +1,7 @@ package tui import ( + "errors" "reflect" "strings" "testing" @@ -19,10 +20,9 @@ func TestIntegratedFilterEditorRoutingAndRefresh(t *testing.T) { if err != nil { t.Fatal(err) } - hidden, err := st.AddTask("alice", board.Task{ + if _, err := st.AddTask("alice", board.Task{ Title: "Hidden card", Status: board.StatusTodo, Prio: 3, Tags: []string{"ui"}, - }) - if err != nil { + }); err != nil { t.Fatal(err) } @@ -50,13 +50,10 @@ func TestIntegratedFilterEditorRoutingAndRefresh(t *testing.T) { updateTestModel(t, &m, tea.KeyPressMsg{Code: 'X', Text: "X"}) beforeFilter := m.filter.value() - beforeColumn := m.boardView.column + updateTestModel(t, &m, boardPointerDownMsg{taskID: task.ID}) updateTestModel(t, &m, filterLabelClickedMsg{tag: "ui"}) - updateTestModel(t, &m, filterClearClickedMsg{}) - updateTestModel(t, &m, boardCardClickedMsg{taskID: hidden.ID}) - updateTestModel(t, &m, boardColumnClickedMsg{status: board.StatusDoing}) - if !reflect.DeepEqual(m.filter.value(), beforeFilter) || m.boardView.column != beforeColumn { - t.Fatalf("editor leaked board mouse input: filter=%+v column=%d", m.filter.value(), m.boardView.column) + if m.move.lifted != nil || !reflect.DeepEqual(m.filter.value(), beforeFilter) { + t.Fatalf("editor leaked board mouse input: move=%#v filter=%+v", m.move, m.filter.value()) } remote := m.board @@ -132,3 +129,216 @@ func TestIntegratedSavedSelectionWaitsForFreshSuccessor(t *testing.T) { }) } } + +func TestIntegratedMoveCancellationRestoresIdentity(t *testing.T) { + t.Run("edit", func(t *testing.T) { + m, first, _ := integratedMultiCardModel(t) + previewFirstBelowSecond(t, &m, first.ID) + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'e'}) + if m.move.lifted != nil || !m.editor.IsOpen() || m.editor.TaskID() != first.ID { + t.Fatalf("edit after cancel = move:%#v editor:%v task:%q", m.move, m.editor.IsOpen(), m.editor.TaskID()) + } + }) + + t.Run("new", func(t *testing.T) { + m, first, _ := integratedMultiCardModel(t) + previewFirstBelowSecond(t, &m, first.ID) + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'n'}) + selected, ok := m.selectedTask() + if m.move.lifted != nil || !m.editor.IsOpen() || !ok || selected.ID != first.ID { + t.Fatalf("new after cancel = move:%#v editor:%v selected:%+v,%v", m.move, m.editor.IsOpen(), selected, ok) + } + }) + + t.Run("filter hides lifted card", func(t *testing.T) { + m, first, second := integratedMultiCardModel(t) + previewFirstBelowSecond(t, &m, first.ID) + updateTestModel(t, &m, filterLabelClickedMsg{tag: "ui"}) + selected, ok := m.selectedTask() + if m.move.lifted != nil || !reflect.DeepEqual(m.filter.tags, []string{"ui"}) || !ok || selected.ID != second.ID { + t.Fatalf("filter after cancel = move:%#v tags:%v selected:%+v,%v", m.move, m.filter.tags, selected, ok) + } + }) + + t.Run("watcher refresh", func(t *testing.T) { + m, first, _ := integratedMultiCardModel(t) + previewFirstBelowSecond(t, &m, first.ID) + load := m.requireFreshBoard() + selected, ok := m.selectedTask() + if load == nil || m.move.lifted != nil || !ok || selected.ID != first.ID { + t.Fatalf("watcher cancel = load:%v move:%#v selected:%+v,%v", load, m.move, selected, ok) + } + completeBoardLoad(t, &m, load) + selected, ok = m.selectedTask() + if !ok || selected.ID != first.ID { + t.Fatalf("watcher refresh selection = %+v,%v", selected, ok) + } + }) +} + +func TestIntegratedHungMoveWriteStillQuits(t *testing.T) { + m, first, _ := integratedMultiCardModel(t) + previewFirstBelowSecond(t, &m, first.ID) + m.move.saving = true + quit := updateTestModel(t, &m, tea.KeyPressMsg{Code: 'q'}) + if quit == nil || !m.stopped { + t.Fatalf("q during move write = command:%v stopped:%v", quit, m.stopped) + } +} + +func TestIntegratedFilteredMovePreservesHiddenOrder(t *testing.T) { + current := board.Board{Tasks: []board.Task{ + {ID: "hidden-0", Title: "H0", Status: board.StatusTodo}, + {ID: "visible-a", Title: "A", Status: board.StatusTodo, Tags: []string{"bug"}}, + {ID: "moving", Title: "M", Status: board.StatusTodo, Tags: []string{"bug"}}, + {ID: "hidden-1", Title: "H1", Status: board.StatusTodo}, + {ID: "visible-b", Title: "B", Status: board.StatusTodo, Tags: []string{"bug"}}, + {ID: "hidden-2", Title: "H2", Status: board.StatusTodo}, + }} + filter := newBoardFilterState() + filter.tags = []string{"bug"} + visible := filter.project(current) + moving, _ := boardTaskByID(current, "moving") + statuses := []board.Status{board.StatusTodo, board.StatusDoing, board.StatusDone} + + var keyboard cardMoveState + keyboard.beginVisible(current, visible, moving, statuses, false) + preview, handled := keyboard.previewKey("up") + if !handled || columnNames(preview, board.StatusTodo) != "H0,M,A,H1,B,H2" { + t.Fatalf("filtered key preview = %q handled=%v", columnNames(preview, board.StatusTodo), handled) + } + if got := hiddenIDs(preview); !reflect.DeepEqual(got, []string{"hidden-0", "hidden-1", "hidden-2"}) { + t.Fatalf("filtered key move reordered hidden cards: %v", got) + } + + var mouse cardMoveState + mouse.beginVisible(current, visible, moving, statuses, true) + preview, handled = mouse.previewMouse(board.StatusTodo, "") + if !handled || columnNames(preview, board.StatusTodo) != "H0,A,H1,B,H2,M" { + t.Fatalf("filtered mouse preview = %q handled=%v", columnNames(preview, board.StatusTodo), handled) + } + if got := hiddenIDs(preview); !reflect.DeepEqual(got, []string{"hidden-0", "hidden-1", "hidden-2"}) { + t.Fatalf("filtered mouse move reordered hidden cards: %v", got) + } +} + +func TestIntegratedMouseHitAndReleaseProtocols(t *testing.T) { + handler := boardMouseHandler([]boardHit{ + {x0: 0, x1: 8, y0: 1, y1: 2, kind: boardHitDefault, taskID: "card", status: board.StatusTodo}, + {x0: 0, x1: 8, y0: 1, y1: 2, kind: boardHitFilterLabel, tag: "bug"}, + }, true) + click := handler(tea.MouseClickMsg{X: 1, Y: 1, Button: tea.MouseLeft}) + if click == nil { + t.Fatal("filter label click was ignored") + } + if msg, ok := click().(filterLabelClickedMsg); !ok || msg.tag != "bug" { + t.Fatalf("filter label became drag anchor: %#v", msg) + } + motion := handler(tea.MouseMotionMsg{X: 1, Y: 1, Button: tea.MouseLeft}) + if motion == nil { + t.Fatal("card tag did not retain its drag anchor") + } + if msg, ok := motion().(boardPointerMoveMsg); !ok || msg.beforeTaskID != "card" { + t.Fatalf("card tag drag anchor = %#v", msg) + } + for _, button := range []tea.MouseButton{tea.MouseLeft, tea.MouseNone} { + release := handler(tea.MouseReleaseMsg{Button: button}) + if release == nil { + t.Fatalf("active pointer release %v was ignored", button) + } + if _, ok := release().(boardPointerUpMsg); !ok { + t.Fatalf("active pointer release %v = %T", button, release()) + } + } + if release := boardMouseHandler(nil, false)(tea.MouseReleaseMsg{Button: tea.MouseNone}); release != nil { + t.Fatalf("inactive X10 release emitted %#v", release()) + } +} + +func TestIntegratedMoveModalAndFooterPrecedence(t *testing.T) { + st := newSettingsTestStore(t) + task, err := st.AddTask("alice", board.Task{Title: "Move me", Status: board.StatusTodo, Prio: 3, Tags: []string{"bug"}}) + if err != nil { + t.Fatal(err) + } + m := NewModel(st, nil, "alice") + completeBoardLoad(t, &m, m.Init()) + m.filter.tags = []string{"bug"} + m.loadErr = errors.New("stale load error") + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + if m.move.lifted == nil { + t.Fatal("move did not lift filtered card") + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'n'}) + if !m.editor.IsOpen() || m.move.lifted != nil { + t.Fatalf("move modal did not cancel before editor: editor=%v move=%#v", m.editor.IsOpen(), m.move) + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEscape}) + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'd'}) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + view := ansi.Strip(m.View().Content) + if !strings.Contains(view, "Lifted Move me") || strings.Contains(view, "stale load error") || !strings.Contains(view, "1 of 1 cards") { + t.Fatalf("active move footer precedence failed:\n%s", view) + } + + m.move.saving = true + column := m.boardView.column + filterBefore := m.filter.value() + updateTestModel(t, &m, boardColumnClickedMsg{status: board.StatusDoing}) + updateTestModel(t, &m, filterLabelClickedMsg{tag: "ui"}) + updateTestModel(t, &m, boardPointerDownMsg{taskID: task.ID}) + if m.boardView.column != column || !reflect.DeepEqual(m.filter.value(), filterBefore) || m.move.lifted == nil { + t.Fatalf("saving move accepted modal input: column=%d filter=%+v move=%#v", m.boardView.column, m.filter.value(), m.move) + } + + m.move.saving = false + m.move.lifted = nil + m.move.notice = false + view = ansi.Strip(m.View().Content) + if !strings.Contains(view, "stale load error") || strings.Contains(view, "Lifted Move me") { + t.Fatalf("stale move notice masked root error:\n%s", view) + } +} + +func hiddenIDs(current board.Board) []string { + var ids []string + for _, task := range current.Tasks { + if strings.HasPrefix(task.ID, "hidden-") { + ids = append(ids, task.ID) + } + } + return ids +} + +func integratedMultiCardModel(t *testing.T) (Model, board.Task, board.Task) { + t.Helper() + st := newSettingsTestStore(t) + first, err := st.AddTask("alice", board.Task{ + Title: "A", Status: board.StatusTodo, Prio: 3, Tags: []string{"bug"}, + }) + if err != nil { + t.Fatal(err) + } + second, err := st.AddTask("alice", board.Task{ + Title: "B", Status: board.StatusTodo, Prio: 3, Tags: []string{"ui"}, + }) + if err != nil { + t.Fatal(err) + } + m := NewModel(st, nil, "alice") + completeBoardLoad(t, &m, m.Init()) + if !m.boardView.focusTask(m.filteredBoard(), first.ID) { + t.Fatal("could not focus first card") + } + return m, first, second +} + +func previewFirstBelowSecond(t *testing.T, m *Model, firstID string) { + t.Helper() + updateTestModel(t, m, tea.KeyPressMsg{Code: tea.KeySpace}) + updateTestModel(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) + selected, ok := m.selectedTask() + if m.move.lifted == nil || !ok || selected.ID != firstID || m.boardView.rows[0] != 1 { + t.Fatalf("preview setup = move:%#v selected:%+v,%v row:%d", m.move, selected, ok, m.boardView.rows[0]) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 300c935..519a06c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -51,6 +51,7 @@ type pollTickMsg struct{} // messages, so Update remains deterministic. type Model struct { store boardReader + moveStore taskMoveStore watcher dataVersionReader user string board board.Board @@ -76,6 +77,7 @@ type Model struct { prefPending *tuiPreferences settings *settingsModel settingsNew func() *settingsModel + move cardMoveState } // NewModel creates the root model for one local board owner. @@ -91,8 +93,10 @@ func newModel( ) Model { detailReader, _ := store.(carddetail.Reader) editorStore, _ := store.(cardeditor.Store) + moveStore, _ := store.(taskMoveStore) return Model{ store: store, + moveStore: moveStore, watcher: watcher, user: user, board: board.Board{Title: "Board"}, @@ -126,6 +130,9 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if m.stopped { return m, nil } + if m.move.lifted == nil && m.move.notice && isBoardUserInput(message) { + m.move.notice = false + } if m.settings != nil && isSettingsMessage(message) { command := m.settings.Update(message) if m.settings.closed { @@ -150,7 +157,8 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } return m, m.editor.Update(msg) case boardCardClickedMsg, boardColumnClickedMsg, - filterTextClickedMsg, filterLabelClickedMsg, filterClearClickedMsg: + filterTextClickedMsg, filterLabelClickedMsg, filterClearClickedMsg, + boardPointerDownMsg, boardPointerMoveMsg, boardPointerUpMsg: return m, nil } } @@ -175,7 +183,8 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return m, m.detail.Update(message) } case boardCardClickedMsg, boardColumnClickedMsg, - filterTextClickedMsg, filterLabelClickedMsg, filterClearClickedMsg: + filterTextClickedMsg, filterLabelClickedMsg, filterClearClickedMsg, + boardPointerDownMsg, boardPointerMoveMsg, boardPointerUpMsg: return m, nil default: detailCmd = m.detail.Update(message) @@ -198,6 +207,31 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } return m, command } + if m.move.lifted != nil { + key := msg.String() + if m.move.saving && key != "q" { + return m, nil + } + switch key { + case "esc": + m.cancelCardMove("") + return m, nil + case "q": + // The root quit contract remains available while a write is hung. + case "enter", "space": + return m, m.startCardDrop() + case "up", "down", "left", "right", "h", "j", "k", "l": + if preview, handled := m.move.previewKey(key); handled { + m.board = preview + m.boardView.focusTask(m.filteredBoard(), m.move.lifted.taskID) + } + return m, nil + case "s", "c", "1", "2", "3", "4", "tab", "shift+tab", "n", "e", "/", "f", "x": + m.cancelCardMove("focus changed") + default: + return m, nil + } + } if handled, command := m.handleFilterKey(msg); handled { return m, command } @@ -227,12 +261,25 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return m, m.editor.OpenEdit(task) } } + case "space": + if !m.loading { + if task, ok := m.selectedTask(); ok { + m.move.beginVisible(m.board, m.filteredBoard(), task, m.boardView.visibleStatuses(), false) + } + } + return m, nil default: if m.boardView.handleKey(msg.String(), m.filteredBoard()) == boardToggledCancelled { return m, m.queuePreferences() } } case boardCardClickedMsg: + if m.move.saving { + return m, nil + } + if m.move.lifted != nil && !m.move.saving { + m.cancelCardMove("focus changed") + } m.filter.blur() if m.boardView.focusTask(m.filteredBoard(), msg.taskID) { if task, ok := m.selectedTask(); ok { @@ -241,22 +288,83 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } } case boardColumnClickedMsg: + if m.move.saving { + return m, nil + } + if m.move.lifted != nil && !m.move.saving { + m.cancelCardMove("focus changed") + } m.filter.blur() m.boardView.focusColumn(msg.status, m.filteredBoard()) + case boardPointerDownMsg: + if m.loading || m.move.saving { + return m, nil + } + if m.move.lifted != nil { + m.cancelCardMove("focus changed") + } + m.filter.blur() + if !m.boardView.focusTask(m.filteredBoard(), msg.taskID) { + return m, nil + } + if task, ok := m.selectedTask(); ok { + m.move.beginVisible(m.board, m.filteredBoard(), task, m.boardView.visibleStatuses(), true) + } + case boardPointerMoveMsg: + if preview, handled := m.move.previewMouse(msg.status, msg.beforeTaskID); handled { + m.board = preview + m.boardView.focusTask(m.filteredBoard(), m.move.lifted.taskID) + } + case boardPointerUpMsg: + if m.move.lifted == nil || !m.move.lifted.fromMouse { + return m, nil + } + if !m.move.lifted.dragged { + taskID := m.move.lifted.taskID + m.cancelCardMove("") + m.move.status = "" + m.boardView.focusTask(m.filteredBoard(), taskID) + if task, ok := m.selectedTask(); ok { + m.detail.Resize(m.width, m.height) + return m, m.detail.Open(task) + } + return m, nil + } + return m, m.startCardDrop() + case cardMoveStoredMsg: + return m, m.finishCardDrop(msg) case filterTextClickedMsg: if m.settings != nil { return m, nil } + if m.move.saving { + return m, nil + } + if m.move.lifted != nil { + m.cancelCardMove("focus changed") + } return m, m.filter.focusText() case filterLabelClickedMsg: if m.settings != nil { return m, nil } + if m.move.saving { + return m, nil + } + if m.move.lifted != nil { + m.cancelCardMove("focus changed") + } return m, m.mutateFilter(func(filter *boardFilterState) { filter.toggleTag(msg.tag) }) case filterClearClickedMsg: if m.settings != nil { return m, nil } + if m.move.saving { + return m, nil + } + if m.move.lifted != nil { + m.cancelCardMove("focus changed") + } return m, m.mutateFilter(func(filter *boardFilterState) { filter.clear() }) case preferenceSavedMsg: return m, m.finishPreferences(msg) @@ -280,6 +388,17 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return m, detailCmd } +func isBoardUserInput(message tea.Msg) bool { + switch message.(type) { + case tea.KeyPressMsg, boardCardClickedMsg, boardColumnClickedMsg, + boardPointerDownMsg, boardPointerMoveMsg, boardPointerUpMsg, + filterTextClickedMsg, filterLabelClickedMsg, filterClearClickedMsg: + return true + default: + return false + } +} + // observeDataVersion advances the watcher baseline and schedules exactly one // successor poll. Baselines are opaque: only equality with the last successful // value matters. @@ -362,6 +481,10 @@ func (m *Model) reconcileDetail() tea.Cmd { // startBoardLoad starts a fallback or retry only when no load is active. The // active load already satisfies that obligation, so it does not queue another. func (m *Model) startBoardLoad() tea.Cmd { + if m.move.saving { + m.reloadPending = true + return nil + } if m.loading { return nil } @@ -372,6 +495,13 @@ func (m *Model) startBoardLoad() tea.Cmd { // requireFreshBoard records a new baseline/change obligation while a load is // active. Multiple obligations coalesce into one serialized successor. func (m *Model) requireFreshBoard() tea.Cmd { + if m.move.saving { + m.reloadPending = true + return nil + } + if m.move.lifted != nil { + m.cancelCardMove("board changed; refreshing") + } if m.loading { m.reloadPending = true return nil @@ -379,6 +509,18 @@ func (m *Model) requireFreshBoard() tea.Cmd { return m.startBoardLoad() } +func (m *Model) cancelCardMove(reason string) { + if m.move.lifted == nil { + return + } + taskID := m.move.lifted.taskID + m.board = m.move.cancel(reason) + filtered := m.filteredBoard() + if !m.boardView.focusTask(filtered, taskID) { + m.boardView.normalizeSelection(filtered) + } +} + func pollAfter(load tea.Cmd) tea.Cmd { if load == nil { return schedulePoll() @@ -422,7 +564,8 @@ func (m Model) View() tea.View { view.AltScreen = true view.MouseMode = tea.MouseModeCellMotion if m.settings == nil && !m.editor.IsOpen() { - view.OnMouse = boardMouseHandler(hits) + pointerActive := m.move.lifted != nil && m.move.lifted.fromMouse + view.OnMouse = boardMouseHandler(hits, pointerActive) } return view } diff --git a/internal/tui/move_model.go b/internal/tui/move_model.go new file mode 100644 index 0000000..b2e2ad0 --- /dev/null +++ b/internal/tui/move_model.go @@ -0,0 +1,413 @@ +package tui + +import ( + "fmt" + + "github.com/RandomCodeSpace/kb/internal/board" +) + +type cardLift struct { + canonical board.Board + taskID string + title string + target board.Status + slot int + visibleIDs map[board.Status][]string + visibleAt map[board.Status]map[string]int + fullIDs map[board.Status][]string + fullAt map[board.Status]map[string]int + preview board.Board + previewAt map[string]int + statuses []board.Status + fromMouse bool + dragged bool + mouseCell struct { + set bool + status board.Status + beforeTaskID string + } +} + +type cardMoveState struct { + lifted *cardLift + saving bool + status string + statusError bool + notice bool +} + +func (s *cardMoveState) begin(current board.Board, task board.Task, statuses []board.Status, fromMouse bool) bool { + return s.beginVisible(current, current, task, statuses, fromMouse) +} + +func (s *cardMoveState) beginVisible( + current board.Board, + visibleBoard board.Board, + task board.Task, + statuses []board.Status, + fromMouse bool, +) bool { + full := visibleTaskIDs(current, task.ID) + visible := visibleTaskIDs(visibleBoard, task.ID) + visibleSlots := taskSlots(visible) + fullSlots := taskSlots(full) + ids := visible[task.Status] + slot := 0 + for _, candidate := range tasksInStatus(visibleBoard, task.Status) { + if candidate.ID == task.ID { + break + } + slot++ + } + if slot > len(ids) { + slot = len(ids) + } + s.lifted = &cardLift{ + canonical: cloneBoard(current), + taskID: task.ID, + title: task.Title, + target: task.Status, + slot: slot, + visibleIDs: visible, + visibleAt: visibleSlots, + fullIDs: full, + fullAt: fullSlots, + preview: cloneBoard(current), + previewAt: boardTaskSlots(current), + statuses: append([]board.Status(nil), statuses...), + fromMouse: fromMouse, + } + s.statusError = false + s.notice = false + s.status = fmt.Sprintf("Lifted %s. Arrows or hjkl move; Enter/Space drop; Escape cancel.", task.Title) + return true +} + +func (s *cardMoveState) previewKey(key string) (board.Board, bool) { + if s.lifted == nil || s.saving { + return board.Board{}, false + } + lift := s.lifted + changed := false + switch key { + case "up", "k": + if lift.slot > 0 { + lift.slot-- + changed = true + } + case "down", "j": + if lift.slot < len(lift.visibleIDs[lift.target]) { + lift.slot++ + changed = true + } + case "left", "h": + changed = s.moveColumn(-1) + case "right", "l": + changed = s.moveColumn(1) + default: + return board.Board{}, false + } + if changed { + lift.dragged = true + } + preview := repositionLiftPreview(lift) + s.announcePosition("") + return preview, true +} + +func (s *cardMoveState) previewMouse(status board.Status, beforeTaskID string) (board.Board, bool) { + if s.lifted == nil || s.saving || !s.lifted.fromMouse { + return board.Board{}, false + } + lift := s.lifted + if beforeTaskID == lift.taskID { + return board.Board{}, false + } + if statusIndexExact(status) < 0 { + return board.Board{}, false + } + if !containsStatus(lift.statuses, status) { + return board.Board{}, false + } + if lift.mouseCell.set && lift.mouseCell.status == status && lift.mouseCell.beforeTaskID == beforeTaskID { + return board.Board{}, false + } + lift.mouseCell.set = true + lift.mouseCell.status = status + lift.mouseCell.beforeTaskID = beforeTaskID + ids := lift.visibleIDs[status] + slot := len(ids) + if beforeTaskID != "" { + if at, ok := lift.visibleAt[status][beforeTaskID]; ok { + slot = at + } + } + if lift.target == status && lift.slot == slot { + return board.Board{}, false + } + lift.dragged = true + lift.target, lift.slot = status, slot + preview := repositionLiftPreview(lift) + s.announcePosition("") + return preview, true +} + +func (s *cardMoveState) moveColumn(delta int) bool { + lift := s.lifted + current := -1 + for index, status := range lift.statuses { + if status == lift.target { + current = index + break + } + } + next := current + delta + if current < 0 || next < 0 || next >= len(lift.statuses) { + return false + } + lift.target = lift.statuses[next] + lift.slot = min(lift.slot, len(lift.visibleIDs[lift.target])) + return true +} + +func (s *cardMoveState) cancel(reason string) board.Board { + if s.lifted == nil { + return board.Board{} + } + lift := s.lifted + canonical := cloneBoard(lift.canonical) + s.lifted = nil + s.saving = false + s.statusError = false + s.notice = true + if reason == "" { + s.status = fmt.Sprintf("Move cancelled: %s restored.", lift.title) + } else { + s.status = fmt.Sprintf("Move cancelled: %s; %s.", lift.title, reason) + } + return canonical +} + +func (s *cardMoveState) announcePosition(prefix string) { + if s.lifted == nil { + return + } + lift := s.lifted + if prefix != "" { + prefix += " " + } + s.status = fmt.Sprintf("%s%s, %s, position %d of %d", prefix, lift.title, + statusLabelTitle(lift.target), lift.slot+1, len(lift.visibleIDs[lift.target])+1) + s.statusError = false + s.notice = false +} + +// repositionLiftPreview applies the current target to the lift's existing +// preview slice. The indexes are built once at lift time; each transition then +// rotates only the tasks between the old and new slots and allocates nothing. +func repositionLiftPreview(lift *cardLift) board.Board { + fullIndex := len(lift.fullIDs[lift.target]) + if lift.slot < len(lift.visibleIDs[lift.target]) { + anchor := lift.visibleIDs[lift.target][lift.slot] + if at, ok := lift.fullAt[lift.target][anchor]; ok { + fullIndex = at + } + } + repositionPreviewTask(lift, lift.target, fullIndex) + return lift.preview +} + +func repositionPreviewTask(lift *cardLift, target board.Status, fullIndex int) { + tasks := lift.preview.Tasks + source, ok := lift.previewAt[lift.taskID] + if !ok || source < 0 || source >= len(tasks) { + return + } + + destination := len(tasks) + targetIDs := lift.fullIDs[target] + switch { + case fullIndex < len(targetIDs): + destination = lift.previewAt[targetIDs[fullIndex]] + case len(targetIDs) > 0: + destination = lift.previewAt[targetIDs[len(targetIDs)-1]] + 1 + default: + targetOrder := statusIndexExact(target) + for _, status := range boardStatuses { + ids := lift.fullIDs[status] + if statusIndexExact(status) > targetOrder && len(ids) > 0 { + destination = lift.previewAt[ids[0]] + break + } + } + } + if source < destination { + destination-- + } + oldStatus := tasks[source].Status + oldPosition := tasks[source].Position + if destination == source { + tasks[source].Status = target + tasks[source].Position = fullIndex + updateAffectedPositions(lift, oldStatus, oldPosition, target, fullIndex) + return + } + + moving := tasks[source] + moving.Status = target + moving.Position = fullIndex + if source < destination { + copy(tasks[source:destination], tasks[source+1:destination+1]) + tasks[destination] = moving + updatePreviewIndexes(lift, source, destination) + } else { + copy(tasks[destination+1:source+1], tasks[destination:source]) + tasks[destination] = moving + updatePreviewIndexes(lift, destination, source) + } + updateAffectedPositions(lift, oldStatus, oldPosition, target, fullIndex) +} + +func updatePreviewIndexes(lift *cardLift, start, end int) { + for index := start; index <= end; index++ { + lift.previewAt[lift.preview.Tasks[index].ID] = index + } +} + +func updateAffectedPositions( + lift *cardLift, + oldStatus board.Status, + oldPosition int, + target board.Status, + fullIndex int, +) { + setPosition := func(status board.Status, ordinal, position int) { + id := lift.fullIDs[status][ordinal] + lift.preview.Tasks[lift.previewAt[id]].Position = position + } + if oldStatus == target { + if fullIndex < oldPosition { + for ordinal := fullIndex; ordinal < oldPosition; ordinal++ { + setPosition(target, ordinal, ordinal+1) + } + } else { + for ordinal := oldPosition; ordinal < fullIndex; ordinal++ { + setPosition(target, ordinal, ordinal) + } + } + return + } + for ordinal := oldPosition; ordinal < len(lift.fullIDs[oldStatus]); ordinal++ { + setPosition(oldStatus, ordinal, ordinal) + } + for ordinal := fullIndex; ordinal < len(lift.fullIDs[target]); ordinal++ { + setPosition(target, ordinal, ordinal+1) + } +} + +func cloneTaskColumns(columns map[board.Status][]string) map[board.Status][]string { + clone := make(map[board.Status][]string, len(columns)) + for status, ids := range columns { + clone[status] = append([]string(nil), ids...) + } + return clone +} + +func taskSlots(columns map[board.Status][]string) map[board.Status]map[string]int { + slots := make(map[board.Status]map[string]int, len(columns)) + for status, ids := range columns { + column := make(map[string]int, len(ids)) + for index, id := range ids { + column[id] = index + } + slots[status] = column + } + return slots +} + +func boardTaskSlots(current board.Board) map[string]int { + slots := make(map[string]int, len(current.Tasks)) + for index, task := range current.Tasks { + slots[task.ID] = index + } + return slots +} + +// visibleSlotToFullColumnIndex maps an insertion slot in the cards currently +// visible to the store's full destination-column index. A slot after the last +// visible card appends to the full column, including when hidden cards trail +// the last match. This is the parity contract future filters must retain. +func visibleSlotToFullColumnIndex( + current board.Board, + status board.Status, + movingID string, + visibleIDs []string, + slot int, +) int { + full := make([]string, 0) + for _, task := range current.Tasks { + if task.Status == status && task.ID != movingID { + full = append(full, task.ID) + } + } + slot = max(slot, 0) + if slot >= len(visibleIDs) { + return len(full) + } + anchor := visibleIDs[slot] + for index, id := range full { + if id == anchor { + return index + } + } + return len(full) +} + +func visibleTaskIDs(current board.Board, movingID string) map[board.Status][]string { + visible := make(map[board.Status][]string, len(boardStatuses)) + for _, task := range current.Tasks { + if task.ID != movingID { + visible[task.Status] = append(visible[task.Status], task.ID) + } + } + return visible +} + +func cloneBoard(current board.Board) board.Board { + next := current + next.Tasks = append([]board.Task(nil), current.Tasks...) + return next +} + +func containsStatus(statuses []board.Status, status board.Status) bool { + for _, candidate := range statuses { + if candidate == status { + return true + } + } + return false +} + +func statusIndexExact(status board.Status) int { + for index, candidate := range boardStatuses { + if candidate == status { + return index + } + } + return -1 +} + +func statusLabelTitle(status board.Status) string { + switch status { + case board.StatusTodo: + return "To Do" + case board.StatusDoing: + return "Doing" + case board.StatusDone: + return "Done" + case board.StatusCancelled: + return "Cancelled" + default: + return string(status) + } +} diff --git a/internal/tui/move_model_test.go b/internal/tui/move_model_test.go new file mode 100644 index 0000000..7f4b84f --- /dev/null +++ b/internal/tui/move_model_test.go @@ -0,0 +1,1004 @@ +package tui + +import ( + "bytes" + "errors" + "fmt" + "io" + "strings" + "sync" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/teatest/v2" + + "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/store" +) + +func moveFixture() board.Board { + stamp := time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC) + return board.Board{Title: "Moves", Tasks: []board.Task{ + {ID: "a", Title: "A", Status: board.StatusTodo, Position: 0, MovedAt: stamp}, + {ID: "b", Title: "B", Status: board.StatusTodo, Position: 1, MovedAt: stamp.Add(time.Hour)}, + {ID: "c", Title: "C", Status: board.StatusTodo, Position: 2, MovedAt: stamp.Add(2 * time.Hour)}, + {ID: "x", Title: "X", Status: board.StatusDoing, Position: 0, MovedAt: stamp}, + {ID: "y", Title: "Y", Status: board.StatusDoing, Position: 1, MovedAt: stamp}, + }} +} + +func taskNamed(t *testing.T, current board.Board, title string) board.Task { + t.Helper() + for _, task := range current.Tasks { + if task.Title == title { + return task + } + } + t.Fatalf("task %q not found", title) + return board.Task{} +} + +func columnNames(current board.Board, status board.Status) string { + var names []string + for _, task := range current.Tasks { + if task.Status == status { + names = append(names, task.Title) + } + } + return strings.Join(names, ",") +} + +func taskLayout(current board.Board) string { + parts := make([]string, 0, len(current.Tasks)) + for _, task := range current.Tasks { + parts = append(parts, fmt.Sprintf("%s:%s:%d", task.ID, task.Status, task.Position)) + } + return strings.Join(parts, ",") +} + +func TestCardMovePreviewIsLocalClampedAndDoesNotWrap(t *testing.T) { + current := moveFixture() + before := taskNamed(t, current, "B") + var state cardMoveState + state.begin(current, before, []board.Status{board.StatusTodo, board.StatusDoing, board.StatusDone}, false) + if !strings.Contains(state.status, "Arrows or hjkl") || !strings.Contains(state.status, "Enter/Space") { + t.Fatalf("lift status = %q", state.status) + } + + preview, handled := state.previewKey("up") + got := columnNames(preview, board.StatusTodo) + if !handled || got != "B,A,C" { + t.Fatalf("preview up = %q handled=%v", got, handled) + } + if got := taskNamed(t, preview, "B").MovedAt; !got.Equal(before.MovedAt) { + t.Fatalf("preview reset age: got %v want %v", got, before.MovedAt) + } + preview, _ = state.previewKey("right") + if got := columnNames(preview, board.StatusDoing); got != "B,X,Y" { + t.Fatalf("preview right = %q", got) + } + preview, _ = state.previewKey("down") + if got := columnNames(preview, board.StatusDoing); got != "X,B,Y" { + t.Fatalf("preview down = %q", got) + } + if !strings.Contains(state.status, "B, Doing, position 2 of 3") { + t.Fatalf("position status = %q", state.status) + } + + state.previewKey("right") + state.previewKey("right") // Done is the last visible column; no wrap. + if state.lifted.target != board.StatusDone { + t.Fatalf("right edge wrapped to %s", state.lifted.target) + } + for range 5 { + state.previewKey("down") + } + if state.lifted.slot != 0 || !strings.Contains(state.status, "position 1 of 1") { + t.Fatalf("empty-column clamp = slot %d status %q", state.lifted.slot, state.status) + } + + restored := state.cancel("") + if got := columnNames(restored, board.StatusTodo); got != "A,B,C" || state.lifted != nil { + t.Fatalf("cancel restored %q state=%#v", got, state) + } + if !strings.Contains(state.status, "Move cancelled: B restored") { + t.Fatalf("cancel status = %q", state.status) + } +} + +func TestVisibleSlotToFullColumnIndexKeepsFilterSemantics(t *testing.T) { + current := board.Board{Tasks: []board.Task{ + {ID: "hidden-0", Status: board.StatusTodo}, + {ID: "visible-a", Status: board.StatusTodo}, + {ID: "moving", Status: board.StatusDoing}, + {ID: "hidden-1", Status: board.StatusTodo}, + {ID: "visible-b", Status: board.StatusTodo}, + {ID: "hidden-2", Status: board.StatusTodo}, + }} + visible := []string{"visible-a", "visible-b"} + for _, test := range []struct { + slot int + want int + }{ + {slot: -5, want: 1}, + {slot: 0, want: 1}, + {slot: 1, want: 3}, + {slot: 2, want: 5}, // past the last visible card appends after hidden-2 + {slot: 99, want: 5}, + } { + if got := visibleSlotToFullColumnIndex(current, board.StatusTodo, "moving", visible, test.slot); got != test.want { + t.Errorf("slot %d = full index %d, want %d", test.slot, got, test.want) + } + } +} + +type moveTestStore struct { + mu sync.Mutex + board board.Board + writeErr error + reloadErr error + writes int + target board.Status + index int +} + +func (s *moveTestStore) Board(string) (board.Board, error) { + s.mu.Lock() + defer s.mu.Unlock() + return cloneBoard(s.board), s.reloadErr +} + +func (s *moveTestStore) writeCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.writes +} + +func (s *moveTestStore) UpdateAndMoveTask( + _ string, + id string, + _ store.TaskPatch, + target *board.Status, + index *int, + _ func(board.Task) error, +) (board.Task, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.writes++ + if target == nil || index == nil { + return board.Task{}, errors.New("missing explicit destination") + } + s.target, s.index = *target, *index + if s.writeErr != nil { + return board.Task{}, s.writeErr + } + s.board = oracleMoveBoard(s.board, id, *target, *index) + for _, task := range s.board.Tasks { + if task.ID == id { + return task, nil + } + } + return board.Task{}, errors.New("task not found") +} + +// oracleMoveBoard is deliberately independent from the preview implementation. +// The fake store is routing scaffolding; sharing production reorder code here +// would let preview and persistence be wrong in exactly the same way. +func oracleMoveBoard(current board.Board, taskID string, target board.Status, index int) board.Board { + columns := map[board.Status][]board.Task{} + var moving board.Task + for _, task := range current.Tasks { + if task.ID == taskID { + moving = task + continue + } + columns[task.Status] = append(columns[task.Status], task) + } + moving.Status = target + destination := columns[target] + index = min(max(index, 0), len(destination)) + destination = append(destination[:index], append([]board.Task{moving}, destination[index:]...)...) + columns[target] = destination + next := board.Board{Title: current.Title} + for _, status := range boardStatuses { + for position, task := range columns[status] { + task.Position = position + next.Tasks = append(next.Tasks, task) + } + } + return next +} + +func loadedMoveModel(s *moveTestStore) Model { + m := NewModel(s, nil, "u") + m.loading = false + m.board = cloneBoard(s.board) + return m +} + +func TestModelKeyboardDropWritesExplicitMoveAndAnnouncesCanonicalPosition(t *testing.T) { + s := &moveTestStore{board: moveFixture()} + m := loadedMoveModel(s) + m.boardView.rows[0] = 1 // B + + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + if m.move.lifted == nil || !strings.Contains(ansi.Strip(m.View().Content), "Arrows or hjkl") { + t.Fatalf("lift state/view = %#v\n%s", m.move, ansi.Strip(m.View().Content)) + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyRight}) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyDown}) + drop := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if drop == nil || !m.move.saving { + t.Fatalf("drop did not start: %#v command=%v", m.move, drop) + } + updateTestModel(t, &m, drop()) + if s.writes != 1 || s.target != board.StatusDoing || s.index != 2 { + t.Fatalf("store write = count %d target %s index %d", s.writes, s.target, s.index) + } + if got := columnNames(m.board, board.StatusDoing); got != "X,Y,B" { + t.Fatalf("canonical board = %q", got) + } + if selected, ok := m.selectedTask(); !ok || selected.ID != "b" { + t.Fatalf("truthful focus = %+v,%v", selected, ok) + } + if got := m.move.status; got != "Dropped B, Doing, position 3 of 3" { + t.Fatalf("drop status = %q", got) + } +} + +func TestFailedMoveRestoresCanonicalBoardFocusAndError(t *testing.T) { + want := errors.New("write refused") + canonical := moveFixture() + s := &moveTestStore{board: canonical, writeErr: want} + m := loadedMoveModel(s) + m.boardView.rows[0] = 1 + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyDown}) + if got := columnNames(m.board, board.StatusTodo); got != "A,C,B" { + t.Fatalf("local preview = %q", got) + } + drop := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + updateTestModel(t, &m, drop()) + if got := columnNames(m.board, board.StatusTodo); got != "A,B,C" { + t.Fatalf("failed write board = %q", got) + } + selected, ok := m.selectedTask() + if !ok || selected.ID != "b" || !m.move.statusError || !strings.Contains(m.move.status, want.Error()) { + t.Fatalf("failed write focus/status = %+v,%v error=%v status=%q", selected, ok, m.move.statusError, m.move.status) + } +} + +func TestMoveFailurePathsStayTruthful(t *testing.T) { + t.Run("unsupported store", func(t *testing.T) { + m := NewModel(stubBoardReader{board: moveFixture()}, nil, "u") + completeBoardLoad(t, &m, m.Init()) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + if command := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}); command != nil { + t.Fatalf("unsupported store returned command %v", command) + } + if !m.move.statusError || !strings.Contains(m.move.status, "does not support") || columnNames(m.board, board.StatusTodo) != "A,B,C" { + t.Fatalf("unsupported store = error %v status %q board %q", m.move.statusError, m.move.status, columnNames(m.board, board.StatusTodo)) + } + }) + + t.Run("write and canonical reload fail", func(t *testing.T) { + s := &moveTestStore{board: moveFixture(), writeErr: errors.New("write failed"), reloadErr: errors.New("reload failed")} + m := loadedMoveModel(s) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyDown}) + drop := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + reload := updateTestModel(t, &m, drop()) + if reload == nil || !m.loading || columnNames(m.board, board.StatusTodo) != "A,B,C" { + t.Fatalf("double failure = loading %v board %q command %v", m.loading, columnNames(m.board, board.StatusTodo), reload) + } + if !strings.Contains(m.move.status, "write failed") || !strings.Contains(m.move.status, "canonical reload failed") { + t.Fatalf("double failure status = %q", m.move.status) + } + }) + + t.Run("successful write and canonical reload fails", func(t *testing.T) { + s := &moveTestStore{board: moveFixture(), reloadErr: errors.New("reload failed")} + m := loadedMoveModel(s) + m.boardView.rows[0] = 1 // B + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyDown}) + preview := taskLayout(m.board) + const wantPreview = "a:todo:0,c:todo:1,b:todo:2,x:doing:0,y:doing:1" + if preview != wantPreview { + t.Fatalf("preview ordinals = %s, want %s", preview, wantPreview) + } + drop := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + reload := updateTestModel(t, &m, drop()) + if reload == nil || !m.loading { + t.Fatalf("reload failure = loading %v command %v", m.loading, reload) + } + if got := taskLayout(m.board); got != preview { + t.Fatalf("reload failure replaced preview\n got: %s\nwant: %s", got, preview) + } + if got := columnNames(m.board, board.StatusTodo); got != "A,C,B" { + t.Fatalf("reload failure preview = %q", got) + } + }) + + t.Run("successful write missing from canonical board", func(t *testing.T) { + s := &moveTestStore{board: moveFixture()} + m := loadedMoveModel(s) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + m.move.saving = true + canonical := moveFixture() + canonical.Tasks = canonical.Tasks[1:] + updateTestModel(t, &m, cardMoveStoredMsg{taskID: "a", title: "A", board: canonical}) + if !m.move.statusError || !strings.Contains(m.move.status, "absent") { + t.Fatalf("missing canonical task status = error %v %q", m.move.statusError, m.move.status) + } + if selected, ok := m.selectedTask(); !ok || selected.ID == "a" { + t.Fatalf("missing canonical task retained stale focus = %+v,%v", selected, ok) + } + }) +} + +func TestWatcherChangeCancelsUnsavedPreviewBeforeRefresh(t *testing.T) { + s := &moveTestStore{board: moveFixture()} + m := loadedMoveModel(s) + m.watcher = stubVersionReader{version: 2} + m.haveVersion, m.dataVersion = true, 1 + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyDown}) + command := updateTestModel(t, &m, dataVersionMsg{version: 2}) + if command == nil || !m.loading || m.move.lifted != nil || columnNames(m.board, board.StatusTodo) != "A,B,C" { + t.Fatalf("watcher cancel = loading %v lifted %v board %q command %v", m.loading, m.move.lifted, columnNames(m.board, board.StatusTodo), command) + } + if !strings.Contains(m.move.status, "board changed; refreshing") { + t.Fatalf("watcher cancel status = %q", m.move.status) + } +} + +func TestMoveSerializesWatcherRefreshBehindStoreWrite(t *testing.T) { + s := &moveTestStore{board: moveFixture()} + m := loadedMoveModel(s) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + drop := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if command := updateTestModel(t, &m, dataVersionMsg{version: 2}); command == nil { + t.Fatal("watcher update stopped the poll chain") + } + if !m.reloadPending || m.loading { + t.Fatalf("watcher raced write: loading=%v pending=%v", m.loading, m.reloadPending) + } + reload := updateTestModel(t, &m, drop()) + if reload == nil || !m.loading || m.reloadPending { + t.Fatalf("write did not start serialized reload: loading=%v pending=%v command=%v", m.loading, m.reloadPending, reload) + } + completeBoardLoad(t, &m, reload) + if m.loading || m.move.saving || m.move.lifted != nil { + t.Fatalf("serialized refresh incomplete: %#v", m) + } +} + +func TestMouseDragPreviewsAndDropsBetweenColumns(t *testing.T) { + s := &moveTestStore{board: moveFixture()} + m := loadedMoveModel(s) + m.width, m.height = 140, 20 + _, hits := m.renderBoard() + var source, destination boardHit + for _, hit := range hits { + switch hit.taskID { + case "a": + source = hit + case "y": + destination = hit + } + } + handler := boardMouseHandler(hits, false) + down := handler(tea.MouseClickMsg{X: source.x0 + 1, Y: source.y0, Button: tea.MouseLeft}) + updateTestModel(t, &m, down()) + move := handler(tea.MouseMotionMsg{X: destination.x0 + 1, Y: destination.y0, Button: tea.MouseLeft}) + updateTestModel(t, &m, move()) + if got := columnNames(m.board, board.StatusDoing); got != "X,A,Y" { + t.Fatalf("mouse preview = %q", got) + } + up := handler(tea.MouseReleaseMsg{X: destination.x0 + 1, Y: destination.y0, Button: tea.MouseLeft}) + drop := updateTestModel(t, &m, up()) + updateTestModel(t, &m, drop()) + if s.target != board.StatusDoing || s.index != 1 || columnNames(m.board, board.StatusDoing) != "X,A,Y" { + t.Fatalf("mouse drop = target %s index %d board %q", s.target, s.index, columnNames(m.board, board.StatusDoing)) + } +} + +func TestSameColumnDropPreservesStoredCardAge(t *testing.T) { + st, err := store.Open(t.TempDir()+"/kb.db", []byte("move-age-test")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + first, err := st.AddTask("u", board.Task{Title: "First"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.AddTask("u", board.Task{Title: "Second"}); err != nil { + t.Fatal(err) + } + m := NewModel(st, nil, "u") + completeBoardLoad(t, &m, m.Init()) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyDown}) + drop := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + updateTestModel(t, &m, drop()) + after := taskNamed(t, m.board, "First") + if !after.MovedAt.Equal(first.MovedAt) || after.Position != 1 { + t.Fatalf("same-column age/position = %v/%d, want %v/1", after.MovedAt, after.Position, first.MovedAt) + } +} + +func TestCardMoveTeatestInteraction(t *testing.T) { + s := &moveTestStore{board: moveFixture()} + m := loadedMoveModel(s) + tm := teatest.NewTestModel(t, m, + teatest.WithInitialTermSize(120, 20), + teatest.WithProgramOptions(tea.WithColorProfile(colorprofile.ASCII)), + ) + t.Cleanup(func() { _ = tm.Quit() }) + var captured bytes.Buffer + output := io.TeeReader(tm.Output(), &captured) + teatest.WaitFor(t, output, func(got []byte) bool { return bytes.Contains(got, []byte("ready")) }, + teatest.WithDuration(5*time.Second), teatest.WithCheckInterval(10*time.Millisecond)) + for _, key := range []tea.KeyPressMsg{ + {Code: tea.KeySpace}, + {Code: tea.KeyRight}, + {Code: tea.KeyDown}, + {Code: tea.KeyEnter}, + } { + tm.Send(key) + } + teatest.WaitFor(t, output, func(got []byte) bool { + // Terminal diff chunks may split or overwrite any status substring. + // The synchronized store receipt is the stable interaction boundary. + return s.writeCount() == 1 + }, teatest.WithDuration(5*time.Second), teatest.WithCheckInterval(10*time.Millisecond)) + tm.Send(tea.KeyPressMsg{Code: 'q'}) + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) +} + +func TestMoveModelCoverageEdges(t *testing.T) { + current := moveFixture() + allStatuses := []board.Status{board.StatusTodo, board.StatusDoing, board.StatusDone} + + var empty cardMoveState + if _, ok := empty.previewKey("down"); ok { + t.Fatal("preview without lift succeeded") + } + if _, ok := empty.previewMouse(board.StatusTodo, ""); ok { + t.Fatal("mouse preview without lift succeeded") + } + if got := empty.cancel(""); len(got.Tasks) != 0 { + t.Fatalf("empty cancel = %+v", got) + } + empty.announcePosition("ignored") + + missing := board.Task{ID: "missing", Title: "Missing", Status: board.StatusTodo} + empty.begin(current, missing, allStatuses, false) + if empty.lifted.slot != 3 { + t.Fatalf("missing-card slot = %d", empty.lifted.slot) + } + empty.saving = true + if _, ok := empty.previewKey("down"); ok { + t.Fatal("preview while saving succeeded") + } + if _, ok := empty.previewMouse(board.StatusTodo, ""); ok { + t.Fatal("mouse preview while saving succeeded") + } + + var keyboard cardMoveState + keyboard.begin(current, taskNamed(t, current, "A"), allStatuses, false) + if _, ok := keyboard.previewKey("unknown"); ok { + t.Fatal("unknown move key was handled") + } + keyboard.previewKey("l") + keyboard.previewKey("h") + if _, ok := keyboard.previewMouse(board.StatusDoing, "x"); ok { + t.Fatal("keyboard lift accepted mouse preview") + } + + var mouse cardMoveState + mouse.begin(current, taskNamed(t, current, "A"), []board.Status{board.StatusTodo}, true) + if _, ok := mouse.previewMouse("bogus", ""); ok { + t.Fatal("invalid mouse status was handled") + } + if _, ok := mouse.previewMouse(board.StatusDoing, "x"); ok { + t.Fatal("hidden mouse column was handled") + } + if preview, changed := mouse.previewMouse(board.StatusTodo, "a"); changed || len(preview.Tasks) != 0 { + t.Fatal("motion over lifted card rebuilt preview") + } + if preview, ok := mouse.previewMouse(board.StatusTodo, ""); !ok || columnNames(preview, board.StatusTodo) != "B,C,A" { + t.Fatalf("blank-column mouse preview = %q,%v", columnNames(preview, board.StatusTodo), ok) + } + + filtered := board.Board{Tasks: []board.Task{{ID: "a", Status: board.StatusTodo}}} + if got := visibleSlotToFullColumnIndex(filtered, board.StatusTodo, "", []string{"gone"}, 0); got != 1 { + t.Fatalf("missing visible anchor = %d", got) + } + if containsStatus(allStatuses, board.StatusCancelled) || statusIndexExact("bogus") != -1 { + t.Fatal("status helpers accepted unknown values") + } + for status, want := range map[board.Status]string{ + board.StatusTodo: "To Do", board.StatusDoing: "Doing", board.StatusDone: "Done", + board.StatusCancelled: "Cancelled", board.Status("bogus"): "bogus", + } { + if got := statusLabelTitle(status); got != want { + t.Errorf("statusLabelTitle(%q) = %q, want %q", status, got, want) + } + } +} + +func TestMoveRootRoutingCoverageEdges(t *testing.T) { + s := &moveTestStore{board: moveFixture()} + + t.Run("saving ignores additional input", func(t *testing.T) { + m := loadedMoveModel(s) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + m.move.saving = true + if command := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyDown}); command != nil || !m.move.saving { + t.Fatalf("saving input = command %v state %#v", command, m.move) + } + if command := m.startBoardLoad(); command != nil || !m.reloadPending { + t.Fatalf("saving load = command %v pending %v", command, m.reloadPending) + } + }) + + t.Run("escape and focus changes cancel", func(t *testing.T) { + m := loadedMoveModel(s) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEsc}) + if m.move.lifted != nil || !strings.Contains(m.move.status, "cancelled") { + t.Fatalf("escape state = %#v", m.move) + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyTab}) + if m.move.lifted != nil || m.boardView.column != 1 { + t.Fatalf("tab focus change = move %#v column %d", m.move, m.boardView.column) + } + }) + + t.Run("loading and empty boards cannot lift", func(t *testing.T) { + m := loadedMoveModel(s) + m.loading = true + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + if m.move.lifted != nil { + t.Fatal("loading board lifted a card") + } + m.loading, m.board = false, board.Board{} + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + if m.move.lifted != nil { + t.Fatal("empty board lifted a card") + } + }) + + t.Run("click and column focus cancel keyboard lift", func(t *testing.T) { + m := loadedMoveModel(s) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + updateTestModel(t, &m, boardColumnClickedMsg{status: board.StatusDoing}) + if m.move.lifted != nil || m.boardView.column != 1 { + t.Fatalf("column click = move %#v column %d", m.move, m.boardView.column) + } + m.boardView.column = 0 + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + updateTestModel(t, &m, boardCardClickedMsg{taskID: "b"}) + if m.move.lifted != nil || !m.detail.IsOpen() { + t.Fatalf("card click = move %#v detail %v", m.move, m.detail.IsOpen()) + } + }) + + t.Run("pointer guards and click release", func(t *testing.T) { + m := loadedMoveModel(s) + m.loading = true + updateTestModel(t, &m, boardPointerDownMsg{taskID: "a"}) + m.loading = false + updateTestModel(t, &m, boardPointerDownMsg{taskID: "missing"}) + if m.move.lifted != nil { + t.Fatal("guarded pointer down lifted a card") + } + updateTestModel(t, &m, boardPointerUpMsg{}) + updateTestModel(t, &m, boardPointerDownMsg{taskID: "a"}) + if command := updateTestModel(t, &m, boardPointerUpMsg{}); command != nil || !m.detail.IsOpen() { + t.Fatalf("click release = command %v detail %v", command, m.detail.IsOpen()) + } + }) +} + +func TestMoveBoardViewCoverageEdges(t *testing.T) { + if got := taskIndex(moveFixture(), board.StatusDone, "missing"); got != 0 { + t.Fatalf("missing task index = %d", got) + } + if body, hits := joinColumns(nil); body != "" || hits != nil { + t.Fatalf("empty columns = %q,%v", body, hits) + } + if got := settingsBoardFooter("ready", "off", false, 1); got != "q" { + t.Fatalf("tiny footer = %q", got) + } + hits := []boardHit{{x1: 5, y1: 5, status: board.StatusDoing}} + handler := boardMouseHandler(hits, false) + if command := handler(tea.MouseClickMsg{X: 1, Y: 1, Button: tea.MouseLeft}); command == nil { + t.Fatal("column click was ignored") + } else if msg := command().(boardColumnClickedMsg); msg.status != board.StatusDoing { + t.Fatalf("column click status = %s", msg.status) + } + if command := handler(tea.MouseMotionMsg{X: 9, Y: 9, Button: tea.MouseLeft}); command != nil { + t.Fatalf("off-board motion = %v", command) + } + if command := handler(tea.MouseWheelMsg{X: 1, Y: 1, Button: tea.MouseLeft}); command != nil { + t.Fatalf("left-button wheel = %v", command) + } +} + +func TestMouseReleaseProtocolsClickAndDrag(t *testing.T) { + protocols := []struct { + name string + button tea.MouseButton + }{ + {name: "SGR", button: tea.MouseLeft}, + {name: "X10", button: tea.MouseNone}, + } + for _, protocol := range protocols { + for _, drag := range []bool{false, true} { + name := protocol.name + "/click" + if drag { + name = protocol.name + "/drag" + } + t.Run(name, func(t *testing.T) { + s := &moveTestStore{board: moveFixture()} + m := loadedMoveModel(s) + m.width, m.height = 140, 20 + _, hits := m.renderBoard() + var source, destination boardHit + for _, hit := range hits { + switch hit.taskID { + case "a": + source = hit + case "y": + destination = hit + } + } + down := boardMouseHandler(hits, false)(tea.MouseClickMsg{ + X: source.x0 + 1, Y: source.y0, Button: tea.MouseLeft, + }) + updateTestModel(t, &m, down()) + active := boardMouseHandler(hits, true) + if drag { + motion := active(tea.MouseMotionMsg{ + X: destination.x0 + 1, Y: destination.y0, Button: tea.MouseLeft, + }) + updateTestModel(t, &m, motion()) + } + release := active(tea.MouseReleaseMsg{ + X: destination.x0 + 1, Y: destination.y0, Button: protocol.button, + }) + if release == nil { + t.Fatalf("%s release was ignored", protocol.name) + } + command := updateTestModel(t, &m, release()) + if drag { + if command == nil { + t.Fatal("drag release did not start drop") + } + updateTestModel(t, &m, command()) + if s.writes != 1 || s.target != board.StatusDoing { + t.Fatalf("drag write = %d/%s", s.writes, s.target) + } + } else if !m.detail.IsOpen() || s.writes != 0 { + t.Fatalf("click release = detail %v writes %d", m.detail.IsOpen(), s.writes) + } + }) + } + } + if command := boardMouseHandler(nil, false)(tea.MouseReleaseMsg{Button: tea.MouseNone}); command != nil { + t.Fatalf("unrelated X10 release produced %v", command) + } +} + +func TestMoveFooterSanitizesTitlesStatusesAndStoreErrors(t *testing.T) { + hostile := "\x1b]8;;https://evil.example\x07pwn\x1b]8;;\x07\x1b[31mred\x1b[0m\x00\x01\x7f\u0085" + assertSafeFooter := func(t *testing.T, model Model, wants ...string) { + t.Helper() + lines := strings.Split(model.render(), "\n") + footer := lines[len(lines)-1] + for _, want := range wants { + if !strings.Contains(footer, want) { + t.Errorf("footer missing %q: %q", want, footer) + } + } + for _, r := range footer { + if r <= 0x1f || (r >= 0x7f && r <= 0x9f) { + t.Fatalf("footer retained control U+%04X: %q", r, footer) + } + } + if strings.Contains(footer, "evil.example") || strings.ContainsRune(footer, '\x1b') { + t.Fatalf("footer retained terminal control payload: %q", footer) + } + } + + t.Run("lift title and status", func(t *testing.T) { + s := &moveTestStore{board: moveFixture()} + s.board.Tasks[0].Title = hostile + m := loadedMoveModel(s) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + assertSafeFooter(t, m, "pwnred", "Arrows or hjkl") + }) + + t.Run("store error", func(t *testing.T) { + s := &moveTestStore{board: moveFixture(), writeErr: errors.New(hostile)} + m := loadedMoveModel(s) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + drop := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + updateTestModel(t, &m, drop()) + assertSafeFooter(t, m, "Move failed for A", "pwnred") + }) +} + +func TestRepeatedSameCellMotionDoesNotRebuildLargePreview(t *testing.T) { + const cards = 20_000 + current := board.Board{Title: "Large"} + for i := 0; i < cards; i++ { + current.Tasks = append(current.Tasks, board.Task{ + ID: fmt.Sprintf("t-%d", i), Title: fmt.Sprintf("Task %d", i), Status: board.StatusTodo, + }) + } + current.Tasks = append(current.Tasks, board.Task{ID: "doing", Title: "Doing", Status: board.StatusDoing}) + var state cardMoveState + state.begin(current, current.Tasks[0], []board.Status{board.StatusTodo, board.StatusDoing}, true) + preview, changed := state.previewMouse(board.StatusTodo, "t-10000") + if !changed || len(preview.Tasks) != len(current.Tasks) { + t.Fatalf("meaningful motion = changed %v tasks %d", changed, len(preview.Tasks)) + } + rebuilds := 0 + allocations := testing.AllocsPerRun(1000, func() { + preview, changed := state.previewMouse(board.StatusTodo, "t-10000") + if changed || len(preview.Tasks) != 0 { + rebuilds++ + } + }) + if rebuilds != 0 || allocations != 0 { + t.Fatalf("same-cell motion rebuilt preview %d times with %.1f allocations/run", rebuilds, allocations) + } +} + +func TestAdjacentMouseTargetsReuseLargePreviewStorage(t *testing.T) { + const cards = 20_000 + current := board.Board{Title: "Large"} + for i := 0; i < cards; i++ { + current.Tasks = append(current.Tasks, board.Task{ + ID: fmt.Sprintf("t-%d", i), Title: fmt.Sprintf("Task %d", i), Status: board.StatusTodo, + }) + } + current.Tasks = append(current.Tasks, board.Task{ID: "doing", Title: "Doing", Status: board.StatusDoing}) + var state cardMoveState + state.begin(current, current.Tasks[0], []board.Status{board.StatusTodo, board.StatusDoing}, true) + backing := &state.lifted.preview.Tasks[0] + if _, changed := state.previewMouse(board.StatusTodo, "t-10000"); !changed { + t.Fatal("initial meaningful motion was ignored") + } + + targetFirst := true + rebuilds := 0 + unchanged := 0 + allocations := testing.AllocsPerRun(1000, func() { + target := "t-10000" + if targetFirst { + target = "t-10001" + } + targetFirst = !targetFirst + preview, changed := state.previewMouse(board.StatusTodo, target) + if !changed { + unchanged++ + } + if &preview.Tasks[0] != backing { + rebuilds++ + } + }) + if unchanged != 0 || rebuilds != 0 { + t.Fatalf("adjacent motion ignored %d targets and rebuilt preview %d times", unchanged, rebuilds) + } + // Status formatting owns the small fixed allocation cost. A board rebuild + // grows this well past the bound and changes the backing array above. + if allocations > 6 { + t.Fatalf("adjacent motion allocated %.1f objects/run, want at most 6", allocations) + } +} + +func TestDropAnnouncementUsesCanonicalTaskStatusTitleAndPosition(t *testing.T) { + s := &moveTestStore{board: moveFixture()} + m := loadedMoveModel(s) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyRight}) // requested Doing + canonical := moveFixture() + canonical.Tasks = append(canonical.Tasks, board.Task{ + ID: "killed-first", Title: "Killed first", Status: board.StatusCancelled, + }) + for i := range canonical.Tasks { + if canonical.Tasks[i].ID == "a" { + canonical.Tasks[i].Title = "Renamed concurrently" + canonical.Tasks[i].Status = board.StatusCancelled + } + } + updateTestModel(t, &m, cardMoveStoredMsg{taskID: "a", title: "A", board: canonical}) + if m.move.statusError || m.move.status != "Dropped Renamed concurrently, Cancelled, position 1 of 2" { + t.Fatalf("canonical announcement = error %v status %q", m.move.statusError, m.move.status) + } + if m.boardView.column == statusIndex(board.StatusCancelled) { + t.Fatal("hidden Cancelled task stole visible focus") + } +} + +func TestActiveMoveStatusPrecedesLingeringErrors(t *testing.T) { + for name, install := range map[string]func(*Model){ + "load": func(m *Model) { m.loadErr = errors.New("old load error") }, + "poll": func(m *Model) { m.pollErr = errors.New("old poll error") }, + "preference": func(m *Model) { m.preferenceErr = errors.New("old preference error") }, + } { + t.Run(name, func(t *testing.T) { + s := &moveTestStore{board: moveFixture()} + m := loadedMoveModel(s) + install(&m) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + footer := lastRenderLine(m) + if !strings.Contains(footer, "Lifted A") || strings.Contains(footer, "old ") { + t.Fatalf("active footer = %q", footer) + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEsc}) + footer = lastRenderLine(m) + if !strings.Contains(footer, "Move cancelled: A restored") || strings.Contains(footer, "old ") { + t.Fatalf("cancel footer = %q", footer) + } + updateTestModel(t, &m, tea.WindowSizeMsg{Width: 120, Height: 20}) + if footer = lastRenderLine(m); !strings.Contains(footer, "Move cancelled: A restored") { + t.Fatalf("background update hid cancel footer = %q", footer) + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyRight}) + if footer = lastRenderLine(m); !strings.Contains(footer, "old ") { + t.Fatalf("restored error footer = %q", footer) + } + }) + } +} + +func TestCompletedDropAndFailurePrecedeLingeringErrors(t *testing.T) { + t.Run("drop", func(t *testing.T) { + s := &moveTestStore{board: moveFixture()} + m := loadedMoveModel(s) + m.pollErr = errors.New("old poll error") + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + drop := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + updateTestModel(t, &m, drop()) + if footer := lastRenderLine(m); !strings.Contains(footer, "Dropped A") || strings.Contains(footer, "old poll") { + t.Fatalf("drop footer = %q", footer) + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyRight}) + if footer := lastRenderLine(m); !strings.Contains(footer, "old poll") { + t.Fatalf("restored poll footer = %q", footer) + } + }) + + t.Run("failure", func(t *testing.T) { + s := &moveTestStore{board: moveFixture(), writeErr: errors.New("write failed")} + m := loadedMoveModel(s) + m.preferenceErr = errors.New("old preference error") + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + drop := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + updateTestModel(t, &m, drop()) + if footer := lastRenderLine(m); !strings.Contains(footer, "Move failed for A: write failed") || strings.Contains(footer, "old preference") { + t.Fatalf("failure footer = %q", footer) + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyRight}) + if footer := lastRenderLine(m); !strings.Contains(footer, "old preference") { + t.Fatalf("restored preference footer = %q", footer) + } + }) +} + +func lastRenderLine(model Model) string { + lines := strings.Split(ansi.Strip(model.render()), "\n") + return lines[len(lines)-1] +} + +func TestPreviewMatchesRealSQLiteIndexedMoves(t *testing.T) { + for _, test := range []struct { + name string + input string + titles []string + statuses []board.Status + moving string + target board.Status + visible []string + slot int + wantColumn string + }{ + { + name: "same column", input: "key-down", titles: []string{"A", "B", "C"}, + statuses: []board.Status{board.StatusTodo, board.StatusTodo, board.StatusTodo}, + moving: "B", target: board.StatusTodo, visible: []string{"A", "C"}, slot: 2, + wantColumn: "A,C,B", + }, + { + name: "cross column", input: "key-cross", titles: []string{"A", "X", "Y"}, + statuses: []board.Status{board.StatusTodo, board.StatusDoing, board.StatusDoing}, + moving: "A", target: board.StatusDoing, visible: []string{"X", "Y"}, slot: 1, + wantColumn: "X,A,Y", + }, + { + name: "filtered append", input: "mouse-filtered", titles: []string{"hidden-0", "visible-a", "hidden-1", "visible-b", "hidden-2", "moving"}, + statuses: []board.Status{board.StatusTodo, board.StatusTodo, board.StatusTodo, board.StatusTodo, board.StatusTodo, board.StatusDoing}, + moving: "moving", target: board.StatusTodo, visible: []string{"visible-a", "visible-b"}, slot: 2, + wantColumn: "hidden-0,visible-a,hidden-1,visible-b,hidden-2,moving", + }, + } { + t.Run(test.name, func(t *testing.T) { + st, err := store.Open(t.TempDir()+"/kb.db", []byte("preview-store-parity")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + ids := map[string]string{} + for i, title := range test.titles { + added, addErr := st.AddTask("u", board.Task{Title: title, Status: test.statuses[i]}) + if addErr != nil { + t.Fatal(addErr) + } + ids[title] = added.ID + } + canonical, err := st.Board("u") + if err != nil { + t.Fatal(err) + } + visibleIDs := make([]string, len(test.visible)) + for i, title := range test.visible { + visibleIDs[i] = ids[title] + } + moving := taskNamed(t, canonical, test.moving) + var state cardMoveState + state.begin(canonical, moving, boardStatuses[:], test.input == "mouse-filtered") + var preview board.Board + var changed bool + switch test.input { + case "key-down": + preview, changed = state.previewKey("down") + case "key-cross": + if _, handled := state.previewKey("right"); !handled { + t.Fatal("cross-column transition was ignored") + } + preview, changed = state.previewKey("down") + case "mouse-filtered": + state.lifted.visibleIDs = cloneTaskColumns(state.lifted.fullIDs) + state.lifted.visibleIDs[test.target] = visibleIDs + state.lifted.visibleAt = taskSlots(state.lifted.visibleIDs) + preview, changed = state.previewMouse(test.target, "") + default: + t.Fatalf("unknown input %q", test.input) + } + if !changed || state.lifted.target != test.target || state.lifted.slot != test.slot { + t.Fatalf("transition = changed %v target %s slot %d", changed, state.lifted.target, state.lifted.slot) + } + index := visibleSlotToFullColumnIndex( + canonical, state.lifted.target, state.lifted.taskID, + state.lifted.visibleIDs[state.lifted.target], state.lifted.slot, + ) + if _, err := st.UpdateAndMoveTask("u", state.lifted.taskID, store.TaskPatch{}, &state.lifted.target, &index, nil); err != nil { + t.Fatal(err) + } + persisted, err := st.Board("u") + if err != nil { + t.Fatal(err) + } + if got := columnNames(preview, test.target); got != test.wantColumn { + t.Fatalf("preview = %q, want %q", got, test.wantColumn) + } + if got := columnNames(persisted, test.target); got != test.wantColumn { + t.Fatalf("persisted = %q, want %q", got, test.wantColumn) + } + if got, want := taskLayout(preview), taskLayout(persisted); got != want { + t.Fatalf("preview/store layout mismatch\n got: %s\nwant: %s", got, want) + } + }) + } +} diff --git a/internal/tui/move_store.go b/internal/tui/move_store.go new file mode 100644 index 0000000..2ebe0e7 --- /dev/null +++ b/internal/tui/move_store.go @@ -0,0 +1,110 @@ +package tui + +import ( + "fmt" + + tea "charm.land/bubbletea/v2" + "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/store" +) + +type taskMoveStore interface { + boardReader + UpdateAndMoveTask(string, string, store.TaskPatch, *board.Status, *int, func(board.Task) error) (board.Task, error) +} + +type cardMoveStoredMsg struct { + taskID string + title string + board board.Board + writeErr error + reloadErr error +} + +func (m *Model) startCardDrop() tea.Cmd { + if m.move.lifted == nil || m.move.saving { + return nil + } + lift := *m.move.lifted + index := visibleSlotToFullColumnIndex( + lift.canonical, lift.target, lift.taskID, lift.visibleIDs[lift.target], lift.slot, + ) + if m.moveStore == nil { + title := lift.title + m.cancelCardMove("") + m.move.status = fmt.Sprintf("Move failed for %s: store does not support card moves", title) + m.move.statusError = true + return nil + } + m.move.saving = true + m.move.announcePosition("Dropping") + moveStore := m.moveStore + user := m.user + return func() tea.Msg { + _, writeErr := moveStore.UpdateAndMoveTask( + user, lift.taskID, store.TaskPatch{}, &lift.target, &index, nil, + ) + canonical, reloadErr := moveStore.Board(user) + return cardMoveStoredMsg{ + taskID: lift.taskID, title: lift.title, + board: canonical, + writeErr: writeErr, reloadErr: reloadErr, + } + } +} + +func (m *Model) finishCardDrop(msg cardMoveStoredMsg) tea.Cmd { + previous := m.filteredBoard() + lift := m.move.lifted + fallback := m.board + if lift != nil { + fallback = lift.canonical + } + if msg.reloadErr == nil { + m.board = msg.board + } else if msg.writeErr != nil { + m.board = cloneBoard(fallback) + } + m.move.lifted = nil + m.move.saving = false + m.move.statusError = msg.writeErr != nil || msg.reloadErr != nil + m.move.notice = true + filtered := m.filteredBoard() + m.boardView.adoptBoard(previous, filtered) + canonicalTask, found := boardTaskByID(m.board, msg.taskID) + if found { + m.boardView.focusTask(filtered, msg.taskID) + } + + switch { + case msg.writeErr != nil && msg.reloadErr != nil: + m.move.status = fmt.Sprintf("Move failed for %s: %v; canonical reload failed: %v", msg.title, msg.writeErr, msg.reloadErr) + case msg.writeErr != nil: + m.move.status = fmt.Sprintf("Move failed for %s: %v", msg.title, msg.writeErr) + case msg.reloadErr != nil: + m.move.status = fmt.Sprintf("Dropped %s, but canonical reload failed: %v", msg.title, msg.reloadErr) + case !found: + m.move.statusError = true + m.move.status = fmt.Sprintf("Dropped %s, but it is absent from the canonical board", msg.title) + default: + position := taskIndex(m.board, canonicalTask.Status, msg.taskID) + count := taskCount(m.board, canonicalTask.Status) + m.move.status = fmt.Sprintf("Dropped %s, %s, position %d of %d", canonicalTask.Title, + statusLabelTitle(canonicalTask.Status), position+1, count) + } + + if m.reloadPending || msg.reloadErr != nil { + m.reloadPending = false + return m.startBoardLoad() + } + return nil +} + +func boardTaskByID(current board.Board, taskID string) (board.Task, bool) { + for _, task := range current.Tasks { + if task.ID == taskID { + return task, true + } + } + return board.Task{}, false +}