Skip to content
This repository has been archived by the owner on Dec 15, 2022. It is now read-only.

Add checkpoints as a replacement for open-ended transactions #38

Merged
merged 4 commits into from Nov 21, 2014
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
135 changes: 135 additions & 0 deletions spec/text-buffer-spec.coffee
Expand Up @@ -524,6 +524,141 @@ describe "TextBuffer", ->
it "throws an exception when no transaction is open", ->
expect(-> buffer.commitTransaction()).toThrow("No transaction is open")

describe "checkpoints", ->
beforeEach ->
buffer = new TextBuffer

describe "::revertToCheckpoint(checkpoint)", ->
it "undoes all changes following the checkpoint", ->
buffer.append("hello")
checkpoint = buffer.createCheckpoint()

buffer.transact ->
buffer.append("\n")
buffer.append("world")

buffer.append("\n")
buffer.append("how are you?")

result = buffer.revertToCheckpoint(checkpoint)
expect(result).toBe(true)
expect(buffer.getText()).toBe("hello")

buffer.redo()
expect(buffer.getText()).toBe("hello")

describe "::groupChangesSinceCheckpoint(checkpoint)", ->
it "combines all changes since the checkpoint into a single transaction", ->
buffer.append("one\n")
checkpoint = buffer.createCheckpoint()
buffer.append("two\n")
buffer.transact ->
buffer.append("three\n")
buffer.append("four")

result = buffer.groupChangesSinceCheckpoint(checkpoint)

expect(result).toBe true
expect(buffer.getText()).toBe """
one
two
three
four
"""

buffer.undo()
expect(buffer.getText()).toBe("one\n")

buffer.redo()
expect(buffer.getText()).toBe """
one
two
three
four
"""

it "skips any later checkpoints when grouping changes", ->
buffer.append("one\n")
checkpoint = buffer.createCheckpoint()
buffer.append("two\n")
checkpoint2 = buffer.createCheckpoint()
buffer.append("three")

buffer.groupChangesSinceCheckpoint(checkpoint)
expect(buffer.revertToCheckpoint(checkpoint2)).toBe(false)

expect(buffer.getText()).toBe """
one
two
three
"""

buffer.undo()
expect(buffer.getText()).toBe("one\n")

buffer.redo()
expect(buffer.getText()).toBe """
one
two
three
"""

it "returns false and does nothing when no changes have been made since the checkpoint", ->
buffer.append("one\n")
checkpoint = buffer.createCheckpoint()
result = buffer.groupChangesSinceCheckpoint(checkpoint)
expect(result).toBe false
buffer.undo()
expect(buffer.getText()).toBe ""

it "returns false and does nothing when the checkpoint is not in the buffer's history", ->
buffer.append("hello\n")
checkpoint = buffer.createCheckpoint()
buffer.undo()
buffer.append("world")
result = buffer.groupChangesSinceCheckpoint(checkpoint)
expect(result).toBe(false)
buffer.undo()
expect(buffer.getText()).toBe ""

it "skips checkpoints when undoing", ->
buffer.append("hello")
buffer.createCheckpoint()
buffer.undo()
expect(buffer.getText()).toBe("")

it "preserves checkpoints across undo and redo", ->
buffer.append("hello\n")
checkpoint = buffer.createCheckpoint()
buffer.undo()
expect(buffer.getText()).toBe("")

buffer.redo()
expect(buffer.getText()).toBe("hello\n")

buffer.append("world")
buffer.revertToCheckpoint(checkpoint)
expect(buffer.getText()).toBe("hello\n")

it "handles checkpoints created when there have been no changes", ->
checkpoint = buffer.createCheckpoint()
buffer.undo()
buffer.append("hello")
buffer.revertToCheckpoint(checkpoint)
expect(buffer.getText()).toBe("")

it "returns false when the checkpoint is not in the buffer's history", ->
buffer.append("hello\n")
checkpoint = buffer.createCheckpoint()
buffer.undo()
buffer.append("world")
expect(buffer.revertToCheckpoint(checkpoint)).toBe(false)
expect(buffer.getText()).toBe("world")

it "does not allow checkpoints inside of transactions", ->
buffer.transact ->
expect(-> buffer.createCheckpoint()).toThrow("Cannot create a checkpoint inside of a transaction")

describe "::getTextInRange(range)", ->
it "returns the text in a given range", ->
buffer = new TextBuffer(text: "hello\nworld\r\nhow are you doing?")
Expand Down
2 changes: 2 additions & 0 deletions src/checkpoint.coffee
@@ -0,0 +1,2 @@
module.exports =
class Checkpoint
38 changes: 38 additions & 0 deletions src/history.coffee
@@ -1,6 +1,7 @@
Serializable = require 'serializable'
Transaction = require './transaction'
BufferPatch = require './buffer-patch'
Checkpoint = require './checkpoint'
{last} = require 'underscore-plus'

TransactionAborted = new Error("Transaction Aborted")
Expand Down Expand Up @@ -38,18 +39,27 @@ class History extends Serializable

undo: ->
throw new Error("Can't undo with an open transaction") if @currentTransaction?

if last(@undoStack) instanceof Checkpoint
return unless @undoStack.length > 1 # Abort unless changes exist before checkpoint
@redoStack.push(@undoStack.pop())

if patch = @undoStack.pop()
inverse = patch.invert(@buffer)
@redoStack.push(inverse)
inverse.applyTo(@buffer)

redo: ->
throw new Error("Can't redo with an open transaction") if @currentTransaction?

