diff --git a/cmd/task-master/main.go b/cmd/task-master/main.go index d05cbd8..e7c054d 100644 --- a/cmd/task-master/main.go +++ b/cmd/task-master/main.go @@ -48,7 +48,10 @@ func run() error { return fmt.Errorf("ensure project: %w", err) } - history := command.NewHistory() + history, err := command.OpenHistory(command.HistoryPath(cfg.DataDir), store) + if err != nil { + return fmt.Errorf("history: %w", err) + } model := ui.New(cfg, store, projects, history) prog := tea.NewProgram(model, tea.WithAltScreen()) if _, err := prog.Run(); err != nil { diff --git a/internal/command/executor.go b/internal/command/executor.go index 96d6d34..c4836ce 100644 --- a/internal/command/executor.go +++ b/internal/command/executor.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "sync" + + "task/internal/ticket" ) // Executor runs commands and supports undo/redo of successful ones. @@ -17,16 +19,36 @@ type Executor interface { LastRedoName() string } -// History is an in-memory undo/redo stack for Command values. +// History is an undo/redo stack for Command values, optionally persisted to disk. type History struct { mu sync.Mutex undoS []Command redoS []Command + path string + store ticket.Store + max int } -// NewHistory creates an empty command history. +// NewHistory creates an empty in-memory command history (cap MaxHistory). func NewHistory() *History { - return &History{} + return &History{max: MaxHistory} +} + +// OpenHistory loads a persisted history from path (DataDir/history.json) or +// starts empty if the file is missing. Subsequent Do/Undo/Redo write back. +func OpenHistory(path string, store ticket.Store) (*History, error) { + h := &History{ + path: path, + store: store, + max: MaxHistory, + } + undo, redo, err := loadHistoryFile(path, store) + if err != nil { + return nil, err + } + h.undoS = undo + h.redoS = redo + return h, nil } // Do executes cmd and pushes it onto the undo stack. The redo stack is cleared. @@ -37,8 +59,9 @@ func (h *History) Do(cmd Command) error { h.mu.Lock() defer h.mu.Unlock() h.undoS = append(h.undoS, cmd) + h.undoS = trimStack(h.undoS, h.max) h.redoS = nil - return nil + return h.persistLocked() } // Undo reverses the last successful command. @@ -60,9 +83,10 @@ func (h *History) Undo() error { } h.mu.Lock() + defer h.mu.Unlock() h.redoS = append(h.redoS, cmd) - h.mu.Unlock() - return nil + h.redoS = trimStack(h.redoS, h.max) + return h.persistLocked() } // Redo re-applies the last undone command. @@ -84,9 +108,10 @@ func (h *History) Redo() error { } h.mu.Lock() + defer h.mu.Unlock() h.undoS = append(h.undoS, cmd) - h.mu.Unlock() - return nil + h.undoS = trimStack(h.undoS, h.max) + return h.persistLocked() } // CanUndo reports whether Undo would succeed. diff --git a/internal/command/persist.go b/internal/command/persist.go new file mode 100644 index 0000000..b4cb67c --- /dev/null +++ b/internal/command/persist.go @@ -0,0 +1,181 @@ +package command + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "task/internal/ticket" +) + +const ( + historyFileName = "history.json" + // MaxHistory is the maximum number of undo (and redo) entries kept. + MaxHistory = 20 +) + +type persistedFile struct { + Undo []persistedCmd `json:"undo"` + Redo []persistedCmd `json:"redo"` +} + +type persistedCmd struct { + Type string `json:"type"` + Ticket *ticket.Ticket `json:"ticket,omitempty"` + Snapshot *ticket.Ticket `json:"snapshot,omitempty"` + Before *ticket.Ticket `json:"before,omitempty"` + After *ticket.Ticket `json:"after,omitempty"` +} + +const ( + typeCreate = "create" + typeDelete = "delete" + typeUpdate = "update" +) + +// HistoryPath returns DataDir/history.json. +func HistoryPath(dataDir string) string { + return filepath.Join(dataDir, historyFileName) +} + +func encodeCmd(cmd Command) (persistedCmd, error) { + switch c := cmd.(type) { + case *CreateTicket: + t := c.Ticket + if c.created.ID != "" { + t = c.created + } + cp := t + return persistedCmd{Type: typeCreate, Ticket: &cp}, nil + case *DeleteTicket: + cp := c.Snapshot + return persistedCmd{Type: typeDelete, Snapshot: &cp}, nil + case *UpdateTicket: + b, a := c.Before, c.After + return persistedCmd{Type: typeUpdate, Before: &b, After: &a}, nil + default: + return persistedCmd{}, fmt.Errorf("unsupported command type %T", cmd) + } +} + +func decodeCmd(rec persistedCmd, store ticket.Store) (Command, error) { + switch rec.Type { + case typeCreate: + if rec.Ticket == nil { + return nil, fmt.Errorf("create record missing ticket") + } + return &CreateTicket{Store: store, Ticket: *rec.Ticket, created: *rec.Ticket}, nil + case typeDelete: + if rec.Snapshot == nil { + return nil, fmt.Errorf("delete record missing snapshot") + } + return &DeleteTicket{Store: store, Snapshot: *rec.Snapshot}, nil + case typeUpdate: + if rec.Before == nil || rec.After == nil { + return nil, fmt.Errorf("update record missing before/after") + } + return &UpdateTicket{Store: store, Before: *rec.Before, After: *rec.After}, nil + default: + return nil, fmt.Errorf("unknown command type %q", rec.Type) + } +} + +func encodeStack(cmds []Command) ([]persistedCmd, error) { + out := make([]persistedCmd, 0, len(cmds)) + for _, cmd := range cmds { + rec, err := encodeCmd(cmd) + if err != nil { + return nil, err + } + out = append(out, rec) + } + return out, nil +} + +func decodeStack(recs []persistedCmd, store ticket.Store) ([]Command, error) { + out := make([]Command, 0, len(recs)) + for _, rec := range recs { + cmd, err := decodeCmd(rec, store) + if err != nil { + return nil, err + } + out = append(out, cmd) + } + return out, nil +} + +func trimStack(cmds []Command, max int) []Command { + if max <= 0 || len(cmds) <= max { + return cmds + } + return cmds[len(cmds)-max:] +} + +func (h *History) persistLocked() error { + if h.path == "" || h.store == nil { + return nil + } + undo, err := encodeStack(h.undoS) + if err != nil { + return err + } + redo, err := encodeStack(h.redoS) + if err != nil { + return err + } + data, err := json.MarshalIndent(persistedFile{Undo: undo, Redo: redo}, "", " ") + if err != nil { + return fmt.Errorf("marshal history: %w", err) + } + data = append(data, '\n') + + if err := os.MkdirAll(filepath.Dir(h.path), 0o755); err != nil { + return fmt.Errorf("create history dir: %w", err) + } + + tmp, err := os.CreateTemp(filepath.Dir(h.path), "history-*.tmp") + if err != nil { + return fmt.Errorf("create temp history: %w", err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temp history: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp history: %w", err) + } + if err := os.Rename(tmpName, h.path); err != nil { + return fmt.Errorf("rename history: %w", err) + } + return nil +} + +func loadHistoryFile(path string, store ticket.Store) (undo, redo []Command, err error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil, nil + } + return nil, nil, fmt.Errorf("read history: %w", err) + } + if len(data) == 0 { + return nil, nil, nil + } + var file persistedFile + if err := json.Unmarshal(data, &file); err != nil { + return nil, nil, fmt.Errorf("parse history: %w", err) + } + undo, err = decodeStack(file.Undo, store) + if err != nil { + return nil, nil, err + } + redo, err = decodeStack(file.Redo, store) + if err != nil { + return nil, nil, err + } + return trimStack(undo, MaxHistory), trimStack(redo, MaxHistory), nil +} diff --git a/internal/command/persist_test.go b/internal/command/persist_test.go new file mode 100644 index 0000000..5e2b383 --- /dev/null +++ b/internal/command/persist_test.go @@ -0,0 +1,80 @@ +package command_test + +import ( + "os" + "path/filepath" + "testing" + + "task/internal/command" + "task/internal/ticket" +) + +func TestHistoryPersistsAcrossOpen(t *testing.T) { + dir := t.TempDir() + store := ticket.NewJSONStore(dir) + path := command.HistoryPath(dir) + + h1, err := command.OpenHistory(path, store) + if err != nil { + t.Fatal(err) + } + if err := h1.Do(&command.CreateTicket{ + Store: store, + Ticket: ticket.Ticket{ + Title: "Persist me", ProjectID: "inbox", Status: ticket.StatusTodo, + }, + }); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("history file missing: %v", err) + } + + h2, err := command.OpenHistory(path, store) + if err != nil { + t.Fatal(err) + } + if !h2.CanUndo() { + t.Fatal("expected undo after reload") + } + if err := h2.Undo(); err != nil { + t.Fatal(err) + } + list, err := store.List("inbox") + if err != nil || len(list) != 0 { + t.Fatalf("after persistent undo: %v len=%d", err, len(list)) + } +} + +func TestHistoryCapsAtMax(t *testing.T) { + dir := t.TempDir() + store := ticket.NewJSONStore(dir) + h, err := command.OpenHistory(filepath.Join(dir, "history.json"), store) + if err != nil { + t.Fatal(err) + } + for i := 0; i < command.MaxHistory+5; i++ { + if err := h.Do(&command.CreateTicket{ + Store: store, + Ticket: ticket.Ticket{ + Title: "t", ProjectID: "inbox", Status: ticket.StatusTodo, + }, + }); err != nil { + t.Fatal(err) + } + } + // Count undos available + n := 0 + for h.CanUndo() { + if err := h.Undo(); err != nil { + t.Fatal(err) + } + n++ + if n > command.MaxHistory+1 { + t.Fatal("undo stack larger than cap") + } + } + if n != command.MaxHistory { + t.Fatalf("undo count = %d, want %d", n, command.MaxHistory) + } +}