Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion internal/ui/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ type FormModel struct {

// Progressive disclosure: hide advanced fields for simpler first experience
showAdvanced bool

// Undo/redo stacks for text fields
titleUndo *undoStack
bodyUndo *undoStack
}

// Autocomplete message types for async LLM suggestions
Expand Down Expand Up @@ -203,6 +207,8 @@ func NewEditFormModel(database *db.DB, task *db.Task, width, height int, availab
taskRefAutocomplete: NewTaskRefAutocompleteModel(database, width-24),
attachmentCursor: -1,
showAdvanced: true, // Always show all fields when editing
titleUndo: newUndoStack(),
bodyUndo: newUndoStack(),
}

// Load task types from database
Expand Down Expand Up @@ -312,6 +318,8 @@ func NewFormModel(database *db.DB, width, height int, workingDir string, availab
taskRefAutocomplete: NewTaskRefAutocompleteModel(database, width-24),
attachmentCursor: -1,
showAdvanced: showAdvanced, // Load from user preference
titleUndo: newUndoStack(),
bodyUndo: newUndoStack(),
}

// Load task types from database
Expand Down Expand Up @@ -614,6 +622,52 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.submitted = true
return m, nil

case "ctrl+z":
// Undo in text fields
if m.focused == FieldTitle {
if val, pos, ok := m.titleUndo.Undo(m.titleInput.Value(), m.titleInput.Position()); ok {
m.titleInput.SetValue(val)
m.titleInput.SetCursor(pos)
m.lastTitleValue = val
m.clearGhostText()
}
return m, nil
}
if m.focused == FieldBody {
if val, pos, ok := m.bodyUndo.Undo(m.bodyInput.Value(), m.bodyInput.LineInfo().ColumnOffset); ok {
m.bodyInput.SetValue(val)
m.bodyInput.SetCursor(pos)
m.lastBodyValue = val
m.updateBodyHeight()
m.clearGhostText()
}
return m, nil
}
return m, nil

case "ctrl+y", "ctrl+shift+z":
// Redo in text fields
if m.focused == FieldTitle {
if val, pos, ok := m.titleUndo.Redo(m.titleInput.Value(), m.titleInput.Position()); ok {
m.titleInput.SetValue(val)
m.titleInput.SetCursor(pos)
m.lastTitleValue = val
m.clearGhostText()
}
return m, nil
}
if m.focused == FieldBody {
if val, pos, ok := m.bodyUndo.Redo(m.bodyInput.Value(), m.bodyInput.LineInfo().ColumnOffset); ok {
m.bodyInput.SetValue(val)
m.bodyInput.SetCursor(pos)
m.lastBodyValue = val
m.updateBodyHeight()
m.clearGhostText()
}
return m, nil
}
return m, nil

case "tab":
// If there's ghost text, accept it instead of moving to next field
if m.ghostText != "" && (m.focused == FieldTitle || m.focused == FieldBody) {
Expand Down Expand Up @@ -739,11 +793,17 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}

// Update the focused input
// Update the focused input, capturing state before for undo tracking
var cmd tea.Cmd
switch m.focused {
case FieldTitle:
prevValue := m.titleInput.Value()
prevCursor := m.titleInput.Position()
m.titleInput, cmd = m.titleInput.Update(msg)
// Save to undo stack if value changed
if m.titleInput.Value() != prevValue {
m.titleUndo.Save(prevValue, prevCursor)
}
// Trigger debounced autocomplete if title changed
if m.titleInput.Value() != m.lastTitleValue {
m.lastTitleValue = m.titleInput.Value()
Expand All @@ -752,7 +812,13 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmd, debounceCmd)
}
case FieldBody:
prevValue := m.bodyInput.Value()
prevCursor := m.bodyInput.LineInfo().ColumnOffset
m.bodyInput, cmd = m.bodyInput.Update(msg)
// Save to undo stack if value changed
if m.bodyInput.Value() != prevValue {
m.bodyUndo.Save(prevValue, prevCursor)
}
m.updateBodyHeight() // Autogrow as content changes
// Check for task reference autocomplete trigger (#)
m.updateTaskRefAutocomplete()
Expand Down
108 changes: 108 additions & 0 deletions internal/ui/undo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package ui

import "time"

// undoEntry represents a snapshot of a text field's state.
type undoEntry struct {
value string
cursorPos int // For textinput: character position; for textarea: we store a combined value
timestamp time.Time
}

// undoStack provides undo/redo functionality for a text field.
// It merges rapid consecutive changes (within mergeWindow) into a single undo entry
// to avoid creating one entry per keystroke.
type undoStack struct {
history []undoEntry
future []undoEntry
mergeWindow time.Duration
}

// newUndoStack creates a new undo stack with a default merge window.
func newUndoStack() *undoStack {
return &undoStack{
mergeWindow: 800 * time.Millisecond,
}
}

