diff --git a/spec/selection-spec.js b/spec/selection-spec.js index cb586da26..8afc67575 100644 --- a/spec/selection-spec.js +++ b/spec/selection-spec.js @@ -154,4 +154,118 @@ describe('Selection', () => { expect(editor.isFoldedAtBufferRow(0)).toBe(false) }) }) + + describe('within a read-only editor', () => { + beforeEach(() => { + editor.setReadOnly(true) + selection.setBufferRange([[0, 0], [0, 13]]) + }) + + const modifications = [ + { + name: 'insertText', + op: opts => selection.insertText('yes', opts) + }, + { + name: 'backspace', + op: opts => selection.backspace(opts) + }, + { + name: 'deleteToPreviousWordBoundary', + op: opts => selection.deleteToPreviousWordBoundary(opts) + }, + { + name: 'deleteToNextWordBoundary', + op: opts => selection.deleteToNextWordBoundary(opts) + }, + { + name: 'deleteToBeginningOfWord', + op: opts => selection.deleteToBeginningOfWord(opts) + }, + { + name: 'deleteToBeginningOfLine', + op: opts => selection.deleteToBeginningOfLine(opts) + }, + { + name: 'delete', + op: opts => selection.delete(opts) + }, + { + name: 'deleteToEndOfLine', + op: opts => selection.deleteToEndOfLine(opts) + }, + { + name: 'deleteToEndOfWord', + op: opts => selection.deleteToEndOfWord(opts) + }, + { + name: 'deleteToBeginningOfSubword', + op: opts => selection.deleteToBeginningOfSubword(opts) + }, + { + name: 'deleteToEndOfSubword', + op: opts => selection.deleteToEndOfSubword(opts) + }, + { + name: 'deleteSelectedText', + op: opts => selection.deleteSelectedText(opts) + }, + { + name: 'deleteLine', + op: opts => selection.deleteLine(opts) + }, + { + name: 'joinLines', + op: opts => selection.joinLines(opts) + }, + { + name: 'outdentSelectedRows', + op: opts => selection.outdentSelectedRows(opts) + }, + { + name: 'autoIndentSelectedRows', + op: opts => selection.autoIndentSelectedRows(opts) + }, + { + name: 'toggleLineComments', + op: opts => selection.toggleLineComments(opts) + }, + { + name: 'cutToEndOfLine', + op: opts => selection.cutToEndOfLine(false, opts) + }, + { + name: 'cutToEndOfBufferLine', + op: opts => selection.cutToEndOfBufferLine(false, opts) + }, + { + name: 'cut', + op: opts => selection.cut(false, false, opts.bypassReadOnly) + }, + { + name: 'indent', + op: opts => selection.indent(opts) + }, + { + name: 'indentSelectedRows', + op: opts => selection.indentSelectedRows(opts) + }, + ] + + describe('without bypassReadOnly', () => { + for (const {name, op} of modifications) { + it(`throws an error on ${name}`, () => { + expect(op).toThrow() + }) + } + }) + + describe('with bypassReadOnly', () => { + for (const {name, op} of modifications) { + it(`permits ${name}`, () => { + op({bypassReadOnly: true}) + }) + } + }) + }) }) diff --git a/spec/text-editor-spec.js b/spec/text-editor-spec.js index 815a0e8d4..a84a1f233 100644 --- a/spec/text-editor-spec.js +++ b/spec/text-editor-spec.js @@ -5488,6 +5488,195 @@ describe('TextEditor', () => { }) }) }) + + describe('when readonly', () => { + beforeEach(() => { + editor.setReadOnly(true) + }) + + const modifications = [ + { + name: 'moveLineUp', + op: (opts = {}) => { + editor.setCursorBufferPosition([1, 0]) + editor.moveLineUp(opts) + } + }, + { + name: 'moveLineDown', + op: (opts = {}) => { + editor.setCursorBufferPosition([0, 0]) + editor.moveLineDown(opts) + } + }, + { + name: 'insertText', + op: (opts = {}) => { + editor.setSelectedBufferRange([[1, 0], [1, 2]]) + editor.insertText('xxx', opts) + } + }, + { + name: 'insertNewline', + op: (opts = {}) => { + editor.setCursorScreenPosition({row: 1, column: 0}) + editor.insertNewline(opts) + } + }, + { + name: 'insertNewlineBelow', + op: (opts = {}) => { + editor.setCursorBufferPosition([0, 2]) + editor.insertNewlineBelow(opts) + } + }, + { + name: 'insertNewlineAbove', + op: (opts = {}) => { + editor.setCursorBufferPosition([0]) + editor.insertNewlineAbove(opts) + } + }, + { + name: 'backspace', + op: (opts = {}) => { + editor.setCursorScreenPosition({row: 1, column: 7}) + editor.backspace(opts) + } + }, + { + name: 'deleteToPreviousWordBoundary', + op: (opts = {}) => { + editor.setCursorBufferPosition([0, 16]) + editor.deleteToPreviousWordBoundary(opts) + } + }, + { + name: 'deleteToNextWordBoundary', + op: (opts = {}) => { + editor.setCursorBufferPosition([0, 15]) + editor.deleteToNextWordBoundary(opts) + } + }, + { + name: 'deleteToBeginningOfWord', + op: (opts = {}) => { + editor.setCursorBufferPosition([1, 24]) + editor.deleteToBeginningOfWord(opts) + } + }, + { + name: 'deleteToEndOfLine', + op: (opts = {}) => { + editor.setCursorBufferPosition([1, 24]) + editor.deleteToEndOfLine(opts) + } + }, + { + name: 'deleteToBeginningOfLine', + op: (opts = {}) => { + editor.setCursorBufferPosition([1, 24]) + editor.deleteToBeginningOfLine(opts) + } + }, + { + name: 'delete', + op: (opts = {}) => { + editor.setCursorScreenPosition([1, 6]) + editor.delete(opts) + } + }, + { + name: 'deleteToEndOfWord', + op: (opts = {}) => { + editor.setCursorBufferPosition([1, 24]) + editor.deleteToEndOfWord(opts) + } + }, + { + name: 'indent', + op: (opts = {}) => { + editor.indent(opts) + } + }, + { + name: 'cutSelectedText', + op: (opts = {}) => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) + editor.cutSelectedText(opts) + } + }, + { + name: 'cutToEndOfLine', + op: (opts = {}) => { + editor.setCursorBufferPosition([2, 20]) + editor.cutToEndOfLine(opts) + } + }, + { + name: 'cutToEndOfBufferLine', + op: (opts = {}) => { + editor.setCursorBufferPosition([2, 20]) + editor.cutToEndOfBufferLine(opts) + } + }, + { + name: 'pasteText', + op: (opts = {}) => { + editor.setSelectedBufferRanges([[[0, 4], [0, 13]], [[1, 6], [1, 10]]]) + atom.clipboard.write('first') + editor.pasteText(opts) + } + }, + { + name: 'indentSelectedRows', + op: (opts = {}) => { + editor.setSelectedBufferRange([[0, 3], [0, 3]]) + editor.indentSelectedRows(opts) + } + }, + { + name: 'outdentSelectedRows', + op: (opts = {}) => { + editor.setSelectedBufferRange([[1, 3], [1, 3]]) + editor.outdentSelectedRows(opts) + } + }, + { + name: 'autoIndentSelectedRows', + op: (opts = {}) => { + editor.setCursorBufferPosition([2, 0]) + editor.insertText('function() {\ninside=true\n}\n i=1\n', opts) + editor.getLastSelection().setBufferRange([[2, 0], [6, 0]]) + editor.autoIndentSelectedRows(opts) + } + }, + { + name: 'undo/redo', + op: (opts = {}) => { + editor.insertText('foo', opts) + editor.undo(opts) + editor.redo(opts) + } + } + ] + + describe('without bypassReadOnly', () => { + for (const {name, op} of modifications) { + it(`throws an error on ${name}`, () => { + expect(op).toThrow() + }) + } + }) + + describe('with bypassReadOnly', () => { + for (const {name, op} of modifications) { + it(`permits ${name}`, () => { + op({bypassReadOnly: true}) + }) + } + }) + }) }) describe('reading text', () => { diff --git a/src/register-default-commands.coffee b/src/register-default-commands.coffee index a367e6188..7f1503b73 100644 --- a/src/register-default-commands.coffee +++ b/src/register-default-commands.coffee @@ -166,15 +166,35 @@ module.exports = ({commandRegistry, commandInstaller, config, notificationManage false ) + commandRegistry.add( + 'atom-text-editor:not([readonly])', + stopEventPropagation({ + 'core:undo': -> @undo() + 'core:redo': -> @redo() + }), + false + ) + commandRegistry.add( 'atom-text-editor', + stopEventPropagationAndGroupUndo( + config, + { + 'core:copy': -> @copySelectedText() + 'editor:copy-selection': -> @copyOnlySelectedText() + } + ), + false + ) + + commandRegistry.add( + 'atom-text-editor:not([readonly])', stopEventPropagationAndGroupUndo( config, { 'core:backspace': -> @backspace() 'core:delete': -> @delete() 'core:cut': -> @cutSelectedText() - 'core:copy': -> @copySelectedText() 'core:paste': -> @pasteText() 'editor:paste-without-reformatting': -> @pasteText({ normalizeLineEndings: false, @@ -195,7 +215,6 @@ module.exports = ({commandRegistry, commandInstaller, config, notificationManage 'editor:transpose': -> @transpose() 'editor:upper-case': -> @upperCase() 'editor:lower-case': -> @lowerCase() - 'editor:copy-selection': -> @copyOnlySelectedText() } ), false @@ -266,7 +285,7 @@ module.exports = ({commandRegistry, commandInstaller, config, notificationManage ) commandRegistry.add( - 'atom-text-editor:not([mini])', + 'atom-text-editor:not([mini]):not([readonly])', stopEventPropagationAndGroupUndo( config, { diff --git a/src/selection.js b/src/selection.js index 2c64fa126..b41095e26 100644 --- a/src/selection.js +++ b/src/selection.js @@ -407,6 +407,25 @@ class Selection { if (autoscroll) this.cursor.autoscroll() } + // Private: Ensure that the {TextEditor} is not marked read-only before allowing a buffer modification to occur. if + // the editor is read-only, require an explicit opt-in option to proceed (`bypassReadOnly`) or throw an Error. + ensureWritable (methodName, opts) { + if (!opts.bypassReadOnly && this.editor.isReadOnly()) { + if (atom.inDevMode() || atom.inSpecMode()) { + const e = new Error('Attempt to mutate a read-only TextEditor through a Selection') + e.detail = + `Your package is attempting to call ${methodName} on a selection within an editor that has been marked ` + + ' read-only. Pass {bypassReadOnly: true} to modify it anyway, or test editors with .isReadOnly() before ' + + ' attempting modifications.' + throw e + } + + return false + } + + return true + } + /* Section: Modifying the selected text */ @@ -428,7 +447,10 @@ class Selection { // level between the first lines and the trailing lines. // * `normalizeLineEndings` (optional) {Boolean} (default: true) // * `undo` If `skip`, skips the undo stack for this operation. + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) insertText (text, options = {}) { + if (!this.ensureWritable('insertText', options)) return + let desiredIndentLevel, indentAdjustment const oldBufferRange = this.getBufferRange() const wasReversed = this.isReversed() @@ -492,90 +514,134 @@ class Selection { // Public: Removes the first character before the selection if the selection // is empty otherwise it deletes the selection. - backspace () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + backspace (options = {}) { + if (!this.ensureWritable('backspace', options)) return if (this.isEmpty()) this.selectLeft() - this.deleteSelectedText() + this.deleteSelectedText(options) } // Public: Removes the selection or, if nothing is selected, then all // characters from the start of the selection back to the previous word // boundary. - deleteToPreviousWordBoundary () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteToPreviousWordBoundary (options = {}) { + if (!this.ensureWritable('deleteToPreviousWordBoundary', options)) return if (this.isEmpty()) this.selectToPreviousWordBoundary() - this.deleteSelectedText() + this.deleteSelectedText(options) } // Public: Removes the selection or, if nothing is selected, then all // characters from the start of the selection up to the next word // boundary. - deleteToNextWordBoundary () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteToNextWordBoundary (options = {}) { + if (!this.ensureWritable('deleteToNextWordBoundary', options)) return if (this.isEmpty()) this.selectToNextWordBoundary() - this.deleteSelectedText() + this.deleteSelectedText(options) } // Public: Removes from the start of the selection to the beginning of the // current word if the selection is empty otherwise it deletes the selection. - deleteToBeginningOfWord () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteToBeginningOfWord (options = {}) { + if (!this.ensureWritable('deleteToBeginningOfWord', options)) return if (this.isEmpty()) this.selectToBeginningOfWord() - this.deleteSelectedText() + this.deleteSelectedText(options) } // Public: Removes from the beginning of the line which the selection begins on // all the way through to the end of the selection. - deleteToBeginningOfLine () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteToBeginningOfLine (options = {}) { + if (!this.ensureWritable('deleteToBeginningOfLine', options)) return if (this.isEmpty() && this.cursor.isAtBeginningOfLine()) { this.selectLeft() } else { this.selectToBeginningOfLine() } - this.deleteSelectedText() + this.deleteSelectedText(options) } // Public: Removes the selection or the next character after the start of the // selection if the selection is empty. - delete () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + delete (options = {}) { + if (!this.ensureWritable('delete', options)) return if (this.isEmpty()) this.selectRight() - this.deleteSelectedText() + this.deleteSelectedText(options) } // Public: If the selection is empty, removes all text from the cursor to the // end of the line. If the cursor is already at the end of the line, it // removes the following newline. If the selection isn't empty, only deletes // the contents of the selection. - deleteToEndOfLine () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteToEndOfLine (options = {}) { + if (!this.ensureWritable('deleteToEndOfLine', options)) return if (this.isEmpty()) { if (this.cursor.isAtEndOfLine()) { - this.delete() + this.delete(options) return } this.selectToEndOfLine() } - this.deleteSelectedText() + this.deleteSelectedText(options) } // Public: Removes the selection or all characters from the start of the // selection to the end of the current word if nothing is selected. - deleteToEndOfWord () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteToEndOfWord (options = {}) { + if (!this.ensureWritable('deleteToEndOfWord', options)) return if (this.isEmpty()) this.selectToEndOfWord() - this.deleteSelectedText() + this.deleteSelectedText(options) } // Public: Removes the selection or all characters from the start of the // selection to the end of the current word if nothing is selected. - deleteToBeginningOfSubword () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteToBeginningOfSubword (options = {}) { + if (!this.ensureWritable('deleteToBeginningOfSubword', options)) return if (this.isEmpty()) this.selectToPreviousSubwordBoundary() - this.deleteSelectedText() + this.deleteSelectedText(options) } // Public: Removes the selection or all characters from the start of the // selection to the end of the current word if nothing is selected. - deleteToEndOfSubword () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteToEndOfSubword (options = {}) { + if (!this.ensureWritable('deleteToEndOfSubword', options)) return if (this.isEmpty()) this.selectToNextSubwordBoundary() - this.deleteSelectedText() + this.deleteSelectedText(options) } // Public: Removes only the selected text. - deleteSelectedText () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteSelectedText (options = {}) { + if (!this.ensureWritable('deleteSelectedText', options)) return const bufferRange = this.getBufferRange() if (!bufferRange.isEmpty()) this.editor.buffer.delete(bufferRange) if (this.cursor) this.cursor.setBufferPosition(bufferRange.start) @@ -584,7 +650,11 @@ class Selection { // Public: Removes the line at the beginning of the selection if the selection // is empty unless the selection spans multiple lines in which case all lines // are removed. - deleteLine () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + deleteLine (options = {}) { + if (!this.ensureWritable('deleteLine', options)) return const range = this.getBufferRange() if (range.isEmpty()) { const start = this.cursor.getScreenRow() @@ -607,7 +677,11 @@ class Selection { // be separated by a single space. // // If there selection spans more than one line, all the lines are joined together. - joinLines () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + joinLines (options = {}) { + if (!this.ensureWritable('joinLines', options)) return let joinMarker const selectedRange = this.getBufferRange() if (selectedRange.isEmpty()) { @@ -629,7 +703,7 @@ class Selection { }) if (trailingWhitespaceRange) { this.setBufferRange(trailingWhitespaceRange) - this.deleteSelectedText() + this.deleteSelectedText(options) } const currentRow = selectedRange.start.row @@ -638,7 +712,7 @@ class Selection { (nextRow <= this.editor.buffer.getLastRow()) && (this.editor.buffer.lineLengthForRow(nextRow) > 0) && (this.editor.buffer.lineLengthForRow(currentRow) > 0) - if (insertSpace) this.insertText(' ') + if (insertSpace) this.insertText(' ', options) this.cursor.moveToEndOfLine() @@ -647,7 +721,7 @@ class Selection { this.cursor.moveRight() this.cursor.moveToFirstCharacterOfLine() }) - this.deleteSelectedText() + this.deleteSelectedText(options) if (insertSpace) this.cursor.moveLeft() } @@ -660,7 +734,11 @@ class Selection { } // Public: Removes one level of indent from the currently selected rows. - outdentSelectedRows () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + outdentSelectedRows (options = {}) { + if (!this.ensureWritable('outdentSelectedRows', options)) return const [start, end] = this.getBufferRowRange() const {buffer} = this.editor const leadingTabRegex = new RegExp(`^( {1,${this.editor.getTabLength()}}|\t)`) @@ -674,7 +752,11 @@ class Selection { // Public: Sets the indentation level of all selected rows to values suggested // by the relevant grammars. - autoIndentSelectedRows () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + autoIndentSelectedRows (options = {}) { + if (!this.ensureWritable('autoIndentSelectedRows', options)) return const [start, end] = this.getBufferRowRange() return this.editor.autoIndentBufferRows(start, end) } @@ -683,29 +765,45 @@ class Selection { // of a comment. // // Removes the comment if they are currently wrapped in a comment. - toggleLineComments () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + toggleLineComments (options = {}) { + if (!this.ensureWritable('toggleLineComments', options)) return this.editor.toggleLineCommentsForBufferRows(...(this.getBufferRowRange() || [])) } // Public: Cuts the selection until the end of the screen line. - cutToEndOfLine (maintainClipboard) { + // + // * `maintainClipboard` {Boolean} + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + cutToEndOfLine (maintainClipboard, options = {}) { + if (!this.ensureWritable('cutToEndOfLine', options)) return if (this.isEmpty()) this.selectToEndOfLine() - return this.cut(maintainClipboard) + return this.cut(maintainClipboard, false, options.bypassReadOnly) } // Public: Cuts the selection until the end of the buffer line. - cutToEndOfBufferLine (maintainClipboard) { + // + // * `maintainClipboard` {Boolean} + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + cutToEndOfBufferLine (maintainClipboard, options = {}) { + if (!this.ensureWritable('cutToEndOfBufferLine', options)) return if (this.isEmpty()) this.selectToEndOfBufferLine() - this.cut(maintainClipboard) + this.cut(maintainClipboard, false, options.bypassReadOnly) } // Public: Copies the selection to the clipboard and then deletes it. // // * `maintainClipboard` {Boolean} (default: false) See {::copy} // * `fullLine` {Boolean} (default: false) See {::copy} - cut (maintainClipboard = false, fullLine = false) { + // * `bypassReadOnly` {Boolean} (default: false) Must be `true` to modify text within a read-only editor. + cut (maintainClipboard = false, fullLine = false, bypassReadOnly = false) { + if (!this.ensureWritable('cut', {bypassReadOnly})) return this.copy(maintainClipboard, fullLine) - this.delete() + this.delete({bypassReadOnly}) } // Public: Copies the current selection to the clipboard. @@ -783,7 +881,9 @@ class Selection { // * `options` (optional) {Object} with the keys: // * `autoIndent` If `true`, the line is indented to an automatically-inferred // level. Otherwise, {TextEditor::getTabText} is inserted. - indent ({autoIndent} = {}) { + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + indent ({autoIndent, bypassReadOnly} = {}) { + if (!this.ensureWritable('indent', {bypassReadOnly})) return const {row} = this.cursor.getBufferPosition() if (this.isEmpty()) { @@ -793,17 +893,21 @@ class Selection { if (autoIndent && delta > 0) { if (!this.editor.getSoftTabs()) delta = Math.max(delta, 1) - this.insertText(this.editor.buildIndentString(delta)) + this.insertText(this.editor.buildIndentString(delta), {bypassReadOnly}) } else { - this.insertText(this.editor.buildIndentString(1, this.cursor.getBufferColumn())) + this.insertText(this.editor.buildIndentString(1, this.cursor.getBufferColumn()), {bypassReadOnly}) } } else { - this.indentSelectedRows() + this.indentSelectedRows({bypassReadOnly}) } } // Public: If the selection spans multiple rows, indent all of them. - indentSelectedRows () { + // + // * `options` (optional) {Object} with the keys: + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify text within a read-only editor. (default: false) + indentSelectedRows (options = {}) { + if (!this.ensureWritable('indentSelectedRows', options)) return const [start, end] = this.getBufferRowRange() for (let row = start; row <= end; row++) { if (this.editor.buffer.lineLengthForRow(row) !== 0) { diff --git a/src/text-editor.js b/src/text-editor.js index 51470a6b2..5e0984802 100644 --- a/src/text-editor.js +++ b/src/text-editor.js @@ -42,9 +42,10 @@ const DEFAULT_NON_WORD_CHARACTERS = "/\\()\"':,.;<>~!@#$%^&*|+=[]{}`?-…" // then be called with all current editor instances and also when any editor is // created in the future. // -// ```coffee -// atom.workspace.observeTextEditors (editor) -> +// ```js +// atom.workspace.observeTextEditors(editor => { // editor.insertText('Hello World') +// }) // ``` // // ## Buffer vs. Screen Coordinates @@ -1306,7 +1307,12 @@ class TextEditor { // Essential: Replaces the entire contents of the buffer with the given {String}. // // * `text` A {String} to replace with - setText (text) { return this.buffer.setText(text) } + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. + setText (text, options = {}) { + if (!this.ensureWritable('setText', options)) return + return this.buffer.setText(text) + } // Essential: Set the text in the given {Range} in buffer coordinates. // @@ -1315,9 +1321,11 @@ class TextEditor { // * `options` (optional) {Object} // * `normalizeLineEndings` (optional) {Boolean} (default: true) // * `undo` (optional) {String} 'skip' will skip the undo system + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) // // Returns the {Range} of the newly-inserted text. - setTextInBufferRange (range, text, options) { + setTextInBufferRange (range, text, options = {}) { + if (!this.ensureWritable('setTextInBufferRange', options)) return return this.getBuffer().setTextInRange(range, text, options) } @@ -1326,9 +1334,9 @@ class TextEditor { // * `text` A {String} representing the text to insert. // * `options` (optional) See {Selection::insertText}. // - // Returns a {Range} when the text has been inserted - // Returns a {Boolean} false when the text has not been inserted + // Returns a {Range} when the text has been inserted. Returns a {Boolean} `false` when the text has not been inserted. insertText (text, options = {}) { + if (!this.ensureWritable('insertText', options)) return if (!this.emitWillInsertTextEvent(text)) return false let groupLastChanges = false @@ -1352,20 +1360,31 @@ class TextEditor { } // Essential: For each selection, replace the selected text with a newline. - insertNewline (options) { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + insertNewline (options = {}) { return this.insertText('\n', options) } // Essential: For each selection, if the selection is empty, delete the character // following the cursor. Otherwise delete the selected text. - delete () { - return this.mutateSelectedText(selection => selection.delete()) + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + delete (options = {}) { + if (!this.ensureWritable('delete', options)) return + return this.mutateSelectedText(selection => selection.delete(options)) } // Essential: For each selection, if the selection is empty, delete the character // preceding the cursor. Otherwise delete the selected text. - backspace () { - return this.mutateSelectedText(selection => selection.backspace()) + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + backspace (options = {}) { + if (!this.ensureWritable('backspace', options)) return + return this.mutateSelectedText(selection => selection.backspace(options)) } // Extended: Mutate the text of all the selections in a single transaction. @@ -1386,7 +1405,12 @@ class TextEditor { // Move lines intersecting the most recent selection or multiple selections // up by one row in screen coordinates. - moveLineUp () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + moveLineUp (options = {}) { + if (!this.ensureWritable('moveLineUp', options)) return + const selections = this.getSelectedBufferRanges().sort((a, b) => a.compare(b)) if (selections[0].start.row === 0) return @@ -1454,7 +1478,12 @@ class TextEditor { // Move lines intersecting the most recent selection or multiple selections // down by one row in screen coordinates. - moveLineDown () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + moveLineDown (options = {}) { + if (!this.ensureWritable('moveLineDown', options)) return + const selections = this.getSelectedBufferRanges() selections.sort((a, b) => b.compare(a)) @@ -1526,7 +1555,11 @@ class TextEditor { } // Move any active selections one column to the left. - moveSelectionLeft () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + moveSelectionLeft (options = {}) { + if (!this.ensureWritable('moveSelectionLeft', options)) return const selections = this.getSelectedBufferRanges() const noSelectionAtStartOfLine = selections.every(selection => selection.start.column !== 0) @@ -1550,7 +1583,11 @@ class TextEditor { } // Move any active selections one column to the right. - moveSelectionRight () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + moveSelectionRight (options = {}) { + if (!this.ensureWritable('moveSelectionRight', options)) return const selections = this.getSelectedBufferRanges() const noSelectionAtEndOfLine = selections.every(selection => { return selection.end.column !== this.buffer.lineLengthForRow(selection.end.row) @@ -1575,7 +1612,12 @@ class TextEditor { } } - duplicateLines () { + // Duplicate all lines containing active selections. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + duplicateLines (options = {}) { + if (!this.ensureWritable('duplicateLines', options)) return this.transact(() => { const selections = this.getSelectionsOrderedByBufferPosition() const previousSelectionRanges = [] @@ -1662,7 +1704,11 @@ class TextEditor { // // If the selection is empty, the characters preceding and following the cursor // are swapped. Otherwise, the selected characters are reversed. - transpose () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + transpose (options = {}) { + if (!this.ensureWritable('transpose', options)) return this.mutateSelectedText(selection => { if (selection.isEmpty()) { selection.selectRight() @@ -1680,23 +1726,35 @@ class TextEditor { // // For each selection, if the selection is empty, converts the containing word // to upper case. Otherwise convert the selected text to upper case. - upperCase () { - this.replaceSelectedText({selectWordIfEmpty: true}, text => text.toUpperCase()) + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + upperCase (options = {}) { + if (!this.ensureWritable('upperCase', options)) return + this.replaceSelectedText({selectWordIfEmpty: true}, text => text.toUpperCase(options)) } // Extended: Convert the selected text to lower case. // // For each selection, if the selection is empty, converts the containing word // to upper case. Otherwise convert the selected text to upper case. - lowerCase () { - this.replaceSelectedText({selectWordIfEmpty: true}, text => text.toLowerCase()) + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + lowerCase (options = {}) { + if (!this.ensureWritable('lowerCase', options)) return + this.replaceSelectedText({selectWordIfEmpty: true}, text => text.toLowerCase(options)) } // Extended: Toggle line comments for rows intersecting selections. // // If the current grammar doesn't support comments, does nothing. - toggleLineCommentsInSelection () { - this.mutateSelectedText(selection => selection.toggleLineComments()) + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + toggleLineCommentsInSelection (options = {}) { + if (!this.ensureWritable('toggleLineCommentsInSelection', options)) return + this.mutateSelectedText(selection => selection.toggleLineComments(options)) } // Convert multiple lines to a single line. @@ -1707,20 +1765,32 @@ class TextEditor { // // Joining a line means that multiple lines are converted to a single line with // the contents of each of the original non-empty lines separated by a space. - joinLines () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + joinLines (options = {}) { + if (!this.ensureWritable('joinLines', options)) return this.mutateSelectedText(selection => selection.joinLines()) } // Extended: For each cursor, insert a newline at beginning the following line. - insertNewlineBelow () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + insertNewlineBelow (options = {}) { + if (!this.ensureWritable('insertNewlineBelow', options)) return this.transact(() => { this.moveToEndOfLine() - this.insertNewline() + this.insertNewline(options) }) } // Extended: For each cursor, insert a newline at the end of the preceding line. - insertNewlineAbove () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + insertNewlineAbove (options = {}) { + if (!this.ensureWritable('insertNewlineAbove', options)) return this.transact(() => { const bufferRow = this.getCursorBufferPosition().row const indentLevel = this.indentationForBufferRow(bufferRow) @@ -1728,7 +1798,7 @@ class TextEditor { this.moveToBeginningOfLine() this.moveLeft() - this.insertNewline() + this.insertNewline(options) if (this.shouldAutoIndent() && (this.indentationForBufferRow(bufferRow) < indentLevel)) { this.setIndentationForBufferRow(bufferRow, indentLevel) @@ -1744,62 +1814,117 @@ class TextEditor { // Extended: For each selection, if the selection is empty, delete all characters // of the containing word that precede the cursor. Otherwise delete the // selected text. - deleteToBeginningOfWord () { - this.mutateSelectedText(selection => selection.deleteToBeginningOfWord()) + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteToBeginningOfWord (options = {}) { + if (!this.ensureWritable('deleteToBeginningOfWord', options)) return + this.mutateSelectedText(selection => selection.deleteToBeginningOfWord(options)) } // Extended: Similar to {::deleteToBeginningOfWord}, but deletes only back to the // previous word boundary. - deleteToPreviousWordBoundary () { - this.mutateSelectedText(selection => selection.deleteToPreviousWordBoundary()) + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteToPreviousWordBoundary (options = {}) { + if (!this.ensureWritable('deleteToPreviousWordBoundary', options)) return + this.mutateSelectedText(selection => selection.deleteToPreviousWordBoundary(options)) } // Extended: Similar to {::deleteToEndOfWord}, but deletes only up to the // next word boundary. - deleteToNextWordBoundary () { - this.mutateSelectedText(selection => selection.deleteToNextWordBoundary()) + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteToNextWordBoundary (options = {}) { + if (!this.ensureWritable('deleteToNextWordBoundary', options)) return + this.mutateSelectedText(selection => selection.deleteToNextWordBoundary(options)) } // Extended: For each selection, if the selection is empty, delete all characters // of the containing subword following the cursor. Otherwise delete the selected // text. - deleteToBeginningOfSubword () { - this.mutateSelectedText(selection => selection.deleteToBeginningOfSubword()) + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteToBeginningOfSubword (options = {}) { + if (!this.ensureWritable('deleteToBeginningOfSubword', options)) return + this.mutateSelectedText(selection => selection.deleteToBeginningOfSubword(options)) } // Extended: For each selection, if the selection is empty, delete all characters // of the containing subword following the cursor. Otherwise delete the selected // text. - deleteToEndOfSubword () { - this.mutateSelectedText(selection => selection.deleteToEndOfSubword()) + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteToEndOfSubword (options = {}) { + if (!this.ensureWritable('deleteToEndOfSubword', options)) return + this.mutateSelectedText(selection => selection.deleteToEndOfSubword(options)) } // Extended: For each selection, if the selection is empty, delete all characters // of the containing line that precede the cursor. Otherwise delete the // selected text. - deleteToBeginningOfLine () { - this.mutateSelectedText(selection => selection.deleteToBeginningOfLine()) + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteToBeginningOfLine (options = {}) { + if (!this.ensureWritable('deleteToBeginningOfLine', options)) return + this.mutateSelectedText(selection => selection.deleteToBeginningOfLine(options)) } // Extended: For each selection, if the selection is not empty, deletes the // selection; otherwise, deletes all characters of the containing line // following the cursor. If the cursor is already at the end of the line, // deletes the following newline. - deleteToEndOfLine () { - this.mutateSelectedText(selection => selection.deleteToEndOfLine()) + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteToEndOfLine (options = {}) { + if (!this.ensureWritable('deleteToEndOfLine', options)) return + this.mutateSelectedText(selection => selection.deleteToEndOfLine(options)) } // Extended: For each selection, if the selection is empty, delete all characters // of the containing word following the cursor. Otherwise delete the selected // text. - deleteToEndOfWord () { - this.mutateSelectedText(selection => selection.deleteToEndOfWord()) + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteToEndOfWord (options = {}) { + if (!this.ensureWritable('deleteToEndOfWord', options)) return + this.mutateSelectedText(selection => selection.deleteToEndOfWord(options)) } // Extended: Delete all lines intersecting selections. - deleteLine () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + deleteLine (options = {}) { + if (!this.ensureWritable('deleteLine', options)) return this.mergeSelectionsOnSameRows() - this.mutateSelectedText(selection => selection.deleteLine()) + this.mutateSelectedText(selection => selection.deleteLine(options)) + } + + // Private: Ensure that this editor is not marked read-only before allowing a buffer modification to occur. If + // the editor is read-only, require an explicit opt-in option to proceed (`bypassReadOnly`) or throw an Error. + ensureWritable (methodName, opts) { + if (!opts.bypassReadOnly && this.isReadOnly()) { + if (atom.inDevMode() || atom.inSpecMode()) { + const e = new Error('Attempt to mutate a read-only TextEditor') + e.detail = + `Your package is attempting to call ${methodName} on an editor that has been marked read-only. ` + + 'Pass {bypassReadOnly: true} to modify it anyway, or test editors with .isReadOnly() before attempting ' + + 'modifications.' + throw e + } + + return false + } + + return true } /* @@ -1807,13 +1932,21 @@ class TextEditor { */ // Essential: Undo the last change. - undo () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + undo (options = {}) { + if (!this.ensureWritable('undo', options)) return this.avoidMergingSelections(() => this.buffer.undo({selectionsMarkerLayer: this.selectionsMarkerLayer})) this.getLastSelection().autoscroll() } // Essential: Redo the last change. - redo () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. (default: false) + redo (options = {}) { + if (!this.ensureWritable('redo', options)) return this.avoidMergingSelections(() => this.buffer.redo({selectionsMarkerLayer: this.selectionsMarkerLayer})) this.getLastSelection().autoscroll() } @@ -1967,11 +2100,11 @@ class TextEditor { // // ## Examples // - // ```coffee - // editor.clipBufferPosition([-1, -1]) # -> `[0, 0]` + // ```js + // editor.clipBufferPosition([-1, -1]) // -> `[0, 0]` // - // # When the line at buffer row 2 is 10 characters long - // editor.clipBufferPosition([2, Infinity]) # -> `[2, 10]` + // // When the line at buffer row 2 is 10 characters long + // editor.clipBufferPosition([2, Infinity]) // -> `[2, 10]` // ``` // // * `bufferPosition` The {Point} representing the position to clip. @@ -1996,11 +2129,11 @@ class TextEditor { // // ## Examples // - // ```coffee - // editor.clipScreenPosition([-1, -1]) # -> `[0, 0]` + // ```js + // editor.clipScreenPosition([-1, -1]) // -> `[0, 0]` // - // # When the line at screen row 2 is 10 characters long - // editor.clipScreenPosition([2, Infinity]) # -> `[2, 10]` + // // When the line at screen row 2 is 10 characters long + // editor.clipScreenPosition([2, Infinity]) // -> `[2, 10]` // ``` // // * `screenPosition` The {Point} representing the position to clip. @@ -3558,13 +3691,21 @@ class TextEditor { } // Extended: Indent rows intersecting selections by one level. - indentSelectedRows () { - return this.mutateSelectedText(selection => selection.indentSelectedRows()) + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. + indentSelectedRows (options = {}) { + if (!this.ensureWritable('indentSelectedRows', options)) return + return this.mutateSelectedText(selection => selection.indentSelectedRows(options)) } // Extended: Outdent rows intersecting selections by one level. - outdentSelectedRows () { - return this.mutateSelectedText(selection => selection.outdentSelectedRows()) + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. + outdentSelectedRows (options = {}) { + if (!this.ensureWritable('outdentSelectedRows', options)) return + return this.mutateSelectedText(selection => selection.outdentSelectedRows(options)) } // Extended: Get the indentation level of the given line of text. @@ -3595,13 +3736,21 @@ class TextEditor { // Extended: Indent rows intersecting selections based on the grammar's suggested // indent level. - autoIndentSelectedRows () { - return this.mutateSelectedText(selection => selection.autoIndentSelectedRows()) + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. + autoIndentSelectedRows (options = {}) { + if (!this.ensureWritable('autoIndentSelectedRows', options)) return + return this.mutateSelectedText(selection => selection.autoIndentSelectedRows(options)) } // Indent all lines intersecting selections. See {Selection::indent} for more // information. + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. indent (options = {}) { + if (!this.ensureWritable('indent', options)) return if (options.autoIndent == null) options.autoIndent = this.shouldAutoIndent() this.mutateSelectedText(selection => selection.indent(options)) } @@ -3739,14 +3888,18 @@ class TextEditor { } // Essential: For each selection, cut the selected text. - cutSelectedText () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. + cutSelectedText (options = {}) { + if (!this.ensureWritable('cutSelectedText', options)) return let maintainClipboard = false this.mutateSelectedText(selection => { if (selection.isEmpty()) { selection.selectLine() - selection.cut(maintainClipboard, true) + selection.cut(maintainClipboard, true, options.bypassReadOnly) } else { - selection.cut(maintainClipboard, false) + selection.cut(maintainClipboard, false, options.bypassReadOnly) } maintainClipboard = true }) @@ -3760,7 +3913,8 @@ class TextEditor { // corresponding clipboard selection text. // // * `options` (optional) See {Selection::insertText}. - pasteText (options) { + pasteText (options = {}) { + if (!this.ensureWritable('parseText', options)) return options = Object.assign({}, options) let {text: clipboardText, metadata} = this.constructor.clipboard.readWithMetadata() if (!this.emitWillInsertTextEvent(clipboardText)) return false @@ -3801,10 +3955,14 @@ class TextEditor { // Essential: For each selection, if the selection is empty, cut all characters // of the containing screen line following the cursor. Otherwise cut the selected // text. - cutToEndOfLine () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. + cutToEndOfLine (options = {}) { + if (!this.ensureWritable('cutToEndOfLine', options)) return let maintainClipboard = false this.mutateSelectedText(selection => { - selection.cutToEndOfLine(maintainClipboard) + selection.cutToEndOfLine(maintainClipboard, options) maintainClipboard = true }) } @@ -3812,10 +3970,14 @@ class TextEditor { // Essential: For each selection, if the selection is empty, cut all characters // of the containing buffer line following the cursor. Otherwise cut the // selected text. - cutToEndOfBufferLine () { + // + // * `options` (optional) {Object} + // * `bypassReadOnly` (optional) {Boolean} Must be `true` to modify a read-only editor. + cutToEndOfBufferLine (options = {}) { + if (!this.ensureWritable('cutToEndOfBufferLine', options)) return let maintainClipboard = false this.mutateSelectedText(selection => { - selection.cutToEndOfBufferLine(maintainClipboard) + selection.cutToEndOfBufferLine(maintainClipboard, options) maintainClipboard = true }) }