summaryrefslogtreecommitdiff
path: root/internal/ui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-22 13:51:57 +0300
committerPaul Buetow <paul@buetow.org>2026-06-22 13:51:57 +0300
commitc3f3c356beda685e0327fd7346b43310fba990ab (patch)
tree49c3a6b172ffa7215839ecd778358be2567552c0 /internal/ui
parentea9fe5e52b3a95651792b5781327f6100afd8fcb (diff)
Fix lq0 task mutation cancellation
Diffstat (limited to 'internal/ui')
-rw-r--r--internal/ui/editor_handlers.go4
-rw-r--r--internal/ui/handlers.go33
-rw-r--r--internal/ui/keyactions.go49
-rw-r--r--internal/ui/table.go18
-rw-r--r--internal/ui/table_test.go41
5 files changed, 111 insertions, 34 deletions
diff --git a/internal/ui/editor_handlers.go b/internal/ui/editor_handlers.go
index 8f81420..2db20d0 100644
--- a/internal/ui/editor_handlers.go
+++ b/internal/ui/editor_handlers.go
@@ -56,7 +56,9 @@ func (m *Model) handleDescEditDone(msg descEditDoneMsg) (tea.Model, tea.Cmd) {
// Update the description
newDesc := strings.TrimSpace(string(content))
if m.currentTaskDetail != nil {
- err = task.SetDescription(m.currentTaskDetail.ID, newDesc)
+ ctx, cancel := m.taskOperationContext()
+ err = task.SetDescriptionContext(ctx, m.currentTaskDetail.ID, newDesc)
+ cancel()
if err != nil {
m.statusMsg = fmt.Sprintf("Error updating description: %v", err)
cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg {
diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go
index 45c0b10..05309b2 100644
--- a/internal/ui/handlers.go
+++ b/internal/ui/handlers.go
@@ -47,14 +47,14 @@ func (m *Model) handleAnnotationMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
if m.replaceAnnotations {
- ctx, cancel := m.taskExportContext()
+ ctx, cancel := m.taskOperationContext()
defer cancel()
if err := task.ReplaceAnnotations(ctx, m.annotateID, value); err != nil {
return err
}
m.replaceAnnotations = false
} else {
- ctx, cancel := m.taskExportContext()
+ ctx, cancel := m.taskOperationContext()
defer cancel()
if err := task.AnnotateContext(ctx, m.annotateID, value); err != nil {
return err
@@ -85,7 +85,9 @@ func (m *Model) handleDescriptionMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd)
if err := validateDescription(value); err != nil {
return err
}
- if err := task.SetDescription(m.descID, value); err != nil {
+ ctx, cancel := m.taskOperationContext()
+ defer cancel()
+ if err := task.SetDescriptionContext(ctx, m.descID, value); err != nil {
return err
}
if err := m.reload(); err != nil {
@@ -130,7 +132,7 @@ func (m *Model) handleTagsMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
}
if len(adds) > 0 || len(removes) > 0 {
- ctx, cancel := m.taskExportContext()
+ ctx, cancel := m.taskOperationContext()
defer cancel()
if len(adds) > 0 {
if err := task.AddTagsContext(ctx, m.tagsID, adds); err != nil {
@@ -168,7 +170,10 @@ func (m *Model) handleTagsMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
func (m *Model) handleDueEditMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
- if err := task.SetDueDate(m.dueID, m.dueDate.Format("2006-01-02")); err != nil {
+ ctx, cancel := m.taskOperationContext()
+ err := task.SetDueDateContext(ctx, m.dueID, m.dueDate.Format("2006-01-02"))
+ cancel()
+ if err != nil {
m.statusMsg = fmt.Sprintf("Error: %v", err)
cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg {
return struct{ clearStatus bool }{true}
@@ -213,7 +218,9 @@ func (m *Model) handleRecurrenceMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if err := validateRecurrence(value); err != nil {
return err
}
- if err := task.SetRecurrence(m.recurID, value); err != nil {
+ ctx, cancel := m.taskOperationContext()
+ defer cancel()
+ if err := task.SetRecurrenceContext(ctx, m.recurID, value); err != nil {
return err
}
if err := m.reload(); err != nil {
@@ -244,7 +251,9 @@ func (m *Model) handleRecurrenceMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
// handleProjectMode handles project editing
func (m *Model) handleProjectMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
onEnter := func(value string) error {
- return task.SetProject(m.projID, value)
+ ctx, cancel := m.taskOperationContext()
+ defer cancel()
+ return task.SetProjectContext(ctx, m.projID, value)
}
onExit := func() {
@@ -275,7 +284,10 @@ func (m *Model) handlePriorityMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
})
return m, cmd
}
- if err := task.SetPriority(m.priorityID, priority); err != nil {
+ ctx, cancel := m.taskOperationContext()
+ err := task.SetPriorityContext(ctx, m.priorityID, priority)
+ cancel()
+ if err != nil {
m.statusMsg = fmt.Sprintf("Error: %v", err)
cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg {
return struct{ clearStatus bool }{true}
@@ -351,7 +363,10 @@ func (m *Model) handleAddTaskMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
oldIDs[tsk.ID] = struct{}{}
}
- if err := task.AddLine(m.addInput.Value()); err != nil {
+ ctx, cancel := m.taskOperationContext()
+ err := task.AddLineContext(ctx, m.addInput.Value())
+ cancel()
+ if err != nil {
m.statusMsg = fmt.Sprintf("Error: %v", err)
cmd := tea.Tick(2*time.Second, func(time.Time) tea.Msg {
return struct{ clearStatus bool }{true}
diff --git a/internal/ui/keyactions.go b/internal/ui/keyactions.go
index c84c172..4d8c682 100644
--- a/internal/ui/keyactions.go
+++ b/internal/ui/keyactions.go
@@ -1,6 +1,7 @@
package ui
import (
+ "context"
"fmt"
"math/rand"
"os/exec"
@@ -38,12 +39,18 @@ func (m *Model) handleToggleStart() (tea.Model, tea.Cmd) {
}
if started {
- if err := task.Stop(id); err != nil {
+ ctx, cancel := m.taskOperationContext()
+ err := task.StopContext(ctx, id)
+ cancel()
+ if err != nil {
m.showError(err)
return m, nil
}
} else {
- if err := task.Start(id); err != nil {
+ ctx, cancel := m.taskOperationContext()
+ err := task.StartContext(ctx, id)
+ cancel()
+ if err != nil {
m.showError(err)
return m, nil
}
@@ -119,12 +126,15 @@ func (m *Model) handleUndo() (tea.Model, tea.Cmd) {
}
action := m.undoStack[len(m.undoStack)-1]
+ ctx, cancel := m.taskOperationContext()
for _, restore := range action.restores {
- if err := task.SetStatusUUID(restore.uuid, restore.status); err != nil {
+ if err := task.SetStatusUUIDContext(ctx, restore.uuid, restore.status); err != nil {
+ cancel()
m.showError(err)
return m, nil
}
}
+ cancel()
m.undoStack = m.undoStack[:len(m.undoStack)-1]
// Reload the task list to get the updated task with its new ID
@@ -159,7 +169,7 @@ func (m *Model) handleUndo() (tea.Model, tea.Cmd) {
}
filters = append(filters, "status:"+restore.status)
- ctx, cancel := m.taskExportContext()
+ ctx, cancel := m.taskOperationContext()
tasks, err := task.Export(ctx, filters...)
cancel()
if err == nil && len(tasks) > 0 {
@@ -199,10 +209,10 @@ func (m *Model) deleteTaskWithUndo(tsk task.Task) (int, bool, error) {
recurring := isRecurringTask(tsk)
tasks := []task.Task{tsk}
+ ctx, cancel := m.taskOperationContext()
+ defer cancel()
if recurring {
- ctx, cancel := m.taskExportContext()
series, err := task.RecurringSeries(ctx, recurringRootUUID(tsk))
- cancel()
if err != nil {
return 0, true, fmt.Errorf("loading recurring series: %w", err)
}
@@ -223,8 +233,8 @@ func (m *Model) deleteTaskWithUndo(tsk task.Task) (int, bool, error) {
completed := make([]undoRestore, 0, len(restores))
for _, restore := range restores {
- if err := task.SetStatusUUID(restore.uuid, "deleted"); err != nil {
- rollbackUndoRestores(completed)
+ if err := task.SetStatusUUIDContext(ctx, restore.uuid, "deleted"); err != nil {
+ rollbackUndoRestores(ctx, completed)
return 0, recurring, fmt.Errorf("deleting task %s: %w", restore.uuid, err)
}
completed = append(completed, restore)
@@ -294,9 +304,9 @@ func undoStatusForTask(tsk task.Task) string {
return tsk.Status
}
-func rollbackUndoRestores(restores []undoRestore) {
+func rollbackUndoRestores(ctx context.Context, restores []undoRestore) {
for i := len(restores) - 1; i >= 0; i-- {
- _ = task.SetStatusUUID(restores[i].uuid, restores[i].status)
+ _ = task.SetStatusUUIDContext(ctx, restores[i].uuid, restores[i].status)
}
}
@@ -328,7 +338,10 @@ func (m *Model) handleRemoveDueDate() (tea.Model, tea.Cmd) {
}
// In Taskwarrior, passing an empty value to due: removes the due date
- if err := task.SetDueDate(id, ""); err != nil {
+ ctx, cancel := m.taskOperationContext()
+ err = task.SetDueDateContext(ctx, id, "")
+ cancel()
+ if err != nil {
m.showError(err)
return m, nil
}
@@ -348,7 +361,10 @@ func (m *Model) handleRandomDueDate() (tea.Model, tea.Cmd) {
days := rand.Intn(31) + 7
due := time.Now().AddDate(0, 0, days).Format("2006-01-02")
- if err := task.SetDueDate(id, due); err != nil {
+ ctx, cancel := m.taskOperationContext()
+ err = task.SetDueDateContext(ctx, id, due)
+ cancel()
+ if err != nil {
m.showError(err)
return m, nil
}
@@ -489,18 +505,21 @@ func (m *Model) handleTagToProject() (tea.Model, tea.Cmd) {
firstTag := currentTask.Tags[0]
// Set the tag as project
- if err := task.SetProject(id, firstTag); err != nil {
+ ctx, cancel := m.taskOperationContext()
+ err = task.SetProjectContext(ctx, id, firstTag)
+ if err != nil {
+ cancel()
m.showError(err)
return m, nil
}
// Remove the tag from the task
- ctx, cancel := m.taskExportContext()
- defer cancel()
if err := task.RemoveTagsContext(ctx, id, []string{firstTag}); err != nil {
+ cancel()
m.showError(err)
return m, nil
}
+ cancel()
if !m.reloadAndReport() {
return m, nil
diff --git a/internal/ui/table.go b/internal/ui/table.go
index c948d89..f37d2ab 100644
--- a/internal/ui/table.go
+++ b/internal/ui/table.go
@@ -25,7 +25,7 @@ import (
var priorityOptions = []string{"H", "M", "L", ""}
-const taskExportTimeout = 30 * time.Second
+const taskOperationTimeout = 30 * time.Second
var (
urlRegex = regexp.MustCompile(`https?://\S+`)
@@ -278,9 +278,9 @@ func (m *Model) cancelTaskOperations() {
}
}
-func (m *Model) taskExportContext() (context.Context, context.CancelFunc) {
+func (m *Model) taskOperationContext() (context.Context, context.CancelFunc) {
m.initTaskContext()
- return context.WithTimeout(m.taskContext, taskExportTimeout)
+ return context.WithTimeout(m.taskContext, taskOperationTimeout)
}
// blinkInterval controls how quickly the row flashes when a task changes.
@@ -397,7 +397,10 @@ func (m *Model) startBlink(id int, markDone bool) tea.Cmd {
break
}
}
- if err := task.Done(id); err != nil {
+ ctx, cancel := m.taskOperationContext()
+ err := task.DoneContext(ctx, id)
+ cancel()
+ if err != nil {
m.showError(err)
}
}
@@ -510,7 +513,7 @@ func (m *Model) fetchTasks() (reloadData, error) {
// Always show only pending tasks by default.
filters := append([]string(nil), m.filters...)
filters = append(filters, "status:pending")
- ctx, cancel := m.taskExportContext()
+ ctx, cancel := m.taskOperationContext()
defer cancel()
tasks, err := task.Export(ctx, filters...)
@@ -752,7 +755,10 @@ func (m *Model) handleBlinkMsg() (tea.Model, tea.Cmd) {
break
}
}
- if err := task.Done(id); err != nil {
+ ctx, cancel := m.taskOperationContext()
+ err := task.DoneContext(ctx, id)
+ cancel()
+ if err != nil {
m.showError(err)
}
}
diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go
index 5549e97..8880a03 100644
--- a/internal/ui/table_test.go
+++ b/internal/ui/table_test.go
@@ -1503,9 +1503,9 @@ func TestEscDoesNotQuitFromTable(t *testing.T) {
}
}
-func TestQuitCancelsTaskExportContext(t *testing.T) {
+func TestQuitCancelsTaskOperationContext(t *testing.T) {
m := Model{}
- ctx, cancel := m.taskExportContext()
+ ctx, cancel := m.taskOperationContext()
defer cancel()
_, cmd := m.handleQuitKey()
@@ -1513,7 +1513,42 @@ func TestQuitCancelsTaskExportContext(t *testing.T) {
t.Fatal("quit returned nil command; want tea.Quit")
}
if !errors.Is(ctx.Err(), context.Canceled) {
- t.Fatalf("task export context error = %v, want context canceled", ctx.Err())
+ t.Fatalf("task operation context error = %v, want context canceled", ctx.Err())
+ }
+}
+
+func TestCanceledTaskOperationContextReachesToggleStart(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := filepath.Join(tmp, "task")
+ startedFile := filepath.Join(tmp, "started")
+
+ script := fmt.Sprintf("#!/bin/sh\n"+
+ "if echo \"$@\" | grep -q export; then\n"+
+ " echo '{\"id\":1,\"uuid\":\"x\",\"description\":\"alpha\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n"+
+ " exit 0\n"+
+ "fi\n"+
+ "if [ \"$1\" = \"1\" ] && [ \"$2\" = \"start\" ]; then\n"+
+ " printf started > %q\n"+
+ " exit 0\n"+
+ "fi\n", startedFile)
+ if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ setupEnv(t, taskPath)
+
+ m, err := New(nil, "firefox")
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ m.cancelTaskOperations()
+
+ mv, _ := (&m).Update(tea.KeyPressMsg{Code: 's', Text: "s"})
+ m = *mv.(*Model)
+ if !strings.Contains(m.statusMsg, context.Canceled.Error()) {
+ t.Fatalf("status = %q, want context canceled error", m.statusMsg)
+ }
+ if _, err := os.Stat(startedFile); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("start command ran despite canceled model context; stat error = %v", err)
}
}