summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-25 17:46:29 +0300
committerPaul Buetow <paul@buetow.org>2026-06-25 17:46:29 +0300
commit2c59b030590d99970b15f1d84158da72ddc28310 (patch)
treeeca6e4c90b51a1c8d7689242cc35f410063eb354
parent6ba890330df991eb12cd313a42cf2704f3c30227 (diff)
Fix rq0 detail view task state
-rw-r--r--internal/ui/detail_handlers.go16
-rw-r--r--internal/ui/editor_handlers.go5
-rw-r--r--internal/ui/handlers.go2
-rw-r--r--internal/ui/input_helpers.go4
-rw-r--r--internal/ui/keyactions.go32
-rw-r--r--internal/ui/keyhandlers.go4
-rw-r--r--internal/ui/table.go18
-rw-r--r--internal/ui/table_test.go101
-rw-r--r--internal/ui/taskdetail.go63
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++
}