Add offline-first task-master TUI with tickets, board lists, and undo.

Charm-based CLI with JSON persistence, status/list filters, external editor editing, command-history undo/redo, and glamour markdown without AutoStyle OSC hangs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Skruecken
2026-10-19 10:21:48 +02:00
co-authored by Cursor
parent 4a3d0d777b
commit 24c1e146ec
37 changed files with 3259 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
package command
import "context"
// Command is a reversible unit of work for future offline sync / undo trees.
type Command interface {
Execute(ctx context.Context) error
Undo(ctx context.Context) error
Name() string
}
+124
View File
@@ -0,0 +1,124 @@
package command
import (
"context"
"fmt"
"sync"
)
// Executor runs commands and supports undo/redo of successful ones.
type Executor interface {
Do(cmd Command) error
Undo() error
Redo() error
CanUndo() bool
CanRedo() bool
LastUndoName() string
LastRedoName() string
}
// History is an in-memory undo/redo stack for Command values.
type History struct {
mu sync.Mutex
undoS []Command
redoS []Command
}
// NewHistory creates an empty command history.
func NewHistory() *History {
return &History{}
}
// Do executes cmd and pushes it onto the undo stack. The redo stack is cleared.
func (h *History) Do(cmd Command) error {
if err := cmd.Execute(context.Background()); err != nil {
return err
}
h.mu.Lock()
defer h.mu.Unlock()
h.undoS = append(h.undoS, cmd)
h.redoS = nil
return nil
}
// Undo reverses the last successful command.
func (h *History) Undo() error {
h.mu.Lock()
if len(h.undoS) == 0 {
h.mu.Unlock()
return fmt.Errorf("nothing to undo")
}
cmd := h.undoS[len(h.undoS)-1]
h.undoS = h.undoS[:len(h.undoS)-1]
h.mu.Unlock()
if err := cmd.Undo(context.Background()); err != nil {
h.mu.Lock()
h.undoS = append(h.undoS, cmd)
h.mu.Unlock()
return err
}
h.mu.Lock()
h.redoS = append(h.redoS, cmd)
h.mu.Unlock()
return nil
}
// Redo re-applies the last undone command.
func (h *History) Redo() error {
h.mu.Lock()
if len(h.redoS) == 0 {
h.mu.Unlock()
return fmt.Errorf("nothing to redo")
}
cmd := h.redoS[len(h.redoS)-1]
h.redoS = h.redoS[:len(h.redoS)-1]
h.mu.Unlock()
if err := cmd.Execute(context.Background()); err != nil {
h.mu.Lock()
h.redoS = append(h.redoS, cmd)
h.mu.Unlock()
return err
}
h.mu.Lock()
h.undoS = append(h.undoS, cmd)
h.mu.Unlock()
return nil
}
// CanUndo reports whether Undo would succeed.
func (h *History) CanUndo() bool {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.undoS) > 0
}
// CanRedo reports whether Redo would succeed.
func (h *History) CanRedo() bool {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.redoS) > 0
}
// LastUndoName returns the name of the next undo command, or "".
func (h *History) LastUndoName() string {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.undoS) == 0 {
return ""
}
return h.undoS[len(h.undoS)-1].Name()
}
// LastRedoName returns the name of the next redo command, or "".
func (h *History) LastRedoName() string {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.redoS) == 0 {
return ""
}
return h.redoS[len(h.redoS)-1].Name()
}
+90
View File
@@ -0,0 +1,90 @@
package command_test
import (
"testing"
"task/internal/command"
"task/internal/ticket"
)
func TestHistoryUndoRedoCreateDelete(t *testing.T) {
store := ticket.NewJSONStore(t.TempDir())
h := command.NewHistory()
create := &command.CreateTicket{
Store: store,
Ticket: ticket.Ticket{
Title: "A",
ProjectID: "inbox",
Status: ticket.StatusTodo,
},
}
if err := h.Do(create); err != nil {
t.Fatal(err)
}
list, err := store.List("inbox")
if err != nil || len(list) != 1 {
t.Fatalf("after create: %v len=%d", err, len(list))
}
if err := h.Undo(); err != nil {
t.Fatal(err)
}
list, err = store.List("inbox")
if err != nil || len(list) != 0 {
t.Fatalf("after undo create: %v len=%d", err, len(list))
}
if err := h.Redo(); err != nil {
t.Fatal(err)
}
list, err = store.List("inbox")
if err != nil || len(list) != 1 {
t.Fatalf("after redo create: %v len=%d", err, len(list))
}
del := &command.DeleteTicket{Store: store, Snapshot: list[0]}
if err := h.Do(del); err != nil {
t.Fatal(err)
}
list, _ = store.List("inbox")
if len(list) != 0 {
t.Fatal("expected empty after delete")
}
if err := h.Undo(); err != nil {
t.Fatal(err)
}
list, _ = store.List("inbox")
if len(list) != 1 {
t.Fatal("expected restored after undo delete")
}
}
func TestHistoryUpdateUndo(t *testing.T) {
store := ticket.NewJSONStore(t.TempDir())
created, err := store.Create(ticket.Ticket{
Title: "A", ProjectID: "inbox", Status: ticket.StatusTodo,
})
if err != nil {
t.Fatal(err)
}
after := created
after.Status = ticket.StatusDone
after.Title = "B"
h := command.NewHistory()
if err := h.Do(&command.UpdateTicket{Store: store, Before: created, After: after}); err != nil {
t.Fatal(err)
}
got, _ := store.Get(created.ID)
if got.Status != ticket.StatusDone || got.Title != "B" {
t.Fatalf("got %+v", got)
}
if err := h.Undo(); err != nil {
t.Fatal(err)
}
got, _ = store.Get(created.ID)
if got.Status != ticket.StatusTodo || got.Title != "A" {
t.Fatalf("undo got %+v", got)
}
}
+81
View File
@@ -0,0 +1,81 @@
package command
import (
"context"
"fmt"
"task/internal/ticket"
)
// CreateTicket creates a ticket; Undo deletes it.
type CreateTicket struct {
Store ticket.Store
Ticket ticket.Ticket
created ticket.Ticket
}
func (c *CreateTicket) Name() string { return "create ticket" }
func (c *CreateTicket) Execute(ctx context.Context) error {
_ = ctx
created, err := c.Store.Create(c.Ticket)
if err != nil {
return err
}
c.created = created
c.Ticket = created
return nil
}
func (c *CreateTicket) Undo(ctx context.Context) error {
_ = ctx
id := c.created.ID
if id == "" {
id = c.Ticket.ID
}
if id == "" {
return fmt.Errorf("create undo: missing ticket id")
}
return c.Store.Delete(id)
}
// DeleteTicket deletes a ticket; Undo restores the snapshot.
type DeleteTicket struct {
Store ticket.Store
Snapshot ticket.Ticket
}
func (c *DeleteTicket) Name() string { return "delete ticket" }
func (c *DeleteTicket) Execute(ctx context.Context) error {
_ = ctx
if c.Snapshot.ID == "" {
return fmt.Errorf("delete: missing ticket id")
}
return c.Store.Delete(c.Snapshot.ID)
}
func (c *DeleteTicket) Undo(ctx context.Context) error {
_ = ctx
_, err := c.Store.Create(c.Snapshot)
return err
}
// UpdateTicket applies a new ticket state; Undo restores Before.
type UpdateTicket struct {
Store ticket.Store
Before ticket.Ticket
After ticket.Ticket
}
func (c *UpdateTicket) Name() string { return "update ticket" }
func (c *UpdateTicket) Execute(ctx context.Context) error {
_ = ctx
return c.Store.Update(c.After)
}
func (c *UpdateTicket) Undo(ctx context.Context) error {
_ = ctx
return c.Store.Update(c.Before)
}