diff --git a/internal/jsonx/node.go b/internal/jsonx/node.go index 4fd83be5..93acbe73 100644 --- a/internal/jsonx/node.go +++ b/internal/jsonx/node.go @@ -37,6 +37,16 @@ type Node struct { LineNumber int } +type Tombstone struct { + Target *Node + Parent *Node + Prev *Node + Next *Node + EndOf *Node + Index int + HadComma bool +} + // Append ands a node as a child to the current node (body of {...} or [...]). func (n *Node) Append(child *Node) { if n.End == nil { @@ -367,3 +377,87 @@ func (n *Node) ForEach(cb func(*Node)) { } } } + +func (n *Node) GetNodeToDelete() (*Node, bool) { + if n == nil { + return nil, false + } + // Avoid closing bracket nodes (Index == -1 used for brackets) + if n.Index == -1 { + return nil, false + } + parent := n.Parent + if parent == nil { // avoid deleting root + return nil, false + } + // If current points to a wrap placeholder, move to its parent value + node := n + if n.Chunk != "" && n.Value == "" && n.Parent != nil { + node = node.Parent + parent = node.Parent + if parent == nil { + return nil, false + } + } + return node, true +} + +func (n *Node) CreateTombstone() Tombstone { + endOf := n + if n.End != nil { + endOf = n.End + } else if n.ChunkEnd != nil { + endOf = n.ChunkEnd + } + + t := Tombstone{ + Target: n, + EndOf: endOf, + Parent: n.Parent, + Prev: n.Prev, + Next: endOf.Next, + Index: n.Index, + } + + if t.Prev != nil && t.Prev != t.Parent { + t.HadComma = t.Prev.Comma + } + + return t +} + +func (t *Tombstone) DoUndo() { + if t.Prev != nil { + t.Prev.Next = t.Target + } + if t.Next != nil { + t.Next.Prev = t.EndOf + } + + // if it was the first child + if t.Parent != nil && t.Parent.Next == t.Next { + t.Parent.Next = t.Target + } + + // if DeleteNode cleared a comma + if t.Prev != nil && t.Prev != t.Parent { + t.Prev.Comma = t.HadComma + } + + // Reverse Array/Size logic + if t.Parent != nil { + t.Parent.Size++ + if t.Parent.Kind == Array { + for it := t.Next; it != nil && it != t.Parent.End; { + if it.Parent == t.Parent && it.Index >= 0 { + it.Index++ + } + if it.HasChildren() { + it = it.End.Next + } else { + it = it.Next + } + } + } + } +} diff --git a/keymap.go b/keymap.go index a5d1125f..24ef6bb9 100644 --- a/keymap.go +++ b/keymap.go @@ -30,6 +30,8 @@ type KeyMap struct { Preview key.Binding `category:"Actions"` Print key.Binding `category:"Actions"` Open key.Binding `category:"Actions"` + Undo key.Binding `category:"Actions"` + Redo key.Binding `category:"Actions"` ToggleWrap key.Binding `category:"View"` ShowSelector key.Binding `category:"View"` GoBack key.Binding `category:"Navigation"` @@ -148,6 +150,14 @@ func init() { key.WithKeys("d"), key.WithHelp("", "delete node"), ), + Undo: key.NewBinding( + key.WithKeys("u"), + key.WithHelp("", "undo delete"), + ), + Redo: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("", "redo delete"), + ), CommandLine: key.NewBinding( key.WithKeys(":"), key.WithHelp("", "open command line"), diff --git a/main.go b/main.go index 2e5390cc..82c34141 100644 --- a/main.go +++ b/main.go @@ -360,6 +360,8 @@ type model struct { printErrorOnExit error spinner spinner.Model locationHistory []location + undoStack []Tombstone + redoStack []Tombstone locationIndex int // position in locationHistory keysIndex []string keysIndexNodes []*Node @@ -976,7 +978,12 @@ func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, keyMap.Delete): m.deletePending = true + case key.Matches(msg, keyMap.Undo): + m.undoDelete() + case key.Matches(msg, keyMap.Redo): // Make sure Redo is defined in your keyMap + m.redoDelete() } + return m, nil } @@ -1035,6 +1042,46 @@ func (m *model) recordHistory() { m.locationIndex = len(m.locationHistory) } +func (m *model) recordDeleteHistory(ts Tombstone) { + m.undoStack = append(m.undoStack, ts) + m.redoStack = []Tombstone{} + + if len(m.undoStack) > 100 { + m.undoStack = m.undoStack[1:] + } +} + +func (m *model) undoDelete() { + if len(m.undoStack) == 0 { + return + } + + lastIdx := len(m.undoStack) - 1 + t := m.undoStack[lastIdx] + m.undoStack = m.undoStack[:lastIdx] + + t.DoUndo() + + m.redoStack = append(m.redoStack, t) + m.selectNode(t.Target) +} + +func (m *model) redoDelete() { + if len(m.redoStack) == 0 { + return + } + + t := m.redoStack[len(m.redoStack)-1] + m.redoStack = m.redoStack[:len(m.redoStack)-1] + + if next, ok := DeleteNode(t.Target); ok { + m.selectNode(next) + m.recordHistory() + } + + m.undoStack = append(m.undoStack, t) +} + func (m *model) scrollToBottom() { if m.bottom == nil { return @@ -1467,7 +1514,16 @@ func (m *model) deleteAtCursor() { if !ok || at == nil { return } - if next, ok := DeleteNode(at); ok { + + nodeToDelete, ok := at.GetNodeToDelete() + if !ok { + return + } + + ts := nodeToDelete.CreateTombstone() + m.recordDeleteHistory(ts) + + if next, ok := DeleteNode(nodeToDelete); ok { m.selectNode(next) m.recordHistory() } diff --git a/main_test.go b/main_test.go index fb126a99..c80dc443 100644 --- a/main_test.go +++ b/main_test.go @@ -115,3 +115,35 @@ func TestCollapseRecursiveWithSizes(t *testing.T) { tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) } + +func TestUndoRedoInteraction(t *testing.T) { + tm := prepare(t) + targetKey := []byte(`"title"`) + + // Leave root, then delete (first key) + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + + // Verify the node is gone from the output + teatest.WaitFor(t, tm.Output(), func(b []byte) bool { + return !bytes.Contains(b, targetKey) + }, teatest.WithDuration(time.Second)) + + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("u")}) + + // Verify the node reappeared + teatest.WaitFor(t, tm.Output(), func(b []byte) bool { + return bytes.Contains(b, targetKey) + }, teatest.WithDuration(time.Second)) + + tm.Send(tea.KeyMsg{Type: tea.KeyCtrlR}) + + // Verify it is gone again + teatest.WaitFor(t, tm.Output(), func(b []byte) bool { + return !bytes.Contains(b, targetKey) + }, teatest.WithDuration(time.Second)) + + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")}) + tm.WaitFinished(t) +} diff --git a/testdata/TestCollapseRecursive.golden b/testdata/TestCollapseRecursive.golden index 28f6b6d8..4c12dd9d 100644 --- a/testdata/TestCollapseRecursive.golden +++ b/testdata/TestCollapseRecursive.golden @@ -9,7 +9,8 @@ "tags": […], "year": 3000, "funny": true, - "author": {"name":"John Doe",…} + "author": {"name":"John Doe",…}, + "hasAvatar": true } ~ ~ @@ -35,6 +36,5 @@ ~ ~ ~ -~ ~ 1%  \ No newline at end of file diff --git a/testdata/TestCollapseRecursiveWithSizes.golden b/testdata/TestCollapseRecursiveWithSizes.golden index 3f1bdfec..cbab5a81 100644 --- a/testdata/TestCollapseRecursiveWithSizes.golden +++ b/testdata/TestCollapseRecursiveWithSizes.golden @@ -1,4 +1,4 @@ -[?25l[?2004h { (6 keys) +[?25l[?2004h { (7 keys) "title": "Lorem ipsum", "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusm od tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam @@ -9,7 +9,8 @@ "tags": […], (3 items) "year": 3000, "funny": true, - "author": {"name":"John Doe",…} (2 keys) + "author": {"name":"John Doe",…}, (2 keys) + "hasAvatar": true } ~ ~ @@ -35,6 +36,5 @@ ~ ~ ~ -~ ~ 1%  \ No newline at end of file diff --git a/testdata/TestDig.golden b/testdata/TestDig.golden new file mode 100644 index 00000000..7976784f --- /dev/null +++ b/testdata/TestDig.golden @@ -0,0 +1,40 @@ +[?25l[?2004h { + "title": "Lorem ipsum", + "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusm + od tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam + , quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo cons + equat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum d + olore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident + , sunt in culpa qui officia deserunt mollit anim id est laborum.", + "tags": [ + "lorem", + "ipsum", + null + ], + "year": 3000, + "funny": true, + "author": { + "name": "John Doe", + "email": "john@doe.com" + }, + "hasAvatar": true +} +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +~ +.year 56%  \ No newline at end of file diff --git a/testdata/TestGotoLine.golden b/testdata/TestGotoLine.golden index 6d183e6d..fa2970be 100644 --- a/testdata/TestGotoLine.golden +++ b/testdata/TestGotoLine.golden @@ -17,8 +17,9 @@ 11 "author": { 12 "name": "John Doe", 13 "email": "john@doe.com" -14 } -15 } +14 }, +15 "hasAvatar": true +16 } ~ ~ ~ @@ -36,5 +37,4 @@ ~ ~ ~ -~ -.tags[0] 33%  \ No newline at end of file +.tags[0] 31%  \ No newline at end of file diff --git a/testdata/TestGotoLineCollapsed.golden b/testdata/TestGotoLineCollapsed.golden index 8430a018..a495e2b6 100644 --- a/testdata/TestGotoLineCollapsed.golden +++ b/testdata/TestGotoLineCollapsed.golden @@ -14,8 +14,9 @@  8 ],  9 "year": 3000, 10 "funny": true, -11 "author": {"name":"John Doe",…} -15 } +11 "author": {"name":"John Doe",…}, +15 "hasAvatar": true +16 } ~ ~ ~ @@ -36,5 +37,4 @@ ~ ~ ~ -~ -.tags[0] 33%  \ No newline at end of file +.tags[0] 31%  \ No newline at end of file diff --git a/testdata/TestGotoLineInputGreaterThanTotalLines.golden b/testdata/TestGotoLineInputGreaterThanTotalLines.golden index 701ae3be..e478877d 100644 --- a/testdata/TestGotoLineInputGreaterThanTotalLines.golden +++ b/testdata/TestGotoLineInputGreaterThanTotalLines.golden @@ -17,9 +17,9 @@ 11 "author": { 12 "name": "John Doe", 13 "email": "john@doe.com" -14 } -15 } -~ +14 }, +15 "hasAvatar": true +16 } ~ ~ ~ diff --git a/testdata/TestGotoLineInputInvalid.golden b/testdata/TestGotoLineInputInvalid.golden index 9595ef0e..67baf9b1 100644 --- a/testdata/TestGotoLineInputInvalid.golden +++ b/testdata/TestGotoLineInputInvalid.golden @@ -10,8 +10,9 @@  4 "tags": […],  9 "year": 3000, 10 "funny": true, -11 "author": {"name":"John Doe",…} -15 } +11 "author": {"name":"John Doe",…}, +15 "hasAvatar": true +16 } ~ ~ ~ @@ -36,5 +37,4 @@ ~ ~ ~ -~ -.text 20%  \ No newline at end of file +.text 18%  \ No newline at end of file diff --git a/testdata/TestGotoLineInputLessThanOne.golden b/testdata/TestGotoLineInputLessThanOne.golden index ab0aa91f..98deb7f1 100644 --- a/testdata/TestGotoLineInputLessThanOne.golden +++ b/testdata/TestGotoLineInputLessThanOne.golden @@ -17,9 +17,9 @@ 11 "author": { 12 "name": "John Doe", 13 "email": "john@doe.com" -14 } -15 } -~ +14 }, +15 "hasAvatar": true +16 } ~ ~ ~ diff --git a/testdata/TestGotoLineKeepsHistory.golden b/testdata/TestGotoLineKeepsHistory.golden index 77981d07..868857b7 100644 --- a/testdata/TestGotoLineKeepsHistory.golden +++ b/testdata/TestGotoLineKeepsHistory.golden @@ -17,8 +17,9 @@ 11 "author": { 12 "name": "John Doe", 13 "email": "john@doe.com" -14 } -15 } +14 }, +15 "hasAvatar": true +16 } ~ ~ ~ @@ -36,5 +37,4 @@ ~ ~ ~ -~ -.tags 26%  \ No newline at end of file +.tags 25%  \ No newline at end of file diff --git a/testdata/TestNavigation.golden b/testdata/TestNavigation.golden index e0c9b34e..512fc622 100644 --- a/testdata/TestNavigation.golden +++ b/testdata/TestNavigation.golden @@ -16,7 +16,8 @@ "author": { "name": "John Doe", "email": "john@doe.com" - } + }, + "hasAvatar": true } ~ ~ @@ -36,5 +37,4 @@ ~ ~ ~ -~ -.text 20%  \ No newline at end of file +.text 18%  \ No newline at end of file diff --git a/testdata/TestOutput.golden b/testdata/TestOutput.golden index ce5e0774..1270c13f 100644 --- a/testdata/TestOutput.golden +++ b/testdata/TestOutput.golden @@ -16,7 +16,8 @@ "author": { "name": "John Doe", "email": "john@doe.com" - } + }, + "hasAvatar": true } ~ ~ @@ -35,6 +36,5 @@ ~ ~ ~ -~ ~ 1%  \ No newline at end of file diff --git a/testdata/example.json b/testdata/example.json index 4aacc95d..e54b565e 100644 --- a/testdata/example.json +++ b/testdata/example.json @@ -11,5 +11,6 @@ "author": { "name": "John Doe", "email": "john@doe.com" - } + }, + "hasAvatar": true }