summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Bütow <1224732+snonux@users.noreply.github.com>2025-06-20 21:13:33 +0300
committerPaul Bütow <1224732+snonux@users.noreply.github.com>2025-06-20 21:13:33 +0300
commitef46540bb01703ddf05f4397c89771bb4080bc0d (patch)
tree7a5d6edf23399c742173bac622a2527713a56eba
parent8de85810e32076b1e81588b2405e78bdefe94c93 (diff)
ui: add undo for done tasks
-rw-r--r--internal/ui/table.go11
-rw-r--r--internal/ui/table_test.go55
2 files changed, 66 insertions, 0 deletions
diff --git a/internal/ui/table.go b/internal/ui/table.go
index bd1d4ca..3f0a16f 100644
--- a/internal/ui/table.go
+++ b/internal/ui/table.go
@@ -38,6 +38,8 @@ type Model struct {
filter string
tasks []task.Task
+ undoStack []int
+
total int
inProgress int
due int
@@ -227,9 +229,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
idStr := ansi.Strip(row[0])
if id, err := strconv.Atoi(idStr); err == nil {
task.Done(id)
+ m.undoStack = append(m.undoStack, id)
m.reload()
}
}
+ case "U":
+ if n := len(m.undoStack); n > 0 {
+ id := m.undoStack[n-1]
+ m.undoStack = m.undoStack[:n-1]
+ task.SetStatus(id, "pending")
+ m.reload()
+ }
case "d":
if row := m.tbl.SelectedRow(); row != nil {
idStr := ansi.Strip(row[0])
@@ -295,6 +305,7 @@ func (m Model) View() string {
"E: edit task",
"s: toggle start/stop",
"D: mark task done",
+ "U: undo done",
"d: set due date",
"r: random due date",
"a: annotate task",
diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go
index f02d587..0891cf7 100644
--- a/internal/ui/table_test.go
+++ b/internal/ui/table_test.go
@@ -1,6 +1,7 @@
package ui
import (
+ "fmt"
"os"
"path/filepath"
"strings"
@@ -174,6 +175,60 @@ func TestDoneHotkey(t *testing.T) {
}
}
+func TestUndoHotkey(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := filepath.Join(tmp, "task")
+ logFile := filepath.Join(tmp, "log.txt")
+
+ script := fmt.Sprintf("#!/bin/sh\n"+
+ "if echo \"$@\" | grep -q export; then\n"+
+ " echo '{\"id\":1,\"uuid\":\"x\",\"description\":\"d\",\"status\":\"pending\",\"entry\":\"\",\"priority\":\"\",\"urgency\":0}'\n"+
+ " exit 0\n"+
+ "fi\n"+
+ "echo \"$@\" >> %s\n", logFile)
+
+ if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ origPath := os.Getenv("PATH")
+ os.Setenv("PATH", tmp+":"+origPath)
+ t.Cleanup(func() { os.Setenv("PATH", origPath) })
+
+ os.Setenv("TASKDATA", tmp)
+ os.Setenv("TASKRC", "/dev/null")
+ t.Cleanup(func() {
+ os.Unsetenv("TASKDATA")
+ os.Unsetenv("TASKRC")
+ })
+
+ m, err := New("")
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ mv, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}})
+ m = mv.(Model)
+ mv, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'U'}})
+ m = mv.(Model)
+
+ data, err := os.ReadFile(logFile)
+ if err != nil {
+ t.Fatalf("read log: %v", err)
+ }
+
+ lines := strings.Split(strings.TrimSpace(string(data)), "\n")
+ if len(lines) < 2 {
+ t.Fatalf("expected at least two commands, got %d", len(lines))
+ }
+ if lines[0] != "1 done" {
+ t.Fatalf("done not called: %q", lines[0])
+ }
+ if lines[1] != "1 modify status:pending" {
+ t.Fatalf("undo not called: %q", lines[1])
+ }
+}
+
func TestDueDateHotkey(t *testing.T) {
tmp := t.TempDir()
taskPath := filepath.Join(tmp, "task")