Persist undo/redo history across runs with a 20-entry cap.
Serialize create/update/delete commands to DataDir/history.json and reload them on startup via OpenHistory. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -48,7 +48,10 @@ func run() error {
|
|||||||
return fmt.Errorf("ensure project: %w", err)
|
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)
|
model := ui.New(cfg, store, projects, history)
|
||||||
prog := tea.NewProgram(model, tea.WithAltScreen())
|
prog := tea.NewProgram(model, tea.WithAltScreen())
|
||||||
if _, err := prog.Run(); err != nil {
|
if _, err := prog.Run(); err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"task/internal/ticket"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Executor runs commands and supports undo/redo of successful ones.
|
// Executor runs commands and supports undo/redo of successful ones.
|
||||||
@@ -17,16 +19,36 @@ type Executor interface {
|
|||||||
LastRedoName() string
|
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 {
|
type History struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
undoS []Command
|
undoS []Command
|
||||||
redoS []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 {
|
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.
|
// 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()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
h.undoS = append(h.undoS, cmd)
|
h.undoS = append(h.undoS, cmd)
|
||||||
|
h.undoS = trimStack(h.undoS, h.max)
|
||||||
h.redoS = nil
|
h.redoS = nil
|
||||||
return nil
|
return h.persistLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Undo reverses the last successful command.
|
// Undo reverses the last successful command.
|
||||||
@@ -60,9 +83,10 @@ func (h *History) Undo() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
h.redoS = append(h.redoS, cmd)
|
h.redoS = append(h.redoS, cmd)
|
||||||
h.mu.Unlock()
|
h.redoS = trimStack(h.redoS, h.max)
|
||||||
return nil
|
return h.persistLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redo re-applies the last undone command.
|
// Redo re-applies the last undone command.
|
||||||
@@ -84,9 +108,10 @@ func (h *History) Redo() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
h.undoS = append(h.undoS, cmd)
|
h.undoS = append(h.undoS, cmd)
|
||||||
h.mu.Unlock()
|
h.undoS = trimStack(h.undoS, h.max)
|
||||||
return nil
|
return h.persistLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanUndo reports whether Undo would succeed.
|
// CanUndo reports whether Undo would succeed.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user