if patch = @redoStack.pop()
inverse = patch.invert(@buffer)
@undoStack.push(inverse)
inverse.applyTo(@buffer)

if last(@redoStack) instanceof Checkpoint
@undoStack.push(@redoStack.pop())

transact: (groupingInterval, fn) ->
unless fn?
fn = groupingInterval
Expand Down Expand Up @@ -96,6 +106,34 @@ class History extends Serializable
else
throw TransactionAborted

createCheckpoint: ->
throw new Error("Cannot create a checkpoint inside of a transaction") if @isTransacting()
checkpoint = new Checkpoint
@undoStack.push(checkpoint)
checkpoint

revertToCheckpoint: (checkpoint) ->
if checkpoint in @undoStack
@undo() until last(@undoStack) is checkpoint
@clearRedoStack()
true
else
false

groupChangesSinceCheckpoint: (checkpoint) ->
groupedTransaction = new Transaction
index = @undoStack.indexOf(checkpoint) + 1

return false if index is 0
return false if index is @undoStack.length

for patch in @undoStack.splice(index, @undoStack.length - index)
unless patch instanceof Checkpoint
groupedTransaction.merge(patch)

@undoStack.push(groupedTransaction)
true

isTransacting: ->
@currentTransaction?

Expand Down
63 changes: 43 additions & 20 deletions src/text-buffer.coffee
Expand Up @@ -808,38 +808,61 @@ class TextBuffer
# abort the transaction, call {::abortTransaction} to terminate the function's
# execution and revert any changes performed up to the abortion.
#
# * `groupingInterval` (optional) This is the sames as the `groupingInterval`
# parameter in {::beginTransaction}
# * `fn` A {Function} to call inside the transaction.
transact: (groupingInterval, fn) -> @history.transact(groupingInterval, fn)

# Public: Start an open-ended transaction.
#
# Call {::commitTransaction} or {::abortTransaction} to terminate the
# transaction. If you nest calls to transactions, only the outermost
# transaction is considered. You must match every begin with a matching
# commit, but a single call to abort will cancel all nested transactions.
#
# * `groupingInterval` (optional) The {Number} of milliseconds for which this
# transaction should be considered 'open for grouping' after it begins. If a
# transaction with a positive `groupingInterval` is committed while the previous
# transaction is still open for grouping, the two transactions are merged with
# respect to undo and redo.
beginTransaction: (groupingInterval) -> @history.beginTransaction(groupingInterval)
# * `fn` A {Function} to call inside the transaction.
transact: (groupingInterval, fn) -> @history.transact(groupingInterval, fn)

# Public: Commit an open-ended transaction started with {::beginTransaction}
# and push it to the undo stack.
# Deprecated: Use {::createCheckpoint} instead.
#
# If transactions are nested, only the outermost commit takes effect.
commitTransaction: -> @history.commitTransaction()
# * `groupingInterval` (optional) This is the sames as the `groupingInterval`
# parameter in {::transact}
beginTransaction: (groupingInterval) ->
Grim.deprecate("Open-ended transactions are deprecated. Use checkpoints instead.")
@history.beginTransaction(groupingInterval)

# Public: Abort an open transaction, undoing any operations performed so far
# within the transaction.
abortTransaction: -> @history.abortTransaction()
# Deprecated: Use {::groupChangesSinceCheckpoint} instead.
commitTransaction: ->
Grim.deprecate("Open-ended transactions are deprecated. Use checkpoints instead.")
@history.commitTransaction()

# Deprecated: Use {::revertToCheckpoint} instead.
abortTransaction: ->
Grim.deprecate("Open-ended transactions are deprecated. Use checkpoints instead.")
@history.abortTransaction()

# Public: Clear the undo stack.
clearUndoStack: -> @history.clearUndoStack()

# Experimental: Create a pointer to the current state of the buffer for use
# with {::revertToCheckpoint} and {::groupChangesSinceCheckpoint}.
#
# Returns a checkpoint value.
createCheckpoint: -> @history.createCheckpoint()

# Experimental: Revert the buffer to the state it was in when the given
# checkpoint was created.
#
# The redo stack will be empty following this operation, so changes since the
# checkpoint will be lost. If the given checkpoint is no longer present in the
# undo history, no changes will be made to the buffer and this method will
# return `false`.
#
# Returns a {Boolean} indicating whether the operation succeeded.
revertToCheckpoint: (checkpoint) -> @history.revertToCheckpoint(checkpoint)

# Experimental: Group all changes since the given checkpoint into a single
# transaction for purposes of undo/redo.
#
# If the given checkpoint is no longer present in the undo history, no
# grouping will be performed and this method will return `false`.
#
# Returns a {Boolean} indicating whether the operation succeeded.
groupChangesSinceCheckpoint: (checkpoint) -> @history.groupChangesSinceCheckpoint(checkpoint)

###
Section: Search And Replace
###
Expand Down
9 changes: 6 additions & 3 deletions src/transaction.coffee
Expand Up @@ -30,9 +30,12 @@ class Transaction extends Serializable
hasBufferPatches: ->
find @patches, (patch) -> patch instanceof BufferPatch

merge: (transaction) ->
@push(patch) for patch in transaction.patches
{@groupingExpirationTime} = transaction
merge: (patch) ->
if patch instanceof Transaction
@push(subpatch) for subpatch in patch.patches
{@groupingExpirationTime} = patch
else
@push(patch)

isOpenForGrouping: ->
@groupingExpirationTime > Date.now()