From 2c59b030590d99970b15f1d84158da72ddc28310 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 25 Jun 2026 17:46:29 +0300 Subject: Fix rq0 detail view task state --- internal/ui/detail_handlers.go | 16 ++++--- internal/ui/editor_handlers.go | 5 +- internal/ui/handlers.go | 2 +- internal/ui/input_helpers.go | 4 +- internal/ui/keyactions.go | 32 ++++++------- internal/ui/keyhandlers.go | 4 +- internal/ui/table.go | 18 ++++---- internal/ui/table_test.go | 101 ++++++++++++++++++++++++++++++++++++----- internal/ui/taskdetail.go | 63 +++++++++++++++---------- 9 files changed, 169 insertions(+), 76 deletions(-) diff --git a/internal/ui/detail_handlers.go b/internal/ui/detail_handlers.go index 0c57572..a53749d 100644 --- a/internal/ui/detail_handlers.go +++ b/internal/ui/detail_handlers.go @@ -96,19 +96,21 @@ func (m *Model) handleTaskDetailMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // for the blink animation and so the (now-completed) task isn't shown as // pending after the reload triggered by startBlink. func (m *Model) handleDetailMarkDone() (tea.Model, tea.Cmd) { - if m.currentTaskDetail == nil { + t := m.currentDetailTask() + if t == nil { return m, nil } - id := m.currentTaskDetail.ID + id := t.ID m.closeDetailView() return m, m.startBlink(id, true) } func (m *Model) handleDetailDeleteTask() (tea.Model, tea.Cmd) { - if m.currentTaskDetail == nil { + t := m.currentDetailTask() + if t == nil { return m, nil } - tsk := *m.currentTaskDetail + tsk := *t m.closeDetailView() count, recurring, err := m.deleteTaskWithUndo(tsk) if err != nil { @@ -143,7 +145,7 @@ func (m *Model) handleDetailUndo() (tea.Model, tea.Cmd) { // done, undo). func (m *Model) closeDetailView() { m.showTaskDetail = false - m.currentTaskDetail = nil + m.currentTaskDetailID = 0 m.detailSearching = false m.detailSearchRegex = nil m.detailSearchInput.SetValue("") @@ -153,10 +155,10 @@ func (m *Model) closeDetailView() { // detail view. Fields 0-2 (ID, UUID, Status) and 6, 8 (Start, Entry) are // read-only; all others delegate to the appropriate activation helper. func (m *Model) handleDetailFieldEdit() (tea.Model, tea.Cmd) { - if m.currentTaskDetail == nil { + t := m.currentDetailTask() + if t == nil { return m, nil } - t := m.currentTaskDetail id := t.ID // Fixed-position fields (indices always match the fieldXxx constants). diff --git a/internal/ui/editor_handlers.go b/internal/ui/editor_handlers.go index e85acf4..8f5e08d 100644 --- a/internal/ui/editor_handlers.go +++ b/internal/ui/editor_handlers.go @@ -44,9 +44,10 @@ func (m *Model) handleDescEditDone(msg descEditDoneMsg) (tea.Model, tea.Cmd) { // Update the description newDesc := strings.TrimSpace(string(content)) - if m.currentTaskDetail != nil { + t := m.currentDetailTask() + if t != nil { ctx, cancel := m.taskOperationContext() - err = m.taskwarriorClient().SetDescriptionContext(ctx, m.currentTaskDetail.ID, newDesc) + err = m.taskwarriorClient().SetDescriptionContext(ctx, t.ID, newDesc) cancel() if err != nil { return m, m.showStatusTimed(fmt.Sprintf("Error updating description: %v", err)) diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index cd6b9de..7832070 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -225,7 +225,7 @@ func (m *Model) handleRecurrenceMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { model, cmd := m.handleTextInput(msg, &m.recurInput, onEnter, onExit) if msg.String() == "enter" { if m.showTaskDetail { - if m.currentTaskDetail != nil && m.currentTaskDetail.Recur != "" { + if t := m.currentDetailTask(); t != nil && t.Recur != "" { return model, m.startDetailBlink(fieldRecur) } } diff --git a/internal/ui/input_helpers.go b/internal/ui/input_helpers.go index d1fdad6..32706a2 100644 --- a/internal/ui/input_helpers.go +++ b/internal/ui/input_helpers.go @@ -113,8 +113,8 @@ func (m *Model) getTaskAtCursor() *task.Task { // getTaskForOpenURL returns the task that should be used by the open-URL // hotkey, honoring the active view's highlighted task. func (m *Model) getTaskForOpenURL() *task.Task { - if m.showTaskDetail && m.currentTaskDetail != nil { - return m.currentTaskDetail + if m.showTaskDetail { + return m.currentDetailTask() } if m.showUltra { diff --git a/internal/ui/keyactions.go b/internal/ui/keyactions.go index 9fede46..9c80585 100644 --- a/internal/ui/keyactions.go +++ b/internal/ui/keyactions.go @@ -213,8 +213,8 @@ func (m *Model) handleUndo() (tea.Model, tea.Cmd) { } func (m *Model) getTaskForDelete() *task.Task { - if m.showTaskDetail && m.currentTaskDetail != nil { - return m.currentTaskDetail + if m.showTaskDetail { + return m.currentDetailTask() } return m.getTaskAtCursor() } @@ -689,22 +689,18 @@ func (m *Model) handleShowTaskDetail() (tea.Model, tea.Cmd) { return m, nil } - // Find the task with this ID - for i := range m.tasks { - if m.tasks[i].ID == id { - m.showTaskDetail = true - m.currentTaskDetail = &m.tasks[i] - m.detailSearching = false - m.detailSearchRegex = nil - m.detailFieldIndex = 0 - m.detailBlinkField = -1 - m.detailBlinkOn = false - m.detailBlinkCount = 0 - m.detailSearchInput = textinput.New() - m.detailSearchInput.Placeholder = "Search..." - m.detailSearchInput.SetWidth(30) - break - } + if m.taskByID(id) != nil { + m.showTaskDetail = true + m.currentTaskDetailID = id + m.detailSearching = false + m.detailSearchRegex = nil + m.detailFieldIndex = 0 + m.detailBlinkField = -1 + m.detailBlinkOn = false + m.detailBlinkCount = 0 + m.detailSearchInput = textinput.New() + m.detailSearchInput.Placeholder = "Search..." + m.detailSearchInput.SetWidth(30) } return m, nil diff --git a/internal/ui/keyhandlers.go b/internal/ui/keyhandlers.go index 767dc9c..116f850 100644 --- a/internal/ui/keyhandlers.go +++ b/internal/ui/keyhandlers.go @@ -274,7 +274,7 @@ func (m *Model) handleQuitKey() (tea.Model, tea.Cmd) { } if m.showTaskDetail { m.showTaskDetail = false - m.currentTaskDetail = nil + m.currentTaskDetailID = 0 m.detailSearching = false m.detailSearchRegex = nil m.detailSearchInput.SetValue("") @@ -313,7 +313,7 @@ func (m *Model) handleEscapeKey() (tea.Model, tea.Cmd) { } if m.showTaskDetail { m.showTaskDetail = false - m.currentTaskDetail = nil + m.currentTaskDetailID = 0 m.detailSearching = false m.detailSearchRegex = nil m.detailSearchInput.SetValue("") diff --git a/internal/ui/table.go b/internal/ui/table.go index f7e9ed6..69e8dff 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -82,15 +82,15 @@ type searchState struct { // Blink fields here are separate from blinkState because they drive a // per-field highlight inside the detail view rather than a table row. type detailViewState struct { - showTaskDetail bool - currentTaskDetail *task.Task - detailSearching bool - detailSearchInput textinput.Model - detailSearchRegex *regexp.Regexp - detailFieldIndex int // currently selected field (-1 = none) - detailBlinkField int // field currently blinking (-1 = none) - detailBlinkOn bool // whether the blink is currently on - detailBlinkCount int // number of blink cycles completed so far + showTaskDetail bool + currentTaskDetailID int + detailSearching bool + detailSearchInput textinput.Model + detailSearchRegex *regexp.Regexp + detailFieldIndex int // currently selected field (-1 = none) + detailBlinkField int // field currently blinking (-1 = none) + detailBlinkOn bool // whether the blink is currently on + detailBlinkCount int // number of blink cycles completed so far } // ultraState holds the state for the ultra mode task list and its search UI. diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index a74fcce..e30e6d7 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -209,6 +209,81 @@ func TestNewWithTaskwarriorRejectsNilClient(t *testing.T) { } } +func TestHandleShowTaskDetailTracksTaskID(t *testing.T) { + fake := &fakeTaskwarrior{ + tasks: []task.Task{ + {ID: 1, UUID: "fake-1", Description: "original detail", Status: "pending"}, + }, + } + m, err := NewWithTaskwarrior(nil, "firefox", fake) + if err != nil { + t.Fatalf("NewWithTaskwarrior: %v", err) + } + + mv, _ := (&m).handleShowTaskDetail() + m = *mv.(*Model) + + if !m.showTaskDetail { + t.Fatalf("detail view was not shown") + } + if m.currentTaskDetailID != 1 { + t.Fatalf("current detail task ID = %d, want 1", m.currentTaskDetailID) + } + if got := m.renderTaskDetail(); !strings.Contains(got, "original detail") { + t.Fatalf("rendered detail %q does not include original task description", got) + } +} + +func TestTaskDetailUsesReplacedTaskSlice(t *testing.T) { + fake := &fakeTaskwarrior{ + tasks: []task.Task{ + {ID: 1, UUID: "fake-1", Description: "old detail", Status: "pending"}, + }, + } + m, err := NewWithTaskwarrior(nil, "firefox", fake) + if err != nil { + t.Fatalf("NewWithTaskwarrior: %v", err) + } + + mv, _ := (&m).handleShowTaskDetail() + m = *mv.(*Model) + m.tasks = []task.Task{ + {ID: 1, UUID: "fake-1", Description: "new detail", Status: "pending"}, + } + + got := m.renderTaskDetail() + if !strings.Contains(got, "new detail") { + t.Fatalf("rendered detail %q does not include replacement task description", got) + } + if strings.Contains(got, "old detail") { + t.Fatalf("rendered detail %q still includes stale task description", got) + } +} + +func TestRefreshCurrentTaskDetailClosesMissingTask(t *testing.T) { + m := Model{ + detailViewState: detailViewState{ + showTaskDetail: true, + currentTaskDetailID: 1, + }, + tasks: []task.Task{ + {ID: 2, UUID: "fake-2", Description: "different task", Status: "pending"}, + }, + } + + m.refreshCurrentTaskDetail() + + if m.showTaskDetail { + t.Fatalf("detail view stayed open for missing task") + } + if m.currentTaskDetailID != 0 { + t.Fatalf("current detail task ID = %d, want 0", m.currentTaskDetailID) + } + if got := m.renderTaskDetail(); got != "No task selected" { + t.Fatalf("rendered detail = %q, want no task selected", got) + } +} + func TestZeroValueModelTaskwarriorClientFailsFast(t *testing.T) { defer func() { r := recover() @@ -443,7 +518,7 @@ func TestHandleDescEditDoneUpdatesDescriptionAndRemovesTempFile(t *testing.T) { if err != nil { t.Fatalf("New: %v", err) } - m.currentTaskDetail = &task.Task{ID: 1, Description: "old description"} + m.currentTaskDetailID = 1 m.showTaskDetail = true m.detailDescEditing = true @@ -1310,8 +1385,9 @@ func TestHandleRecurrenceModeDetailBlinkTargetsRecurField(t *testing.T) { m := newRecurrenceDetailModel(t, "") m.showTaskDetail = true - m.currentTaskDetail = &m.tasks[0] - m.activateRecurEdit(m.currentTaskDetail.ID, m.currentTaskDetail.Recur) + m.currentTaskDetailID = m.tasks[0].ID + current := m.currentDetailTask() + m.activateRecurEdit(current.ID, current.Recur) m.recurInput.SetValue("daily") mv, cmd := (&m).handleRecurrenceMode(tea.KeyPressMsg{Code: tea.KeyEnter}) @@ -1320,11 +1396,12 @@ func TestHandleRecurrenceModeDetailBlinkTargetsRecurField(t *testing.T) { if cmd == nil { t.Fatalf("recurrence edit did not start a blink command") } - if m.currentTaskDetail == nil { + current = m.currentDetailTask() + if current == nil { t.Fatalf("current task detail was cleared") } - if m.currentTaskDetail.Recur != "daily" { - t.Fatalf("current detail recurrence = %q, want daily", m.currentTaskDetail.Recur) + if current.Recur != "daily" { + t.Fatalf("current detail recurrence = %q, want daily", current.Recur) } if m.detailBlinkField != fieldRecur { t.Fatalf("detail blink field = %d, want recurrence field %d", m.detailBlinkField, fieldRecur) @@ -1341,9 +1418,10 @@ func TestHandleRecurrenceModeDetailFallsBackWhenRecurrenceRemoved(t *testing.T) m := newRecurrenceDetailModel(t, "daily") m.showTaskDetail = true - m.currentTaskDetail = &m.tasks[0] + m.currentTaskDetailID = m.tasks[0].ID m.detailBlinkField = -1 - m.activateRecurEdit(m.currentTaskDetail.ID, m.currentTaskDetail.Recur) + current := m.currentDetailTask() + m.activateRecurEdit(current.ID, current.Recur) m.recurInput.SetValue("") mv, cmd := (&m).handleRecurrenceMode(tea.KeyPressMsg{Code: tea.KeyEnter}) @@ -1352,11 +1430,12 @@ func TestHandleRecurrenceModeDetailFallsBackWhenRecurrenceRemoved(t *testing.T) if cmd == nil { t.Fatalf("recurrence removal did not start a fallback blink command") } - if m.currentTaskDetail == nil { + current = m.currentDetailTask() + if current == nil { t.Fatalf("current task detail was cleared") } - if m.currentTaskDetail.Recur != "" { - t.Fatalf("current detail recurrence = %q, want empty", m.currentTaskDetail.Recur) + if current.Recur != "" { + t.Fatalf("current detail recurrence = %q, want empty", current.Recur) } if m.detailBlinkField == fieldRecur { t.Fatalf("detail blink targeted recurrence field after recurrence row was removed") diff --git a/internal/ui/taskdetail.go b/internal/ui/taskdetail.go index f8417cf..b39af51 100644 --- a/internal/ui/taskdetail.go +++ b/internal/ui/taskdetail.go @@ -6,6 +6,8 @@ import ( "strings" "charm.land/lipgloss/v2" + + "codeberg.org/snonux/tasksamurai/internal/task" ) // wordWrap wraps text to fit within the specified width, breaking at word boundaries @@ -59,10 +61,10 @@ const ( // It delegates each visual section to a focused helper so that the // overall structure is easy to follow at a glance. func (m *Model) renderTaskDetail() string { - if m.currentTaskDetail == nil { + t := m.currentDetailTask() + if t == nil { return "No task selected" } - t := m.currentTaskDetail titleStyle, labelStyle, valueStyle, descStyle := m.detailStyles() @@ -99,7 +101,7 @@ func (m *Model) detailStyles() (title, label, value, desc lipgloss.Style) { // optional Recurrence) to lines and returns the updated slice together with the // field index of the next unrendered field (Description). func (m *Model) renderDetailFieldRows(lines []string, labelStyle, valueStyle lipgloss.Style) ([]string, int) { - t := m.currentTaskDetail + t := m.currentDetailTask() cf := 0 // current field counter lines = append(lines, m.renderTaskFieldWithIndex("ID", fmt.Sprintf("%d", t.ID), labelStyle, valueStyle, cf)) @@ -130,7 +132,7 @@ func (m *Model) renderDetailFieldRows(lines []string, labelStyle, valueStyle lip // renderDetailPriorityField renders the Priority row, showing the selection // widget when the user is actively changing it. func (m *Model) renderDetailPriorityField(labelStyle, valueStyle lipgloss.Style, cf int) string { - t := m.currentTaskDetail + t := m.currentDetailTask() if m.prioritySelecting && m.priorityID == t.ID { return m.renderEditingField("Priority", m.priorityView(false), labelStyle, cf) } @@ -156,7 +158,7 @@ func (m *Model) renderDetailPriorityField(labelStyle, valueStyle lipgloss.Style, // renderDetailTagsField renders the Tags row, showing the text input when // the user is actively editing it. func (m *Model) renderDetailTagsField(labelStyle, valueStyle lipgloss.Style, cf int) string { - t := m.currentTaskDetail + t := m.currentDetailTask() if m.tagsEditing && m.tagsID == t.ID { orig := m.tagsInput.Prompt m.tagsInput.Prompt = "" @@ -174,7 +176,7 @@ func (m *Model) renderDetailTagsField(labelStyle, valueStyle lipgloss.Style, cf // renderDetailDueField renders the Due row, showing the date picker when // the user is actively editing it. func (m *Model) renderDetailDueField(labelStyle, valueStyle lipgloss.Style, cf int) string { - t := m.currentTaskDetail + t := m.currentDetailTask() if m.dueEditing && m.dueID == t.ID { return m.renderEditingField("Due", m.dueView(false), labelStyle, cf) } @@ -184,7 +186,7 @@ func (m *Model) renderDetailDueField(labelStyle, valueStyle lipgloss.Style, cf i // renderDetailProjectField renders the Project row, showing the text input // when the user is actively editing it. func (m *Model) renderDetailProjectField(labelStyle, valueStyle lipgloss.Style, cf int) string { - t := m.currentTaskDetail + t := m.currentDetailTask() if m.projEditing && m.projID == t.ID { orig := m.projInput.Prompt m.projInput.Prompt = "" @@ -202,7 +204,7 @@ func (m *Model) renderDetailProjectField(labelStyle, valueStyle lipgloss.Style, // renderDetailRecurField renders the Recurrence row, showing the text input // when the user is actively editing it. func (m *Model) renderDetailRecurField(labelStyle lipgloss.Style, cf int) string { - t := m.currentTaskDetail + t := m.currentDetailTask() if m.recurEditing && m.recurID == t.ID { orig := m.recurInput.Prompt m.recurInput.Prompt = "" @@ -216,7 +218,7 @@ func (m *Model) renderDetailRecurField(labelStyle lipgloss.Style, cf int) string // renderDetailDescription appends the Description section (label + wrapped body) // to lines, applying selection/blink highlighting and search match colouring. func (m *Model) renderDetailDescription(lines []string, cf int, labelStyle, descStyle lipgloss.Style) []string { - t := m.currentTaskDetail + t := m.currentDetailTask() lines = append(lines, "") ls, vs := labelStyle, descStyle @@ -255,7 +257,7 @@ func (m *Model) renderDetailDescription(lines []string, cf int, labelStyle, desc // renderDetailAnnotations appends the Annotations section to lines when the // task has annotations, applying selection highlighting and search colouring. func (m *Model) renderDetailAnnotations(lines []string, cf int, labelStyle, descStyle lipgloss.Style) []string { - t := m.currentTaskDetail + t := m.currentDetailTask() if len(t.Annotations) == 0 { return lines } @@ -358,23 +360,34 @@ func (m *Model) formatTaskDate(dateStr string) string { return dateStr } -// refreshCurrentTaskDetail updates the current task detail pointer after a reload -func (m *Model) refreshCurrentTaskDetail() { - if m.currentTaskDetail == nil { - return - } - - id := m.currentTaskDetail.ID +func (m *Model) taskByID(id int) *task.Task { for i := range m.tasks { if m.tasks[i].ID == id { - m.currentTaskDetail = &m.tasks[i] - return + return &m.tasks[i] } } + return nil +} + +func (m *Model) currentDetailTask() *task.Task { + if !m.showTaskDetail || m.currentTaskDetailID == 0 { + return nil + } + return m.taskByID(m.currentTaskDetailID) +} + +// refreshCurrentTaskDetail validates detail state after a reload. +func (m *Model) refreshCurrentTaskDetail() { + if m.currentTaskDetailID == 0 { + return + } + if m.currentDetailTask() != nil { + return + } // Task no longer exists, clear detail view m.showTaskDetail = false - m.currentTaskDetail = nil + m.currentTaskDetailID = 0 } // detailDescriptionFieldIndex returns the navigable field index for the @@ -382,7 +395,8 @@ func (m *Model) refreshCurrentTaskDetail() { // occupies index fieldRecur (9), pushing Description to index 10. Without // Recur, Description is at index 9. func (m *Model) detailDescriptionFieldIndex() int { - if m.currentTaskDetail != nil && m.currentTaskDetail.Recur != "" { + t := m.currentDetailTask() + if t != nil && t.Recur != "" { return fieldRecur + 1 // 10 } return fieldRecur // 9 @@ -390,7 +404,8 @@ func (m *Model) detailDescriptionFieldIndex() int { // getDetailFieldCount returns the actual number of navigable fields for the current task func (m *Model) getDetailFieldCount() int { - if m.currentTaskDetail == nil { + t := m.currentDetailTask() + if t == nil { return 0 } @@ -398,12 +413,12 @@ func (m *Model) getDetailFieldCount() int { count := 10 // Add recurrence if present - if m.currentTaskDetail.Recur != "" { + if t.Recur != "" { count++ } // Add annotations if present - if len(m.currentTaskDetail.Annotations) > 0 { + if len(t.Annotations) > 0 { count++ } -- cgit v1.2.3