// Save records the current state. Rapid changes are merged into the most recent entry.
func (u *undoStack) Save(value string, cursorPos int) {
now := time.Now()

entry := undoEntry{
value: value,
cursorPos: cursorPos,
timestamp: now,
}

// Merge with the last entry if the change was very recent (typing stream)
if len(u.history) > 0 {
last := u.history[len(u.history)-1]
if now.Sub(last.timestamp) < u.mergeWindow {
// Update the last entry in-place instead of pushing a new one
u.history[len(u.history)-1] = entry
// Still clear future on new input
u.future = u.future[:0]
return
}
}

u.history = append(u.history, entry)
// Any new change invalidates the redo stack
u.future = u.future[:0]

// Cap history at 100 entries to bound memory
if len(u.history) > 100 {
u.history = u.history[len(u.history)-100:]
}
}

// Undo reverts to the previous state. Returns the restored state and true if successful.
func (u *undoStack) Undo(currentValue string, currentCursor int) (string, int, bool) {
if len(u.history) == 0 {
return "", 0, false
}

// Push current state to future (redo) stack
u.future = append(u.future, undoEntry{
value: currentValue,
cursorPos: currentCursor,
timestamp: time.Now(),
})

// Pop from history
entry := u.history[len(u.history)-1]
u.history = u.history[:len(u.history)-1]

return entry.value, entry.cursorPos, true
}

// Redo re-applies a previously undone change. Returns the restored state and true if successful.
func (u *undoStack) Redo(currentValue string, currentCursor int) (string, int, bool) {
if len(u.future) == 0 {
return "", 0, false
}

// Push current state to history
u.history = append(u.history, undoEntry{
value: currentValue,
cursorPos: currentCursor,
timestamp: time.Now(),
})

// Pop from future
entry := u.future[len(u.future)-1]
u.future = u.future[:len(u.future)-1]

return entry.value, entry.cursorPos, true
}

// CanUndo returns true if there are entries to undo.
func (u *undoStack) CanUndo() bool {
return len(u.history) > 0
}

// CanRedo returns true if there are entries to redo.
func (u *undoStack) CanRedo() bool {
return len(u.future) > 0
}
118 changes: 118 additions & 0 deletions internal/ui/undo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package ui

import (
"testing"
"time"
)

func TestUndoStack_BasicUndoRedo(t *testing.T) {
u := newUndoStack()
// Override merge window to avoid test timing issues
u.mergeWindow = 0

// Type "hello" one character at a time
u.Save("", 0) // empty -> h
u.Save("h", 1) // h -> he
u.Save("he", 2) // he -> hel
u.Save("hel", 3) // hel -> hell
u.Save("hell", 4) // hell -> hello

// Current state is "hello" at position 5

// Undo should restore "hell"
val, pos, ok := u.Undo("hello", 5)
if !ok || val != "hell" || pos != 4 {
t.Fatalf("expected (hell, 4, true), got (%s, %d, %t)", val, pos, ok)
}

// Redo should restore "hello"
val, pos, ok = u.Redo("hell", 4)
if !ok || val != "hello" || pos != 5 {
t.Fatalf("expected (hello, 5, true), got (%s, %d, %t)", val, pos, ok)
}
}

func TestUndoStack_MergeRapidChanges(t *testing.T) {
u := newUndoStack()
u.mergeWindow = 2 * time.Second // Very large window to ensure merging

// Simulate rapid typing - all within merge window
u.Save("", 0)
u.Save("h", 1)
u.Save("he", 2)
u.Save("hel", 3)
u.Save("hell", 4)

// All rapid changes should be merged into one entry
if len(u.history) != 1 {
t.Fatalf("expected 1 merged entry, got %d", len(u.history))
}

// The merged entry should have the last saved state
val, _, ok := u.Undo("hello", 5)
if !ok || val != "hell" {
t.Fatalf("expected (hell, true), got (%s, %t)", val, ok)
}

// No more history
_, _, ok = u.Undo("hell", 4)
if ok {
t.Fatal("expected no more undo history")
}
}

func TestUndoStack_RedoClearedOnNewChange(t *testing.T) {
u := newUndoStack()
u.mergeWindow = 0

u.Save("", 0)
u.Save("a", 1)

// Undo
u.Undo("ab", 2)
if !u.CanRedo() {
t.Fatal("expected redo to be available")
}

// New change should clear redo
u.Save("x", 1)
if u.CanRedo() {
t.Fatal("expected redo to be cleared after new change")
}
}

func TestUndoStack_EmptyStack(t *testing.T) {
u := newUndoStack()

_, _, ok := u.Undo("test", 4)
if ok {
t.Fatal("expected undo to fail on empty stack")
}

_, _, ok = u.Redo("test", 4)
if ok {
t.Fatal("expected redo to fail on empty stack")
}
}

func TestUndoStack_CanUndoRedo(t *testing.T) {
u := newUndoStack()
u.mergeWindow = 0

if u.CanUndo() {
t.Fatal("expected CanUndo false on empty stack")
}
if u.CanRedo() {
t.Fatal("expected CanRedo false on empty stack")
}

u.Save("", 0)
if !u.CanUndo() {
t.Fatal("expected CanUndo true after save")
}

u.Undo("a", 1)
if !u.CanRedo() {
t.Fatal("expected CanRedo true after undo")
}
}