diff --git a/internal/tui/carddetail/actions.go b/internal/tui/carddetail/actions.go new file mode 100644 index 0000000..9eb1344 --- /dev/null +++ b/internal/tui/carddetail/actions.go @@ -0,0 +1,577 @@ +package carddetail + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/store" +) + +// Writer is the direct SQLite projection used by detail actions. Reader stays +// separate so read-only and lightweight callers do not need mutation stubs. +type Writer interface { + AddComment(user, taskRef, author, body string) (store.Comment, error) + DeleteComment(user string, id int) (store.Comment, error) + Link(user, blockerRef, blockedRef string) (blocker, blocked board.Task, err error) + Unlink(user, aRef, bRef string) error +} + +type actionMode uint8 + +const ( + actionNone actionMode = iota + actionAddComment + actionDeleteComment + actionAddLink + actionDeleteLink +) + +type mutationKind uint8 + +const ( + mutationAddComment mutationKind = iota + mutationDeleteComment + mutationAddLink + mutationDeleteLink +) + +type mutationCompletedMsg struct { + taskID string + session uint64 + kind mutationKind + comment store.Comment + blocker board.Task + blocked board.Task + other board.Task + currentSeq int + err error +} + +type linkChoice struct { + task board.Task + blocks bool +} + +func newCommentInput() textarea.Model { + input := textarea.New() + input.Prompt = "" + input.Placeholder = "Write a comment" + input.ShowLineNumbers = false + input.SetWidth(64) + input.SetHeight(6) + return input +} + +func newLinkInput() textinput.Model { + input := textinput.New() + input.Prompt = "" + input.Placeholder = "task number, UUID, or unique prefix" + input.SetWidth(48) + input.CharLimit = 256 + return input +} + +// OwnsInput is the root-routing seam for text entry, selection, confirmation, +// and in-flight writes. Destructive or focus-changing root shortcuts must not +// run while it returns true. +func (m Model) OwnsInput() bool { return m.action != actionNone || m.saving } + +// ConsumeChanged reports one acknowledged mutation exactly once. +func (m *Model) ConsumeChanged() bool { + changed := m.changed + m.changed = false + return changed +} + +// IsMutationMessage identifies action results without exposing their concrete +// message types to the root package. +func IsMutationMessage(message tea.Msg) bool { + _, ok := message.(mutationCompletedMsg) + return ok +} + +func (m *Model) beginAction(next actionMode) tea.Cmd { + if m.writer == nil || m.saving { + return nil + } + switch next { + case actionDeleteComment: + if m.loading { + m.setStatus("comments are still loading", false) + m.rebuildBody() + return nil + } + if m.commentsErr != nil { + m.setStatus("comments unavailable; retry after the next refresh", true) + m.rebuildBody() + return nil + } + if len(m.comments) == 0 { + m.setStatus("no comments to delete", false) + m.rebuildBody() + return nil + } + case actionDeleteLink: + if m.loading { + m.setStatus("blocker links are still loading", false) + m.rebuildBody() + return nil + } + if m.linksErr != nil { + m.setStatus("blocker links unavailable; retry after the next refresh", true) + m.rebuildBody() + return nil + } + if len(m.linkChoices()) == 0 { + m.setStatus("no blocker links to remove", false) + m.rebuildBody() + return nil + } + } + m.actionSession++ + m.action = next + m.selection = 0 + m.confirm = false + m.statusMessage = "" + m.statusIsError = false + m.commentInput = newCommentInput() + m.linkInput = newLinkInput() + m.currentBlocks = true + if next == actionAddComment { + m.commentInput.Focus() + } + if next == actionAddLink { + m.linkInput.Focus() + } + m.scroll = 0 + m.rebuildBody() + m.focusActionSelection() + return nil +} + +func (m *Model) cancelAction() { + if m.saving { + m.setStatus("write in progress", false) + m.rebuildBody() + return + } + if m.confirm { + m.confirm = false + m.setStatus("deletion cancelled", false) + m.rebuildBody() + return + } + m.actionSession++ + m.action = actionNone + m.selection = 0 + m.statusMessage = "" + m.statusIsError = false + m.scroll = 0 + m.rebuildBody() +} + +func (m *Model) updateActionKey(msg tea.KeyPressMsg) tea.Cmd { + key := msg.String() + if key == "esc" { + m.cancelAction() + return nil + } + if m.saving { + return nil + } + switch m.action { + case actionAddComment: + if key == "ctrl+s" { + return m.startAddComment() + } + var command tea.Cmd + m.commentInput, command = m.commentInput.Update(msg) + if value := safeText(m.commentInput.Value(), true); value != m.commentInput.Value() { + m.commentInput.SetValue(value) + } + m.rebuildBody() + return command + case actionAddLink: + if key == "tab" || key == "shift+tab" { + m.currentBlocks = !m.currentBlocks + m.rebuildBody() + return nil + } + if key == "enter" { + return m.startAddLink() + } + var command tea.Cmd + m.linkInput, command = m.linkInput.Update(msg) + if value := safeText(m.linkInput.Value(), false); value != m.linkInput.Value() { + m.linkInput.SetValue(value) + } + m.rebuildBody() + return command + case actionDeleteComment, actionDeleteLink: + return m.updateDeleteKey(key) + } + return nil +} + +func (m *Model) updateDeleteKey(key string) tea.Cmd { + count := len(m.comments) + if m.action == actionDeleteLink { + count = len(m.linkChoices()) + } + if count == 0 { + m.cancelAction() + return nil + } + if m.confirm { + if key == "enter" { + if m.action == actionDeleteComment { + return m.startDeleteComment() + } + return m.startDeleteLink() + } + return nil + } + switch key { + case "up", "k": + m.selection = (m.selection - 1 + count) % count + case "down", "j": + m.selection = (m.selection + 1) % count + case "enter": + m.confirm = true + m.setStatus("press Enter again to delete; Esc cancels", true) + } + m.rebuildBody() + m.focusActionSelection() + return nil +} + +func (m *Model) focusActionSelection() { + if m.action != actionDeleteComment && m.action != actionDeleteLink { + return + } + focusLine := 0 + for i, line := range m.bodyLines { + if strings.HasPrefix(line, "> ") { + focusLine = i + break + } + } + _, innerHeight, _ := paneGeometry(m.width, m.height) + if focusLine < m.scroll { + m.scroll = focusLine + } + if focusLine >= m.scroll+innerHeight { + m.scroll = focusLine - innerHeight + 1 + } + m.clampScroll() +} + +func (m *Model) startAddComment() tea.Cmd { + body := strings.TrimSpace(safeText(m.commentInput.Value(), true)) + if body == "" { + m.setStatus("comment must not be empty", true) + m.rebuildBody() + return nil + } + m.saving = true + m.setStatus("adding comment...", false) + m.rebuildBody() + taskID, session, user, writer := m.task.ID, m.actionSession, m.user, m.writer + return func() tea.Msg { + comment, err := writer.AddComment(user, taskID, user, body) + return mutationCompletedMsg{ + taskID: taskID, session: session, kind: mutationAddComment, comment: comment, err: err, + } + } +} + +func (m *Model) startDeleteComment() tea.Cmd { + if len(m.comments) == 0 { + return nil + } + comment := m.comments[min(max(m.selection, 0), len(m.comments)-1)] + m.saving = true + m.setStatus(fmt.Sprintf("deleting comment c%d...", comment.ID), false) + m.rebuildBody() + taskID, session, user, writer := m.task.ID, m.actionSession, m.user, m.writer + return func() tea.Msg { + deleted, err := writer.DeleteComment(user, comment.ID) + return mutationCompletedMsg{ + taskID: taskID, session: session, kind: mutationDeleteComment, comment: deleted, err: err, + } + } +} + +func (m *Model) startAddLink() tea.Cmd { + target := strings.TrimSpace(safeText(m.linkInput.Value(), false)) + if target == "" { + m.setStatus("target task is required", true) + m.rebuildBody() + return nil + } + m.saving = true + m.setStatus("adding blocker link...", false) + m.rebuildBody() + taskID, session, user, writer := m.task.ID, m.actionSession, m.user, m.writer + currentBlocks := m.currentBlocks + return func() tea.Msg { + blockerRef, blockedRef := taskID, target + if !currentBlocks { + blockerRef, blockedRef = target, taskID + } + blocker, blocked, err := writer.Link(user, blockerRef, blockedRef) + return mutationCompletedMsg{ + taskID: taskID, session: session, kind: mutationAddLink, + blocker: blocker, blocked: blocked, err: err, + } + } +} + +func (m *Model) startDeleteLink() tea.Cmd { + choices := m.linkChoices() + if len(choices) == 0 { + return nil + } + choice := choices[min(max(m.selection, 0), len(choices)-1)] + m.saving = true + m.setStatus("removing blocker link...", false) + m.rebuildBody() + taskID, session, user, writer := m.task.ID, m.actionSession, m.user, m.writer + currentSeq := m.task.Seq + return func() tea.Msg { + err := writer.Unlink(user, taskID, choice.task.ID) + return mutationCompletedMsg{ + taskID: taskID, session: session, kind: mutationDeleteLink, + other: choice.task, currentSeq: currentSeq, err: err, + } + } +} + +func (m *Model) finishMutation(msg mutationCompletedMsg) tea.Cmd { + if msg.taskID != m.task.ID || msg.session != m.actionSession { + return nil + } + m.saving = false + if msg.err != nil { + m.setStatus("write refused: "+safeText(msg.err.Error(), false), true) + m.rebuildBody() + return nil + } + m.changed = true + m.action = actionNone + m.confirm = false + m.selection = 0 + switch msg.kind { + case mutationAddComment: + m.setStatus(fmt.Sprintf("comment c%d added", msg.comment.ID), false) + case mutationDeleteComment: + m.setStatus(fmt.Sprintf("comment c%d deleted", msg.comment.ID), false) + case mutationAddLink: + m.setStatus(taskActionRef(msg.blocker)+" now blocks "+taskActionRef(msg.blocked), false) + case mutationDeleteLink: + current := board.Task{ID: msg.taskID, Seq: msg.currentSeq} + m.setStatus("link between "+taskActionRef(current)+" and "+taskActionRef(msg.other)+" removed", false) + } + m.scroll = 0 + if m.reader == nil { + m.rebuildBody() + return nil + } + if m.loading { + m.reloadPending = true + m.rebuildBody() + return nil + } + return m.startLoad() +} + +func taskActionRef(task board.Task) string { + if task.Seq > 0 { + return fmt.Sprintf("#%d", task.Seq) + } + return safeText(task.ID, false) +} + +func (m *Model) setStatus(message string, isError bool) { + m.statusMessage = safeText(message, false) + m.statusIsError = isError +} + +func (m Model) linkChoices() []linkChoice { + choices := make([]linkChoice, 0, len(m.links.Blocks)+len(m.links.BlockedBy)) + for _, task := range m.links.Blocks { + choices = append(choices, linkChoice{task: task, blocks: true}) + } + for _, task := range m.links.BlockedBy { + choices = append(choices, linkChoice{task: task}) + } + return choices +} + +func (m Model) actionBody(width int) string { + ref := m.task.ID + if m.task.Seq > 0 { + ref = fmt.Sprintf("#%d", m.task.Seq) + } + var lines []string + switch m.action { + case actionAddComment: + lines = []string{"ADD COMMENT / " + ref, "", "Comment:"} + lines = append(lines, textareaLines(m.commentInput, width, 8)...) + case actionDeleteComment: + lines = []string{"DELETE COMMENT / " + ref, ""} + start, end := selectionWindow(len(m.comments), m.selection, max(m.height-10, 3)) + if start > 0 { + lines = append(lines, fmt.Sprintf(" ... %d earlier", start)) + } + for i := start; i < end; i++ { + comment := m.comments[i] + marker := " " + if i == m.selection { + marker = "> " + } + preview := strings.ReplaceAll(safeText(comment.Body, true), "\n", " ") + lines = append(lines, fmt.Sprintf("%sc%d %s %s", marker, comment.ID, safeText(comment.Author, false), preview)) + } + if end < len(m.comments) { + lines = append(lines, fmt.Sprintf(" ... %d later", len(m.comments)-end)) + } + case actionAddLink: + direction := "this card blocks target" + if !m.currentBlocks { + direction = "target blocks this card" + } + lines = []string{ + "ADD BLOCKER LINK / " + ref, + "", + "Direction: " + direction, + "Target: " + textInputLine(m.linkInput, width-len("Target: ")), + } + case actionDeleteLink: + lines = []string{"REMOVE BLOCKER LINK / " + ref, ""} + choices := m.linkChoices() + start, end := selectionWindow(len(choices), m.selection, max(m.height-10, 3)) + if start > 0 { + lines = append(lines, fmt.Sprintf(" ... %d earlier", start)) + } + for i := start; i < end; i++ { + choice := choices[i] + marker := " " + if i == m.selection { + marker = "> " + } + direction := "blocks" + if !choice.blocks { + direction = "blocked by" + } + lines = append(lines, marker+direction+" "+taskChips([]board.Task{choice.task})) + } + if end < len(choices) { + lines = append(lines, fmt.Sprintf(" ... %d later", len(choices)-end)) + } + } + if m.statusMessage != "" { + prefix := "status: " + if m.statusIsError { + prefix = "error: " + } + lines = append(lines, "", prefix+m.statusMessage) + } + for i := range lines { + lines[i] = fitDetailLine(safeText(lines[i], true), max(width-2, 1)) + } + return strings.Join(lines, "\n") +} + +func selectionWindow(count, selection, limit int) (int, int) { + if count <= 0 { + return 0, 0 + } + limit = min(max(limit, 1), count) + selection = min(max(selection, 0), count-1) + start := max(selection-limit/2, 0) + start = min(start, count-limit) + return start, start + limit +} + +func (m Model) actionFooter(width int) string { + if m.saving { + return "write in progress | esc stays here" + } + if m.action == actionNone && m.statusMessage != "" { + prefix := "status: " + if m.statusIsError { + prefix = "error: " + } + return prefix + m.statusMessage + } + if m.confirm { + return "enter confirm delete | esc cancel" + } + switch m.action { + case actionAddComment: + return "ctrl+s add comment | esc back" + case actionDeleteComment: + return "up/down choose | enter delete | esc back" + case actionAddLink: + return "tab direction | enter add | esc back" + case actionDeleteLink: + return "up/down choose | enter remove | esc back" + default: + switch { + case width >= 40: + return "e edit c add d/u rm b link esc close ↑/↓" + case width >= 26: + return "e c add d/u rm b esc close" + default: + return "e c d u b esc" + } + } +} + +func textInputLine(input textinput.Model, width int) string { + value := safeText(input.Value(), false) + if value == "" { + value = safeText(input.Placeholder, false) + } + position := min(max(input.Position(), 0), len([]rune(input.Value()))) + visible := insertCursor(value, position) + return ansi.Truncate(visible, max(width, 0), "") +} + +func textareaLines(input textarea.Model, width, rows int) []string { + value := input.Value() + placeholder := false + if value == "" { + value = input.Placeholder + placeholder = true + } + logical := strings.Split(safeText(value, true), "\n") + line := min(max(input.Line(), 0), len(logical)-1) + start := max(line-rows+1, 0) + end := min(start+rows, len(logical)) + out := make([]string, 0, rows) + for i := start; i < end; i++ { + content := logical[i] + if i == line && !placeholder { + content = insertCursor(content, min(input.Column(), len([]rune(content)))) + } + out = append(out, " "+ansi.Truncate(content, max(width-2, 0), "")) + } + for len(out) < rows { + out = append(out, " ") + } + return out +} + +func insertCursor(value string, position int) string { + runes := []rune(value) + position = min(max(position, 0), len(runes)) + return string(runes[:position]) + "|" + string(runes[position:]) +} diff --git a/internal/tui/carddetail/actions_test.go b/internal/tui/carddetail/actions_test.go new file mode 100644 index 0000000..3e6855e --- /dev/null +++ b/internal/tui/carddetail/actions_test.go @@ -0,0 +1,597 @@ +package carddetail + +import ( + "errors" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + + "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/store" +) + +type actionStore struct { + comments []store.Comment + links store.TaskLinks + addErr error + deleteErr error + linkErr error + unlinkErr error + + addedBody string + addedTask, addedAuthor string + deletedID int + blockerRef, blockedRef string + unlinkA, unlinkB string + commentLoads, linkLoads int +} + +func (s *actionStore) Comments(_ string, taskRef string) ([]store.Comment, error) { + s.commentLoads++ + out := make([]store.Comment, 0, len(s.comments)) + for _, comment := range s.comments { + if comment.TaskID == "" || comment.TaskID == taskRef { + out = append(out, comment) + } + } + return out, nil +} + +func (s *actionStore) TaskLinks(string, string) (store.TaskLinks, error) { + s.linkLoads++ + return store.TaskLinks{ + Blocks: append([]board.Task(nil), s.links.Blocks...), + BlockedBy: append([]board.Task(nil), s.links.BlockedBy...), + }, nil +} + +func (*actionStore) Tombstone(string, string) (store.Tombstone, bool, error) { + return store.Tombstone{}, false, nil +} + +func (s *actionStore) AddComment(_ string, taskRef, author, body string) (store.Comment, error) { + s.addedTask, s.addedAuthor, s.addedBody = taskRef, author, body + if s.addErr != nil { + return store.Comment{}, s.addErr + } + created := store.Comment{ + ID: len(s.comments) + 1, TaskID: taskRef, TaskSeq: 7, Author: author, Body: body, + CreatedAt: time.Date(2026, time.August, 18, 1, 0, 0, 0, time.UTC), + } + s.comments = append(s.comments, created) + return created, nil +} + +func (s *actionStore) DeleteComment(_ string, id int) (store.Comment, error) { + s.deletedID = id + if s.deleteErr != nil { + return store.Comment{}, s.deleteErr + } + for i, comment := range s.comments { + if comment.ID == id { + s.comments = append(s.comments[:i], s.comments[i+1:]...) + return comment, nil + } + } + return store.Comment{}, store.ErrNotFound +} + +func (s *actionStore) Link(_ string, blockerRef, blockedRef string) (board.Task, board.Task, error) { + s.blockerRef, s.blockedRef = blockerRef, blockedRef + if s.linkErr != nil { + return board.Task{}, board.Task{}, s.linkErr + } + blocker := board.Task{ID: blockerRef, Seq: 2, Status: board.StatusDoing} + blocked := board.Task{ID: blockedRef, Seq: 7, Status: board.StatusTodo} + return blocker, blocked, nil +} + +func (s *actionStore) Unlink(_ string, aRef, bRef string) error { + s.unlinkA, s.unlinkB = aRef, bRef + return s.unlinkErr +} + +func openActionModel(t *testing.T, st *actionStore) *Model { + t.Helper() + m := New(st, "alice") + load := m.Open(board.Task{ID: "task-7", Seq: 7, Title: "Seven", Status: board.StatusTodo, Prio: 3}) + if load == nil { + t.Fatal("detail open did not load enrichment") + } + if command := m.Update(load()); command != nil { + t.Fatalf("initial load returned command %v", command) + } + return &m +} + +func key(code rune) tea.KeyPressMsg { return tea.KeyPressMsg{Code: code, Text: string(code)} } + +func TestCommentAddPreservesRefusedInputAndReloadsAcknowledgedWrite(t *testing.T) { + st := &actionStore{addErr: errors.New("refused\x1b[31m\nunsafe")} + m := openActionModel(t, st) + if command := m.Update(key('c')); command != nil || m.action == actionNone || !m.OwnsInput() { + t.Fatalf("comment action = command:%v action:%v owned:%v", command, m.action, m.OwnsInput()) + } + if command := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}); command != nil || !m.statusIsError { + t.Fatalf("empty comment save = command:%v status:%q", command, m.statusMessage) + } + + m.commentInput.SetValue("hello\x1b[31m red\nnext") + preserved := m.commentInput.Value() + save := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + if save == nil || !m.saving { + t.Fatalf("comment save = command:%v busy:%v", save, m.saving) + } + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.action == actionNone || !m.saving { + t.Fatal("Escape dismissed an in-flight comment write") + } + if reload := m.Update(save()); reload != nil || m.action == actionNone || m.saving || !m.statusIsError { + t.Fatalf("refused write = reload:%v action:%v busy:%v error:%v", reload, m.action, m.saving, m.statusIsError) + } + if got := m.commentInput.Value(); got != preserved { + t.Fatalf("refused write changed input %q", got) + } + if strings.Contains(m.statusMessage, "\x1b") || strings.Contains(m.statusMessage, "\n") { + t.Fatalf("unsafe error reached status %q", m.statusMessage) + } + + st.addErr = nil + save = m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + reload := m.Update(save()) + if reload == nil || m.action != actionNone || m.saving || !m.ConsumeChanged() || m.ConsumeChanged() { + t.Fatalf("acknowledged write = reload:%v action:%v busy:%v", reload, m.action, m.saving) + } + if st.addedTask != "task-7" || st.addedAuthor != "alice" || st.addedBody != "hello[31m red\nnext" { + t.Fatalf("AddComment args = task:%q author:%q body:%q", st.addedTask, st.addedAuthor, st.addedBody) + } + if command := m.Update(reload()); command != nil || len(m.comments) != 1 || m.comments[0].Body != "hello[31m red\nnext" { + t.Fatalf("post-write reload = command:%v comments:%+v", command, m.comments) + } +} + +func TestCommentDeleteRequiresConfirmationAndEscapeDisarms(t *testing.T) { + st := &actionStore{comments: []store.Comment{{ID: 4, Author: "a", Body: "first"}, {ID: 9, Author: "b", Body: "second"}}} + m := openActionModel(t, st) + m.Update(key('d')) + m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !m.confirm || m.selection != 1 { + t.Fatalf("delete selection = confirm:%v selection:%d", m.confirm, m.selection) + } + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.confirm || m.action == actionNone { + t.Fatal("first Escape did not disarm only the confirmation") + } + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + remove := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if remove == nil || st.deletedID != 0 { + t.Fatalf("delete command = %v, store called synchronously with %d", remove, st.deletedID) + } + reload := m.Update(remove()) + if reload == nil || st.deletedID != 9 || m.action != actionNone { + t.Fatalf("delete result = reload:%v id:%d action:%v", reload, st.deletedID, m.action) + } + m.Update(reload()) + if len(m.comments) != 1 || m.comments[0].ID != 4 { + t.Fatalf("comments after delete = %+v", m.comments) + } +} + +func TestRefreshDisarmsConfirmedCommentAndLinkDeletion(t *testing.T) { + t.Run("comment", func(t *testing.T) { + st := &actionStore{comments: []store.Comment{{ID: 4, Body: "first"}, {ID: 9, Body: "second"}}} + m := openActionModel(t, st) + m.Update(key('d')) + m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + m.Update(detailLoadedMsg{ + taskID: m.task.ID, generation: m.generation, + comments: []store.Comment{{ID: 9, Body: "second"}, {ID: 4, Body: "first"}}, + }) + if m.confirm || m.action != actionDeleteComment || !strings.Contains(m.statusMessage, "confirm again") { + t.Fatalf("refresh retained confirmation: action=%v confirm=%v status=%q", m.action, m.confirm, m.statusMessage) + } + if command := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}); command != nil || !m.confirm || st.deletedID != 0 { + t.Fatalf("first Enter after refresh mutated: command=%v confirm=%v deleted=%d", command, m.confirm, st.deletedID) + } + }) + + t.Run("link", func(t *testing.T) { + st := &actionStore{links: store.TaskLinks{Blocks: []board.Task{ + {ID: "task-8", Seq: 8}, {ID: "task-9", Seq: 9}, + }}} + m := openActionModel(t, st) + m.Update(key('u')) + m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + m.Update(detailLoadedMsg{ + taskID: m.task.ID, generation: m.generation, + links: store.TaskLinks{Blocks: []board.Task{ + {ID: "task-9", Seq: 9}, {ID: "task-8", Seq: 8}, + }}, + }) + if m.confirm || m.action != actionDeleteLink || !strings.Contains(m.statusMessage, "confirm again") { + t.Fatalf("refresh retained confirmation: action=%v confirm=%v status=%q", m.action, m.confirm, m.statusMessage) + } + if command := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}); command != nil || !m.confirm || st.unlinkA != "" { + t.Fatalf("first Enter after refresh mutated: command=%v confirm=%v unlink=%q", command, m.confirm, st.unlinkA) + } + }) + + for _, test := range []struct { + name string + store actionStore + key rune + loaded detailLoadedMsg + want string + wantError bool + }{ + { + name: "comments disappear", store: actionStore{comments: []store.Comment{{ID: 4}}}, key: 'd', + loaded: detailLoadedMsg{}, want: "none remain", + }, + { + name: "comments unavailable", store: actionStore{comments: []store.Comment{{ID: 4}}}, key: 'd', + loaded: detailLoadedMsg{commentsErr: errors.New("read failed")}, want: "comments unavailable", wantError: true, + }, + { + name: "links unavailable", store: actionStore{links: store.TaskLinks{Blocks: []board.Task{{ID: "task-8"}}}}, key: 'u', + loaded: detailLoadedMsg{linksErr: errors.New("read failed")}, want: "links unavailable", wantError: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + m := openActionModel(t, &test.store) + m.Update(key(test.key)) + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + test.loaded.taskID, test.loaded.generation = m.task.ID, m.generation + m.Update(test.loaded) + if m.action != actionNone || m.confirm || !strings.Contains(m.statusMessage, test.want) || m.statusIsError != test.wantError { + t.Fatalf("refresh cancellation = action:%v confirm:%v error:%v status:%q", m.action, m.confirm, m.statusIsError, m.statusMessage) + } + }) + } +} + +func TestDirectionalLinkAddAndConfirmedUnlink(t *testing.T) { + st := &actionStore{linkErr: errors.New("cycle refused")} + m := openActionModel(t, st) + m.Update(key('b')) + m.linkInput.SetValue("2") + m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + if m.currentBlocks { + t.Fatal("Tab did not switch link direction") + } + link := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if link == nil { + t.Fatal("link input did not start a write") + } + if reload := m.Update(link()); reload != nil || m.action == actionNone || m.linkInput.Value() != "2" || !m.statusIsError { + t.Fatalf("refused link = reload:%v action:%v target:%q error:%v", reload, m.action, m.linkInput.Value(), m.statusIsError) + } + if st.blockerRef != "2" || st.blockedRef != "task-7" { + t.Fatalf("blocked-by direction = %q -> %q", st.blockerRef, st.blockedRef) + } + + st.linkErr = nil + link = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + reload := m.Update(link()) + if reload == nil || m.action != actionNone || !strings.Contains(m.statusMessage, "#2 now blocks #7") { + t.Fatalf("link success = reload:%v action:%v status:%q", reload, m.action, m.statusMessage) + } + m.Update(reload()) + + st.links = store.TaskLinks{ + Blocks: []board.Task{{ID: "task-8", Seq: 8, Status: board.StatusTodo}}, + BlockedBy: []board.Task{{ID: "task-2", Seq: 2, Status: board.StatusDoing}}, + } + m.links = st.links + m.Update(key('u')) + m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + unlink := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if unlink == nil { + t.Fatal("unlink confirmation did not start write") + } + reload = m.Update(unlink()) + if reload == nil || st.unlinkA != "task-7" || st.unlinkB != "task-2" { + t.Fatalf("Unlink args = %q, %q reload:%v", st.unlinkA, st.unlinkB, reload) + } +} + +func TestStaleMutationCannotCrossDetailSession(t *testing.T) { + st := &actionStore{} + m := openActionModel(t, st) + m.Update(key('c')) + m.commentInput.SetValue("old session") + save := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + if save == nil { + t.Fatal("old session save was nil") + } + newLoad := m.Open(board.Task{ID: "task-8", Seq: 8, Title: "Eight", Status: board.StatusDoing}) + if command := m.Update(save()); command != nil || m.TaskID() != "task-8" || m.ConsumeChanged() { + t.Fatalf("stale mutation changed new session: command:%v task:%q", command, m.TaskID()) + } + m.Update(newLoad()) + if len(m.comments) != 0 { + t.Fatalf("new session adopted old task comments: %+v", m.comments) + } +} + +func TestMutationDuringEnrichmentQueuesFreshSuccessor(t *testing.T) { + st := &actionStore{} + m := New(st, "alice") + initial := m.Open(board.Task{ID: "task-7", Seq: 7, Title: "Seven", Status: board.StatusTodo}) + if initial == nil || !m.loading { + t.Fatal("initial detail load did not start") + } + m.Update(key('c')) + m.commentInput.SetValue("new comment") + save := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + if save == nil { + t.Fatal("comment write did not start") + } + if command := m.Update(save()); command != nil || !m.reloadPending || !m.loading { + t.Fatalf("write during load = command:%v pending:%v loading:%v", command, m.reloadPending, m.loading) + } + successor := m.Update(detailLoadedMsg{ + taskID: "task-7", generation: m.generation, + comments: []store.Comment{{ID: 99, TaskID: "task-7", Body: "stale"}}, + }) + if successor == nil || len(m.comments) != 0 || m.reloadPending || !m.loading { + t.Fatalf("stale load adoption = successor:%v comments:%+v pending:%v loading:%v", successor, m.comments, m.reloadPending, m.loading) + } + if command := m.Update(successor()); command != nil || len(m.comments) != 1 || m.comments[0].Body != "new comment" { + t.Fatalf("fresh successor = command:%v comments:%+v", command, m.comments) + } +} + +func TestCardDetailActionGolden(t *testing.T) { + m := openActionModel(t, &actionStore{}) + m.Update(key('c')) + m.commentInput.SetValue("First line\nSecond line") + m.rebuildBody() + lines := strings.Split(ansi.Strip(m.View(60, 20)), "\n") + for i := range lines { + lines[i] = strings.TrimSpace(lines[i]) + } + golden.RequireEqual(t, strings.Trim(strings.Join(lines, "\n"), "\n")+"\n") +} + +func TestCompletionGateAndActionViewsAreTerminalSafe(t *testing.T) { + gate := renderCompletionGate( + board.Task{Blocked: true, Checks: []board.Check{{Text: "open"}}}, + store.TaskLinks{BlockedBy: []board.Task{ + {ID: "one", Seq: 1, Status: board.StatusTodo}, + {ID: "two", Seq: 2, Status: board.StatusDone}, + }}, false, nil, + ) + for _, want := range []string{"1 of 1 checklist", "flagged blocked", "1 open linked blocker", "[#1 todo]"} { + if !strings.Contains(gate, want) { + t.Errorf("completion gate missing %q: %s", want, gate) + } + } + if clear := renderCompletionGate(board.Task{}, store.TaskLinks{}, false, nil); clear != "completion gate clear" { + t.Fatalf("clear gate = %q", clear) + } + if unknown := renderCompletionGate(board.Task{}, store.TaskLinks{}, true, nil); unknown != "completion gate unknown: linked blockers loading" { + t.Fatalf("loading gate = %q", unknown) + } + if unknown := renderCompletionGate(board.Task{}, store.TaskLinks{}, false, errors.New("broken")); unknown != "completion gate unknown: linked blockers unavailable" { + t.Fatalf("failed gate = %q", unknown) + } + + comments := make([]store.Comment, 20) + for i := range comments { + comments[i] = store.Comment{ID: i + 1, Author: "bad\x1b[31m\a", Body: strings.Repeat("wide 界 ", 20)} + } + m := openActionModel(t, &actionStore{comments: comments}) + m.Resize(20, 8) + m.Update(key('d')) + for range 19 { + m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + } + view := m.View(20, 8) + if strings.Contains(view, "\x1b[31m") || !strings.Contains(ansi.Strip(view), "c20") { + t.Fatalf("unsafe or invisible selected action:\n%s", view) + } + for _, line := range strings.Split(view, "\n") { + if ansi.StringWidth(line) > 20 { + t.Fatalf("action line wider than terminal: %q", line) + } + } + if start, end := selectionWindow(20, 19, 3); start != 17 || end != 20 { + t.Fatalf("selection window = %d:%d", start, end) + } +} + +func TestActionEdgeStatesAndKeyboardEditing(t *testing.T) { + readOnly := New(stubReader{}, "u") + readOnly.Open(board.Task{ID: "read-only", Status: board.StatusTodo}) + if command := readOnly.beginAction(actionAddComment); command != nil || readOnly.action != actionNone { + t.Fatalf("read-only action = command:%v action:%v", command, readOnly.action) + } + if IsMutationMessage(struct{}{}) || !IsMutationMessage(mutationCompletedMsg{}) { + t.Fatal("mutation message classifier returned the wrong ownership") + } + + st := &actionStore{} + m := New(st, "u") + load := m.Open(board.Task{ID: "task", Seq: 1, Status: board.StatusTodo}) + m.beginAction(actionDeleteComment) + if m.action != actionNone || m.statusMessage != "comments are still loading" { + t.Fatalf("loading comments action = action:%v status:%q", m.action, m.statusMessage) + } + m.beginAction(actionDeleteLink) + if m.action != actionNone || m.statusMessage != "blocker links are still loading" { + t.Fatalf("loading links action = action:%v status:%q", m.action, m.statusMessage) + } + m.Update(load()) + m.commentsErr = errors.New("comments failed") + m.beginAction(actionDeleteComment) + if !m.statusIsError || !strings.Contains(m.statusMessage, "unavailable") { + t.Fatalf("comments error action = error:%v status:%q", m.statusIsError, m.statusMessage) + } + m.commentsErr = nil + m.linksErr = errors.New("links failed") + m.beginAction(actionDeleteLink) + if !m.statusIsError || !strings.Contains(m.statusMessage, "unavailable") { + t.Fatalf("links error action = error:%v status:%q", m.statusIsError, m.statusMessage) + } + m.linksErr = nil + m.beginAction(actionDeleteComment) + if m.statusMessage != "no comments to delete" { + t.Fatalf("empty comments action status = %q", m.statusMessage) + } + m.beginAction(actionDeleteLink) + if m.statusMessage != "no blocker links to remove" { + t.Fatalf("empty links action status = %q", m.statusMessage) + } + + m.beginAction(actionAddComment) + for _, char := range "ab" { + m.updateActionKey(tea.KeyPressMsg{Code: char, Text: string(char)}) + } + if m.commentInput.Value() != "ab" { + t.Fatalf("comment keyboard input = %q", m.commentInput.Value()) + } + m.cancelAction() + if m.action != actionNone { + t.Fatal("plain action cancellation did not return to detail") + } + + m.beginAction(actionAddLink) + for _, char := range "42" { + m.updateActionKey(tea.KeyPressMsg{Code: char, Text: string(char)}) + } + if m.linkInput.Value() != "42" { + t.Fatalf("link keyboard input = %q", m.linkInput.Value()) + } + for _, key := range []tea.KeyPressMsg{ + {Code: tea.KeyLeft}, {Code: tea.KeyRight}, {Code: tea.KeyTab, Mod: tea.ModShift}, + } { + m.updateActionKey(key) + } + m.linkInput.SetValue("") + if command := m.startAddLink(); command != nil || !m.statusIsError { + t.Fatalf("empty link = command:%v error:%v", command, m.statusIsError) + } + m.linkInput.SetValue("2") + m.currentBlocks = true + link := m.startAddLink() + if link == nil { + t.Fatal("outgoing link command was nil") + } + result := link().(mutationCompletedMsg) + if st.blockerRef != "task" || st.blockedRef != "2" || result.err != nil { + t.Fatalf("outgoing link = %q -> %q, %v", st.blockerRef, st.blockedRef, result.err) + } + m.saving = false + m.cancelAction() + + if command := m.startDeleteComment(); command != nil { + t.Fatalf("empty direct comment delete returned %v", command) + } + if command := m.startDeleteLink(); command != nil { + t.Fatalf("empty direct link delete returned %v", command) + } + if got := taskActionRef(board.Task{ID: "legacy"}); got != "legacy" { + t.Fatalf("legacy task ref = %q", got) + } + if start, end := selectionWindow(0, 9, 3); start != 0 || end != 0 { + t.Fatalf("empty selection window = %d:%d", start, end) + } + if start, end := selectionWindow(2, -3, 99); start != 0 || end != 2 { + t.Fatalf("clamped selection window = %d:%d", start, end) + } + + footerModel := Model{} + for _, test := range []struct { + mode actionMode + width int + want string + }{ + {actionNone, 80, "c add"}, + {actionNone, 30, "esc close"}, + {actionNone, 10, "e c d"}, + {actionAddComment, 80, "add comment"}, + {actionDeleteComment, 80, "enter delete"}, + {actionAddLink, 80, "direction"}, + {actionDeleteLink, 80, "enter remove"}, + } { + footerModel.action = test.mode + if got := footerModel.actionFooter(test.width); !strings.Contains(got, test.want) { + t.Errorf("footer(%v,%d) = %q, want %q", test.mode, test.width, got, test.want) + } + } + footerModel.confirm = true + if got := footerModel.actionFooter(80); !strings.Contains(got, "confirm") { + t.Fatalf("confirm footer = %q", got) + } + footerModel.saving = true + if got := footerModel.actionFooter(80); !strings.Contains(got, "progress") { + t.Fatalf("saving footer = %q", got) + } + footerModel.saving = false + footerModel.action = actionNone + footerModel.statusMessage = "visible result" + if got := footerModel.actionFooter(80); got != "status: visible result" { + t.Fatalf("status footer = %q", got) + } + footerModel.statusIsError = true + if got := footerModel.actionFooter(80); got != "error: visible result" { + t.Fatalf("error footer = %q", got) + } + footerModel.open = true + footerModel.bodyLines = []string{"one", "two"} + footerModel.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + if footerModel.statusMessage != "" || footerModel.statusIsError { + t.Fatal("normal detail input did not clear the prior status line") + } + footerModel.action = actionAddComment + footerModel.saving = true + footerModel.updateActionKey(key('z')) +} + +func TestDeleteFailuresKeepSelectorsAndInputs(t *testing.T) { + st := &actionStore{ + comments: []store.Comment{{ID: 4, Body: "keep"}}, + links: store.TaskLinks{Blocks: []board.Task{{ID: "other", Seq: 2, Status: board.StatusTodo}}}, + deleteErr: errors.New("delete refused"), + unlinkErr: errors.New("unlink refused"), + } + m := openActionModel(t, st) + m.beginAction(actionDeleteComment) + m.updateDeleteKey("enter") + remove := m.updateDeleteKey("enter") + if remove == nil { + t.Fatal("comment delete command was nil") + } + if reload := m.Update(remove()); reload != nil || m.action != actionDeleteComment || !m.statusIsError { + t.Fatalf("failed comment delete = reload:%v action:%v error:%v", reload, m.action, m.statusIsError) + } + m.cancelAction() // disarm confirmation retained across the refused write. + m.cancelAction() + + m.links = st.links + m.beginAction(actionDeleteLink) + m.updateDeleteKey("enter") + remove = m.updateDeleteKey("enter") + if remove == nil { + t.Fatal("link delete command was nil") + } + if reload := m.Update(remove()); reload != nil || m.action != actionDeleteLink || !m.statusIsError { + t.Fatalf("failed unlink = reload:%v action:%v error:%v", reload, m.action, m.statusIsError) + } + m.saving = true + m.cancelAction() + if m.action != actionDeleteLink || !strings.Contains(m.statusMessage, "progress") { + t.Fatalf("busy cancellation = action:%v status:%q", m.action, m.statusMessage) + } +} diff --git a/internal/tui/carddetail/model.go b/internal/tui/carddetail/model.go index 576ac5c..9bcf0db 100644 --- a/internal/tui/carddetail/model.go +++ b/internal/tui/carddetail/model.go @@ -1,4 +1,5 @@ -// Package carddetail renders the read-only full-card overlay for the TUI. +// Package carddetail renders the full-card overlay and its direct-store +// comment and blocker-link actions. package carddetail import ( @@ -7,6 +8,8 @@ import ( "time" "unicode" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/glamour/v2" "charm.land/glamour/v2/styles" @@ -46,6 +49,7 @@ type markdownRenderer func(source string, width int) string // Model owns the overlay's task snapshot, enriched detail, and scroll state. type Model struct { reader Reader + writer Writer user string task board.Task comments []store.Comment @@ -64,13 +68,26 @@ type Model struct { renderMarkdown markdownRenderer bodyLines []string bodyWidth int + + action actionMode + actionSession uint64 + commentInput textarea.Model + linkInput textinput.Model + currentBlocks bool + selection int + confirm bool + saving bool + changed bool + statusMessage string + statusIsError bool } // New creates a closed detail pane. A nil reader still shows board-resident // task fields; enrichment is simply unavailable to lightweight model tests. func New(reader Reader, user string) Model { + writer, _ := reader.(Writer) return Model{ - reader: reader, user: user, width: defaultWidth, height: defaultHeight, + reader: reader, writer: writer, user: user, width: defaultWidth, height: defaultHeight, renderMarkdown: renderMarkdown, } } @@ -88,6 +105,7 @@ func (m Model) TaskID() string { // Open resets the pane to task and returns the asynchronous enrichment load. func (m *Model) Open(task board.Task) tea.Cmd { + m.actionSession++ m.task = task m.comments = nil m.links = store.TaskLinks{} @@ -99,6 +117,13 @@ func (m *Model) Open(task board.Task) tea.Cmd { m.linksErr = nil m.tombstoneErr = nil m.scroll = 0 + m.action = actionNone + m.selection = 0 + m.confirm = false + m.saving = false + m.changed = false + m.statusMessage = "" + m.statusIsError = false if m.reader == nil { m.rebuildBody() return nil @@ -130,6 +155,7 @@ func (m *Model) Refresh(task board.Task) tea.Cmd { // the current task identity. func (m *Model) Close() { m.generation++ + m.actionSession++ m.open = false m.loading = false m.reloadPending = false @@ -137,6 +163,13 @@ func (m *Model) Close() { m.bodyLines = nil m.bodyWidth = 0 m.scroll = 0 + m.action = actionNone + m.selection = 0 + m.confirm = false + m.saving = false + m.changed = false + m.statusMessage = "" + m.statusIsError = false } // Resize updates the viewport used to bound persistent scroll state. @@ -157,6 +190,8 @@ func (m *Model) Update(message tea.Msg) tea.Cmd { return nil } switch msg := message.(type) { + case mutationCompletedMsg: + return m.finishMutation(msg) case detailLoadedMsg: if msg.taskID != m.task.ID || msg.generation != m.generation { return nil @@ -172,8 +207,27 @@ func (m *Model) Update(message tea.Msg) tea.Cmd { m.commentsErr = msg.commentsErr m.linksErr = msg.linksErr m.tombstoneErr = msg.tombstoneErr + m.reconcileDeleteActionAfterRefresh() m.rebuildBody() case tea.KeyPressMsg: + if m.action != actionNone { + return m.updateActionKey(msg) + } + if m.statusMessage != "" { + m.statusMessage = "" + m.statusIsError = false + m.rebuildBody() + } + switch msg.String() { + case "c": + return m.beginAction(actionAddComment) + case "d": + return m.beginAction(actionDeleteComment) + case "b": + return m.beginAction(actionAddLink) + case "u": + return m.beginAction(actionDeleteLink) + } switch msg.String() { case "up", "k", "pgup": m.scroll = max(0, m.scroll-scrollAmount(msg.String())) @@ -187,6 +241,42 @@ func (m *Model) Update(message tea.Msg) tea.Cmd { return nil } +func (m *Model) reconcileDeleteActionAfterRefresh() { + count, noun := 0, "" + switch m.action { + case actionDeleteComment: + count, noun = len(m.comments), "comments" + if m.commentsErr != nil { + m.cancelDeleteActionAfterRefresh("comments unavailable; deletion cancelled", true) + return + } + case actionDeleteLink: + count, noun = len(m.linkChoices()), "blocker links" + if m.linksErr != nil { + m.cancelDeleteActionAfterRefresh("blocker links unavailable; deletion cancelled", true) + return + } + default: + return + } + if count == 0 { + m.cancelDeleteActionAfterRefresh(noun+" changed; none remain to remove", false) + return + } + m.selection = min(max(m.selection, 0), count-1) + if m.confirm { + m.confirm = false + m.setStatus(noun+" changed; review the selection and confirm again", false) + } +} + +func (m *Model) cancelDeleteActionAfterRefresh(status string, isError bool) { + m.action = actionNone + m.selection = 0 + m.confirm = false + m.setStatus(status, isError) +} + func scrollAmount(key string) int { if key == "pgup" || key == "pgdown" { return 8 @@ -253,6 +343,7 @@ func (m *Model) frame(width, height int) (string, int, int) { height = max(height, 1) m.ensureBody(width, height) innerWidth, innerHeight, paneHeight := paneGeometry(width, height) + displayWidth := max(innerWidth-2, 1) lines := m.bodyLines maxScroll := max(0, len(lines)-innerHeight) @@ -264,11 +355,11 @@ func (m *Model) frame(width, height int) (string, int, int) { } visible := strings.Join(visibleLines, "\n") visible = lipgloss.NewStyle().Width(innerWidth).Height(innerHeight).Render(visible) - footer := "e edit esc close ↑/↓ scroll" + footer := m.actionFooter(displayWidth) if maxScroll > 0 { footer = fmt.Sprintf("%s %d/%d", footer, start+1, maxScroll+1) } - content := visible + "\n" + ansi.Truncate(footer, innerWidth, "…") + content := visible + "\n" + fitDetailLine(footer, displayWidth) frame := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). Padding(0, 1). @@ -279,6 +370,17 @@ func (m *Model) frame(width, height int) (string, int, int) { return frame, lipgloss.Width(frame), lipgloss.Height(frame) } +func fitDetailLine(line string, width int) string { + width = max(width, 0) + if ansi.StringWidth(line) <= width { + return line + } + if width <= 1 { + return ansi.Cut("…", 0, width) + } + return ansi.Cut(line, 0, width-1) + "…" +} + func paneGeometry(width, height int) (innerWidth, innerHeight, paneHeight int) { width = max(width, 1) height = max(height, 1) @@ -337,6 +439,9 @@ func fitTerminal(rendered string, width, height int) string { } func (m Model) renderBody(width int) string { + if m.action != actionNone { + return m.actionBody(width) + } title := strings.TrimSpace(safeText(m.task.Title, false)) if m.task.Emoji != "" { title = safeText(m.task.Emoji, false) + " " + title @@ -367,6 +472,7 @@ func (m Model) renderBody(width int) string { if refs := renderTaskLinks(m.links); refs != "" { sections = append(sections, refs) } + sections = append(sections, renderCompletionGate(m.task, m.links, m.loading, m.linksErr)) if m.linksErr != nil { sections = append(sections, "blocker links error: "+safeText(m.linksErr.Error(), false)) } @@ -484,6 +590,42 @@ func renderTaskLinks(links store.TaskLinks) string { return strings.Join(lines, "\n") } +func renderCompletionGate(task board.Task, links store.TaskLinks, loading bool, linksErr error) string { + var reasons []string + if warning := store.CompletionWarning(task); warning != "" { + reasons = append(reasons, warning) + } + var open []board.Task + for _, blocker := range links.BlockedBy { + if blocker.Status != board.StatusDone && blocker.Status != board.StatusCancelled { + open = append(open, blocker) + } + } + if len(open) > 0 { + noun := "open linked blocker" + if len(open) != 1 { + noun += "s" + } + reasons = append(reasons, fmt.Sprintf("%d %s %s", len(open), noun, taskChips(open))) + } + unknown := "" + if loading { + unknown = "linked blockers loading" + } else if linksErr != nil { + unknown = "linked blockers unavailable" + } + if len(reasons) > 0 { + if unknown != "" { + reasons = append(reasons, unknown) + } + return "completion gate blocked: " + strings.Join(reasons, "; ") + } + if unknown != "" { + return "completion gate unknown: " + unknown + } + return "completion gate clear" +} + func taskChips(tasks []board.Task) string { chips := make([]string, 0, len(tasks)) for _, task := range tasks { diff --git a/internal/tui/carddetail/testdata/TestCardDetailActionGolden.golden b/internal/tui/carddetail/testdata/TestCardDetailActionGolden.golden new file mode 100644 index 0000000..e3d9162 --- /dev/null +++ b/internal/tui/carddetail/testdata/TestCardDetailActionGolden.golden @@ -0,0 +1,17 @@ +╭──────────────────────────────────────────────────╮ +│ ADD COMMENT / #7 │ +│ │ +│ Comment: │ +│ First line │ +│ Second line| │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ctrl+s add comment | esc back │ +╰──────────────────────────────────────────────────╯ diff --git a/internal/tui/carddetail/testdata/TestCardDetailGolden.golden b/internal/tui/carddetail/testdata/TestCardDetailGolden.golden index caed0bb..47fbaf5 100644 --- a/internal/tui/carddetail/testdata/TestCardDetailGolden.golden +++ b/internal/tui/carddetail/testdata/TestCardDetailGolden.golden @@ -13,5 +13,5 @@ │ • first │ │ https://example.com │ │ │ -│ e edit esc close ↑/↓ scroll 1/6 │ +│ e edit c add d/u rm b link esc close ↑/↓ 1/8 │ ╰─────────────────────────────────────────────────────╯ diff --git a/internal/tui/model.go b/internal/tui/model.go index 519a06c..229ee5e 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -130,6 +130,9 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if m.stopped { return m, nil } + if carddetail.IsMutationMessage(message) { + return m, m.updateDetail(message) + } if m.move.lifted == nil && m.move.notice && isBoardUserInput(message) { m.move.notice = false } @@ -168,26 +171,37 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: 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", "ctrl+c": - // Preserve the root quit contract while the overlay is open. + case "q": + if m.detail.OwnsInput() { + return m, m.updateDetail(message) + } + // Preserve the root quit contract while idle detail is open. + case "ctrl+c": + // The explicit terminal interrupt remains global. default: - return m, m.detail.Update(message) + return m, m.updateDetail(message) } case boardCardClickedMsg, boardColumnClickedMsg, filterTextClickedMsg, filterLabelClickedMsg, filterClearClickedMsg, boardPointerDownMsg, boardPointerMoveMsg, boardPointerUpMsg: return m, nil default: - detailCmd = m.detail.Update(message) + detailCmd = m.updateDetail(message) } } switch msg := message.(type) { @@ -388,6 +402,14 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return m, detailCmd } +func (m *Model) updateDetail(message tea.Msg) tea.Cmd { + command := m.detail.Update(message) + if m.detail.ConsumeChanged() { + return batchCommands(command, m.requireFreshBoard()) + } + return command +} + func isBoardUserInput(message tea.Msg) bool { switch message.(type) { case tea.KeyPressMsg, boardCardClickedMsg, boardColumnClickedMsg, diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 441e38b..c2a4462 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -126,6 +126,29 @@ func completeBoardLoad(t *testing.T, model *Model, command tea.Cmd) tea.Cmd { return updateTestModel(t, model, message) } +func drainModelCommands(t *testing.T, model *Model, commands ...tea.Cmd) { + t.Helper() + queue := append([]tea.Cmd(nil), commands...) + for steps := 0; len(queue) > 0; steps++ { + if steps > 30 { + t.Fatal("command drain did not settle") + } + command := queue[0] + queue = queue[1:] + if command == nil { + continue + } + message := command() + if batch, ok := message.(tea.BatchMsg); ok { + queue = append(queue, batch...) + continue + } + if next := updateTestModel(t, model, message); next != nil { + queue = append(queue, next) + } + } +} + func runPoll(t *testing.T, model *Model) tea.Cmd { t.Helper() read := updateTestModel(t, model, pollTickMsg{}) @@ -249,6 +272,123 @@ func TestCardDetailOpenWithoutASelectedTaskIsNoop(t *testing.T) { } } +func TestRootDetailCommentAndLinkActionsOwnInputAndRefresh(t *testing.T) { + st := newSettingsTestStore(t) + current, err := st.AddTask("alice", board.Task{Title: "Current", Status: board.StatusTodo, Prio: 3}) + if err != nil { + t.Fatal(err) + } + other, err := st.AddTask("alice", board.Task{Title: "Blocker", Status: board.StatusDoing, Prio: 3}) + if err != nil { + t.Fatal(err) + } + m := NewModel(st, nil, "alice") + completeBoardLoad(t, &m, m.Init()) + drainModelCommands(t, &m, updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter})) + + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'c', Text: "c"}) + if !m.detail.IsOpen() || !m.detail.OwnsInput() || !strings.Contains(ansi.Strip(m.View().Content), "ADD COMMENT") { + t.Fatalf("comment composer did not own detail input:\n%s", ansi.Strip(m.View().Content)) + } + for _, char := range "xdraq" { + updateTestModel(t, &m, tea.KeyPressMsg{Code: char, Text: string(char)}) + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyDelete}) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEscape}) + if !m.detail.IsOpen() || m.detail.OwnsInput() { + t.Fatal("first Escape did not return from composer to detail") + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.detail.IsOpen() { + t.Fatal("second Escape did not close detail") + } + + // Reopen and persist the same collision-heavy text. If root shortcuts had + // stolen x/d/r/a/Delete, this would either mutate the task or save less text. + drainModelCommands(t, &m, updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter})) + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'c', Text: "c"}) + for _, char := range "xdraq" { + updateTestModel(t, &m, tea.KeyPressMsg{Code: char, Text: string(char)}) + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyDelete}) + drainModelCommands(t, &m, updateTestModel(t, &m, tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl})) + comments, err := st.Comments("alice", current.ID) + if err != nil || len(comments) != 1 || comments[0].Body != "xdraq" || !m.detail.IsOpen() || m.detail.OwnsInput() { + t.Fatalf("saved comments = %+v, err:%v detail:%v owned:%v", comments, err, m.detail.IsOpen(), m.detail.OwnsInput()) + } + if view := ansi.Strip(m.View().Content); !strings.Contains(view, "comment c1 added") { + t.Fatalf("comment acknowledgement missing from status line:\n%s", view) + } + + // Idle d is deliberately detail-scoped comment deletion, not task kill. + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'd', Text: "d"}) + if !m.detail.OwnsInput() || !strings.Contains(ansi.Strip(m.View().Content), "DELETE COMMENT") { + t.Fatalf("idle d did not open comment deletion:\n%s", ansi.Strip(m.View().Content)) + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + drainModelCommands(t, &m, updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter})) + comments, err = st.Comments("alice", current.ID) + if err != nil || len(comments) != 0 { + t.Fatalf("comments after confirmed delete = %+v, %v", comments, err) + } + + // Add the incoming direction: target blocks the current card. + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'b', Text: "b"}) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyTab}) + for _, char := range fmt.Sprintf("%d", other.Seq) { + updateTestModel(t, &m, tea.KeyPressMsg{Code: char, Text: string(char)}) + } + drainModelCommands(t, &m, updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter})) + links, err := st.TaskLinks("alice", current.ID) + if err != nil || len(links.BlockedBy) != 1 || links.BlockedBy[0].ID != other.ID { + t.Fatalf("incoming links = %+v, %v", links, err) + } + view := ansi.Strip(m.View().Content) + if !strings.Contains(view, "blocked by") || !strings.Contains(view, "completion gate") { + t.Fatalf("link and completion gate missing:\n%s", view) + } + + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'u', Text: "u"}) + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + drainModelCommands(t, &m, updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter})) + links, err = st.TaskLinks("alice", current.ID) + if err != nil || len(links.BlockedBy) != 0 || len(links.Blocks) != 0 { + t.Fatalf("links after confirmed unlink = %+v, %v", links, err) + } +} + +func TestPurgedDetailIgnoresLateMutationResult(t *testing.T) { + st := newSettingsTestStore(t) + task, err := st.AddTask("alice", board.Task{Title: "Soon gone", Status: board.StatusTodo, Prio: 3}) + if err != nil { + t.Fatal(err) + } + m := NewModel(st, nil, "alice") + completeBoardLoad(t, &m, m.Init()) + drainModelCommands(t, &m, updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter})) + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'c', Text: "c"}) + for _, char := range "late" { + updateTestModel(t, &m, tea.KeyPressMsg{Code: char, Text: string(char)}) + } + save := updateTestModel(t, &m, tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl}) + if save == nil || !m.detail.OwnsInput() { + t.Fatal("comment write did not start") + } + if _, err := st.DeleteTask("alice", task.ID); err != nil { + t.Fatal(err) + } + updateTestModel(t, &m, boardLoadedMsg{board: board.Board{Title: "Board"}}) + if m.detail.IsOpen() || m.detail.TaskID() != "" { + t.Fatal("purged card retained a detail pane") + } + if next := updateTestModel(t, &m, save()); next != nil || m.detail.IsOpen() || m.detail.TaskID() != "" { + t.Fatalf("late result reopened detail: command:%v open:%v task:%q", next, m.detail.IsOpen(), m.detail.TaskID()) + } + if _, err := st.Comments("alice", task.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("purged task became readable after late result: %v", err) + } +} + func TestRootRoutesCreateEditorAndRefreshesAcknowledgedSave(t *testing.T) { st := newSettingsTestStore(t) existing, err := st.AddTask("alice", board.Task{Title: "Existing card", Status: board.StatusTodo, Prio: 3}) diff --git a/internal/tui/move_model_test.go b/internal/tui/move_model_test.go index 7f4b84f..9cbeb95 100644 --- a/internal/tui/move_model_test.go +++ b/internal/tui/move_model_test.go @@ -163,7 +163,7 @@ func (s *moveTestStore) UpdateAndMoveTask( _ store.TaskPatch, target *board.Status, index *int, - _ func(board.Task) error, + guard func(board.Task) error, ) (board.Task, error) { s.mu.Lock() defer s.mu.Unlock() @@ -175,6 +175,13 @@ func (s *moveTestStore) UpdateAndMoveTask( if s.writeErr != nil { return board.Task{}, s.writeErr } + for _, task := range s.board.Tasks { + if task.ID == id && guard != nil { + if err := guard(task); err != nil { + return board.Task{}, err + } + } + } s.board = oracleMoveBoard(s.board, id, *target, *index) for _, task := range s.board.Tasks { if task.ID == id { @@ -184,6 +191,59 @@ func (s *moveTestStore) UpdateAndMoveTask( return board.Task{}, errors.New("task not found") } +func TestDropToDoneHonorsCompletionGuard(t *testing.T) { + current := moveFixture() + current.Tasks[0].Blocked = true + current.Tasks[0].Seq = 1 + s := &moveTestStore{board: current} + m := loadedMoveModel(s) + + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeySpace}) + 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") + } + 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 task := taskNamed(t, m.board, "A"); task.Status != board.StatusTodo { + t.Fatalf("refused task moved to %s", task.Status) + } +} + +func TestDoneGuardActivatesStoreLinkedBlockerCheck(t *testing.T) { + st, err := store.Open(t.TempDir()+"/kb.db", []byte("tui-done-guard")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + blocker, err := st.AddTask("u", board.Task{Title: "Open blocker", Status: board.StatusDoing}) + if err != nil { + t.Fatal(err) + } + blocked, err := st.AddTask("u", board.Task{Title: "Blocked card", Status: board.StatusTodo}) + if err != nil { + t.Fatal(err) + } + if _, _, err := st.Link("u", blocker.ID, blocked.ID); err != nil { + t.Fatal(err) + } + to := board.StatusDone + if _, err := st.UpdateAndMoveTask("u", blocked.ID, store.TaskPatch{}, &to, nil, cardCompletionGuard(to)); err == nil || !strings.Contains(err.Error(), "still blocks") { + t.Fatalf("linked blocker gate = %v", err) + } + canonical, err := st.Board("u") + if err != nil { + t.Fatal(err) + } + if task := taskNamed(t, canonical, "Blocked card"); task.Status != board.StatusTodo { + t.Fatalf("linked blocker refusal moved task to %s", task.Status) + } +} + // 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. diff --git a/internal/tui/move_store.go b/internal/tui/move_store.go index 2ebe0e7..5ec12f2 100644 --- a/internal/tui/move_store.go +++ b/internal/tui/move_store.go @@ -40,9 +40,10 @@ func (m *Model) startCardDrop() tea.Cmd { m.move.announcePosition("Dropping") moveStore := m.moveStore user := m.user + guard := cardCompletionGuard(lift.target) return func() tea.Msg { _, writeErr := moveStore.UpdateAndMoveTask( - user, lift.taskID, store.TaskPatch{}, &lift.target, &index, nil, + user, lift.taskID, store.TaskPatch{}, &lift.target, &index, guard, ) canonical, reloadErr := moveStore.Board(user) return cardMoveStoredMsg{ @@ -53,6 +54,23 @@ func (m *Model) startCardDrop() tea.Cmd { } } +func cardCompletionGuard(target board.Status) func(board.Task) error { + if target != board.StatusDone { + return nil + } + return func(task board.Task) error { + warning := store.CompletionWarning(task) + if warning == "" { + return nil + } + ref := task.ID + if task.Seq > 0 { + ref = fmt.Sprintf("#%d", task.Seq) + } + return store.NewCompletionBlockedError(warning, ref, task.Title) + } +} + func (m *Model) finishCardDrop(msg cardMoveStoredMsg) tea.Cmd { previous := m.filteredBoard() lift := m.move.lifted