mirror of
https://github.com/atom/atom.git
synced 2026-01-25 23:08:18 -05:00
Replace anchor point/range with a single concept: markers
A "marker" is basically like a persistent selection/cursor composite, having a head and a tail. The "head" is like the cursor in a selection, and the "tail" is like the part of the selection that doesn't move. My goal is for markers to be the only construct used to track regions in the buffer. I want to replace anchors with them.
This commit is contained in:
@@ -666,78 +666,106 @@ describe 'Buffer', ->
|
||||
expect(buffer.positionForCharacterIndex(61)).toEqual [2, 0]
|
||||
expect(buffer.positionForCharacterIndex(408)).toEqual [12, 2]
|
||||
|
||||
fdescribe "anchor points", ->
|
||||
[anchor1Id, anchor2Id, anchor3Id] = []
|
||||
fdescribe "markers", ->
|
||||
[marker1, marker2, marker3] = []
|
||||
beforeEach ->
|
||||
anchor1Id = buffer.createAnchorPoint([4, 23])
|
||||
anchor2Id = buffer.createAnchorPoint([4, 23], ignoreSameLocationInserts: true)
|
||||
anchor3Id = buffer.createAnchorPoint([4, 23], surviveSurroundingChanges: true)
|
||||
marker1 = buffer.createMarker([[4, 20], [4, 23]])
|
||||
marker2 = buffer.createMarker([[4, 20], [4, 23]], stayValid: true)
|
||||
|
||||
describe "when the buffer changes due to a new operation", ->
|
||||
describe "when the change precedes the anchor point", ->
|
||||
it "moves the anchor", ->
|
||||
describe "when the change precedes the marker range", ->
|
||||
it "moves the marker", ->
|
||||
buffer.insert([4, 5], '...')
|
||||
expect(buffer.getAnchorPoint(anchor1Id)).toEqual [4, 26]
|
||||
expect(buffer.getMarkerRange(marker1)).toEqual [[4, 23], [4, 26]]
|
||||
buffer.delete([[4, 5], [4, 8]])
|
||||
expect(buffer.getAnchorPoint(anchor1Id)).toEqual [4, 23]
|
||||
expect(buffer.getMarkerRange(marker1)).toEqual [[4, 20], [4, 23]]
|
||||
buffer.insert([0, 0], '\nhi\n')
|
||||
expect(buffer.getAnchorPoint(anchor1Id)).toEqual [6, 23]
|
||||
expect(buffer.getMarkerRange(marker1)).toEqual [[6, 20], [6, 23]]
|
||||
|
||||
# undo works
|
||||
buffer.undo()
|
||||
expect(buffer.getAnchorPoint(anchor1Id)).toEqual [4, 23]
|
||||
expect(buffer.getMarkerRange(marker1)).toEqual [[4, 20], [4, 23]]
|
||||
buffer.undo()
|
||||
expect(buffer.getAnchorPoint(anchor1Id)).toEqual [4, 26]
|
||||
expect(buffer.getMarkerRange(marker1)).toEqual [[4, 23], [4, 26]]
|
||||
|
||||
describe "when the change follows the anchor point", ->
|
||||
it "does not move the anchor", ->
|
||||
describe "when the change follows the marker range", ->
|
||||
it "does not move the marker", ->
|
||||
buffer.insert([6, 5], '...')
|
||||
expect(buffer.getAnchorPoint(anchor1Id)).toEqual [4, 23]
|
||||
expect(buffer.getMarkerRange(marker1)).toEqual [[4, 20], [4, 23]]
|
||||
buffer.delete([[6, 5], [6, 8]])
|
||||
expect(buffer.getAnchorPoint(anchor1Id)).toEqual [4, 23]
|
||||
expect(buffer.getMarkerRange(marker1)).toEqual [[4, 20], [4, 23]]
|
||||
buffer.insert([10, 0], '\nhi\n')
|
||||
expect(buffer.getAnchorPoint(anchor1Id)).toEqual [4, 23]
|
||||
expect(buffer.getMarkerRange(marker1)).toEqual [[4, 20], [4, 23]]
|
||||
|
||||
describe "when the change is an insertion at the same location as the anchor point", ->
|
||||
describe "if the anchor ignores same location inserts", ->
|
||||
it "treats the insertion as being to the right of the anchor and does not move it", ->
|
||||
buffer.insert([4, 23], '...')
|
||||
expect(buffer.getAnchorPoint(anchor2Id)).toEqual [4, 23]
|
||||
describe "when the change is an insertion at the start of the marker range", ->
|
||||
it "does not move the start point, but does move the end point", ->
|
||||
buffer.insert([4, 20], '...')
|
||||
expect(buffer.getMarkerRange(marker1)).toEqual [[4, 20], [4, 26]]
|
||||
|
||||
describe "if the anchor observes same location inserts", ->
|
||||
it "treats the insertion as being to the left of the anchor and moves it accordingly", ->
|
||||
buffer.insert([4, 23], '...')
|
||||
expect(buffer.getAnchorPoint(anchor1Id)).toEqual [4, 26]
|
||||
describe "when the change is an insertion at the end of the marker range", ->
|
||||
it "moves the end point", ->
|
||||
buffer.insert([4, 23], '...')
|
||||
expect(buffer.getMarkerRange(marker1)).toEqual [[4, 20], [4, 26]]
|
||||
|
||||
describe "when the change surrounds the anchor point", ->
|
||||
describe "when the anchor survives surrounding changes", ->
|
||||
it "preserves the anchor", ->
|
||||
buffer.delete([[4, 20], [4, 26]])
|
||||
expect(buffer.getAnchorPoint(anchor3Id)).toEqual [4, 20]
|
||||
describe "when the change surrounds the marker range", ->
|
||||
describe "when the marker was created with stayValid: false (the default)", ->
|
||||
it "invalidates the marker", ->
|
||||
buffer.delete([[4, 15], [4, 25]])
|
||||
expect(buffer.getMarkerRange(marker1)).toBeUndefined()
|
||||
buffer.undo()
|
||||
expect(buffer.getAnchorPoint(anchor3Id)).toEqual [4, 23]
|
||||
expect(buffer.getMarkerRange(marker1)).toEqual [[4, 20], [4, 23]]
|
||||
|
||||
describe "when the anchor does not survive surrounding changes", ->
|
||||
it "invalidates the anchor but re-validates it on undo", ->
|
||||
buffer.delete([[4, 20], [4, 26]])
|
||||
expect(buffer.getAnchorPoint(anchor1Id)).toBeUndefined()
|
||||
describe "when the marker was created with stayValid: true", ->
|
||||
it "does not invalidate the marker, but sets it to an empty range at the end of the change", ->
|
||||
buffer.change([[4, 15], [4, 25]], "...")
|
||||
expect(buffer.getMarkerRange(marker2)).toEqual [[4, 18], [4, 18]]
|
||||
buffer.undo()
|
||||
expect(buffer.getAnchorPoint(anchor1Id)).toEqual [4, 23]
|
||||
expect(buffer.getMarkerRange(marker2)).toEqual [[4, 20], [4, 23]]
|
||||
|
||||
describe "when the change straddles the start of the marker range", ->
|
||||
describe "when the marker was created with stayValid: false (the default)", ->
|
||||
it "invalidates the marker", ->
|
||||
buffer.delete([[4, 15], [4, 22]])
|
||||
expect(buffer.getMarkerRange(marker1)).toBeUndefined()
|
||||
buffer.undo()
|
||||
expect(buffer.getMarkerRange(marker1)).toEqual [[4, 20], [4, 23]]
|
||||
|
||||
describe "when the marker was created with stayValid: true", ->
|
||||
it "moves the start of the marker range to the end of the change", ->
|
||||
buffer.delete([[4, 15], [4, 22]])
|
||||
expect(buffer.getMarkerRange(marker2)).toEqual [[4, 15], [4, 16]]
|
||||
buffer.undo()
|
||||
expect(buffer.getMarkerRange(marker1)).toEqual [[4, 20], [4, 23]]
|
||||
|
||||
describe "when the change straddles the end of the marker range", ->
|
||||
describe "when the marker was created with stayValid: false (the default)", ->
|
||||
it "invalidates the marker", ->
|
||||
buffer.delete([[4, 22], [4, 25]])
|
||||
expect(buffer.getMarkerRange(marker1)).toBeUndefined()
|
||||
buffer.undo()
|
||||
expect(buffer.getMarkerRange(marker1)).toEqual [[4, 20], [4, 23]]
|
||||
|
||||
describe "when the marker was created with stayValid: true", ->
|
||||
it "moves the end of the marker range to the start of the change", ->
|
||||
buffer.delete([[4, 22], [4, 25]])
|
||||
expect(buffer.getMarkerRange(marker2)).toEqual [[4, 20], [4, 22]]
|
||||
buffer.undo()
|
||||
expect(buffer.getMarkerRange(marker1)).toEqual [[4, 20], [4, 23]]
|
||||
|
||||
describe "when the buffer changes due to the undo or redo of a previous operation", ->
|
||||
it "restores invalidated anchor points when undoing/redoing in the other direction", ->
|
||||
it "restores invalidated markers when undoing/redoing in the other direction", ->
|
||||
buffer.change([[4, 21], [4, 24]], "foo")
|
||||
expect(buffer.getAnchorPoint(anchor1Id)).toBeUndefined()
|
||||
anchor4Id = buffer.createAnchorPoint([4, 23])
|
||||
expect(buffer.getMarkerRange(marker1)).toBeUndefined()
|
||||
marker3 = buffer.createMarker([[4, 20], [4, 23]])
|
||||
buffer.undo()
|
||||
expect(buffer.getAnchorPoint(anchor1Id)).toEqual [4, 23]
|
||||
expect(buffer.getAnchorPoint(anchor4Id)).toBeUndefined()
|
||||
anchor5Id = buffer.createAnchorPoint([4, 23])
|
||||
expect(buffer.getMarkerRange(marker1)).toEqual [[4, 20], [4, 23]]
|
||||
expect(buffer.getMarkerRange(marker3)).toBeUndefined()
|
||||
marker4 = buffer.createMarker([[4, 20], [4, 23]])
|
||||
buffer.redo()
|
||||
expect(buffer.getAnchorPoint(anchor4Id)).toEqual [4, 23]
|
||||
expect(buffer.getAnchorPoint(anchor5Id)).toBeUndefined()
|
||||
expect(buffer.getMarkerRange(marker3)).toEqual [[4, 20], [4, 23]]
|
||||
expect(buffer.getMarkerRange(marker4)).toBeUndefined()
|
||||
buffer.undo()
|
||||
expect(buffer.getAnchorPoint(anchor5Id)).toEqual [4, 23]
|
||||
expect(buffer.getMarkerRange(marker4)).toEqual [[4, 20], [4, 23]]
|
||||
|
||||
describe "anchors", ->
|
||||
[anchor, destroyHandler] = []
|
||||
@@ -873,7 +901,6 @@ describe 'Buffer', ->
|
||||
expect(contentsModifiedHandler).toHaveBeenCalledWith(differsFromDisk:true)
|
||||
bufferToDelete.destroy()
|
||||
|
||||
|
||||
describe "when the buffer text has been changed", ->
|
||||
it "triggers the contents-modified event 'stoppedChangingDelay' ms after the last buffer change", ->
|
||||
delay = buffer.stoppedChangingDelay
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
_ = require 'underscore'
|
||||
Point = require 'point'
|
||||
|
||||
module.exports =
|
||||
class AnchorPoint
|
||||
position: null
|
||||
ignoreSameLocationInserts: false
|
||||
surviveSurroundingChanges: false
|
||||
|
||||
constructor: ({@id, @buffer, position, @ignoreSameLocationInserts, @surviveSurroundingChanges}) ->
|
||||
@setPosition(position)
|
||||
|
||||
tryToInvalidate: (oldRange) ->
|
||||
if oldRange.containsPoint(@getPosition(), exclusive: true)
|
||||
position = @getPosition()
|
||||
if @surviveSurroundingChanges
|
||||
@setPosition(oldRange.start)
|
||||
else
|
||||
@invalidate()
|
||||
[@id, position]
|
||||
|
||||
handleBufferChange: (e) ->
|
||||
{ oldRange, newRange } = e
|
||||
position = @getPosition()
|
||||
|
||||
return if oldRange.containsPoint(position, exclusive: true)
|
||||
return if @ignoreSameLocationInserts and position.isEqual(oldRange.start)
|
||||
return if position.isLessThan(oldRange.end)
|
||||
|
||||
newRow = newRange.end.row
|
||||
newColumn = newRange.end.column
|
||||
if position.row == oldRange.end.row
|
||||
newColumn += position.column - oldRange.end.column
|
||||
else
|
||||
newColumn = position.column
|
||||
newRow += position.row - oldRange.end.row
|
||||
|
||||
@setPosition([newRow, newColumn])
|
||||
|
||||
setPosition: (position, options={}) ->
|
||||
@position = Point.fromObject(position)
|
||||
clip = options.clip ? true
|
||||
@position = @buffer.clipPosition(@position) if clip
|
||||
|
||||
getPosition: ->
|
||||
@position
|
||||
|
||||
invalidate: (preserve) ->
|
||||
delete @buffer.validAnchorPointsById[@id]
|
||||
@buffer.invalidAnchorPointsById[@id] = this
|
||||
@@ -8,8 +8,8 @@ class BufferChangeOperation
|
||||
oldText: null
|
||||
newRange: null
|
||||
newText: null
|
||||
anchorPointsToRestoreOnUndo: null
|
||||
anchorPointsToRestoreOnRedo: null
|
||||
markersToRestoreOnUndo: null
|
||||
markersToRestoreOnRedo: null
|
||||
|
||||
constructor: ({@buffer, @oldRange, @newText, @options}) ->
|
||||
@options ?= {}
|
||||
@@ -17,7 +17,7 @@ class BufferChangeOperation
|
||||
do: ->
|
||||
@oldText = @buffer.getTextInRange(@oldRange)
|
||||
@newRange = @calculateNewRange(@oldRange, @newText)
|
||||
@anchorPointsToRestoreOnUndo = @invalidateAnchorPoints(@oldRange)
|
||||
@markersToRestoreOnUndo = @invalidateMarkers(@oldRange)
|
||||
@changeBuffer
|
||||
oldRange: @oldRange
|
||||
newRange: @newRange
|
||||
@@ -25,16 +25,16 @@ class BufferChangeOperation
|
||||
newText: @newText
|
||||
|
||||
redo: ->
|
||||
@restoreAnchorPoints(@anchorPointsToRestoreOnRedo)
|
||||
@restoreMarkers(@markersToRestoreOnRedo)
|
||||
|
||||
undo: ->
|
||||
@anchorPointsToRestoreOnRedo = @invalidateAnchorPoints(@newRange)
|
||||
@markersToRestoreOnRedo = @invalidateMarkers(@newRange)
|
||||
@changeBuffer
|
||||
oldRange: @newRange
|
||||
newRange: @oldRange
|
||||
oldText: @newText
|
||||
newText: @oldText
|
||||
@restoreAnchorPoints(@anchorPointsToRestoreOnUndo)
|
||||
@restoreMarkers(@markersToRestoreOnUndo)
|
||||
|
||||
splitLines: (text) ->
|
||||
lines = text.split('\n')
|
||||
@@ -47,16 +47,6 @@ class BufferChangeOperation
|
||||
lineEndings[index] = '\n'
|
||||
{lines, lineEndings}
|
||||
|
||||
invalidateAnchorPoints: (oldRange) ->
|
||||
_.compact(@buffer.getAnchorPoints().map (pt) -> pt.tryToInvalidate(oldRange))
|
||||
|
||||
restoreAnchorPoints: (anchorPoints) ->
|
||||
for [id, position] in anchorPoints
|
||||
if existingAnchorPoint = @buffer.validAnchorPointsById[id]
|
||||
existingAnchorPoint.setPosition(position)
|
||||
else
|
||||
@buffer.validAnchorPointsById[id] = @buffer.invalidAnchorPointsById[id]
|
||||
|
||||
changeBuffer: ({ oldRange, newRange, newText, oldText }) ->
|
||||
{ prefix, suffix } = @buffer.prefixAndSuffixForRange(oldRange)
|
||||
{lines, lineEndings} = @splitLines(newText)
|
||||
@@ -83,7 +73,7 @@ class BufferChangeOperation
|
||||
@buffer.trigger 'changed', event
|
||||
@buffer.scheduleStoppedChangingEvent()
|
||||
@buffer.updateAnchors(event)
|
||||
@buffer.updateAnchorPoints(event)
|
||||
@updateMarkers(event)
|
||||
newRange
|
||||
|
||||
calculateNewRange: (oldRange, newText) ->
|
||||
@@ -96,3 +86,17 @@ class BufferChangeOperation
|
||||
newRange.end.row += lastLineIndex
|
||||
newRange.end.column = lines[lastLineIndex].length
|
||||
newRange
|
||||
|
||||
invalidateMarkers: (oldRange) ->
|
||||
_.compact(@buffer.getMarkers().map (marker) -> marker.tryToInvalidate(oldRange))
|
||||
|
||||
updateMarkers: (bufferChange) ->
|
||||
marker.handleBufferChange(bufferChange) for marker in @buffer.getMarkers()
|
||||
|
||||
restoreMarkers: (markersToRestore) ->
|
||||
for [id, previousRange] in markersToRestore
|
||||
if existingMarker = @buffer.validMarkers[id]
|
||||
existingMarker.setRange(previousRange)
|
||||
else
|
||||
@buffer.validMarkers[id] = @buffer.invalidMarkers[id]
|
||||
|
||||
|
||||
81
src/app/buffer-marker.coffee
Normal file
81
src/app/buffer-marker.coffee
Normal file
@@ -0,0 +1,81 @@
|
||||
_ = require 'underscore'
|
||||
Point = require 'point'
|
||||
Range = require 'range'
|
||||
|
||||
module.exports =
|
||||
class BufferMarker
|
||||
headPosition: null
|
||||
tailPosition: null
|
||||
stayValid: false
|
||||
|
||||
constructor: ({@id, @buffer, range, @stayValid}) ->
|
||||
@setRange(range)
|
||||
|
||||
setRange: (range) ->
|
||||
range = @buffer.clipRange(range)
|
||||
@tailPosition = range.start
|
||||
@headPosition = range.end
|
||||
|
||||
getRange: ->
|
||||
new Range(@tailPosition, @headPosition)
|
||||
|
||||
getStartPosition: ->
|
||||
@getRange().start
|
||||
|
||||
getEndPosition: ->
|
||||
@getRange().end
|
||||
|
||||
tryToInvalidate: (oldRange) ->
|
||||
containsStart = oldRange.containsPoint(@getStartPosition(), exclusive: true)
|
||||
containsEnd = oldRange.containsPoint(@getEndPosition(), exclusive: true)
|
||||
return unless containsEnd or containsStart
|
||||
|
||||
if @stayValid
|
||||
previousRange = @getRange()
|
||||
if containsStart and containsEnd
|
||||
@setRange([oldRange.end, oldRange.end])
|
||||
else if containsStart
|
||||
@setRange([oldRange.end, @getEndPosition()])
|
||||
else
|
||||
@setRange([@getStartPosition(), oldRange.start])
|
||||
[@id, previousRange]
|
||||
else
|
||||
@invalidate()
|
||||
[@id]
|
||||
|
||||
handleBufferChange: (bufferChange) ->
|
||||
@setTailPosition(@updatePosition(@tailPosition, bufferChange, true), clip: false)
|
||||
@setHeadPosition(@updatePosition(@headPosition, bufferChange, false), clip: false)
|
||||
|
||||
updatePosition: (position, bufferChange, isFirstPoint) ->
|
||||
{ oldRange, newRange } = bufferChange
|
||||
|
||||
return position if oldRange.containsPoint(position, exclusive: true)
|
||||
return position if isFirstPoint and oldRange.start.isEqual(position)
|
||||
return position if position.isLessThan(oldRange.end)
|
||||
|
||||
newRow = newRange.end.row
|
||||
newColumn = newRange.end.column
|
||||
|
||||
if position.row == oldRange.end.row
|
||||
newColumn += position.column - oldRange.end.column
|
||||
else
|
||||
newColumn = position.column
|
||||
newRow += position.row - oldRange.end.row
|
||||
|
||||
[newRow, newColumn]
|
||||
|
||||
setTailPosition: (tailPosition, options={}) ->
|
||||
@tailPosition = Point.fromObject(tailPosition)
|
||||
@tailPosition = @buffer.clipPosition(@tailPosition) if options.clip ? true
|
||||
|
||||
setHeadPosition: (headPosition, options={}) ->
|
||||
@headPosition = Point.fromObject(headPosition)
|
||||
@headPosition = @buffer.clipPosition(@headPosition) if options.clip ? true
|
||||
|
||||
getPosition: ->
|
||||
@position
|
||||
|
||||
invalidate: (preserve) ->
|
||||
delete @buffer.validMarkers[@id]
|
||||
@buffer.invalidMarkers[@id] = this
|
||||
@@ -8,7 +8,7 @@ UndoManager = require 'undo-manager'
|
||||
BufferChangeOperation = require 'buffer-change-operation'
|
||||
Anchor = require 'anchor'
|
||||
AnchorRange = require 'anchor-range'
|
||||
AnchorPoint = require 'anchor-point'
|
||||
BufferMarker = require 'buffer-marker'
|
||||
|
||||
module.exports =
|
||||
class Buffer
|
||||
@@ -22,17 +22,17 @@ class Buffer
|
||||
lines: null
|
||||
lineEndings: null
|
||||
file: null
|
||||
validAnchorPointsById: null
|
||||
invalidAnchorPointsById: null
|
||||
validMarkers: null
|
||||
invalidMarkers: null
|
||||
anchors: null
|
||||
anchorRanges: null
|
||||
refcount: 0
|
||||
|
||||
constructor: (path, @project) ->
|
||||
@id = @constructor.idCounter++
|
||||
@nextAnchorPointId = 1
|
||||
@validAnchorPointsById = {}
|
||||
@invalidAnchorPointsById = {}
|
||||
@nextMarkerId = 1
|
||||
@validMarkers = {}
|
||||
@invalidMarkers = {}
|
||||
@anchors = []
|
||||
@anchorRanges = []
|
||||
@lines = ['']
|
||||
@@ -268,21 +268,23 @@ class Buffer
|
||||
|
||||
isEmpty: -> @lines.length is 1 and @lines[0].length is 0
|
||||
|
||||
updateAnchorPoints: (bufferChange) ->
|
||||
return unless bufferChange
|
||||
anchorPoint.handleBufferChange(bufferChange) for anchorPoint in @getAnchorPoints()
|
||||
getMarkers: ->
|
||||
_.values(@validMarkers)
|
||||
|
||||
getAnchorPoints: ->
|
||||
_.values(@validAnchorPointsById)
|
||||
createMarker: (range, options) ->
|
||||
marker = new BufferMarker(_.extend({
|
||||
id: @nextMarkerId++
|
||||
buffer: this
|
||||
range: range
|
||||
}, options))
|
||||
@validMarkers[marker.id] = marker
|
||||
marker.id
|
||||
|
||||
createAnchorPoint: (position, options) ->
|
||||
id = @nextAnchorPointId++
|
||||
params = _.extend({buffer: this, id, position}, options)
|
||||
@validAnchorPointsById[id] = new AnchorPoint(params)
|
||||
id
|
||||
getMarkerPosition: (id) ->
|
||||
@validMarkers[id]?.getPosition()
|
||||
|
||||
getAnchorPoint: (id) ->
|
||||
@validAnchorPointsById[id]?.getPosition()
|
||||
getMarkerRange: (id) ->
|
||||
@validMarkers[id]?.getRange()
|
||||
|
||||
getAnchors: -> new Array(@anchors...)
|
||||
|
||||
|
||||
15
src/app/display-buffer-anchor-point.coffee
Normal file
15
src/app/display-buffer-anchor-point.coffee
Normal file
@@ -0,0 +1,15 @@
|
||||
module.exports =
|
||||
class DisplayBufferAnchorPoint
|
||||
bufferPosition: null
|
||||
screenPosition: null
|
||||
|
||||
constructor: ({@displayBuffer, bufferPosition, screenPosition}) ->
|
||||
{@buffer} = @displayBuffer
|
||||
if screenPosition
|
||||
bufferPosition = @displayBuffer.bufferPositionForScreenPosition(screenPosition)
|
||||
|
||||
@id = @buffer.createAnchorPoint(bufferPosition)
|
||||
|
||||
getBufferPosition: ->
|
||||
@buffer.getAnchorPoint(@id)
|
||||
|
||||
@@ -9,7 +9,6 @@ EventEmitter = require 'event-emitter'
|
||||
Subscriber = require 'subscriber'
|
||||
Range = require 'range'
|
||||
AnchorRange = require 'anchor-range'
|
||||
AnchorPoint = require 'anchor-point'
|
||||
_ = require 'underscore'
|
||||
fs = require 'fs'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user