summaryrefslogtreecommitdiff
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
parentea9fe5e52b3a95651792b5781327f6100afd8fcb (diff)
Fix lq0 task mutation cancellation
-rw-r--r--internal/task/task.go125
-rw-r--r--internal/task/task_test.go49
-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
7 files changed, 269 insertions, 50 deletions
diff --git a/internal/task/task.go b/internal/task/task.go
index e419f0a..5777af8 100644
--- a/internal/task/task.go
+++ b/internal/task/task.go
@@ -73,18 +73,26 @@ func runContext(ctx context.Context, args ...string) error {
// modifyTask runs a modify command with validation
func modifyTask(id int, args ...string) error {
+ return modifyTaskContext(context.Background(), id, args...)
+}
+
+func modifyTaskContext(ctx context.Context, id int, args ...string) error {
if id <= 0 {
return fmt.Errorf("invalid task ID: %d", id)
}
- return run(append([]string{strconv.Itoa(id), "modify"}, args...)...)
+ return runContext(ctx, append([]string{strconv.Itoa(id), "modify"}, args...)...)
}
// simpleTaskCommand runs a simple command on a task with validation
func simpleTaskCommand(id int, command string) error {
+ return simpleTaskCommandContext(context.Background(), id, command)
+}
+
+func simpleTaskCommandContext(ctx context.Context, id int, command string) error {
if id <= 0 {
return fmt.Errorf("invalid task ID: %d", id)
}
- return run(strconv.Itoa(id), command)
+ return runContext(ctx, strconv.Itoa(id), command)
}
// debugConfig groups the optional debug-logging state for the task package.
@@ -125,6 +133,12 @@ func SetDebugLog(path string) error {
// Add creates a new task with the given description and tags.
func Add(description string, tags []string) error {
+ return AddContext(context.Background(), description, tags)
+}
+
+// AddContext creates a new task with the given description and tags using ctx
+// for the underlying Taskwarrior command.
+func AddContext(ctx context.Context, description string, tags []string) error {
var args []string
for _, t := range tags {
if len(t) > 0 && t[0] != '+' {
@@ -133,25 +147,38 @@ func Add(description string, tags []string) error {
args = append(args, t)
}
args = append(args, description)
- return AddArgs(args)
+ return AddArgsContext(ctx, args)
}
// AddArgs runs "task add" with the provided arguments. Each element in args
// is passed as a separate command-line argument, allowing the caller to
// specify additional modifiers like due dates or tags.
func AddArgs(args []string) error {
- return run(append([]string{"add"}, args...)...)
+ return AddArgsContext(context.Background(), args)
+}
+
+// AddArgsContext runs "task add" with the provided arguments using ctx for the
+// underlying Taskwarrior command.
+func AddArgsContext(ctx context.Context, args []string) error {
+ return runContext(ctx, append([]string{"add"}, args...)...)
}
// AddLine splits the given line into shell words and runs "task add" with the
// resulting arguments. This allows users to pass raw Taskwarrior parameters
// such as "due:today" directly.
func AddLine(line string) error {
+ return AddLineContext(context.Background(), line)
+}
+
+// AddLineContext splits the given line into shell words and runs "task add"
+// with the resulting arguments using ctx for the underlying Taskwarrior
+// command.
+func AddLineContext(ctx context.Context, line string) error {
fields, err := shlex.Split(line)
if err != nil {
return err
}
- return AddArgs(fields)
+ return AddArgsContext(ctx, fields)
}
// RunLine splits line using shell-word rules and runs the resulting task
@@ -302,12 +329,24 @@ func Export(ctx context.Context, filters ...string) ([]Task, error) {
// SetStatus changes the status of the task with the given id.
func SetStatus(id int, status string) error {
- return modifyTask(id, "status:"+status)
+ return SetStatusContext(context.Background(), id, status)
+}
+
+// SetStatusContext changes the status of the task with the given id using ctx
+// for the underlying Taskwarrior command.
+func SetStatusContext(ctx context.Context, id int, status string) error {
+ return modifyTaskContext(ctx, id, "status:"+status)
}
// SetStatusUUID changes the status of the task with the given UUID.
func SetStatusUUID(uuid, status string) error {
- return run(uuid, "modify", "status:"+status)
+ return SetStatusUUIDContext(context.Background(), uuid, status)
+}
+
+// SetStatusUUIDContext changes the status of the task with the given UUID
+// using ctx for the underlying Taskwarrior command.
+func SetStatusUUIDContext(ctx context.Context, uuid, status string) error {
+ return runContext(ctx, uuid, "modify", "status:"+status)
}
// RecurringSeries returns the recurring template and generated instances for
@@ -321,27 +360,57 @@ func RecurringSeries(ctx context.Context, rootUUID string) ([]Task, error) {
// Start begins the task with the given id.
func Start(id int) error {
- return simpleTaskCommand(id, "start")
+ return StartContext(context.Background(), id)
+}
+
+// StartContext begins the task with the given id using ctx for the underlying
+// Taskwarrior command.
+func StartContext(ctx context.Context, id int) error {
+ return simpleTaskCommandContext(ctx, id, "start")
}
// Stop stops the task with the given id.
func Stop(id int) error {
- return simpleTaskCommand(id, "stop")
+ return StopContext(context.Background(), id)
+}
+
+// StopContext stops the task with the given id using ctx for the underlying
+// Taskwarrior command.
+func StopContext(ctx context.Context, id int) error {
+ return simpleTaskCommandContext(ctx, id, "stop")
}
// Done marks the task with the given id as completed.
func Done(id int) error {
- return simpleTaskCommand(id, "done")
+ return DoneContext(context.Background(), id)
+}
+
+// DoneContext marks the task with the given id as completed using ctx for the
+// underlying Taskwarrior command.
+func DoneContext(ctx context.Context, id int) error {
+ return simpleTaskCommandContext(ctx, id, "done")
}
// Delete removes the task with the given id.
func Delete(id int) error {
- return simpleTaskCommand(id, "delete")
+ return DeleteContext(context.Background(), id)
+}
+
+// DeleteContext removes the task with the given id using ctx for the
+// underlying Taskwarrior command.
+func DeleteContext(ctx context.Context, id int) error {
+ return simpleTaskCommandContext(ctx, id, "delete")
}
// SetPriority changes the priority of the task with the given id.
func SetPriority(id int, priority string) error {
- return modifyTask(id, "priority:"+priority)
+ return SetPriorityContext(context.Background(), id, priority)
+}
+
+// SetPriorityContext changes the priority of the task with the given id using
+// ctx for the underlying Taskwarrior command.
+func SetPriorityContext(ctx context.Context, id int, priority string) error {
+ return modifyTaskContext(ctx, id, "priority:"+priority)
}
// AddTags adds tags to the task with the given id.
@@ -435,22 +504,46 @@ func SetTags(ctx context.Context, id int, tags []string) error {
// SetRecurrence sets the recurrence for the task with the given id.
func SetRecurrence(id int, rec string) error {
- return modifyTask(id, "recur:"+rec)
+ return SetRecurrenceContext(context.Background(), id, rec)
+}
+
+// SetRecurrenceContext sets the recurrence for the task with the given id
+// using ctx for the underlying Taskwarrior command.
+func SetRecurrenceContext(ctx context.Context, id int, rec string) error {
+ return modifyTaskContext(ctx, id, "recur:"+rec)
}
// SetDueDate sets the due date for the task with the given id.
func SetDueDate(id int, due string) error {
- return modifyTask(id, "due:"+due)
+ return SetDueDateContext(context.Background(), id, due)
+}
+
+// SetDueDateContext sets the due date for the task with the given id using ctx
+// for the underlying Taskwarrior command.
+func SetDueDateContext(ctx context.Context, id int, due string) error {
+ return modifyTaskContext(ctx, id, "due:"+due)
}
// SetDescription changes the description of the task with the given id.
func SetDescription(id int, desc string) error {
- return modifyTask(id, "description:"+desc)
+ return SetDescriptionContext(context.Background(), id, desc)
+}
+
+// SetDescriptionContext changes the description of the task with the given id
+// using ctx for the underlying Taskwarrior command.
+func SetDescriptionContext(ctx context.Context, id int, desc string) error {
+ return modifyTaskContext(ctx, id, "description:"+desc)
}
// SetProject changes the project of the task with the given id.
func SetProject(id int, project string) error {
- return modifyTask(id, "project:"+project)
+ return SetProjectContext(context.Background(), id, project)
+}
+
+// SetProjectContext changes the project of the task with the given id using
+// ctx for the underlying Taskwarrior command.
+func SetProjectContext(ctx context.Context, id int, project string) error {
+ return modifyTaskContext(ctx, id, "project:"+project)
}
// Annotate adds an annotation to the task with the given id.
diff --git a/internal/task/task_test.go b/internal/task/task_test.go
index 2bca940..f35f167 100644
--- a/internal/task/task_test.go
+++ b/internal/task/task_test.go
@@ -262,6 +262,55 @@ func TestExportReturnsCapturedErrorOutput(t *testing.T) {
}
}
+func TestMutationHelpersHonorContextCancellation(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := filepath.Join(tmp, "task")
+ script := "#!/bin/sh\nsleep 5\n"
+ if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ origPath := os.Getenv("PATH")
+ t.Setenv("PATH", tmp+":"+origPath)
+
+ tests := []struct {
+ name string
+ run func(context.Context) error
+ }{
+ {"AddContext", func(ctx context.Context) error { return AddContext(ctx, "new task", []string{"tag"}) }},
+ {"AddArgsContext", func(ctx context.Context) error { return AddArgsContext(ctx, []string{"new task", "+tag"}) }},
+ {"AddLineContext", func(ctx context.Context) error { return AddLineContext(ctx, `"new task" +tag`) }},
+ {"SetStatusContext", func(ctx context.Context) error { return SetStatusContext(ctx, 1, "pending") }},
+ {"SetStatusUUIDContext", func(ctx context.Context) error { return SetStatusUUIDContext(ctx, "task-uuid", "pending") }},
+ {"StartContext", func(ctx context.Context) error { return StartContext(ctx, 1) }},
+ {"StopContext", func(ctx context.Context) error { return StopContext(ctx, 1) }},
+ {"DoneContext", func(ctx context.Context) error { return DoneContext(ctx, 1) }},
+ {"DeleteContext", func(ctx context.Context) error { return DeleteContext(ctx, 1) }},
+ {"SetPriorityContext", func(ctx context.Context) error { return SetPriorityContext(ctx, 1, "H") }},
+ {"SetRecurrenceContext", func(ctx context.Context) error { return SetRecurrenceContext(ctx, 1, "daily") }},
+ {"SetDueDateContext", func(ctx context.Context) error { return SetDueDateContext(ctx, 1, "tomorrow") }},
+ {"SetDescriptionContext", func(ctx context.Context) error { return SetDescriptionContext(ctx, 1, "new description") }},
+ {"SetProjectContext", func(ctx context.Context) error { return SetProjectContext(ctx, 1, "home") }},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+
+ start := time.Now()
+ err := tt.run(ctx)
+ elapsed := time.Since(start)
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("%s error = %v, want context deadline exceeded", tt.name, err)
+ }
+ if elapsed > time.Second {
+ t.Fatalf("%s took %s, expected prompt context cancellation", tt.name, elapsed)
+ }
+ })
+ }
+}
+
func TestSetTagsHonorsContextDuringMutations(t *testing.T) {
tmp := t.TempDir()
taskPath := filepath.Join(tmp, "task")
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)
}
}