Skip to content

Content Editing UI — Phase 2a: Undo/Redo + Pre-Save Diff View

Context

Phase 1 shipped an interactive terminal-based YAML editor (sap-devs content edit) with a schema-driven form view, list view with layer badges, and multi-layer merge/save logic. Phase 2 adds six enhancements, decomposed into three sub-projects:

  • Group A (this spec): Undo/redo within editor session + diff view before save
  • Group B (future): Drag-and-drop reordering + bulk editing
  • Group C (future): Content creation wizard for new packs

Goal

Add an undo/redo stack and a pre-save change confirmation screen to the array editor, so users can confidently experiment with edits knowing they can revert mistakes and review all changes before writing to disk.

Decisions

DecisionChoiceRationale
Undo granularityEntry-level (whole operations)Aligns with the existing loop-based architecture where each action exits Bubbletea; avoids hooking into huh's internal form state
Undo depthUnlimitedContent files have 5–50 entries; even deep stacks are a few KB
Implementation strategySnapshot stack (deep copy of []MergedItem before each mutation)Simpler than command pattern; no inverse-operation bugs; diff view falls out as snapshot[0] vs current
Diff view triggerPre-save confirmation on q (save & quit)Acts as a safety net before writing to disk; similar to git diff --stat before commit
ScopeArray editor onlyObject editor is a single-form flow with no list loop; undo/redo not meaningful there
Git integrationDroppedDevelopers already have their own git workflow; recreating it in the TUI is overkill

Architecture

New file: internal/editor/history.go

The only new file. Contains the snapshot stack, change detection, and diff view model.

Data structures

go
type Snapshot struct {
    Items []MergedItem
    Desc  string // e.g. "edited SAP TechEd", "added new item", "deleted Devtoberfest"
}

type History struct {
    baseline  []MergedItem // initial state from LoadMergedItems — never mutated
    undoStack []Snapshot   // past states
    redoStack []Snapshot   // future states (after undo)
}

Operations:

  • NewHistory(items []MergedItem) *History — stores a deep copy as baseline
  • Push(items []MergedItem, desc string) — deep-copies items onto undoStack, clears redoStack
  • Undo() ([]MergedItem, string, bool) — pops undoStack, pushes current to redoStack, returns restored items + description
  • Redo() ([]MergedItem, string, bool) — pops redoStack, pushes current to undoStack, returns restored items + description
  • CanUndo() bool / CanRedo() bool — stack depth checks
  • Changes(current []MergedItem) []Change — compares baseline vs current
  • HasChanges(current []MergedItem) bool — quick check

Deep copy

A deepCopyItems(items []MergedItem) []MergedItem helper clones the slice and each item's Data map (shallow copy of map values is sufficient since YAML values are strings, bools, ints, or []any of strings).

Change detection

go
type ChangeKind int

const (
    ChangeAdded ChangeKind = iota
    ChangeEdited
    ChangeDeleted
)

type FieldDiff struct {
    Key      string
    OldValue string
    NewValue string
}

type Change struct {
    Kind   ChangeKind
    ItemID string      // id or name of the entry
    Fields []FieldDiff // populated only for ChangeEdited
}

Algorithm in Changes():

  1. Build a map of baseline items by itemKey() (reuses existing function from merge.go)
  2. Walk current items: if key exists in baseline map, compare fields; if any differ, emit ChangeEdited with FieldDiff entries. If key not in baseline, emit ChangeAdded
  3. Remaining baseline keys not seen in current → emit ChangeDeleted
  4. Items without an id/name key are matched by positional index and labeled as "item #N"

Diff confirmation view

A Bubbletea model diffModel rendered when the user presses q:

  Review Changes (3 modifications)

  ~ SAP TechEd (edited)
    scope: regional → global
    tags:  +developer

  + SAP Inside Track (new)

  - Devtoberfest (deleted)

  Enter save  Esc back to list  d discard all

Styling (Fiori palette):

  • ~ prefix and edited entry names in orange (#F58B00)
  • + prefix and added entry names in green (#00D68F)
  • - prefix and deleted entry names in red (#FF5C5C)
  • Field diffs indented under edited entries, old value in muted (#8C9BAA), arrow, new value in text color (#EDEDED)
  • Scrollable via up/down if changes exceed terminal height

Keybindings:

  • Enter — confirm and save
  • Esc — back to list (continue editing)
  • d — discard all changes and quit without saving

Edge cases:

  • No changes → skip diff view entirely, print "No changes." and exit
  • Undo all changes back to baseline → HasChanges() returns false → clean exit

Changes to existing files

internal/editor/editor.go

Modify runArrayEditor():

  1. After LoadMergedItems, create history := NewHistory(items)
  2. Pass history pointer into each newListModel() call
  3. Before each mutation (edit/add/delete), call history.Push(items, desc)
  4. When result.save is true: check history.HasChanges(items). If true, run diffModel. If user confirms, save. If user cancels, continue loop. If no changes, exit.
  5. On undo/redo results from the list model, replace items with the returned snapshot

internal/editor/list.go

Modify listModel:

  1. Add history *History field
  2. Add statusMsg string field for action feedback
  3. Add undone bool and redone bool result fields (signals back to the loop)
  4. New keybindings in updateNormal():
    • u — if history.CanUndo(), set undone = true, quit to loop
    • r — if history.CanRedo(), set redone = true, quit to loop
  5. Update footer: ↑/↓ navigate Enter edit a add d delete u undo r redo / filter q save Esc quit
  6. Add status line in View() below the header showing statusMsg and undo stack depth

internal/theme/fiori.go

Add three lipgloss v1 style functions for diff view:

  • DiffAdded() Style — green foreground (#00D68F)
  • DiffEdited() Style — orange foreground (#F58B00)
  • DiffDeleted() Style — red foreground (#FF5C5C)

What this does NOT include

  • Field-level undo inside huh forms
  • Per-entry layer diff (comparing user override vs official version)
  • Git commit/push integration (dropped from Phase 2)
  • Object editor undo (single-form, not applicable)

Testing

  • history_test.go — unit tests for Push/Undo/Redo/Changes/HasChanges with various scenarios (edit, add, delete, undo-all, redo-after-undo, push-clears-redo)
  • go build ./... and go vet ./... locally (Windows Defender blocks go test)
  • CI runs full test suite on ubuntu-latest