Files
atom/src/app/buffer.coffee
Nathan Sobo 59a06acc0b Fire 'modified-status-changed' events on changes after save/reload
Buffer keeps state about the value with which it fired the last
modified-status-changed event so that it doesn't fire it twice with the
same boolean value. Every piece of code that triggers the event also
needs to set this state, so now everything goes through the
`triggerModifiedStatusChanged` method.
2013-03-07 09:30:28 -08:00

458 lines
12 KiB
CoffeeScript

_ = require 'underscore'
fs = require 'fs'
File = require 'file'
Point = require 'point'
Range = require 'range'
EventEmitter = require 'event-emitter'
UndoManager = require 'undo-manager'
BufferChangeOperation = require 'buffer-change-operation'
BufferMarker = require 'buffer-marker'
module.exports =
class Buffer
@idCounter = 1
stoppedChangingDelay: 300
stoppedChangingTimeout: null
undoManager: null
cachedDiskContents: null
cachedMemoryContents: null
conflict: false
lines: null
lineEndings: null
file: null
validMarkers: null
invalidMarkers: null
refcount: 0
constructor: (path, @project) ->
@id = @constructor.idCounter++
@nextMarkerId = 1
@validMarkers = {}
@invalidMarkers = {}
@lines = ['']
@lineEndings = []
if path
throw "Path '#{path}' does not exist" unless fs.exists(path)
@setPath(path)
@reload()
else
@setText('')
@undoManager = new UndoManager(this)
destroy: ->
throw new Error("Destroying buffer twice with path '#{@getPath()}'") if @destroyed
@file?.off()
@destroyed = true
@project?.removeBuffer(this)
retain: ->
@refcount++
this
release: ->
@refcount--
@destroy() if @refcount <= 0
this
hasEditors: -> @refcount > 1
subscribeToFile: ->
@file.on "contents-changed", =>
if @isModified()
@conflict = true
@updateCachedDiskContents()
@trigger "contents-conflicted"
else
@reload()
@file.on "removed", =>
@updateCachedDiskContents()
@triggerModifiedStatusChanged(@isModified())
@file.on "moved", =>
@trigger "path-changed", this
reload: ->
@trigger 'will-reload'
@updateCachedDiskContents()
@setText(@cachedDiskContents)
@triggerModifiedStatusChanged(false)
@trigger 'reloaded'
updateCachedDiskContents: ->
@cachedDiskContents = @file.read()
getBaseName: ->
@file?.getBaseName()
getPath: ->
@file?.getPath()
setPath: (path) ->
return if path == @getPath()
@file?.off()
@file = new File(path)
@file.read() if @file.exists()
@subscribeToFile()
@trigger "path-changed", this
getExtension: ->
if @getPath()
@getPath().split('/').pop().split('.').pop()
else
null
getText: ->
@cachedMemoryContents ?= @getTextInRange(@getRange())
setText: (text) ->
@change(@getRange(), text, normalizeLineEndings: false)
getRange: ->
new Range([0, 0], [@getLastRow(), @getLastLine().length])
getTextInRange: (range) ->
range = @clipRange(range)
if range.start.row == range.end.row
return @lineForRow(range.start.row)[range.start.column...range.end.column]
multipleLines = []
multipleLines.push @lineForRow(range.start.row)[range.start.column..] # first line
multipleLines.push @lineEndingForRow(range.start.row)
for row in [range.start.row + 1...range.end.row]
multipleLines.push @lineForRow(row) # middle lines
multipleLines.push @lineEndingForRow(row)
multipleLines.push @lineForRow(range.end.row)[0...range.end.column] # last line
return multipleLines.join ''
getLines: ->
@lines
lineForRow: (row) ->
@lines[row]
lineEndingForRow: (row) ->
@lineEndings[row] unless row is @getLastRow()
suggestedLineEndingForRow: (row) ->
@lineEndingForRow(row) ? @lineEndingForRow(row - 1)
lineLengthForRow: (row) ->
@lines[row].length
rangeForRow: (row, { includeNewline } = {}) ->
if includeNewline and row < @getLastRow()
new Range([row, 0], [row + 1, 0])
else
new Range([row, 0], [row, @lineLengthForRow(row)])
getLineCount: ->
@getLines().length
getLastRow: ->
@getLines().length - 1
getLastLine: ->
@lineForRow(@getLastRow())
getEofPosition: ->
lastRow = @getLastRow()
new Point(lastRow, @lineLengthForRow(lastRow))
characterIndexForPosition: (position) ->
position = Point.fromObject(position)
index = 0
index += @lineLengthForRow(row) + 1 for row in [0...position.row]
index + position.column
positionForCharacterIndex: (index) ->
row = 0
while index >= (lineLength = @lineLengthForRow(row) + 1)
index -= lineLength
row++
new Point(row, index)
deleteRow: (row) ->
@deleteRows(row, row)
deleteRows: (start, end) ->
startPoint = null
endPoint = null
if end == @getLastRow()
if start > 0
startPoint = [start - 1, @lineLengthForRow(start - 1)]
else
startPoint = [start, 0]
endPoint = [end, @lineLengthForRow(end)]
else
startPoint = [start, 0]
endPoint = [end + 1, 0]
@delete(new Range(startPoint, endPoint))
append: (text) ->
@insert(@getEofPosition(), text)
insert: (point, text) ->
@change(new Range(point, point), text)
delete: (range) ->
@change(range, '')
change: (oldRange, newText, options) ->
oldRange = Range.fromObject(oldRange)
operation = new BufferChangeOperation({buffer: this, oldRange, newText, options})
range = @pushOperation(operation)
range
clipPosition: (position) ->
position = Point.fromObject(position)
eofPosition = @getEofPosition()
if position.isGreaterThan(eofPosition)
eofPosition
else
row = Math.max(position.row, 0)
column = Math.max(position.column, 0)
column = Math.min(@lineLengthForRow(row), column)
new Point(row, column)
clipRange: (range) ->
range = Range.fromObject(range)
new Range(@clipPosition(range.start), @clipPosition(range.end))
prefixAndSuffixForRange: (range) ->
prefix: @lines[range.start.row][0...range.start.column]
suffix: @lines[range.end.row][range.end.column..]
pushOperation: (operation, editSession) ->
if @undoManager
@undoManager.pushOperation(operation, editSession)
else
operation.do()
transact: (fn) -> @undoManager.transact(fn)
undo: (editSession) -> @undoManager.undo(editSession)
redo: (editSession) -> @undoManager.redo(editSession)
commit: -> @undoManager.commit()
abort: -> @undoManager.abort()
save: ->
@saveAs(@getPath()) if @isModified()
saveAs: (path) ->
unless path then throw new Error("Can't save buffer with no file path")
@trigger 'will-be-saved'
@setPath(path)
@cachedDiskContents = @getText()
@file.write(@getText())
@triggerModifiedStatusChanged(false)
@trigger 'saved'
isModified: ->
if @file
@getText() != @cachedDiskContents
else
not @isEmpty()
isInConflict: -> @conflict
isEmpty: -> @lines.length is 1 and @lines[0].length is 0
getMarkers: ->
_.values(@validMarkers)
getMarkerCount: ->
_.size(@validMarkers)
markRange: (range, options={}) ->
marker = new BufferMarker(_.defaults({
id: (@nextMarkerId++).toString()
buffer: this
range
}, options))
@validMarkers[marker.id] = marker
marker.id
markPosition: (position, options) ->
@markRange([position, position], _.defaults({noTail: true}, options))
destroyMarker: (id) ->
delete @validMarkers[id]
delete @invalidMarkers[id]
getMarkerPosition: (args...) ->
@getMarkerHeadPosition(args...)
setMarkerPosition: (args...) ->
@setMarkerHeadPosition(args...)
getMarkerHeadPosition: (id) ->
@validMarkers[id]?.getHeadPosition()
setMarkerHeadPosition: (id, position, options) ->
@validMarkers[id]?.setHeadPosition(position)
getMarkerTailPosition: (id) ->
@validMarkers[id]?.getTailPosition()
setMarkerTailPosition: (id, position, options) ->
@validMarkers[id]?.setTailPosition(position)
getMarkerRange: (id) ->
@validMarkers[id]?.getRange()
setMarkerRange: (id, range, options) ->
@validMarkers[id]?.setRange(range, options)
placeMarkerTail: (id) ->
@validMarkers[id]?.placeTail()
clearMarkerTail: (id) ->
@validMarkers[id]?.clearTail()
isMarkerReversed: (id) ->
@validMarkers[id]?.isReversed()
observeMarker: (id, callback) ->
@validMarkers[id]?.observe(callback)
markersForPosition: (bufferPosition) ->
bufferPosition = Point.fromObject(bufferPosition)
ids = []
for id, marker of @validMarkers
ids.push(id) if marker.containsPoint(bufferPosition)
ids
matchesInCharacterRange: (regex, startIndex, endIndex) ->
text = @getText()
matches = []
regex.lastIndex = startIndex
while match = regex.exec(text)
matchLength = match[0].length
matchStartIndex = match.index
matchEndIndex = matchStartIndex + matchLength
if matchEndIndex > endIndex
regex.lastIndex = 0
if matchStartIndex < endIndex and submatch = regex.exec(text[matchStartIndex...endIndex])
submatch.index = matchStartIndex
matches.push submatch
break
matchEndIndex++ if matchLength is 0
regex.lastIndex = matchEndIndex
matches.push match
matches
scan: (regex, iterator) ->
@scanInRange(regex, @getRange(), iterator)
scanInRange: (regex, range, iterator, reverse=false) ->
range = Range.fromObject(range)
global = regex.global
flags = "gm"
flags += "i" if regex.ignoreCase
regex = new RegExp(regex.source, flags)
startIndex = @characterIndexForPosition(range.start)
endIndex = @characterIndexForPosition(range.end)
matches = @matchesInCharacterRange(regex, startIndex, endIndex)
lengthDelta = 0
keepLooping = null
replacementText = null
stop = -> keepLooping = false
replace = (text) -> replacementText = text
matches.reverse() if reverse
for match in matches
matchLength = match[0].length
matchStartIndex = match.index
matchEndIndex = matchStartIndex + matchLength
startPosition = @positionForCharacterIndex(matchStartIndex + lengthDelta)
endPosition = @positionForCharacterIndex(matchEndIndex + lengthDelta)
range = new Range(startPosition, endPosition)
keepLooping = true
replacementText = null
iterator(match, range, { stop, replace })
if replacementText?
@change(range, replacementText)
lengthDelta += replacementText.length - matchLength unless reverse
break unless global and keepLooping
backwardsScanInRange: (regex, range, iterator) ->
@scanInRange regex, range, iterator, true
isRowBlank: (row) ->
not /\S/.test @lineForRow(row)
previousNonBlankRow: (startRow) ->
return null if startRow == 0
startRow = Math.min(startRow, @getLastRow())
for row in [(startRow - 1)..0]
return row unless @isRowBlank(row)
null
nextNonBlankRow: (startRow) ->
lastRow = @getLastRow()
if startRow < lastRow
for row in [(startRow + 1)..lastRow]
return row unless @isRowBlank(row)
null
usesSoftTabs: ->
for line in @getLines()
if match = line.match(/^\s/)
return match[0][0] != '\t'
undefined
checkoutHead: ->
path = @getPath()
return unless path
git?.checkoutHead(path)
scheduleModifiedEvents: ->
clearTimeout(@stoppedChangingTimeout) if @stoppedChangingTimeout
stoppedChangingCallback = =>
@stoppedChangingTimeout = null
modifiedStatus = @isModified()
@trigger 'contents-modified', modifiedStatus
@triggerModifiedStatusChanged(modifiedStatus)
@stoppedChangingTimeout = setTimeout(stoppedChangingCallback, @stoppedChangingDelay)
triggerModifiedStatusChanged: (modifiedStatus) ->
return if modifiedStatus is @previousModifiedStatus
@previousModifiedStatus = modifiedStatus
@trigger 'modified-status-changed', modifiedStatus
fileExists: ->
@file.exists()
logLines: (start=0, end=@getLastRow())->
for row in [start..end]
line = @lineForRow(row)
console.log row, line, line.length
getDebugSnapshot: ->
lines = ['Buffer:']
for row in [0..@getLastRow()]
lines.push "#{row}: #{@lineForRow(row)}"
lines.join('\n')
_.extend(Buffer.prototype, EventEmitter)