Support start/stop timer, manual durations, grouped reports, and per-ticket edit/delete of time entries with undo. Co-authored-by: Cursor <cursoragent@cursor.com>
153 lines
3.3 KiB
Go
153 lines
3.3 KiB
Go
package command
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
|
|
"task/internal/ticket"
|
|
)
|
|
|
|
// 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 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
|
|
timeStore ticket.TimeStore
|
|
max int
|
|
}
|
|
|
|
// NewHistory creates an empty in-memory command history (cap MaxHistory).
|
|
func NewHistory() *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.
|
|
// timeStore may be nil if time-entry commands are unused.
|
|
func OpenHistory(path string, store ticket.Store, timeStore ticket.TimeStore) (*History, error) {
|
|
h := &History{
|
|
path: path,
|
|
store: store,
|
|
timeStore: timeStore,
|
|
max: MaxHistory,
|
|
}
|
|
undo, redo, err := loadHistoryFile(path, store, timeStore)
|
|
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.
|
|
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.undoS = trimStack(h.undoS, h.max)
|
|
h.redoS = nil
|
|
return h.persistLocked()
|
|
}
|
|
|
|
// 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()
|
|
defer h.mu.Unlock()
|
|
h.redoS = append(h.redoS, cmd)
|
|
h.redoS = trimStack(h.redoS, h.max)
|
|
return h.persistLocked()
|
|
}
|
|
|
|
// 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()
|
|
defer h.mu.Unlock()
|
|
h.undoS = append(h.undoS, cmd)
|
|
h.undoS = trimStack(h.undoS, h.max)
|
|
return h.persistLocked()
|
|
}
|
|
|
|
// 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()
|
|
}
|