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:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user