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