diff --git a/exports/atom.coffee b/exports/atom.coffee index fd2e175e2..35a65c566 100644 --- a/exports/atom.coffee +++ b/exports/atom.coffee @@ -1,4 +1,4 @@ -{Document, Point, Range} = require 'telepath' +{Document, Model, Point, Range} = require 'telepath' module.exports = _: require 'underscore-plus' @@ -9,6 +9,7 @@ module.exports = File: require '../src/file' fs: require 'fs-plus' Git: require '../src/git' + Model: Model Point: Point Range: Range diff --git a/package.json b/package.json index a4a860c37..36af98666 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "clear-cut": "0.2.0", "coffee-script": "1.6.3", "coffeestack": "0.6.0", + "diff": "git://github.com/benogle/jsdiff.git", "emissary": "0.19.0", "first-mate": "0.5.0", "fs-plus": "0.11.0", @@ -42,7 +43,7 @@ "scandal": "0.8.0", "season": "0.14.0", "semver": "1.1.4", - "space-pen": "2.0.1", + "space-pen": "2.0.2", "telepath": "0.73.0", "temp": "0.5.0", "underscore-plus": "0.5.0" @@ -73,12 +74,13 @@ "github-releases": "~0.2.0" }, "packageDependencies": { - "atom-dark-syntax": "0.8.0", + "atom-dark-syntax": "0.10.0", "atom-dark-ui": "0.17.0", - "atom-light-syntax": "0.9.0", + "atom-light-syntax": "0.10.0", "atom-light-ui": "0.16.0", - "base16-tomorrow-dark-theme": "0.7.0", - "solarized-dark-syntax": "0.5.0", + "base16-tomorrow-dark-theme": "0.8.0", + "solarized-dark-syntax": "0.6.0", + "solarized-light-syntax": "0.2.0", "archive-view": "0.16.0", "autocomplete": "0.18.0", "autoflow": "0.11.0", @@ -87,14 +89,14 @@ "bracket-matcher": "0.15.0", "command-logger": "0.8.0", "command-palette": "0.13.0", - "dev-live-reload": "0.18.0", - "editor-stats": "0.8.0", - "exception-reporting": "0.8.0", + "dev-live-reload": "0.20.0", + "editor-stats": "0.9.0", + "exception-reporting": "0.9.0", "feedback": "0.16.0", - "find-and-replace": "0.58.0", + "find-and-replace": "0.59.0", "fuzzy-finder": "0.28.0", "gists": "0.13.0", - "git-diff": "0.20.0", + "git-diff": "0.21.0", "github-sign-in": "0.15.0", "go-to-line": "0.12.0", "grammar-selector": "0.13.0", @@ -115,7 +117,7 @@ "terminal": "0.23.0", "timecop": "0.11.0", "to-the-hubs": "0.15.0", - "tree-view": "0.44.0", + "tree-view": "0.48.0", "visual-bell": "0.6.0", "welcome": "0.3.0", "whitespace": "0.10.0", diff --git a/spec/editor-spec.coffee b/spec/editor-spec.coffee index b99be281b..cb7a75674 100644 --- a/spec/editor-spec.coffee +++ b/spec/editor-spec.coffee @@ -2624,6 +2624,7 @@ describe "Editor", -> describe ".shouldPromptToSave()", -> it "returns false when an edit session's buffer is in use by more than one session", -> + jasmine.unspy(editor, 'shouldPromptToSave') expect(editor.shouldPromptToSave()).toBeFalsy() buffer.setText('changed') expect(editor.shouldPromptToSave()).toBeTruthy() diff --git a/spec/pane-spec.coffee b/spec/pane-spec.coffee index 75d3661dc..d47d0687b 100644 --- a/spec/pane-spec.coffee +++ b/spec/pane-spec.coffee @@ -1,6 +1,6 @@ PaneContainer = require '../src/pane-container' Pane = require '../src/pane' -{$, View} = require 'atom' +{fs, $, View} = require 'atom' path = require 'path' temp = require 'temp' @@ -147,6 +147,7 @@ describe "Pane", -> describe "if the item is modified", -> beforeEach -> + jasmine.unspy(editor2, 'shouldPromptToSave') spyOn(editor2, 'save') spyOn(editor2, 'saveAs') @@ -347,14 +348,14 @@ describe "Pane", -> describe "when the current item has a saveAs method", -> it "opens a save dialog and saves the current item as the selected path", -> - spyOn(editor2, 'saveAs') - editor2.buffer.setPath(undefined) - pane.showItem(editor2) + newEditor = atom.project.openSync() + spyOn(newEditor, 'saveAs') + pane.showItem(newEditor) pane.trigger 'core:save' expect(atom.showSaveDialogSync).toHaveBeenCalled() - expect(editor2.saveAs).toHaveBeenCalledWith('/selected/path') + expect(newEditor.saveAs).toHaveBeenCalledWith('/selected/path') describe "when the current item has no saveAs method", -> it "does nothing", -> @@ -421,6 +422,17 @@ describe "Pane", -> view2.trigger 'title-changed' expect(activeItemTitleChangedHandler).toHaveBeenCalled() + describe "when an unmodifed buffer's path is deleted", -> + it "removes the pane item", -> + filePath = temp.openSync('atom').path + editor = atom.project.openSync(filePath) + pane.showItem(editor) + expect(pane.items).toHaveLength(5) + + fs.removeSync(filePath) + waitsFor -> + pane.items.length == 4 + describe ".remove()", -> it "destroys all the pane's items", -> pane.remove() diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index 629c99890..1bd3b9e1c 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -9,6 +9,7 @@ Keymap = require '../src/keymap' Config = require '../src/config' {Point} = require 'telepath' Project = require '../src/project' +Editor = require '../src/editor' EditorView = require '../src/editor-view' TokenizedBuffer = require '../src/tokenized-buffer' pathwatcher = require 'pathwatcher' @@ -91,6 +92,7 @@ beforeEach -> spyOn(window, "setTimeout").andCallFake window.fakeSetTimeout spyOn(window, "clearTimeout").andCallFake window.fakeClearTimeout spyOn(File.prototype, "detectResurrectionAfterDelay").andCallFake -> @detectResurrection() + spyOn(Editor.prototype, "shouldPromptToSave").andReturn false # make tokenization synchronous TokenizedBuffer.prototype.chunkSize = Infinity diff --git a/spec/text-buffer-spec.coffee b/spec/text-buffer-spec.coffee index 31066cd5b..6bc53aff5 100644 --- a/spec/text-buffer-spec.coffee +++ b/spec/text-buffer-spec.coffee @@ -34,11 +34,11 @@ describe 'TextBuffer', -> expect(buffer.getText()).toBe fs.readFileSync(filePath, 'utf8') describe "when no file exists for the path", -> - it "is modified and is initially empty", -> + it "is not modified and is initially empty", -> filePath = "does-not-exist.txt" expect(fs.existsSync(filePath)).toBeFalsy() buffer = atom.project.bufferForPathSync(filePath) - expect(buffer.isModified()).toBeTruthy() + expect(buffer.isModified()).not.toBeTruthy() expect(buffer.getText()).toBe '' describe "when no path is given", -> @@ -113,10 +113,17 @@ describe 'TextBuffer', -> runs -> [event] = changeHandler.argsForCall[0] - expect(event.oldRange).toEqual [[0, 0], [0, 5]] + expect(event.oldRange).toEqual [[0, 0], [0, 0]] expect(event.newRange).toEqual [[0, 0], [0, 6]] - expect(event.oldText).toBe "first" + expect(event.oldText).toBe "" expect(event.newText).toBe "second" + + [event] = changeHandler.argsForCall[1] + expect(event.oldRange).toEqual [[0, 6], [0, 11]] + expect(event.newRange).toEqual [[0, 6], [0, 6]] + expect(event.oldText).toBe "first" + expect(event.newText).toBe "" + expect(buffer.isModified()).toBeFalsy() describe "when the buffer's memory contents differ from the *previous* disk contents", -> @@ -160,20 +167,38 @@ describe 'TextBuffer', -> filePath = bufferToDelete.getPath() # symlinks may have been converted expect(bufferToDelete.getPath()).toBe filePath - expect(bufferToDelete.isModified()).toBeFalsy() - - removeHandler = jasmine.createSpy('removeHandler') - bufferToDelete.file.on 'removed', removeHandler - fs.removeSync(filePath) - waitsFor "file to be removed", -> - removeHandler.callCount > 0 afterEach -> bufferToDelete.destroy() - it "retains its path and reports the buffer as modified", -> - expect(bufferToDelete.getPath()).toBe filePath - expect(bufferToDelete.isModified()).toBeTruthy() + describe "when the file is modified", -> + beforeEach -> + bufferToDelete.setText("I WAS MODIFIED") + expect(bufferToDelete.isModified()).toBeTruthy() + + removeHandler = jasmine.createSpy('removeHandler') + bufferToDelete.file.on 'removed', removeHandler + fs.removeSync(filePath) + waitsFor "file to be removed", -> + removeHandler.callCount > 0 + + it "retains its path and reports the buffer as modified", -> + expect(bufferToDelete.getPath()).toBe filePath + expect(bufferToDelete.isModified()).toBeTruthy() + + describe "when the file is not modified", -> + beforeEach -> + expect(bufferToDelete.isModified()).toBeFalsy() + + removeHandler = jasmine.createSpy('removeHandler') + bufferToDelete.file.on 'removed', removeHandler + fs.removeSync(filePath) + waitsFor "file to be removed", -> + removeHandler.callCount > 0 + + it "retains its path and reports the buffer as not modified", -> + expect(bufferToDelete.getPath()).toBe filePath + expect(bufferToDelete.isModified()).toBeFalsy() it "resumes watching of the file when it is re-saved", -> bufferToDelete.save() @@ -210,19 +235,6 @@ describe 'TextBuffer', -> advanceClock(buffer.stoppedChangingDelay) expect(modifiedHandler).toHaveBeenCalledWith(false) - it "reports the modified status changing to true after the underlying file is deleted", -> - buffer.release() - filePath = path.join(temp.dir, 'atom-tmp-file') - fs.writeFileSync(filePath, 'delete me') - buffer = atom.project.bufferForPathSync(filePath) - modifiedHandler = jasmine.createSpy("modifiedHandler") - buffer.on 'modified-status-changed', modifiedHandler - - fs.removeSync(filePath) - - waitsFor "modified status to change", -> modifiedHandler.callCount - runs -> expect(buffer.isModified()).toBe true - it "reports the modified status changing to false after a modified buffer is saved", -> filePath = path.join(temp.dir, 'atom-tmp-file') fs.writeFileSync(filePath, '') @@ -454,6 +466,68 @@ describe 'TextBuffer', -> expect(event.oldRange).toEqual expectedPreRange expect(event.newRange).toEqual [[0, 0], [1, 14]] + describe ".setTextViaDiff(text)", -> + it "can change the entire contents of the buffer when there are no newlines", -> + buffer.setText('BUFFER CHANGE') + newText = 'DISK CHANGE' + buffer.setTextViaDiff(newText) + expect(buffer.getText()).toBe newText + + describe "with standard newlines", -> + it "can change the entire contents of the buffer with no newline at the end", -> + newText = "I know you are.\nBut what am I?" + buffer.setTextViaDiff(newText) + expect(buffer.getText()).toBe newText + + it "can change the entire contents of the buffer with a newline at the end", -> + newText = "I know you are.\nBut what am I?\n" + buffer.setTextViaDiff(newText) + expect(buffer.getText()).toBe newText + + it "can change a few lines at the beginning in the buffer", -> + newText = buffer.getText().replace(/function/g, 'omgwow') + buffer.setTextViaDiff(newText) + expect(buffer.getText()).toBe newText + + it "can change a few lines in the middle of the buffer", -> + newText = buffer.getText().replace(/shift/g, 'omgwow') + buffer.setTextViaDiff(newText) + expect(buffer.getText()).toBe newText + + it "can adds a newline at the end", -> + newText = buffer.getText() + '\n' + buffer.setTextViaDiff(newText) + expect(buffer.getText()).toBe newText + + describe "with windows newlines", -> + beforeEach -> + buffer.setText(buffer.getText().replace(/\n/g, '\r\n')) + + it "adds a newline at the end", -> + newText = buffer.getText() + '\r\n' + buffer.setTextViaDiff(newText) + expect(buffer.getText()).toBe newText + + it "changes the entire contents of the buffer with smaller content with no newline at the end", -> + newText = "I know you are.\r\nBut what am I?" + buffer.setTextViaDiff(newText) + expect(buffer.getText()).toBe newText + + it "changes the entire contents of the buffer with smaller content with newline at the end", -> + newText = "I know you are.\r\nBut what am I?\r\n" + buffer.setTextViaDiff(newText) + expect(buffer.getText()).toBe newText + + it "changes a few lines at the beginning in the buffer", -> + newText = buffer.getText().replace(/function/g, 'omgwow') + buffer.setTextViaDiff(newText) + expect(buffer.getText()).toBe newText + + it "changes a few lines in the middle of the buffer", -> + newText = buffer.getText().replace(/shift/g, 'omgwow') + buffer.setTextViaDiff(newText) + expect(buffer.getText()).toBe newText + describe ".save()", -> saveBuffer = null diff --git a/spec/window-spec.coffee b/spec/window-spec.coffee index 8d0d68fce..2b6f1a6fb 100644 --- a/spec/window-spec.coffee +++ b/spec/window-spec.coffee @@ -1,5 +1,6 @@ {$, $$, fs} = require 'atom' path = require 'path' +Editor = require '../src/editor' WindowEventHandler = require '../src/window-event-handler' describe "Window", -> @@ -54,6 +55,7 @@ describe "Window", -> [beforeUnloadEvent] = [] beforeEach -> + jasmine.unspy(Editor.prototype, "shouldPromptToSave") beforeUnloadEvent = $.Event(new Event('beforeunload')) describe "when pane items are are modified", -> diff --git a/src/atom.coffee b/src/atom.coffee index 538dea1bd..3c99098a6 100644 --- a/src/atom.coffee +++ b/src/atom.coffee @@ -191,7 +191,7 @@ class Atom extends Model @menu.update() $(window).on 'unload', => - $(document.body).hide() + $(document.body).css('visibility', 'hidden') @unloadEditorWindow() false diff --git a/src/editor-view.coffee b/src/editor-view.coffee index fb1540d61..67a5d3ce7 100644 --- a/src/editor-view.coffee +++ b/src/editor-view.coffee @@ -780,6 +780,14 @@ class EditorView extends View afterAttach: (onDom) -> return unless onDom + + # TODO: Remove this guard when we understand why this is happening + unless @editor.isAlive() + if atom.isReleasedVersion() + return + else + throw new Error("Assertion failure: EditorView is getting attached to a dead editor. Why?") + @redraw() if @redrawOnReattach return if @attached @attached = true diff --git a/src/editor.coffee b/src/editor.coffee index cba3382fd..c033b4745 100644 --- a/src/editor.coffee +++ b/src/editor.coffee @@ -110,6 +110,7 @@ class Editor extends Model @subscribe @buffer, "contents-modified", => @emit "contents-modified" @subscribe @buffer, "contents-conflicted", => @emit "contents-conflicted" @subscribe @buffer, "modified-status-changed", => @emit "modified-status-changed" + @subscribe @buffer, "destroyed", => @destroy() @preserveCursorPositionOnBufferReload() # Private: diff --git a/src/pane.coffee b/src/pane.coffee index 5fadee731..acdc152d6 100644 --- a/src/pane.coffee +++ b/src/pane.coffee @@ -31,6 +31,7 @@ class Pane extends View # Private: initialize: (args...) -> + @items = [] if args[0] instanceof telepath.Document @state = args[0] @items = _.compact @state.get('items').map (item) -> @@ -43,6 +44,8 @@ class Pane extends View deserializer: 'Pane' items: @items.map (item) -> item.getState?() ? item.serialize() + @handleItemEvents(item) for item in @items + @subscribe @state.get('items'), 'changed', ({index, removedValues, insertedValues, siteId}) => return if siteId is @state.siteId for itemState in removedValues @@ -188,8 +191,14 @@ class Pane extends View @state.get('items').splice(index, 0, item.getState?() ? item.serialize()) if options.updateState ? true @items.splice(index, 0, item) @trigger 'pane:item-added', [item, index] + @handleItemEvents(item) item + handleItemEvents: (item) -> + if _.isFunction(item.on) + @subscribe item, 'destroyed', => + @destroyItem(item) if @state.isAlive() + # Public: Remove the currently active item. destroyActiveItem: => @destroyItem(@activeItem) @@ -197,11 +206,11 @@ class Pane extends View # Public: Remove the specified item. destroyItem: (item) -> + @unsubscribe(item) if _.isFunction(item.off) @trigger 'pane:before-item-destroyed', [item] - container = @getContainer() if @promptToSaveItem(item) - container.itemDestroyed(item) + @getContainer()?.itemDestroyed(item) @removeItem(item) item.destroy?() true diff --git a/src/project.coffee b/src/project.coffee index 27f3c0880..eae064fd6 100644 --- a/src/project.coffee +++ b/src/project.coffee @@ -4,11 +4,10 @@ url = require 'url' _ = require 'underscore-plus' fs = require 'fs-plus' Q = require 'q' -telepath = require 'telepath' +{Model} = require 'telepath' TextBuffer = require './text-buffer' Editor = require './editor' -{Emitter} = require 'emissary' Directory = require './directory' Task = require './task' Git = require './git' @@ -18,8 +17,7 @@ Git = require './git' # Ultimately, a project is a git directory that's been opened. It's a collection # of directories and files that you can operate on. module.exports = -class Project extends telepath.Model - Emitter.includeInto(this) +class Project extends Model @properties buffers: [] diff --git a/src/text-buffer.coffee b/src/text-buffer.coffee index d84ee24da..b5936c8fb 100644 --- a/src/text-buffer.coffee +++ b/src/text-buffer.coffee @@ -1,5 +1,5 @@ _ = require 'underscore-plus' -{Emitter, Subscriber} = require 'emissary' +diff = require 'diff' Q = require 'q' {P} = require 'scandal' telepath = require 'telepath' @@ -14,9 +14,6 @@ File = require './file' # the case, as a `TextBuffer` could be an unsaved chunk of text. module.exports = class TextBuffer extends telepath.Model - Emitter.includeInto(this) - Subscriber.includeInto(this) - @properties text: -> new telepath.String('', replicated: false) filePath: null @@ -87,7 +84,6 @@ class TextBuffer extends telepath.Model @file?.off() @unsubscribe() @alreadyDestroyed = true - @emit 'destroyed', this isRetained: -> @refcount > 0 @@ -117,8 +113,12 @@ class TextBuffer extends telepath.Model @reload() @file.on "removed", => - @updateCachedDiskContents().done => - @emitModifiedStatusChanged(@isModified()) + modified = @getText() != @cachedDiskContents + @wasModifiedBeforeRemove = modified + if modified + @updateCachedDiskContents() + else + @destroy() @file.on "moved", => @emit "path-changed", this @@ -137,7 +137,7 @@ class TextBuffer extends telepath.Model # Sets the buffer's content to the cached disk contents reload: -> @emit 'will-reload' - @setText(@cachedDiskContents) + @setTextViaDiff(@cachedDiskContents) @emitModifiedStatusChanged(false) @emit 'reloaded' @@ -202,6 +202,52 @@ class TextBuffer extends telepath.Model setText: (text) -> @change(@getRange(), text, normalizeLineEndings: false) + # Private: Replaces the current buffer contents. Only apply the differences. + # + # text - A {String} containing the new buffer contents. + setTextViaDiff: (text) -> + currentText = @getText() + return if currentText == text + + endsWithNewline = (str) -> + /[\r\n]+$/g.test(str) + + computeBufferColumn = (str) -> + newlineIndex = Math.max(str.lastIndexOf('\n'), str.lastIndexOf('\r')) + if endsWithNewline(str) + 0 + else if newlineIndex == -1 + str.length + else + str.length - newlineIndex - 1 + + @transact => + row = 0 + column = 0 + currentPosition = [0, 0] + + lineDiff = diff.diffLines(currentText, text) + changeOptions = normalizeLineEndings: false + + for change in lineDiff + lineCount = change.value.match(/\n/g)?.length ? 0 + currentPosition[0] = row + currentPosition[1] = column + + if change.added + @change([currentPosition, currentPosition], change.value, changeOptions) + row += lineCount + column = computeBufferColumn(change.value) + + else if change.removed + endRow = row + lineCount + endColumn = column + computeBufferColumn(change.value) + @change([currentPosition, [endRow, endColumn]], '', changeOptions) + + else + row += lineCount + column = computeBufferColumn(change.value) + # Gets the range of the buffer contents. # # Returns a new {Range}, from `[0, 0]` to the end of the buffer. @@ -403,7 +449,7 @@ class TextBuffer extends telepath.Model if @file.exists() @getText() != @cachedDiskContents else - true + @wasModifiedBeforeRemove ? not @isEmpty() else not @isEmpty()