From f2de234490d63354f7a069adb2ad15f762ba9e54 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 18 Aug 2026 01:49:31 +0000 Subject: [PATCH 1/2] feat(tui): add ship and delete mechanics --- internal/store/delete_cancelled_test.go | 196 ++++++ internal/store/search.go | 34 +- internal/store/store.go | 90 ++- internal/tui/board_view.go | 9 +- internal/tui/board_view_test.go | 4 + internal/tui/model.go | 119 ++-- internal/tui/model_test.go | 159 +++++ internal/tui/move_model_test.go | 11 +- internal/tui/move_store.go | 34 +- internal/tui/preferences.go | 18 +- internal/tui/ship_actions.go | 858 ++++++++++++++++++++++++ internal/tui/ship_actions_test.go | 645 ++++++++++++++++++ 12 files changed, 2101 insertions(+), 76 deletions(-) create mode 100644 internal/store/delete_cancelled_test.go create mode 100644 internal/tui/ship_actions.go create mode 100644 internal/tui/ship_actions_test.go diff --git a/internal/store/delete_cancelled_test.go b/internal/store/delete_cancelled_test.go new file mode 100644 index 0000000..bc64fd7 --- /dev/null +++ b/internal/store/delete_cancelled_test.go @@ -0,0 +1,196 @@ +package store + +import ( + "errors" + "testing" + + "github.com/RandomCodeSpace/kb/internal/board" +) + +func TestDeleteCancelledTaskEnforcesStatusInsideTheWrite(t *testing.T) { + s := newStore(t) + live, err := s.AddTask("alice", board.Task{Title: "Still live", Status: board.StatusTodo}) + if err != nil { + t.Fatal(err) + } + if _, err := s.DeleteCancelledTask("alice", live.ID); !errors.Is(err, ErrTaskNotCancelled) { + t.Fatalf("DeleteCancelledTask(live) = %v, want ErrTaskNotCancelled", err) + } + if _, err := s.UpdateTask("alice", live.ID, TaskPatch{}); err != nil { + t.Fatalf("refused purge removed live task: %v", err) + } + + cancelled, err := s.MoveTask("alice", live.ID, board.StatusCancelled) + if err != nil { + t.Fatal(err) + } + if err := s.RecordTombstone("alice", cancelled.ID, "superseded"); err != nil { + t.Fatal(err) + } + deleted, err := s.DeleteCancelledTask("alice", cancelled.ID) + if err != nil || deleted.ID != cancelled.ID { + t.Fatalf("DeleteCancelledTask(cancelled) = %+v, %v", deleted, err) + } + if _, err := s.UpdateTask("alice", cancelled.ID, TaskPatch{}); !errors.Is(err, ErrNotFound) { + t.Fatalf("purged task lookup = %v, want ErrNotFound", err) + } + if _, found, err := s.Tombstone("alice", cancelled.ID); err != nil || found { + t.Fatalf("purged tombstone = found %v, err %v", found, err) + } +} + +func TestCancelTaskMovesAndTombstonesAtomically(t *testing.T) { + s := newStore(t) + task, err := s.AddTask("alice", board.Task{Title: "Reject me", Status: board.StatusTodo}) + if err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec(`CREATE TRIGGER fail_atomic_tombstone BEFORE INSERT ON tombstones BEGIN SELECT RAISE(ABORT, 'no tombstone'); END`); err != nil { + t.Fatal(err) + } + reason := "superseded" + if _, err := s.CancelTask("alice", task.ID, &reason); err == nil { + t.Fatal("CancelTask succeeded while tombstone insert failed") + } + current, err := s.Task("alice", task.ID) + if err != nil || current.Status != board.StatusTodo { + t.Fatalf("failed atomic cancel persisted status = %s, %v", current.Status, err) + } + if _, found, err := s.Tombstone("alice", task.ID); err != nil || found { + t.Fatalf("failed atomic cancel persisted tombstone = %v, %v", found, err) + } + if _, err := s.db.Exec(`DROP TRIGGER fail_atomic_tombstone`); err != nil { + t.Fatal(err) + } + + cancelled, err := s.CancelTask("alice", task.ID, &reason) + if err != nil || cancelled.Status != board.StatusCancelled { + t.Fatalf("CancelTask = %+v, %v", cancelled, err) + } + tombstone, found, err := s.Tombstone("alice", task.ID) + if err != nil || !found || tombstone.Reason != reason { + t.Fatalf("atomic tombstone = %+v, %v, %v", tombstone, found, err) + } +} + +func TestUpdateAndMoveTaskIfFieldsMatchIsAtomic(t *testing.T) { + s := newStore(t) + original := []board.Check{{Text: "only"}} + task, err := s.AddTask("alice", board.Task{Title: "CAS move", Status: board.StatusTodo, Checks: original}) + if err != nil { + t.Fatal(err) + } + doneChecks := []board.Check{{Text: "only", Done: true}} + target := board.StatusDone + index := 0 + + stale := []board.Check{{Text: "stale"}} + if _, err := s.UpdateAndMoveTaskIfFieldsMatch("alice", task.ID, + TaskPatch{Checks: &stale}, TaskPatch{Checks: &doneChecks}, &target, &index, nil); err == nil { + t.Fatal("stale field match succeeded") + } else { + var conflict *TaskFieldsConflictError + if !errors.As(err, &conflict) { + t.Fatalf("stale field match = %T %v", err, err) + } + } + + guardErr := errors.New("guard refused") + if _, err := s.UpdateAndMoveTaskIfFieldsMatch("alice", task.ID, + TaskPatch{Checks: &original}, TaskPatch{Checks: &doneChecks}, &target, &index, + func(board.Task) error { return guardErr }); !errors.Is(err, guardErr) { + t.Fatalf("guarded CAS move = %v", err) + } + unchanged, err := s.Task("alice", task.ID) + if err != nil || unchanged.Status != board.StatusTodo || unchanged.Checks[0].Done { + t.Fatalf("refused CAS move persisted = %+v, %v", unchanged, err) + } + + moved, err := s.UpdateAndMoveTaskIfFieldsMatch("alice", task.ID, + TaskPatch{Checks: &original}, TaskPatch{Checks: &doneChecks}, &target, &index, nil) + if err != nil || moved.Status != board.StatusDone || !moved.Checks[0].Done { + t.Fatalf("CAS move = %+v, %v", moved, err) + } + + invalidStatus := board.Status("invalid") + if _, err := s.UpdateAndMoveTaskIfFieldsMatch("alice", task.ID, + TaskPatch{}, TaskPatch{}, &invalidStatus, nil, nil); err == nil { + t.Fatal("invalid CAS move status succeeded") + } + negative := -1 + if _, err := s.UpdateAndMoveTaskIfFieldsMatch("alice", task.ID, + TaskPatch{}, TaskPatch{}, nil, &negative, nil); err == nil { + t.Fatal("negative CAS move index succeeded") + } + if _, err := s.UpdateAndMoveTaskIfFieldsMatch("alice", "missing", + TaskPatch{}, TaskPatch{}, nil, nil, nil); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing CAS move = %v", err) + } +} + +func TestRestorePreservesContextAndPurgeRemovesIt(t *testing.T) { + s := newStore(t) + target, err := s.AddTask("alice", board.Task{Title: "Target", Status: board.StatusTodo}) + if err != nil { + t.Fatal(err) + } + blocker, err := s.AddTask("alice", board.Task{Title: "Blocker", Status: board.StatusTodo}) + if err != nil { + t.Fatal(err) + } + blocked, err := s.AddTask("alice", board.Task{Title: "Blocked", Status: board.StatusTodo}) + if err != nil { + t.Fatal(err) + } + if _, _, err := s.Link("alice", target.ID, blocked.ID); err != nil { + t.Fatal(err) + } + if _, _, err := s.Link("alice", blocker.ID, target.ID); err != nil { + t.Fatal(err) + } + if _, err := s.AddComment("alice", target.ID, "alice", "keep this context"); err != nil { + t.Fatal(err) + } + reason := "not now" + if _, err := s.CancelTask("alice", target.ID, &reason); err != nil { + t.Fatal(err) + } + restored, err := s.MoveTask("alice", target.ID, board.StatusTodo) + if err != nil || restored.Status != board.StatusTodo { + t.Fatalf("restore = %+v, %v", restored, err) + } + comments, err := s.Comments("alice", target.ID) + if err != nil || len(comments) != 1 { + t.Fatalf("restore comments = %d, %v", len(comments), err) + } + links, err := s.TaskLinks("alice", target.ID) + if err != nil || len(links.Blocks) != 1 || len(links.BlockedBy) != 1 { + t.Fatalf("restore links = %+v, %v", links, err) + } + if _, found, err := s.Tombstone("alice", target.ID); err != nil || found { + t.Fatalf("restore retained tombstone = %v, %v", found, err) + } + + if _, err := s.CancelTask("alice", target.ID, &reason); err != nil { + t.Fatal(err) + } + if _, err := s.DeleteCancelledTask("alice", target.ID); err != nil { + t.Fatal(err) + } + for table, query := range map[string]string{ + "comments": `SELECT COUNT(*) FROM comments WHERE scope='alice' AND task_id=?`, + "links": `SELECT COUNT(*) FROM task_links WHERE scope='alice' AND (blocker_id=? OR blocked_id=?)`, + "tombstones": `SELECT COUNT(*) FROM tombstones WHERE scope='alice' AND task_id=?`, + } { + var count int + var queryErr error + if table == "links" { + queryErr = s.db.QueryRow(query, target.ID, target.ID).Scan(&count) + } else { + queryErr = s.db.QueryRow(query, target.ID).Scan(&count) + } + if queryErr != nil || count != 0 { + t.Errorf("purge %s rows = %d, %v", table, count, queryErr) + } + } +} diff --git a/internal/store/search.go b/internal/store/search.go index 865684f..7d2ee8a 100644 --- a/internal/store/search.go +++ b/internal/store/search.go @@ -258,9 +258,14 @@ func (s *Store) RecordTombstone(scope, taskID, reason string) error { if err := validateTombstoneReason(reason); err != nil { return err } - killedAt := time.Now().UTC().Format(time.RFC3339Nano) return s.withTx(func(tx *sql.Tx) error { - result, err := tx.Exec(` + return recordTombstoneTx(tx, scope, taskID, reason) + }) +} + +func recordTombstoneTx(tx *sql.Tx, scope, taskID, reason string) error { + killedAt := time.Now().UTC().Format(time.RFC3339Nano) + result, err := tx.Exec(` INSERT INTO tombstones (scope, task_id, reason, killed_at) SELECT ?, ?, ?, ? WHERE EXISTS ( @@ -270,19 +275,18 @@ func (s *Store) RecordTombstone(scope, taskID, reason string) error { ON CONFLICT(scope, task_id) DO UPDATE SET reason = excluded.reason, killed_at = excluded.killed_at`, - scope, taskID, reason, killedAt, scope, taskID) - if err != nil { - return fmt.Errorf("store: record tombstone: %w", err) - } - written, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("store: inspect tombstone write: %w", err) - } - if written != 1 { - return ErrTombstoneTaskNotCancelled - } - return nil - }) + scope, taskID, reason, killedAt, scope, taskID) + if err != nil { + return fmt.Errorf("store: record tombstone: %w", err) + } + written, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("store: inspect tombstone write: %w", err) + } + if written != 1 { + return ErrTombstoneTaskNotCancelled + } + return nil } // Tombstone returns the scoped graveyard reason for taskID when one exists. diff --git a/internal/store/store.go b/internal/store/store.go index e13f8db..8a0e402 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -27,9 +27,10 @@ import ( // Sentinel errors for task ID prefix resolution. var ( - ErrNotFound = errors.New("task not found") - ErrAmbiguous = errors.New("ambiguous task id prefix") - ErrInvalidTaskIDs = errors.New("invalid canonical task ids") + ErrNotFound = errors.New("task not found") + ErrAmbiguous = errors.New("ambiguous task id prefix") + ErrInvalidTaskIDs = errors.New("invalid canonical task ids") + ErrTaskNotCancelled = errors.New("task is not cancelled") ) // RevisionConflictError reports a failed board compare-and-swap. Revisions @@ -1019,6 +1020,44 @@ func (s *Store) UpdateTaskIfFieldsMatch(user, idPrefix string, expected, patch T return out, nil } +// UpdateAndMoveTaskIfFieldsMatch applies patch and move only when every +// non-nil expected field still matches. The comparison, patch, guard, and move +// share one transaction, so a stale modal cannot overwrite a concurrent edit. +func (s *Store) UpdateAndMoveTaskIfFieldsMatch( + user, idPrefix string, + expected, patch TaskPatch, + moveTo *board.Status, + index *int, + guard func(board.Task) error, +) (board.Task, error) { + if moveTo != nil && !moveTo.Valid() { + return board.Task{}, fmt.Errorf("store: invalid status %q", *moveTo) + } + if index != nil && *index < 0 { + return board.Task{}, fmt.Errorf("store: invalid index %d", *index) + } + var out board.Task + err := s.withTx(func(tx *sql.Tx) error { + id, err := resolveID(tx, user, idPrefix) + if err != nil { + return err + } + current, err := getTask(tx, user, id) + if err != nil { + return err + } + if fields := taskFieldConflicts(current, expected); len(fields) > 0 { + return &TaskFieldsConflictError{Fields: fields} + } + out, err = s.updateAndMoveTaskTx(tx, user, id, patch, moveTo, index, guard) + return err + }) + if err != nil { + return board.Task{}, err + } + return out, nil +} + func taskFieldConflicts(task board.Task, expected TaskPatch) []string { fields := make([]string, 0, 9) if expected.Emoji != nil && task.Emoji != *expected.Emoji { @@ -1239,6 +1278,36 @@ func (s *Store) MoveTask(user, idPrefix string, to board.Status) (board.Task, er return s.UpdateAndMoveTask(user, idPrefix, TaskPatch{}, &to, nil, nil) } +// CancelTask soft-deletes a task and optionally records its kill reason in one +// transaction. The status transition happens before the tombstone insert, as +// required by the tombstone invariant, but neither write can escape alone. +func (s *Store) CancelTask(user, idPrefix string, reason *string) (board.Task, error) { + if reason != nil { + trimmed := strings.TrimSpace(*reason) + if err := validateTombstoneReason(trimmed); err != nil { + return board.Task{}, err + } + reason = &trimmed + } + var out board.Task + err := s.withTx(func(tx *sql.Tx) error { + cancelled := board.StatusCancelled + var err error + out, err = s.updateAndMoveTaskTx(tx, user, idPrefix, TaskPatch{}, &cancelled, nil, nil) + if err != nil { + return err + } + if reason != nil { + return recordTombstoneTx(tx, user, out.ID, *reason) + } + return nil + }) + if err != nil { + return board.Task{}, err + } + return out, nil +} + // repositionTask splices id into column st at index, clamped to the column // length, and rewrites that column's positions to 0..n-1. Columns hold a // handful of tasks, so a full rewrite is cheaper to reason about than sparse @@ -1322,6 +1391,18 @@ func moveTask(tx *sql.Tx, user string, t board.Task, to board.Status) (board.Tas // DeleteTask removes the task matching idPrefix and returns it. func (s *Store) DeleteTask(user, idPrefix string) (board.Task, error) { + return s.deleteTask(user, idPrefix, false) +} + +// DeleteCancelledTask permanently deletes a task only while it is in the +// Cancelled column. This is the direct-store hard-delete seam used by the TUI: +// checking the status and removing the row happen in one transaction, so a +// concurrent restore cannot race an already-confirmed purge. +func (s *Store) DeleteCancelledTask(user, idPrefix string) (board.Task, error) { + return s.deleteTask(user, idPrefix, true) +} + +func (s *Store) deleteTask(user, idPrefix string, requireCancelled bool) (board.Task, error) { var out board.Task err := s.withTx(func(tx *sql.Tx) error { id, err := resolveID(tx, user, idPrefix) @@ -1332,6 +1413,9 @@ func (s *Store) DeleteTask(user, idPrefix string) (board.Task, error) { if err != nil { return err } + if requireCancelled && t.Status != board.StatusCancelled { + return ErrTaskNotCancelled + } if _, err := tx.Exec(`DELETE FROM tasks WHERE user = ? AND id = ?`, user, id); err != nil { return fmt.Errorf("store: delete task: %w", err) } diff --git a/internal/tui/board_view.go b/internal/tui/board_view.go index 1bb6699..8307cd6 100644 --- a/internal/tui/board_view.go +++ b/internal/tui/board_view.go @@ -251,6 +251,9 @@ func (m Model) renderBoard() (string, []boardHit) { title = "Board" } header := fitLine(fmt.Sprintf("kb / %s / %s", title, m.user), width) + if shipped := m.shippedCount(); shipped > 0 { + header = fitLine(fmt.Sprintf("kb / %s / %s / ×%d shipped today", title, m.user, shipped), width) + } filterLine, filterHits := m.renderFilterBar(width) statuses := m.boardView.visibleStatuses() @@ -276,6 +279,8 @@ func (m Model) renderBoard() (string, []boardHit) { showMoveStatus := movePriority && m.move.status != "" if showMoveStatus { state = sanitizeTerminal(m.move.status) + } else if m.actionNotice && m.actionStatus != "" { + state = sanitizeTerminal(m.actionStatus) } else if m.loadErr != nil { state = "error: " + m.loadErr.Error() } else if m.pollErr != nil { @@ -295,7 +300,7 @@ 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) { + if showMoveStatus || m.actionNotice || (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 } @@ -384,6 +389,7 @@ func (m Model) renderFilterBar(width int) (string, []boardHit) { func settingsBoardFooter(state, cancelled string, editorEnabled, adrEnabled bool, width int) string { candidates := [][]string{ + {"s settings", "t/x/r/D actions", "j/k cards", "h/l/tab columns", "c cancelled:" + cancelled, "q quit"}, {"s settings", "j/k cards", "h/l/tab columns", "1-4 jump", "c cancelled:" + cancelled, "q quit"}, {"s settings", "j/k cards", "h/l/tab columns", "c cancelled:" + cancelled, "q quit"}, {"s settings", "j/k cards", "h/l/tab columns", "q quit"}, @@ -396,6 +402,7 @@ func settingsBoardFooter(state, cancelled string, editorEnabled, adrEnabled bool } if editorEnabled { candidates = append([][]string{ + {"s settings", "n new", "e edit", "t/x/r/D actions", "j/k cards", "h/l/tab columns", "c cancelled:" + cancelled, "q quit"}, {"s settings", "n new", "e edit", "j/k cards", "h/l/tab columns", "1-4 jump", "c cancelled:" + cancelled, "q quit"}, {"s settings", "n new", "e edit", "j/k cards", "h/l/tab columns", "c cancelled:" + cancelled, "q quit"}, {"s settings", "n new", "e edit", "j/k cards", "c cancelled:" + cancelled, "q quit"}, diff --git a/internal/tui/board_view_test.go b/internal/tui/board_view_test.go index a83ce20..337d541 100644 --- a/internal/tui/board_view_test.go +++ b/internal/tui/board_view_test.go @@ -300,6 +300,10 @@ func TestBoardFooterPrioritizesProductionSettingsActions(t *testing.T) { want []string notWant []string }{ + { + width: 120, + want: []string{"s settings", "t/x/r/D actions", "j/k cards", "h/l/tab columns", "c cancelled:off", "q quit"}, + }, { width: 80, want: []string{"s settings", "j/k cards", "h/l/tab columns", "c cancelled:off", "q quit"}, diff --git a/internal/tui/model.go b/internal/tui/model.go index 649ccb0..abf6843 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -52,35 +52,41 @@ type pollTickMsg struct{} // dimensions, and all message routing; commands perform IO and only return // messages, so Update remains deterministic. type Model struct { - store boardReader - moveStore taskMoveStore - watcher dataVersionReader - user string - board board.Board - boardView boardViewState - filter boardFilterState - detail carddetail.Model - editor cardeditor.Model - adr adrsplit.Model - selectAfterLoad string - width int - height int - loading bool - reloadPending bool - loadErr error - pollErr error - dataVersion int64 - haveVersion bool - stopped bool - readContext context.Context - now func() time.Time - savePreferences func(tuiPreferences) error - preferenceErr error - prefSaving bool - prefPending *tuiPreferences - settings *settingsModel - settingsNew func() *settingsModel - move cardMoveState + store boardReader + moveStore taskMoveStore + actionStore taskActionStore + watcher dataVersionReader + user string + board board.Board + boardView boardViewState + filter boardFilterState + detail carddetail.Model + editor cardeditor.Model + adr adrsplit.Model + selectAfterLoad string + width int + height int + loading bool + reloadPending bool + loadErr error + pollErr error + dataVersion int64 + haveVersion bool + stopped bool + readContext context.Context + now func() time.Time + savePreferences func(tuiPreferences) error + preferenceErr error + prefSaving bool + prefPending *tuiPreferences + settings *settingsModel + settingsNew func() *settingsModel + move cardMoveState + action taskActionState + actionStatus string + actionStatusError bool + actionNotice bool + shipped shippedRecord } func (m *Model) configureAI(runner *ai.Runner, ctx context.Context) { @@ -106,9 +112,11 @@ func newModel( detailReader, _ := store.(carddetail.Reader) editorStore, _ := store.(cardeditor.Store) moveStore, _ := store.(taskMoveStore) + actionStore, _ := store.(taskActionStore) return Model{ store: store, moveStore: moveStore, + actionStore: actionStore, watcher: watcher, user: user, board: board.Board{Title: "Board"}, @@ -120,6 +128,7 @@ func newModel( loading: watcher == nil, readContext: ctx, now: time.Now, + action: newTaskActionState(), } } @@ -142,9 +151,25 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if m.stopped { return m, nil } + if isTaskActionMessage(message) { + return m, m.updateTaskAction(message) + } + if m.action.open() || m.action.busy { + switch message.(type) { + case tea.KeyPressMsg: + return m, m.updateTaskAction(message) + case boardCardClickedMsg, boardColumnClickedMsg, + filterTextClickedMsg, filterLabelClickedMsg, filterClearClickedMsg, + boardPointerDownMsg, boardPointerMoveMsg, boardPointerUpMsg: + return m, nil + } + } if carddetail.IsMutationMessage(message) { return m, m.updateDetail(message) } + if m.actionNotice && isBoardUserInput(message) { + m.actionNotice = false + } if m.move.lifted == nil && m.move.notice && isBoardUserInput(message) { m.move.notice = false } @@ -201,30 +226,27 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if m.detail.IsOpen() { switch msg := message.(type) { case tea.KeyPressMsg: + if m.detail.OwnsInput() { + return m, m.updateDetail(message) + } switch msg.String() { case "esc": - if m.detail.OwnsInput() { - return m, m.updateDetail(message) - } m.detail.Close() return m, nil case "e": - if m.detail.OwnsInput() { - return m, m.updateDetail(message) - } if m.editor.Enabled() { if task, ok := m.taskByID(m.detail.TaskID()); ok { return m, m.editor.OpenEdit(task) } } return m, nil - case "q": - if m.detail.OwnsInput() { - return m, m.updateDetail(message) + case "t", "x", "r", "D", "delete", "backspace": + if handled, command := m.handleSelectedTaskAction(msg.String()); handled { + return m, command } - // Preserve the root quit contract while idle detail is open. - case "ctrl+c": - // The explicit terminal interrupt remains global. + return m, nil + case "q", "ctrl+c": + // Preserve root quit while idle detail is open. default: return m, m.updateDetail(message) } @@ -283,6 +305,9 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if handled, command := m.handleFilterKey(msg); handled { return m, command } + if handled, command := m.handleSelectedTaskAction(msg.String()); handled { + return m, command + } switch msg.String() { case "q": m.stopped = true @@ -541,7 +566,7 @@ 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 { + if m.writeBusy() { m.reloadPending = true return nil } @@ -555,7 +580,7 @@ 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 { + if m.writeBusy() { m.reloadPending = true return nil } @@ -569,6 +594,8 @@ func (m *Model) requireFreshBoard() tea.Cmd { return m.startBoardLoad() } +func (m Model) writeBusy() bool { return m.move.saving || m.action.busy } + func (m *Model) cancelCardMove(reason string) { if m.move.lifted == nil { return @@ -624,10 +651,14 @@ func (m Model) View() tea.View { content = m.editor.Overlay(content, m.width, m.height) hits = nil } + if m.action.open() { + content = m.taskActionOverlay(content) + hits = nil + } view := tea.NewView(content) view.AltScreen = true view.MouseMode = tea.MouseModeCellMotion - if m.settings == nil && !m.editor.IsOpen() && !m.adr.IsOpen() { + if m.settings == nil && !m.editor.IsOpen() && !m.adr.IsOpen() && !m.action.open() { pointerActive := m.move.lifted != nil && m.move.lifted.fromMouse view.OnMouse = boardMouseHandler(hits, pointerActive) } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 304343a..e250969 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -845,6 +845,165 @@ func TestADRSplitRootRoutingMoveCancellationAndShutdown(t *testing.T) { } } +func TestTaskActionsRespectDetailEditorAndADRInputOwnership(t *testing.T) { + st := newSettingsTestStore(t) + _, err := st.AddTask("u", board.Task{ + Title: "routing", Status: board.StatusTodo, Prio: 3, + Checks: []board.Check{{Text: "check"}}, + }) + if err != nil { + t.Fatal(err) + } + m := NewModel(st, nil, "u") + m.configureAI(ai.NewRunner(st, "", nil, nil), context.Background()) + completeBoardLoad(t, &m, m.Init()) + + loadLabels := updateTestModel(t, &m, tea.KeyPressMsg{Code: 'e'}) + if loadLabels != nil { + updateTestModel(t, &m, loadLabels()) + } + for _, key := range []tea.KeyPressMsg{ + {Code: 'x', Text: "x"}, {Code: 'r', Text: "r"}, {Code: 't', Text: "t"}, {Code: tea.KeyDelete}, + } { + updateTestModel(t, &m, key) + } + if m.action.open() || !m.editor.IsOpen() { + t.Fatalf("editor input leaked to task action: action=%#v editor=%v", m.action, m.editor.IsOpen()) + } + + adrModel := NewModel(st, nil, "u") + adrModel.configureAI(ai.NewRunner(st, "", nil, nil), context.Background()) + completeBoardLoad(t, &adrModel, adrModel.Init()) + updateTestModel(t, &adrModel, tea.KeyPressMsg{Code: 'a'}) + if !adrModel.adr.IsOpen() { + t.Fatal("ADR input did not open") + } + for _, key := range []tea.KeyPressMsg{ + {Code: 'x', Text: "x"}, {Code: 'r', Text: "r"}, {Code: 't', Text: "t"}, {Code: tea.KeyDelete}, + } { + updateTestModel(t, &adrModel, key) + } + if adrModel.action.open() || !adrModel.adr.IsOpen() { + t.Fatalf("ADR input leaked to task action: action=%#v adr=%v", adrModel.action, adrModel.adr.IsOpen()) + } + + actionModel := NewModel(st, nil, "u") + actionModel.configureAI(ai.NewRunner(st, "", nil, nil), context.Background()) + completeBoardLoad(t, &actionModel, actionModel.Init()) + drainModelCommands(t, &actionModel, updateTestModel(t, &actionModel, tea.KeyPressMsg{Code: tea.KeyEnter})) + updateTestModel(t, &actionModel, tea.KeyPressMsg{Code: 'x', Text: "x"}) + if actionModel.action.mode != taskActionKill || !actionModel.detail.IsOpen() { + t.Fatalf("idle detail x route = action:%#v detail:%v", actionModel.action, actionModel.detail.IsOpen()) + } + updateTestModel(t, &actionModel, tea.KeyPressMsg{Code: 'a', Text: "a"}) + if actionModel.adr.IsOpen() || actionModel.action.reason.Value() != "a" { + t.Fatalf("task dialog lost priority: adr=%v reason=%q", actionModel.adr.IsOpen(), actionModel.action.reason.Value()) + } + updateTestModel(t, &actionModel, tea.KeyPressMsg{Code: tea.KeyEscape}) + if actionModel.action.open() || !actionModel.detail.IsOpen() { + t.Fatalf("task dialog close disturbed detail: action=%#v detail=%v", actionModel.action, actionModel.detail.IsOpen()) + } +} + +func TestDelayedAutoShipDoesNotStealNestedDetailInput(t *testing.T) { + setup := func(t *testing.T) (Model, *store.Store, board.Task, tea.Cmd) { + t.Helper() + st := newSettingsTestStore(t) + task, err := st.AddTask("u", board.Task{ + Title: "delayed", Status: board.StatusTodo, Prio: 3, + Checks: []board.Check{{Text: "last"}}, + }) + if err != nil { + t.Fatal(err) + } + m := NewModel(st, nil, "u") + completeBoardLoad(t, &m, m.Init()) + updateTestModel(t, &m, tea.KeyPressMsg{Code: 't', Text: "t"}) + write := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + timer := finishActionCommand(t, &m, write) + if timer == nil { + t.Fatal("last checklist tick did not schedule auto-ship") + } + return m, st, task, timer + } + + t.Run("timer check", func(t *testing.T) { + m, st, task, timer := setup(t) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEscape}) + drainModelCommands(t, &m, updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter})) + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'c', Text: "c"}) + if !m.detail.OwnsInput() { + t.Fatal("comment input did not own focus") + } + if command := updateTestModel(t, &m, timer()); command != nil { + t.Fatalf("auto-ship read started behind detail input: %v", command) + } + current, err := st.Task("u", task.ID) + if err != nil || current.Status != board.StatusTodo || m.action.open() || !m.detail.OwnsInput() { + t.Fatalf("timer stole nested input: task=%+v err=%v action=%#v owned=%v", current, err, m.action, m.detail.OwnsInput()) + } + }) + + t.Run("ready result", func(t *testing.T) { + m, st, task, timer := setup(t) + read := updateTestModel(t, &m, timer()) + if read == nil { + t.Fatal("eligible timer did not start canonical read") + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEscape}) + drainModelCommands(t, &m, updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter})) + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'c', Text: "c"}) + if command := updateTestModel(t, &m, read()); command != nil { + t.Fatalf("auto-ship write started behind detail input: %v", command) + } + current, err := st.Task("u", task.ID) + if err != nil || current.Status != board.StatusTodo || m.action.open() || !m.detail.OwnsInput() { + t.Fatalf("ready result stole nested input: task=%+v err=%v action=%#v owned=%v", current, err, m.action, m.detail.OwnsInput()) + } + }) +} + +func TestAutoShipInputOwnershipMatrix(t *testing.T) { + st := newSettingsTestStore(t) + task, err := st.AddTask("u", board.Task{Title: "owner", Status: board.StatusTodo, Prio: 3}) + if err != nil { + t.Fatal(err) + } + newRoot := func() Model { + m := NewModel(st, nil, "u") + completeBoardLoad(t, &m, m.Init()) + return m + } + for _, test := range []struct { + name string + own func(*Model) + }{ + {name: "settings", own: func(m *Model) { m.settings = &settingsModel{} }}, + {name: "editor", own: func(m *Model) { _ = m.editor.OpenAdd(board.StatusTodo) }}, + {name: "ADR", own: func(m *Model) { + m.configureAI(ai.NewRunner(st, "", nil, nil), context.Background()) + _ = m.adr.Open() + }}, + {name: "detail", own: func(m *Model) { _ = m.detail.Open(task) }}, + {name: "filter", own: func(m *Model) { _ = m.filter.focusText() }}, + {name: "move preview", own: func(m *Model) { + m.move.beginVisible(m.board, m.board, task, m.boardView.visibleStatuses(), false) + }}, + {name: "move write", own: func(m *Model) { m.move.saving = true }}, + } { + t.Run(test.name, func(t *testing.T) { + m := newRoot() + if m.autoShipInputOwned() { + t.Fatal("idle root unexpectedly owned auto-ship input") + } + test.own(&m) + if !m.autoShipInputOwned() { + t.Fatal("active owner did not block delayed auto-ship") + } + }) + } +} + func TestEmptyBoardGolden(t *testing.T) { m := NewModel(stubBoardReader{board: board.Board{Title: "Board"}}, nil, "default") // Start from a loaded snapshot so the golden records the frame, not a diff --git a/internal/tui/move_model_test.go b/internal/tui/move_model_test.go index 3ca4e20..90bdaf8 100644 --- a/internal/tui/move_model_test.go +++ b/internal/tui/move_model_test.go @@ -191,7 +191,7 @@ func (s *moveTestStore) UpdateAndMoveTask( return board.Task{}, errors.New("task not found") } -func TestDropToDoneHonorsCompletionGuard(t *testing.T) { +func TestDropToDoneOpensShipPromptForBlockedCard(t *testing.T) { current := moveFixture() current.Tasks[0].Blocked = true current.Tasks[0].Seq = 1 @@ -202,12 +202,11 @@ func TestDropToDoneHonorsCompletionGuard(t *testing.T) { updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyRight}) updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyRight}) drop := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) - if drop == nil { - t.Fatal("Done drop did not start") + if drop != nil { + t.Fatalf("blocked Done drop wrote before confirmation: %v", drop) } - updateTestModel(t, &m, drop()) - if !m.move.statusError || !strings.Contains(m.move.status, "flagged blocked") { - t.Fatalf("Done drop bypassed local guard: error=%v status=%q", m.move.statusError, m.move.status) + if m.action.mode != taskActionShip || !m.action.warning.blocked { + t.Fatalf("blocked Done drop action = %#v", m.action) } if task := taskNamed(t, m.board, "A"); task.Status != board.StatusTodo { t.Fatalf("refused task moved to %s", task.Status) diff --git a/internal/tui/move_store.go b/internal/tui/move_store.go index 5ec12f2..7301314 100644 --- a/internal/tui/move_store.go +++ b/internal/tui/move_store.go @@ -16,6 +16,8 @@ type taskMoveStore interface { type cardMoveStoredMsg struct { taskID string title string + from board.Status + to board.Status board board.Board writeErr error reloadErr error @@ -36,18 +38,30 @@ func (m *Model) startCardDrop() tea.Cmd { m.move.statusError = true return nil } + from := lift.target + if task, found := boardTaskByID(lift.canonical, lift.taskID); found { + from = task.Status + if lift.target == board.StatusDone && from != board.StatusDone { + if warningForShip(task).needed() { + return m.openShipPrompt(task, index) + } + } + } m.move.saving = true m.move.announcePosition("Dropping") moveStore := m.moveStore user := m.user - guard := cardCompletionGuard(lift.target) + var guard func(board.Task) error + if lift.target == board.StatusDone && from != board.StatusDone { + guard = cardCompletionGuard(lift.target) + } return func() tea.Msg { _, writeErr := moveStore.UpdateAndMoveTask( user, lift.taskID, store.TaskPatch{}, &lift.target, &index, guard, ) canonical, reloadErr := moveStore.Board(user) return cardMoveStoredMsg{ - taskID: lift.taskID, title: lift.title, + taskID: lift.taskID, title: lift.title, from: from, to: lift.target, board: canonical, writeErr: writeErr, reloadErr: reloadErr, } @@ -110,12 +124,24 @@ func (m *Model) finishCardDrop(msg cardMoveStoredMsg) tea.Cmd { m.move.status = fmt.Sprintf("Dropped %s, %s, position %d of %d", canonicalTask.Title, statusLabelTitle(canonicalTask.Status), position+1, count) } + preference := tea.Cmd(nil) + if msg.writeErr == nil { + switch { + case msg.from != board.StatusDone && msg.to == board.StatusDone: + m.recordShipped(msg.taskID) + m.move.status = "Shipped " + msg.title + preference = m.queuePreferences() + case msg.from == board.StatusDone && msg.to != board.StatusDone: + m.unrecordShipped(msg.taskID) + preference = m.queuePreferences() + } + } if m.reloadPending || msg.reloadErr != nil { m.reloadPending = false - return m.startBoardLoad() + return batchCommands(preference, m.startBoardLoad()) } - return nil + return preference } func boardTaskByID(current board.Board, taskID string) (board.Task, bool) { diff --git a/internal/tui/preferences.go b/internal/tui/preferences.go index b4b4ef7..49de524 100644 --- a/internal/tui/preferences.go +++ b/internal/tui/preferences.go @@ -14,8 +14,9 @@ import ( ) type tuiPreferences struct { - ShowCancelled bool `json:"show_cancelled"` - Filter boardFilter `json:"filter,omitempty"` + ShowCancelled bool `json:"show_cancelled"` + Filter boardFilter `json:"filter,omitempty"` + Shipped shippedRecord `json:"shipped,omitempty"` } type preferenceSavedMsg struct { @@ -62,6 +63,8 @@ func (m *Model) restorePreferences(path string) { } m.boardView.showCancelled = preferences.ShowCancelled m.filter.restore(preferences.Filter) + m.shipped = preferences.Shipped + m.normalizeShipped() } type preferenceTempFile interface { @@ -139,9 +142,11 @@ func saveTUIPreferencesWithOps(path string, preferences tuiPreferences, ops pref } func (m *Model) preferences() tuiPreferences { + m.normalizeShipped() return tuiPreferences{ ShowCancelled: m.boardView.showCancelled, Filter: m.filter.value(), + Shipped: m.shipped, } } @@ -180,7 +185,9 @@ func (m *Model) finishPreferences(message preferenceSavedMsg) tea.Cmd { } func preferencesEqual(left, right tuiPreferences) bool { - if left.ShowCancelled != right.ShowCancelled || left.Filter.Text != right.Filter.Text || len(left.Filter.Tags) != len(right.Filter.Tags) { + if left.ShowCancelled != right.ShowCancelled || left.Filter.Text != right.Filter.Text || + left.Shipped.Date != right.Shipped.Date || len(left.Filter.Tags) != len(right.Filter.Tags) || + len(left.Shipped.IDs) != len(right.Shipped.IDs) { return false } for i := range left.Filter.Tags { @@ -188,5 +195,10 @@ func preferencesEqual(left, right tuiPreferences) bool { return false } } + for i := range left.Shipped.IDs { + if left.Shipped.IDs[i] != right.Shipped.IDs[i] { + return false + } + } return true } diff --git a/internal/tui/ship_actions.go b/internal/tui/ship_actions.go new file mode 100644 index 0000000..5c085f0 --- /dev/null +++ b/internal/tui/ship_actions.go @@ -0,0 +1,858 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/store" +) + +const autoShipDelay = 350 * time.Millisecond + +type taskActionStore interface { + boardReader + UpdateAndMoveTask(string, string, store.TaskPatch, *board.Status, *int, func(board.Task) error) (board.Task, error) + UpdateAndMoveTaskIfFieldsMatch(string, string, store.TaskPatch, store.TaskPatch, *board.Status, *int, func(board.Task) error) (board.Task, error) + UpdateTaskIfFieldsMatch(string, string, store.TaskPatch, store.TaskPatch) (board.Task, error) + CancelTask(string, string, *string) (board.Task, error) + DeleteCancelledTask(string, string) (board.Task, error) +} + +type taskActionMode uint8 + +const ( + taskActionClosed taskActionMode = iota + taskActionShip + taskActionKill + taskActionChecklist + taskActionPurge +) + +type shipWarning struct { + open, total int + blocked bool +} + +func warningForShip(task board.Task) shipWarning { + warning := shipWarning{total: len(task.Checks), blocked: task.Blocked} + for _, check := range task.Checks { + if !check.Done { + warning.open++ + } + } + return warning +} + +func (w shipWarning) needed() bool { return w.open > 0 || w.blocked } + +type taskActionState struct { + mode taskActionMode + task board.Task + target board.Status + index int + warning shipWarning + choice int + checkIndex int + reason textinput.Model + armed bool + busy bool + errorText string +} + +func newTaskActionState() taskActionState { + reason := textinput.New() + reason.Prompt = "" + reason.Placeholder = "e.g. superseded by the SSO work" + reason.CharLimit = 500 + reason.SetWidth(54) + return taskActionState{reason: reason} +} + +func (s taskActionState) open() bool { return s.mode != taskActionClosed } + +func (s *taskActionState) close() { + *s = newTaskActionState() +} + +func (m *Model) openShipPrompt(task board.Task, index int) tea.Cmd { + m.cancelMoveForAction() + m.action = newTaskActionState() + m.action.mode = taskActionShip + m.action.task = task + m.action.target = board.StatusDone + m.action.index = index + m.action.warning = warningForShip(task) + m.action.choice = 0 + return nil +} + +func (m *Model) openKillPrompt(task board.Task) tea.Cmd { + if task.Status == board.StatusCancelled { + m.setActionStatus("Card is already Cancelled", true) + return nil + } + m.cancelMoveForAction() + m.action = newTaskActionState() + m.action.mode = taskActionKill + m.action.task = task + m.action.target = board.StatusCancelled + m.action.choice = 2 + return m.action.reason.Focus() +} + +func (m *Model) openChecklist(task board.Task) tea.Cmd { + if len(task.Checks) == 0 { + m.setActionStatus("Checklist is empty", true) + return nil + } + m.cancelMoveForAction() + m.action = newTaskActionState() + m.action.mode = taskActionChecklist + m.action.task = task + return nil +} + +func (m *Model) openPurgePrompt(task board.Task) tea.Cmd { + if task.Status != board.StatusCancelled { + m.setActionStatus("Permanent delete is available only in Cancelled", true) + return nil + } + m.cancelMoveForAction() + m.action = newTaskActionState() + m.action.mode = taskActionPurge + m.action.task = task + return nil +} + +func (m *Model) cancelMoveForAction() { + if m.move.lifted == nil || m.move.saving { + return + } + lift := m.move.lifted + m.board = cloneBoard(lift.canonical) + m.boardView.focusTask(m.filteredBoard(), lift.taskID) + m.move.lifted = nil + m.move.status = "" + m.move.statusError = false + m.move.notice = false +} + +func (m *Model) setActionStatus(status string, isError bool) { + m.actionStatus = status + m.actionStatusError = isError + m.actionNotice = status != "" +} + +func (m *Model) handleSelectedTaskAction(key string) (bool, tea.Cmd) { + if key != "t" && key != "x" && key != "r" && key != "D" && key != "delete" && key != "backspace" { + return false, nil + } + task, ok := m.actionTask() + if !ok { + return true, nil + } + switch key { + case "t": + return true, m.openChecklist(task) + case "x": + return true, m.openKillPrompt(task) + case "r": + if task.Status != board.StatusCancelled { + m.setActionStatus("Restore is available only in Cancelled", true) + return true, nil + } + return true, m.startRestore(task) + default: + return true, m.openPurgePrompt(task) + } +} + +func (m Model) actionTask() (board.Task, bool) { + if m.detail.IsOpen() { + return m.taskByID(m.detail.TaskID()) + } + return m.selectedTask() +} + +func isTaskActionMessage(message tea.Msg) bool { + switch message.(type) { + case taskActionStoredMsg, checklistStoredMsg, autoShipCheckMsg, autoShipReadyMsg: + return true + default: + return false + } +} + +func (m *Model) updateTaskAction(message tea.Msg) tea.Cmd { + switch msg := message.(type) { + case taskActionStoredMsg: + return m.finishTaskAction(msg) + case checklistStoredMsg: + return m.finishChecklistWrite(msg) + case autoShipCheckMsg: + if m.action.busy || m.autoShipInputOwned() { + return nil + } + return m.readAutoShipCandidate(msg.taskID) + case autoShipReadyMsg: + return m.finishAutoShipRead(msg) + case tea.KeyPressMsg: + if !m.action.open() { + return nil + } + return m.updateTaskActionKey(msg) + } + return nil +} + +func (m *Model) updateTaskActionKey(msg tea.KeyPressMsg) tea.Cmd { + if msg.String() == "ctrl+c" || (m.action.busy && msg.String() == "q") { + m.stopped = true + m.reloadPending = false + return tea.Quit + } + if m.action.busy { + return nil + } + if msg.String() == "esc" { + m.action.close() + return nil + } + m.action.errorText = "" + switch m.action.mode { + case taskActionShip: + return m.updateShipPrompt(msg.String()) + case taskActionKill: + return m.updateKillPrompt(msg) + case taskActionChecklist: + return m.updateChecklistPrompt(msg.String()) + case taskActionPurge: + if msg.String() == "enter" || msg.String() == "space" { + if !m.action.armed { + m.action.armed = true + return nil + } + return m.startPurge() + } + } + return nil +} + +func (m *Model) updateShipPrompt(key string) tea.Cmd { + count := 2 + if m.action.warning.open > 0 { + count = 3 + } + switch key { + case "left", "h", "shift+tab": + m.action.choice = (m.action.choice - 1 + count) % count + case "right", "l", "tab": + m.action.choice = (m.action.choice + 1) % count + case "enter", "space": + choice := m.action.choice + if choice == 0 { + m.action.close() + return nil + } + if m.action.warning.open > 0 && choice == 1 { + return m.startShip(true) + } + return m.startShip(false) + } + return nil +} + +func (m *Model) updateKillPrompt(msg tea.KeyPressMsg) tea.Cmd { + switch msg.String() { + case "tab": + m.action.choice = (m.action.choice + 1) % 3 + return nil + case "shift+tab": + m.action.choice = (m.action.choice + 2) % 3 + return nil + case "enter": + switch m.action.choice { + case 0: + m.action.close() + return nil + case 1: + return m.startKill("") + default: + reason := strings.TrimSpace(m.action.reason.Value()) + if reason == "" { + m.action.errorText = "Enter a reason or choose Kill without reason" + return nil + } + return m.startKill(reason) + } + } + var command tea.Cmd + m.action.reason, command = m.action.reason.Update(msg) + return command +} + +func (m *Model) updateChecklistPrompt(key string) tea.Cmd { + count := len(m.action.task.Checks) + if count == 0 { + m.action.close() + return nil + } + switch key { + case "up", "k": + m.action.checkIndex = (m.action.checkIndex - 1 + count) % count + case "down", "j": + m.action.checkIndex = (m.action.checkIndex + 1) % count + case "enter", "space": + return m.startChecklistToggle() + } + return nil +} + +func completionGuard(task board.Task) error { + if warning := store.CompletionWarning(task); warning != "" { + return store.NewCompletionBlockedError(warning, task.ID, task.Title) + } + return nil +} + +func autoShipGuard(task board.Task) error { + if !shippableStatus(task.Status) { + return fmt.Errorf("auto-ship refused because card moved to %s", task.Status) + } + return completionGuard(task) +} + +type taskActionKind uint8 + +const ( + actionShip taskActionKind = iota + actionKill + actionRestore + actionPurge +) + +type taskActionStoredMsg struct { + kind taskActionKind + taskID, title string + from, to board.Status + board board.Board + writeErr error + reloadErr error +} + +func (m *Model) startShip(tickAll bool) tea.Cmd { + if m.actionStore == nil { + m.action.errorText = "Store does not support shipping" + return nil + } + action := m.action + m.action.busy = true + checks := append([]board.Check(nil), action.task.Checks...) + patch := store.TaskPatch{} + if tickAll { + for i := range checks { + checks[i].Done = true + } + patch.Checks = &checks + } + backend, user := m.actionStore, m.user + return func() tea.Msg { + var writeErr error + if tickAll { + _, writeErr = backend.UpdateAndMoveTaskIfFieldsMatch(user, action.task.ID, + store.TaskPatch{Checks: &action.task.Checks}, patch, &action.target, &action.index, nil) + } else { + _, writeErr = backend.UpdateAndMoveTask(user, action.task.ID, patch, &action.target, &action.index, nil) + } + canonical, reloadErr := backend.Board(user) + return taskActionStoredMsg{kind: actionShip, taskID: action.task.ID, title: action.task.Title, + from: action.task.Status, to: action.target, board: canonical, writeErr: writeErr, reloadErr: reloadErr} + } +} + +func (m *Model) startKill(reason string) tea.Cmd { + if m.actionStore == nil { + m.action.errorText = "Store does not support deleting cards" + return nil + } + action := m.action + m.action.busy = true + backend, user := m.actionStore, m.user + return func() tea.Msg { + var killReason *string + if reason != "" { + killReason = &reason + } + _, writeErr := backend.CancelTask(user, action.task.ID, killReason) + canonical, reloadErr := backend.Board(user) + return taskActionStoredMsg{kind: actionKill, taskID: action.task.ID, title: action.task.Title, + from: action.task.Status, to: action.target, board: canonical, + writeErr: writeErr, reloadErr: reloadErr} + } +} + +func (m *Model) startRestore(task board.Task) tea.Cmd { + if m.actionStore == nil { + m.setActionStatus("Restore failed: store does not support card actions", true) + return nil + } + m.action = newTaskActionState() + m.action.busy = true + backend, user := m.actionStore, m.user + target := board.StatusTodo + return func() tea.Msg { + _, writeErr := backend.UpdateAndMoveTask(user, task.ID, store.TaskPatch{}, &target, nil, nil) + canonical, reloadErr := backend.Board(user) + return taskActionStoredMsg{kind: actionRestore, taskID: task.ID, title: task.Title, + from: task.Status, to: target, board: canonical, writeErr: writeErr, reloadErr: reloadErr} + } +} + +func (m *Model) startPurge() tea.Cmd { + if m.actionStore == nil { + m.action.errorText = "Store does not support permanent delete" + return nil + } + action := m.action + m.action.busy = true + backend, user := m.actionStore, m.user + return func() tea.Msg { + _, writeErr := backend.DeleteCancelledTask(user, action.task.ID) + canonical, reloadErr := backend.Board(user) + return taskActionStoredMsg{kind: actionPurge, taskID: action.task.ID, title: action.task.Title, + from: action.task.Status, board: canonical, writeErr: writeErr, reloadErr: reloadErr} + } +} + +type checklistStoredMsg struct { + taskID string + task board.Task + board board.Board + writeErr error + reloadErr error + autoShip bool +} + +func (m *Model) startChecklistToggle() tea.Cmd { + if m.actionStore == nil { + m.action.errorText = "Store does not support checklist writes" + return nil + } + action := m.action + if action.checkIndex < 0 || action.checkIndex >= len(action.task.Checks) { + return nil + } + before := append([]board.Check(nil), action.task.Checks...) + after := append([]board.Check(nil), before...) + after[action.checkIndex].Done = !after[action.checkIndex].Done + turningOn := after[action.checkIndex].Done + autoShip := turningOn && shippableStatus(action.task.Status) && checksComplete(after) + m.action.busy = true + backend, user := m.actionStore, m.user + return func() tea.Msg { + updated, writeErr := backend.UpdateTaskIfFieldsMatch(user, action.task.ID, + store.TaskPatch{Checks: &before}, store.TaskPatch{Checks: &after}) + canonical, reloadErr := backend.Board(user) + return checklistStoredMsg{taskID: action.task.ID, task: updated, board: canonical, + writeErr: writeErr, reloadErr: reloadErr, autoShip: autoShip && writeErr == nil} + } +} + +type autoShipCheckMsg struct{ taskID string } + +type autoShipReadyMsg struct { + task board.Task + found bool + err error +} + +func (m Model) scheduleAutoShip(taskID string) tea.Cmd { + return tea.Tick(autoShipDelay, func(time.Time) tea.Msg { return autoShipCheckMsg{taskID: taskID} }) +} + +func (m Model) readAutoShipCandidate(taskID string) tea.Cmd { + if m.actionStore == nil { + return nil + } + backend, user := m.actionStore, m.user + return func() tea.Msg { + current, err := backend.Board(user) + if err != nil { + return autoShipReadyMsg{err: err} + } + task, found := boardTaskByID(current, taskID) + return autoShipReadyMsg{task: task, found: found} + } +} + +func (m *Model) finishAutoShipRead(msg autoShipReadyMsg) tea.Cmd { + if m.action.busy || m.autoShipInputOwned() { + return nil + } + if msg.err != nil { + m.setActionStatus("Auto-ship check failed: "+msg.err.Error(), true) + return nil + } + if !msg.found || !shippableStatus(msg.task.Status) || !checksComplete(msg.task.Checks) { + return nil + } + if m.action.open() && (m.action.mode != taskActionChecklist || m.action.task.ID != msg.task.ID) { + return nil + } + if warningForShip(msg.task).needed() { + index := taskCount(m.board, board.StatusDone) + return m.openShipPrompt(msg.task, index) + } + m.action = newTaskActionState() + m.action.mode = taskActionClosed + m.action.task = msg.task + m.action.target = board.StatusDone + m.action.index = taskCount(m.board, board.StatusDone) + return m.startGuardedAutoShip() +} + +func (m Model) autoShipInputOwned() bool { + detailOwns := m.detail.IsOpen() + if detailOwns && m.action.mode == taskActionChecklist && m.action.task.ID == m.detail.TaskID() && !m.detail.OwnsInput() { + detailOwns = false + } + return m.settings != nil || m.editor.IsOpen() || m.adr.IsOpen() || + m.filter.focus != filterUnfocused || m.move.lifted != nil || m.move.saving || + detailOwns +} + +func (m *Model) startGuardedAutoShip() tea.Cmd { + action := m.action + m.action.busy = true + backend, user := m.actionStore, m.user + return func() tea.Msg { + _, writeErr := backend.UpdateAndMoveTask(user, action.task.ID, store.TaskPatch{}, + &action.target, &action.index, autoShipGuard) + canonical, reloadErr := backend.Board(user) + return taskActionStoredMsg{kind: actionShip, taskID: action.task.ID, title: action.task.Title, + from: action.task.Status, to: action.target, board: canonical, writeErr: writeErr, reloadErr: reloadErr} + } +} + +func shippableStatus(status board.Status) bool { + return status == board.StatusTodo || status == board.StatusDoing +} + +func checksComplete(checks []board.Check) bool { + if len(checks) == 0 { + return false + } + for _, check := range checks { + if !check.Done { + return false + } + } + return true +} + +func (m *Model) finishChecklistWrite(msg checklistStoredMsg) tea.Cmd { + m.action.busy = false + var adopt tea.Cmd + if msg.writeErr != nil { + m.action.errorText = "Checklist update failed: " + msg.writeErr.Error() + if msg.reloadErr == nil { + adopt = m.adoptActionBoard(msg.board, msg.taskID) + } + return batchCommands(adopt, m.reloadAfterActionFailure(msg.reloadErr)) + } + if msg.reloadErr == nil { + adopt = m.adoptActionBoard(msg.board, msg.taskID) + if task, found := boardTaskByID(msg.board, msg.taskID); found { + m.action.task = task + } else { + m.action.close() + } + } else { + m.action.task = msg.task + } + m.setActionStatus("Checklist updated: "+msg.task.Title, false) + commands := []tea.Cmd{adopt, m.reloadAfterActionFailure(msg.reloadErr)} + if msg.autoShip { + commands = append(commands, m.scheduleAutoShip(msg.taskID)) + } + return batchCommands(commands...) +} + +func (m *Model) finishTaskAction(msg taskActionStoredMsg) tea.Cmd { + m.action.busy = false + var adopt tea.Cmd + if msg.writeErr != nil { + status := actionVerb(msg.kind) + " failed for " + msg.title + ": " + msg.writeErr.Error() + m.setActionStatus(status, true) + if m.action.open() { + m.action.errorText = status + } + if msg.reloadErr == nil { + adopt = m.adoptActionBoard(msg.board, msg.taskID) + } + return batchCommands(adopt, m.reloadAfterActionFailure(msg.reloadErr)) + } + + if msg.reloadErr == nil { + adopt = m.adoptActionBoard(msg.board, msg.taskID) + } + if msg.kind == actionShip && msg.from != board.StatusDone && msg.to == board.StatusDone { + m.recordShipped(msg.taskID) + m.setActionStatus("Shipped "+msg.title, false) + } else if msg.from == board.StatusDone && msg.to != board.StatusDone { + m.unrecordShipped(msg.taskID) + m.setActionStatus(actionSuccess(msg.kind, msg.title), false) + } else { + m.setActionStatus(actionSuccess(msg.kind, msg.title), false) + } + m.action.close() + var reload tea.Cmd + if msg.reloadErr != nil { + reload = m.reloadAfterActionFailure(msg.reloadErr) + } else if m.reloadPending { + reload = m.reloadAfterActionFailure(nil) + } + return batchCommands(adopt, m.queuePreferences(), reload) +} + +func actionVerb(kind taskActionKind) string { + switch kind { + case actionShip: + return "Ship" + case actionKill: + return "Cancel" + case actionRestore: + return "Restore" + default: + return "Permanent delete" + } +} + +func actionSuccess(kind taskActionKind, title string) string { + switch kind { + case actionKill: + return "Cancelled " + title + case actionRestore: + return "Restored " + title + " to To Do" + case actionPurge: + return "Permanently deleted " + title + default: + return "Shipped " + title + } +} + +func (m *Model) adoptActionBoard(current board.Board, taskID string) tea.Cmd { + previous := m.filteredBoard() + m.board = current + filtered := m.filteredBoard() + m.boardView.adoptBoard(previous, filtered) + if !m.boardView.focusTask(filtered, taskID) { + m.boardView.normalizeSelection(filtered) + } + if m.detail.IsOpen() { + if task, found := boardTaskByID(current, m.detail.TaskID()); found { + return m.detail.Refresh(task) + } else { + m.detail.Close() + } + } + return nil +} + +func (m *Model) reloadAfterActionFailure(reloadErr error) tea.Cmd { + if reloadErr == nil && !m.reloadPending { + return nil + } + if reloadErr != nil { + m.setActionStatus(m.actionStatus+"; canonical reload failed: "+reloadErr.Error(), true) + } + m.reloadPending = false + return m.startBoardLoad() +} + +type shippedRecord struct { + Date string `json:"date,omitempty"` + IDs []string `json:"ids,omitempty"` +} + +func (m *Model) normalizeShipped() { + today := m.now().Format("2006-01-02") + if m.shipped.Date == "" && len(m.shipped.IDs) == 0 { + return + } + if m.shipped.Date != today { + m.shipped = shippedRecord{Date: today} + return + } + seen := make(map[string]struct{}, len(m.shipped.IDs)) + source := append([]string(nil), m.shipped.IDs...) + ids := m.shipped.IDs[:0] + for _, id := range source { + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + m.shipped.IDs = ids +} + +func (m *Model) recordShipped(taskID string) { + m.normalizeShipped() + if m.shipped.Date == "" { + m.shipped.Date = m.now().Format("2006-01-02") + } + for _, id := range m.shipped.IDs { + if id == taskID { + return + } + } + m.shipped.IDs = append(m.shipped.IDs, taskID) +} + +func (m *Model) unrecordShipped(taskID string) { + m.normalizeShipped() + for index, id := range m.shipped.IDs { + if id == taskID { + m.shipped.IDs = append(m.shipped.IDs[:index], m.shipped.IDs[index+1:]...) + return + } + } +} + +func (m Model) shippedCount() int { + m.normalizeShipped() + return len(m.shipped.IDs) +} + +func (m Model) taskActionOverlay(background string) string { + if !m.action.open() { + return background + } + width, height := max(m.width, 1), max(m.height, 1) + frame := m.renderTaskAction(max(min(width-4, 72), 1)) + frame = fitActionFrame(frame, width, height) + paneWidth := min(ansi.StringWidth(strings.Split(frame, "\n")[0]), width) + paneHeight := min(len(strings.Split(frame, "\n")), height) + return lipgloss.NewCompositor( + lipgloss.NewLayer(background), + lipgloss.NewLayer(frame).X(max((width-paneWidth)/2, 0)).Y(max((height-paneHeight)/2, 0)).Z(3), + ).Render() +} + +func (m Model) renderTaskAction(width int) string { + width = max(width, 1) + if width < 5 { + lines := m.taskActionLines(1) + if len(lines) == 0 { + return "" + } + return ansi.Truncate(lines[0], width, "") + } + inner := max(width-4, 1) + lines := m.taskActionLines(inner) + for i := range lines { + lines[i] = "│ " + padLine(ansi.Truncate(lines[i], inner, ""), inner, " ") + " │" + } + return "┌" + strings.Repeat("─", width-2) + "┐\n" + strings.Join(lines, "\n") + + "\n└" + strings.Repeat("─", width-2) + "┘" +} + +func fitActionFrame(frame string, width, height int) string { + lines := strings.Split(frame, "\n") + if len(lines) > height { + lines = lines[:height] + } + for index := range lines { + lines[index] = ansi.Truncate(lines[index], width, "") + } + return strings.Join(lines, "\n") +} + +func (m Model) taskActionLines(width int) []string { + a := m.action + busy := "" + if a.busy { + busy = " (saving...)" + } + switch a.mode { + case taskActionShip: + lines := []string{"Move " + sanitizeTerminal(a.task.Title) + " to Done?" + busy} + if a.warning.open > 0 { + lines = append(lines, fmt.Sprintf("%d of %d checklist items are still open.", a.warning.open, a.warning.total)) + } + if a.warning.blocked { + lines = append(lines, "This card is flagged blocked.") + } + choices := []string{"Cancel"} + if a.warning.open > 0 { + choices = append(choices, "Tick everything") + } + choices = append(choices, "Ship anyway") + lines = append(lines, "", actionChoices(choices, a.choice), "Tab choose Enter confirm Esc cancel") + return appendActionError(lines, a.errorText) + case taskActionKill: + lines := []string{"Why reject " + sanitizeTerminal(a.task.Title) + "?" + busy, + "The card moves to Cancelled. The reason is optional.", + "Reason: " + settingsInputDisplay(a.reason, false, true, max(width-8, 1)), "", + actionChoices([]string{"Cancel", "Kill without reason", "Kill with reason"}, a.choice), + "Tab choose Enter confirm Esc cancel"} + return appendActionError(lines, a.errorText) + case taskActionChecklist: + lines := []string{"Checklist: " + sanitizeTerminal(a.task.Title) + busy} + for index, check := range a.task.Checks { + marker := " " + if index == a.checkIndex { + marker = "> " + } + box := "[ ]" + if check.Done { + box = "[x]" + } + lines = append(lines, marker+box+" "+sanitizeTerminal(check.Text)) + } + lines = append(lines, "", "j/k choose Space toggle Esc close") + return appendActionError(lines, a.errorText) + case taskActionPurge: + lines := []string{"Delete " + sanitizeTerminal(a.task.Title) + " permanently?" + busy, + "The card, comments, links, and kill reason are removed for good."} + if a.armed { + lines = append(lines, "", "ARMED - press Enter again to delete permanently") + } else { + lines = append(lines, "", "Press Enter to arm permanent delete") + } + lines = append(lines, "Esc cancel") + return appendActionError(lines, a.errorText) + default: + return nil + } +} + +func actionChoices(choices []string, selected int) string { + parts := make([]string, len(choices)) + for index, choice := range choices { + if index == selected { + parts[index] = ">[" + choice + "]<" + } else { + parts[index] = "[" + choice + "]" + } + } + return strings.Join(parts, " ") +} + +func appendActionError(lines []string, message string) []string { + if message == "" { + return lines + } + return append(lines, "", "error: "+sanitizeTerminal(message)) +} diff --git a/internal/tui/ship_actions_test.go b/internal/tui/ship_actions_test.go new file mode 100644 index 0000000..8f9cbb7 --- /dev/null +++ b/internal/tui/ship_actions_test.go @@ -0,0 +1,645 @@ +package tui + +import ( + "errors" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/store" +) + +func actionTestModel(t *testing.T, tasks ...board.Task) (Model, *store.Store, []board.Task) { + t.Helper() + backend := newSettingsTestStore(t) + created := make([]board.Task, len(tasks)) + for index, task := range tasks { + if task.Prio == 0 { + task.Prio = 3 + } + var err error + created[index], err = backend.AddTask("alice", task) + if err != nil { + t.Fatal(err) + } + } + m := NewModel(backend, nil, "alice") + completeBoardLoad(t, &m, m.Init()) + return m, backend, created +} + +func finishActionCommand(t *testing.T, m *Model, command tea.Cmd) tea.Cmd { + t.Helper() + if command == nil { + t.Fatal("action command is nil") + } + return updateTestModel(t, m, command()) +} + +func liftToDone(t *testing.T, m *Model, taskID string) tea.Cmd { + t.Helper() + if !m.boardView.focusTask(m.filteredBoard(), taskID) { + t.Fatalf("focus task %s", taskID) + } + updateTestModel(t, m, tea.KeyPressMsg{Code: tea.KeySpace}) + updateTestModel(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + updateTestModel(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + return updateTestModel(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) +} + +func TestOrdinaryDoneDropUsesTransactionalCompletionGuard(t *testing.T) { + m, backend, tasks := actionTestModel(t, + board.Task{Title: "Open blocker", Status: board.StatusTodo}, + board.Task{Title: "Clear card", Status: board.StatusTodo}, + ) + blocker, target := tasks[0], tasks[1] + if _, _, err := backend.Link("alice", blocker.ID, target.ID); err != nil { + t.Fatal(err) + } + // Refresh the model after the direct fixture write; the task snapshots are + // unchanged, but the guard reads the link in the same transaction as move. + command := liftToDone(t, &m, target.ID) + finishActionCommand(t, &m, command) + current, err := backend.Task("alice", target.ID) + if err != nil || current.Status != board.StatusTodo { + t.Fatalf("guarded drop persisted = %s, %v", current.Status, err) + } + if !m.move.statusError || !strings.Contains(m.move.status, "still blocks") { + t.Fatalf("guard refusal status = error %v, %q", m.move.statusError, m.move.status) + } +} + +func TestShipPromptTickAllAndShipAnywayAreExplicitForcedChoices(t *testing.T) { + for _, test := range []struct { + name string + choice int + wantChecked bool + }{ + {name: "tick everything", choice: 1, wantChecked: true}, + {name: "ship anyway", choice: 2, wantChecked: false}, + } { + t.Run(test.name, func(t *testing.T) { + m, backend, tasks := actionTestModel(t, + board.Task{Title: "Open blocker", Status: board.StatusTodo}, + board.Task{Title: "Warned card", Status: board.StatusTodo, Blocked: true, + Checks: []board.Check{{Text: "unfinished"}}}, + ) + blocker, target := tasks[0], tasks[1] + if _, _, err := backend.Link("alice", blocker.ID, target.ID); err != nil { + t.Fatal(err) + } + if command := liftToDone(t, &m, target.ID); command != nil { + t.Fatalf("warned drop wrote before confirmation: %v", command) + } + if m.action.mode != taskActionShip || m.move.lifted != nil || + !strings.Contains(ansi.Strip(m.View().Content), "Tick everything") { + t.Fatalf("ship prompt state = action %#v move %#v\n%s", m.action, m.move, m.View().Content) + } + m.action.choice = test.choice + command := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + finishActionCommand(t, &m, command) + + current, err := backend.Task("alice", target.ID) + if err != nil || current.Status != board.StatusDone || current.Checks[0].Done != test.wantChecked { + t.Fatalf("confirmed ship = %+v, %v", current, err) + } + links, err := backend.TaskLinks("alice", target.ID) + if err != nil || len(links.BlockedBy) != 1 { + t.Fatalf("forced ship changed blocker link = %+v, %v", links, err) + } + if m.shippedCount() != 1 || !strings.Contains(ansi.Strip(m.View().Content), "×1 shipped today") || + m.actionStatus != "Shipped Warned card" { + t.Fatalf("ship feedback = count %d status %q\n%s", m.shippedCount(), m.actionStatus, m.View().Content) + } + }) + } +} + +func TestTickEverythingRefusesStaleChecklist(t *testing.T) { + m, backend, tasks := actionTestModel(t, board.Task{ + Title: "Concurrent checklist", Status: board.StatusTodo, Blocked: true, + Checks: []board.Check{{Text: "original"}}, + }) + task := tasks[0] + m.openShipPrompt(task, 0) + concurrent := []board.Check{{Text: "original"}, {Text: "added elsewhere"}} + if _, err := backend.UpdateTask("alice", task.ID, store.TaskPatch{Checks: &concurrent}); err != nil { + t.Fatal(err) + } + finishActionCommand(t, &m, m.startShip(true)) + + current, err := backend.Task("alice", task.ID) + if err != nil || current.Status != board.StatusTodo || len(current.Checks) != 2 || current.Checks[1].Text != "added elsewhere" { + t.Fatalf("stale tick-all persisted = %+v, %v", current, err) + } + if !strings.Contains(m.action.errorText, "checklist") { + t.Fatalf("stale tick-all feedback = %q", m.action.errorText) + } +} + +func TestChecklistLastTickAutoShipsAfterCanonicalRecheck(t *testing.T) { + m, backend, tasks := actionTestModel(t, board.Task{ + Title: "Auto ship", Status: board.StatusDoing, + Checks: []board.Check{{Text: "first", Done: true}, {Text: "last"}}, + }) + task := tasks[0] + if !m.boardView.focusTask(m.filteredBoard(), task.ID) { + t.Fatal("focus task") + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: 't'}) + m.action.checkIndex = 1 + write := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + afterWrite := finishActionCommand(t, &m, write) + if afterWrite == nil { + t.Fatal("last tick did not schedule auto-ship") + } + check := afterWrite() + read := updateTestModel(t, &m, check) + ready := read() + ship := updateTestModel(t, &m, ready) + finishActionCommand(t, &m, ship) + current, err := backend.Task("alice", task.ID) + if err != nil || current.Status != board.StatusDone || !checksComplete(current.Checks) { + t.Fatalf("auto-ship = %+v, %v", current, err) + } + if m.actionStatus != "Shipped Auto ship" || m.shippedCount() != 1 { + t.Fatalf("auto-ship feedback = %q, %d", m.actionStatus, m.shippedCount()) + } +} + +func TestAutoShipRecheckObservesUndoBeforeTimer(t *testing.T) { + m, backend, tasks := actionTestModel(t, board.Task{ + Title: "Undo tick", Status: board.StatusTodo, Checks: []board.Check{{Text: "only"}}, + }) + task := tasks[0] + m.boardView.focusTask(m.filteredBoard(), task.ID) + updateTestModel(t, &m, tea.KeyPressMsg{Code: 't'}) + first := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + timer := finishActionCommand(t, &m, first) + undo := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + finishActionCommand(t, &m, undo) + read := updateTestModel(t, &m, timer()) + if command := updateTestModel(t, &m, read()); command != nil { + t.Fatalf("undone checklist still auto-shipped: %v", command) + } + current, err := backend.Task("alice", task.ID) + if err != nil || current.Status != board.StatusTodo || current.Checks[0].Done { + t.Fatalf("undo recheck = %+v, %v", current, err) + } +} + +func TestAutoShipWaitsForInFlightChecklistWrite(t *testing.T) { + m, backend, tasks := actionTestModel(t, board.Task{ + Title: "In-flight undo", Status: board.StatusTodo, Checks: []board.Check{{Text: "only"}}, + }) + task := tasks[0] + m.boardView.focusTask(m.filteredBoard(), task.ID) + updateTestModel(t, &m, tea.KeyPressMsg{Code: 't'}) + tick := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + timer := finishActionCommand(t, &m, tick) + undo := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + if !m.action.busy { + t.Fatal("undo write did not enter busy state") + } + if command := updateTestModel(t, &m, timer()); command != nil { + t.Fatalf("timer read started during undo: %v", command) + } + if command := m.finishAutoShipRead(autoShipReadyMsg{task: m.action.task, found: true}); command != nil { + t.Fatalf("stale auto-ship readiness started during undo: %v", command) + } + finishActionCommand(t, &m, undo) + current, err := backend.Task("alice", task.ID) + if err != nil || current.Status != board.StatusTodo || current.Checks[0].Done { + t.Fatalf("in-flight undo = %+v, %v", current, err) + } +} + +func TestAutoShipTransactionRefusesConcurrentCancellation(t *testing.T) { + m, backend, tasks := actionTestModel(t, board.Task{ + Title: "Cancelled elsewhere", Status: board.StatusTodo, + Checks: []board.Check{{Text: "done", Done: true}}, + }) + task := tasks[0] + ship := m.finishAutoShipRead(autoShipReadyMsg{task: task, found: true}) + if ship == nil { + t.Fatal("eligible auto-ship did not start") + } + if _, err := backend.MoveTask("alice", task.ID, board.StatusCancelled); err != nil { + t.Fatal(err) + } + finishActionCommand(t, &m, ship) + current, err := backend.Task("alice", task.ID) + if err != nil || current.Status != board.StatusCancelled { + t.Fatalf("auto-ship resurrected cancellation = %+v, %v", current, err) + } + if !strings.Contains(m.actionStatus, "auto-ship refused") { + t.Fatalf("concurrent cancellation feedback = %q", m.actionStatus) + } +} + +func TestKillRestoreAndArmedPurgeRouting(t *testing.T) { + m, backend, tasks := actionTestModel(t, board.Task{Title: "Lifecycle", Status: board.StatusDone}) + task := tasks[0] + m.boardView.focusTask(m.filteredBoard(), task.ID) + m.recordShipped(task.ID) + + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'x'}) + if m.action.mode != taskActionKill { + t.Fatalf("x action = %v, want kill", m.action.mode) + } + m.action.reason.SetValue("superseded") + kill := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + finishActionCommand(t, &m, kill) + cancelled, err := backend.Task("alice", task.ID) + if err != nil || cancelled.Status != board.StatusCancelled || m.shippedCount() != 0 { + t.Fatalf("kill = %+v, %v, shipped=%d", cancelled, err, m.shippedCount()) + } + if tombstone, found, err := backend.Tombstone("alice", task.ID); err != nil || !found || tombstone.Reason != "superseded" { + t.Fatalf("kill tombstone = %+v, %v, %v", tombstone, found, err) + } + + m.boardView.showCancelled = true + if !m.boardView.focusTask(m.filteredBoard(), task.ID) { + t.Fatal("focus cancelled task") + } + restore := updateTestModel(t, &m, tea.KeyPressMsg{Code: 'r'}) + finishActionCommand(t, &m, restore) + restored, err := backend.Task("alice", task.ID) + if err != nil || restored.Status != board.StatusTodo { + t.Fatalf("restore = %+v, %v", restored, err) + } + if _, found, err := backend.Tombstone("alice", task.ID); err != nil || found { + t.Fatalf("restore tombstone = %v, %v", found, err) + } + if command := updateTestModel(t, &m, tea.KeyPressMsg{Code: 'D', Text: "D"}); command != nil || m.action.open() { + t.Fatalf("live purge opened = command %v action %#v", command, m.action) + } + + if _, err := backend.CancelTask("alice", task.ID, nil); err != nil { + t.Fatal(err) + } + completeBoardLoad(t, &m, m.startBoardLoad()) + m.boardView.showCancelled = true + m.boardView.focusTask(m.filteredBoard(), task.ID) + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'D', Text: "D"}) + if m.action.mode != taskActionPurge || m.action.armed { + t.Fatalf("purge prompt = %#v", m.action) + } + if command := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}); command != nil || !m.action.armed { + t.Fatalf("first purge confirmation = command %v armed %v", command, m.action.armed) + } + purge := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + finishActionCommand(t, &m, purge) + if _, err := backend.Task("alice", task.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("purged task lookup = %v", err) + } +} + +func TestShippedRecordPersistenceRolloverAndIdentity(t *testing.T) { + now := time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC) + m := NewModel(stubBoardReader{}, nil, "alice") + m.now = func() time.Time { return now } + m.shipped = shippedRecord{Date: "2026-08-18", IDs: []string{"a", "a", "", "b"}} + if got := m.shippedCount(); got != 2 { + t.Fatalf("normalized shipped count = %d", got) + } + preferences := m.preferences() + if !preferencesEqual(preferences, tuiPreferences{Shipped: shippedRecord{Date: "2026-08-18", IDs: []string{"a", "b"}}}) { + t.Fatalf("shipped preferences = %+v", preferences) + } + m.recordShipped("c") + m.unrecordShipped("a") + if got := m.shippedCount(); got != 2 { + t.Fatalf("identity tally = %d", got) + } + now = now.Add(24 * time.Hour) + if got := m.shippedCount(); got != 0 || m.shipped.Date != "2026-08-18" { + // shippedCount uses a value receiver so rendering cannot mutate state; + // the next actual record operation performs the rollover. + t.Fatalf("value rollover = count %d record %+v", got, m.shipped) + } + m.recordShipped("next") + if m.shipped.Date != "2026-08-19" || len(m.shipped.IDs) != 1 || m.shipped.IDs[0] != "next" { + t.Fatalf("record rollover = %+v", m.shipped) + } +} + +func TestTaskActionOverlaySanitizesAndStaysBounded(t *testing.T) { + m := NewModel(stubBoardReader{}, nil, "alice") + m.width, m.height = 24, 9 + task := board.Task{ID: "x", Title: "bad\x1b[2Jtitle", Status: board.StatusTodo, + Checks: []board.Check{{Text: "line\x1b]8;;https://evil.invalid\a"}}} + m.openChecklist(task) + view := m.View().Content + if strings.Contains(view, "\x1b[2J") || strings.Contains(view, "evil.invalid") { + t.Fatalf("action overlay leaked control sequence: %q", view) + } + for _, line := range strings.Split(view, "\n") { + if ansi.StringWidth(line) > m.width { + t.Fatalf("action overlay width = %d: %q", ansi.StringWidth(line), line) + } + } +} + +type faultActionStore struct { + *store.Store + boardErr, moveErr, updateErr, cancelErr, deleteErr error +} + +func (s *faultActionStore) Board(user string) (board.Board, error) { + if s.boardErr != nil { + return board.Board{}, s.boardErr + } + return s.Store.Board(user) +} + +func (s *faultActionStore) UpdateAndMoveTask(user, id string, patch store.TaskPatch, target *board.Status, index *int, guard func(board.Task) error) (board.Task, error) { + if s.moveErr != nil { + return board.Task{}, s.moveErr + } + return s.Store.UpdateAndMoveTask(user, id, patch, target, index, guard) +} + +func (s *faultActionStore) UpdateAndMoveTaskIfFieldsMatch(user, id string, expected, patch store.TaskPatch, target *board.Status, index *int, guard func(board.Task) error) (board.Task, error) { + if s.moveErr != nil { + return board.Task{}, s.moveErr + } + return s.Store.UpdateAndMoveTaskIfFieldsMatch(user, id, expected, patch, target, index, guard) +} + +func (s *faultActionStore) UpdateTaskIfFieldsMatch(user, id string, expected, patch store.TaskPatch) (board.Task, error) { + if s.updateErr != nil { + return board.Task{}, s.updateErr + } + return s.Store.UpdateTaskIfFieldsMatch(user, id, expected, patch) +} + +func (s *faultActionStore) CancelTask(user, id string, reason *string) (board.Task, error) { + if s.cancelErr != nil { + return board.Task{}, s.cancelErr + } + return s.Store.CancelTask(user, id, reason) +} + +func (s *faultActionStore) DeleteCancelledTask(user, id string) (board.Task, error) { + if s.deleteErr != nil { + return board.Task{}, s.deleteErr + } + return s.Store.DeleteCancelledTask(user, id) +} + +func TestTaskActionKeyRoutingEdges(t *testing.T) { + m, _, tasks := actionTestModel(t, board.Task{ + Title: "Keys", Status: board.StatusTodo, + Checks: []board.Check{{Text: "first"}, {Text: "second"}}, + }) + task := tasks[0] + m.boardView.focusTask(m.filteredBoard(), task.ID) + + if handled, command := m.handleSelectedTaskAction("unknown"); handled || command != nil { + t.Fatalf("unknown action = %v, %v", handled, command) + } + if handled, _ := m.handleSelectedTaskAction("r"); !handled || !m.actionStatusError { + t.Fatal("live restore was not refused") + } + empty := task + empty.Checks = nil + m.board.Tasks[0] = empty + if handled, _ := m.handleSelectedTaskAction("t"); !handled || !strings.Contains(m.actionStatus, "empty") { + t.Fatal("empty checklist was not refused") + } + m.board.Tasks[0] = task + + m.openShipPrompt(task, 0) + for _, key := range []string{"left", "right", "tab", "shift+tab", "h", "l"} { + m.updateShipPrompt(key) + } + m.action.choice = 0 + if command := m.updateShipPrompt("enter"); command != nil || m.action.open() { + t.Fatalf("ship cancel = %v, %#v", command, m.action) + } + + m.openKillPrompt(task) + for _, key := range []tea.KeyPressMsg{ + {Code: tea.KeyTab}, + {Code: tea.KeyTab, Mod: tea.ModShift}, + {Code: 'a', Text: "a"}, + } { + m.updateKillPrompt(key) + } + m.action.reason.SetValue("") + m.action.choice = 2 + if command := m.updateKillPrompt(tea.KeyPressMsg{Code: tea.KeyEnter}); command != nil || m.action.errorText == "" { + t.Fatalf("blank kill reason = command %v error %q", command, m.action.errorText) + } + m.action.choice = 0 + m.updateKillPrompt(tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.action.open() { + t.Fatal("kill cancel stayed open") + } + + m.openChecklist(task) + m.updateChecklistPrompt("up") + if m.action.checkIndex != 1 { + t.Fatalf("checklist up wrap = %d", m.action.checkIndex) + } + m.updateChecklistPrompt("down") + if m.action.checkIndex != 0 { + t.Fatalf("checklist down wrap = %d", m.action.checkIndex) + } + m.action.task.Checks = nil + m.updateChecklistPrompt("space") + if m.action.open() { + t.Fatal("empty open checklist stayed open") + } + + m.openPurgePrompt(board.Task{ID: "cancelled", Title: "Gone", Status: board.StatusCancelled}) + if command := m.updateTaskActionKey(tea.KeyPressMsg{Code: tea.KeyEscape}); command != nil || m.action.open() { + t.Fatalf("escape action = %v, %#v", command, m.action) + } + m.action.busy = true + if command := m.updateTaskActionKey(tea.KeyPressMsg{Code: 'q'}); command == nil || !m.stopped { + t.Fatal("hung action did not preserve q quit") + } +} + +func TestUnsupportedActionStoreAndAutoShipEdges(t *testing.T) { + task := board.Task{ID: "task", Title: "Unsupported", Status: board.StatusTodo, Checks: []board.Check{{Text: "one"}}} + m := NewModel(stubBoardReader{board: board.Board{Tasks: []board.Task{task}}}, nil, "alice") + completeBoardLoad(t, &m, m.Init()) + + m.openShipPrompt(task, 0) + if command := m.startShip(false); command != nil || m.action.errorText == "" { + t.Fatal("unsupported ship was not reported") + } + m.openKillPrompt(task) + if command := m.startKill(""); command != nil || m.action.errorText == "" { + t.Fatal("unsupported kill was not reported") + } + m.action.close() + if command := m.startRestore(board.Task{ID: "c", Title: "c", Status: board.StatusCancelled}); command != nil || !m.actionStatusError { + t.Fatal("unsupported restore was not reported") + } + m.openPurgePrompt(board.Task{ID: "c", Title: "c", Status: board.StatusCancelled}) + if command := m.startPurge(); command != nil || m.action.errorText == "" { + t.Fatal("unsupported purge was not reported") + } + m.openChecklist(task) + if command := m.startChecklistToggle(); command != nil || m.action.errorText == "" { + t.Fatal("unsupported checklist was not reported") + } + if command := m.readAutoShipCandidate(task.ID); command != nil { + t.Fatalf("unsupported auto-ship read = %v", command) + } + + for _, candidate := range []autoShipReadyMsg{ + {err: errors.New("read failed")}, + {}, + {found: true, task: board.Task{Status: board.StatusDone, Checks: []board.Check{{Done: true}}}}, + {found: true, task: board.Task{Status: board.StatusTodo, Checks: []board.Check{{Done: false}}}}, + } { + if command := m.finishAutoShipRead(candidate); command != nil { + t.Fatalf("noncandidate auto-ship = %v for %#v", command, candidate) + } + } + other := task + other.ID = "other" + m.openKillPrompt(other) + ready := autoShipReadyMsg{found: true, task: board.Task{ID: task.ID, Status: board.StatusTodo, Checks: []board.Check{{Done: true}}}} + if command := m.finishAutoShipRead(ready); command != nil || m.action.task.ID != other.ID { + t.Fatal("auto-ship replaced unrelated action") + } +} + +func TestActionFailureReloadAndViewEdges(t *testing.T) { + m, backend, tasks := actionTestModel(t, board.Task{Title: "Faults", Status: board.StatusTodo, Checks: []board.Check{{Text: "one"}}}) + task := tasks[0] + faults := &faultActionStore{Store: backend} + m.actionStore = faults + + for kind, want := range map[taskActionKind]string{ + actionShip: "Ship", actionKill: "Cancel", actionRestore: "Restore", actionPurge: "Permanent delete", + } { + m.action = newTaskActionState() + m.action.mode = taskActionKill + m.action.task = task + m.action.busy = true + m.finishTaskAction(taskActionStoredMsg{kind: kind, taskID: task.ID, title: task.Title, + board: m.board, writeErr: errors.New("refused")}) + if !strings.Contains(m.actionStatus, want+" failed") || m.action.errorText == "" { + t.Fatalf("%v failure = %q / %q", kind, m.actionStatus, m.action.errorText) + } + } + + m.action = newTaskActionState() + m.action.mode = taskActionChecklist + m.action.task = task + m.action.busy = true + m.finishChecklistWrite(checklistStoredMsg{taskID: task.ID, board: m.board, writeErr: errors.New("stale")}) + if !strings.Contains(m.action.errorText, "Checklist update failed") { + t.Fatalf("checklist failure = %q", m.action.errorText) + } + m.action.busy = true + load := m.finishChecklistWrite(checklistStoredMsg{taskID: task.ID, task: task, reloadErr: errors.New("reload")}) + if load == nil || !strings.Contains(m.actionStatus, "canonical reload failed") { + t.Fatalf("checklist reload failure = %v, %q", load, m.actionStatus) + } + m.loading = false + m.reloadPending = true + if command := m.reloadAfterActionFailure(nil); command == nil || !m.loading || m.reloadPending { + t.Fatalf("pending action reload = %v loading %v pending %v", command, m.loading, m.reloadPending) + } + m.loading = false + + views := []taskActionState{ + {mode: taskActionShip, task: task, warning: shipWarning{blocked: true}, busy: true, errorText: "no"}, + {mode: taskActionKill, task: task, reason: newTaskActionState().reason, busy: true, errorText: "no"}, + {mode: taskActionChecklist, task: task, busy: true, errorText: "no"}, + {mode: taskActionPurge, task: task, armed: true, busy: true, errorText: "no"}, + } + for _, action := range views { + m.action = action + if rendered := m.renderTaskAction(60); !strings.Contains(rendered, "saving") || !strings.Contains(rendered, "error") { + t.Fatalf("action view %v = %q", action.mode, rendered) + } + } + if got := m.renderTaskAction(1); ansi.StringWidth(got) > 1 { + t.Fatalf("one-cell action = %q", got) + } + if got := fitActionFrame("12345\nabcde\nlast", 3, 2); got != "123\nabc" { + t.Fatalf("fitActionFrame = %q", got) + } +} + +func TestActionSuccessDetailAndDispatchEdges(t *testing.T) { + task := board.Task{ID: "task", Title: "Detail task", Status: board.StatusTodo, Checks: []board.Check{{Done: true}}} + reader := &mutableDetailReader{board: board.Board{Tasks: []board.Task{task}}} + m := NewModel(reader, nil, "alice") + completeBoardLoad(t, &m, m.Init()) + detailLoad := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + updateTestModel(t, &m, detailLoad()) + if selected, ok := m.actionTask(); !ok || selected.ID != task.ID { + t.Fatalf("detail action task = %+v, %v", selected, ok) + } + refresh := m.adoptActionBoard(board.Board{Tasks: []board.Task{task}}, task.ID) + if refresh == nil { + t.Fatal("detail adoption did not refresh enrichment") + } + updateTestModel(t, &m, refresh()) + if reader.commentLoads < 2 { + t.Fatalf("detail refresh loads = %d", reader.commentLoads) + } + m.adoptActionBoard(board.Board{}, task.ID) + if m.detail.IsOpen() { + t.Fatal("purged detail stayed open") + } + + for kind, want := range map[taskActionKind]string{ + actionShip: "Shipped X", + actionKill: "Cancelled X", + actionRestore: "Restored X to To Do", + actionPurge: "Permanently deleted X", + } { + if got := actionSuccess(kind, "X"); got != want { + t.Errorf("actionSuccess(%v) = %q, want %q", kind, got, want) + } + } + if err := completionGuard(board.Task{ID: "clear", Title: "Clear"}); err != nil { + t.Fatalf("clear completion guard = %v", err) + } + if err := completionGuard(board.Task{ID: "open", Title: "Open", Checks: []board.Check{{Text: "x"}}}); err == nil { + t.Fatal("open completion guard succeeded") + } + m.recordShipped("same") + m.recordShipped("same") + if m.shippedCount() != 1 { + t.Fatalf("duplicate shipped record = %+v", m.shipped) + } + + for _, message := range []tea.Msg{ + tea.KeyPressMsg{Code: tea.KeyEscape}, + taskActionStoredMsg{}, checklistStoredMsg{}, autoShipCheckMsg{}, autoShipReadyMsg{}, struct{}{}, + } { + m.action.close() + _ = m.updateTaskAction(message) + } +} + +func TestMoveOutOfDoneRemovesShippedIdentity(t *testing.T) { + m, backend, tasks := actionTestModel(t, board.Task{Title: "Reopen", Status: board.StatusDone}) + task := tasks[0] + m.recordShipped(task.ID) + m.boardView.focusTask(m.filteredBoard(), task.ID) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyLeft}) + drop := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + finishActionCommand(t, &m, drop) + current, err := backend.Task("alice", task.ID) + if err != nil || current.Status != board.StatusDoing || m.shippedCount() != 0 { + t.Fatalf("reopen = %+v, %v, shipped=%d", current, err, m.shippedCount()) + } +} From 10a5167b21769e9d5b6fb08103212e462a012e42 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 18 Aug 2026 02:58:23 +0000 Subject: [PATCH 2/2] test(tui): cover ship action edge cases --- internal/tui/ship_actions_test.go | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/internal/tui/ship_actions_test.go b/internal/tui/ship_actions_test.go index 8f9cbb7..a56a688 100644 --- a/internal/tui/ship_actions_test.go +++ b/internal/tui/ship_actions_test.go @@ -241,6 +241,38 @@ func TestAutoShipTransactionRefusesConcurrentCancellation(t *testing.T) { } } +func TestCancelledTaskCannotOpenKillPrompt(t *testing.T) { + m, _, tasks := actionTestModel(t, board.Task{Title: "Already gone", Status: board.StatusCancelled}) + m.openKillPrompt(tasks[0]) + if m.action.open() || m.actionStatus != "Card is already Cancelled" || !m.actionStatusError { + t.Fatalf("cancelled kill prompt = action:%#v status:%q error:%v", m.action, m.actionStatus, m.actionStatusError) + } +} + +func TestAutoShipStopsWhenCandidateDisappears(t *testing.T) { + m := NewModel(stubBoardReader{}, nil, "u") + eligible := board.Task{Status: board.StatusTodo, Checks: []board.Check{{Text: "done", Done: true}}} + if command := m.finishAutoShipRead(autoShipReadyMsg{task: eligible, found: false}); command != nil || m.action.open() || m.action.busy { + t.Fatalf("missing auto-ship candidate = command:%v action:%#v", command, m.action) + } +} + +func TestSameTaskChecklistRetainsDelayedAutoShip(t *testing.T) { + m, _, tasks := actionTestModel(t, board.Task{ + Title: "Same detail", Status: board.StatusTodo, Checks: []board.Check{{Text: "done", Done: true}}, + }) + task := tasks[0] + if command := m.detail.Open(task); command == nil { + t.Fatal("detail did not schedule its load") + } + m.action.mode = taskActionChecklist + m.action.task = task + if !m.detail.IsOpen() || m.detail.OwnsInput() || m.autoShipInputOwned() { + t.Fatalf("same-task checklist ownership = detail:%v input:%v blocked:%v", + m.detail.IsOpen(), m.detail.OwnsInput(), m.autoShipInputOwned()) + } +} + func TestKillRestoreAndArmedPurgeRouting(t *testing.T) { m, backend, tasks := actionTestModel(t, board.Task{Title: "Lifecycle", Status: board.StatusDone}) task := tasks[0]