This repository has been archived by the owner on Dec 15, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 47
/
Copy pathfile.coffee
412 lines (351 loc) · 12.4 KB
/
file.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
crypto = require 'crypto'
path = require 'path'
_ = require 'underscore-plus'
{Emitter, Disposable} = require 'event-kit'
fs = require 'fs-plus'
Grim = require 'grim'
Q = require 'q'
runas = null # Defer until used
iconv = null # Defer until used
Directory = null
PathWatcher = require './main'
# Extended: Represents an individual file that can be watched, read from, and
# written to.
module.exports =
class File
encoding: 'utf8'
realPath: null
subscriptionCount: 0
###
Section: Construction
###
# Public: Configures a new File instance, no files are accessed.
#
# * `filePath` A {String} containing the absolute path to the file
# * `symlink` A {Boolean} indicating if the path is a symlink (default: false).
constructor: (filePath, @symlink=false) ->
filePath = path.normalize(filePath) if filePath
@path = filePath
@emitter = new Emitter
if Grim.includeDeprecatedAPIs
@on 'contents-changed-subscription-will-be-added', @willAddSubscription
@on 'moved-subscription-will-be-added', @willAddSubscription
@on 'removed-subscription-will-be-added', @willAddSubscription
@on 'contents-changed-subscription-removed', @didRemoveSubscription
@on 'moved-subscription-removed', @didRemoveSubscription
@on 'removed-subscription-removed', @didRemoveSubscription
@cachedContents = null
@reportOnDeprecations = true
# Public: Creates the file on disk that corresponds to `::getPath()` if no
# such file already exists.
#
# Returns a {Promise} that resolves once the file is created on disk. It
# resolves to a boolean value that is true if the file was created or false if
# it already existed.
create: ->
@exists().then (isExistingFile) =>
unless isExistingFile
parent = @getParent()
parent.create().then =>
@write('').then -> true
else
false
###
Section: Event Subscription
###
# Public: Invoke the given callback when the file's contents change.
#
# * `callback` {Function} to be called when the file's contents change.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChange: (callback) ->
@willAddSubscription()
@trackUnsubscription(@emitter.on('did-change', callback))
# Public: Invoke the given callback when the file's path changes.
#
# * `callback` {Function} to be called when the file's path changes.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidRename: (callback) ->
@willAddSubscription()
@trackUnsubscription(@emitter.on('did-rename', callback))
# Public: Invoke the given callback when the file is deleted.
#
# * `callback` {Function} to be called when the file is deleted.
#
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidDelete: (callback) ->
@willAddSubscription()
@trackUnsubscription(@emitter.on('did-delete', callback))
# Public: Invoke the given callback when there is an error with the watch.
# When your callback has been invoked, the file will have unsubscribed from
# the file watches.
#
# * `callback` {Function} callback
# * `errorObject` {Object}
# * `error` {Object} the error object
# * `handle` {Function} call this to indicate you have handled the error.
# The error will not be thrown if this function is called.
onWillThrowWatchError: (callback) ->
@emitter.on('will-throw-watch-error', callback)
willAddSubscription: =>
@subscriptionCount++
try
@subscribeToNativeChangeEvents()
didRemoveSubscription: =>
@subscriptionCount--
@unsubscribeFromNativeChangeEvents() if @subscriptionCount is 0
trackUnsubscription: (subscription) ->
new Disposable =>
subscription.dispose()
@didRemoveSubscription()
###
Section: File Metadata
###
# Public: Returns a {Boolean}, always true.
isFile: -> true
# Public: Returns a {Boolean}, always false.
isDirectory: -> false
# Public: Returns a promise that resolves to a {Boolean}, true if the file
# exists, false otherwise.
exists: ->
Q.Promise (resolve, reject) =>
fs.exists @getPath(), resolve
# Public: Returns a {Boolean}, true if the file exists, false otherwise.
existsSync: ->
fs.existsSync(@getPath())
# Public: Get the SHA-1 digest of this file
#
# Returns a promise that resolves to a {String}.
getDigest: ->
return Q(@digest) if @digest
@read().then (contents) =>
# read sets digest
@digest
# Public: Get the SHA-1 digest of this file
#
# Returns a {String}.
getDigestSync: ->
@readSync() unless @digest
@digest
setDigest: (contents) ->
@digest = crypto.createHash('sha1').update(contents ? '').digest('hex')
# Public: Sets the file's character set encoding name.
#
# * `encoding` The {String} encoding to use (default: 'utf8')
setEncoding: (encoding='utf8') ->
# Throws if encoding doesn't exist. Better to throw an exception early
# instead of waiting until the file is saved.
if encoding isnt 'utf8'
iconv ?= require 'iconv-lite'
iconv.getCodec(encoding)
@encoding = encoding
# Public: Returns the {String} encoding name for this file (default: 'utf8').
getEncoding: -> @encoding
###
Section: Managing Paths
###
# Public: Returns the {String} path for the file.
getPath: -> @path
# Sets the path for the file.
setPath: (@path) ->
@realPath = null
# Public: Returns this file's completely resolved {String} path.
getRealPathSync: ->
unless @realPath?
try
@realPath = fs.realpathSync(@path)
catch error
@realPath = @path
@realPath
# Public: Returns a promise that resolves to the file's completely resolved {String} path.
getRealPath: ->
if @realPath?
Q(@realPath)
else
Q.nfcall(fs.realpath, @path).then (realPath) =>
@realPath = realPath
# Public: Return the {String} filename without any directory information.
getBaseName: ->
path.basename(@path)
###
Section: Traversing
###
# Public: Return the {Directory} that contains this file.
getParent: ->
Directory ?= require './directory'
new Directory(path.dirname @path)
###
Section: Reading and Writing
###
readSync: (flushCache) ->
if not @existsSync()
@cachedContents = null
else if not @cachedContents? or flushCache
encoding = @getEncoding()
if encoding is 'utf8'
@cachedContents = fs.readFileSync(@getPath(), encoding)
else
iconv ?= require 'iconv-lite'
@cachedContents = iconv.decode(fs.readFileSync(@getPath()), encoding)
@setDigest(@cachedContents)
@cachedContents
writeFileSync: (filePath, contents) ->
encoding = @getEncoding()
if encoding is 'utf8'
fs.writeFileSync(filePath, contents, {encoding})
else
iconv ?= require 'iconv-lite'
fs.writeFileSync(filePath, iconv.encode(contents, encoding))
# Public: Reads the contents of the file.
#
# * `flushCache` A {Boolean} indicating whether to require a direct read or if
# a cached copy is acceptable.
#
# Returns a promise that resovles to a String.
read: (flushCache) ->
if @cachedContents? and not flushCache
promise = Q(@cachedContents)
else
deferred = Q.defer()
promise = deferred.promise
content = []
bytesRead = 0
encoding = @getEncoding()
if encoding is 'utf8'
readStream = fs.createReadStream(@getPath(), {encoding})
else
iconv ?= require 'iconv-lite'
readStream = fs.createReadStream(@getPath()).pipe(iconv.decodeStream(encoding))
readStream.on 'data', (chunk) ->
content.push(chunk)
bytesRead += chunk.length
deferred.notify(bytesRead)
readStream.on 'end', ->
deferred.resolve(content.join(''))
readStream.on 'error', (error) ->
if error.code == 'ENOENT'
deferred.resolve(null)
else
deferred.reject(error)
promise.then (contents) =>
@setDigest(contents)
@cachedContents = contents
# Public: Overwrites the file with the given text.
#
# * `text` The {String} text to write to the underlying file.
#
# Returns a {Promise} that resolves when the file has been written.
write: (text) ->
@exists().then (previouslyExisted) =>
@writeFile(@getPath(), text).then =>
@cachedContents = text
@subscribeToNativeChangeEvents() if not previouslyExisted and @hasSubscriptions()
undefined
# Public: Overwrites the file with the given text.
#
# * `text` The {String} text to write to the underlying file.
#
# Returns undefined.
writeSync: (text) ->
previouslyExisted = @existsSync()
@writeFileWithPrivilegeEscalationSync(@getPath(), text)
@cachedContents = text
@subscribeToNativeChangeEvents() if not previouslyExisted and @hasSubscriptions()
undefined
writeFile: (filePath, contents) ->
encoding = @getEncoding()
if encoding is 'utf8'
Q.nfcall(fs.writeFile, filePath, contents, {encoding})
else
iconv ?= require 'iconv-lite'
Q.nfcall(fs.writeFile, filePath, iconv.encode(contents, encoding))
# Writes the text to specified path.
#
# Privilege escalation would be asked when current user doesn't have
# permission to the path.
writeFileWithPrivilegeEscalationSync: (filePath, text) ->
try
@writeFileSync(filePath, text)
catch error
if error.code is 'EACCES' and process.platform is 'darwin'
runas ?= require 'runas'
# Use dd to read from stdin and write to filePath, same thing could be
# done with tee but it would also copy the file to stdout.
unless runas('/bin/dd', ["of=#{filePath}"], stdin: text, admin: true) is 0
throw error
else
throw error
###
Section: Private
###
handleNativeChangeEvent: (eventType, eventPath) ->
switch eventType
when 'delete'
@unsubscribeFromNativeChangeEvents()
@detectResurrectionAfterDelay()
when 'rename'
@setPath(eventPath)
@emit 'moved' if Grim.includeDeprecatedAPIs
@emitter.emit 'did-rename'
when 'change', 'resurrect'
oldContents = @cachedContents
handleReadError = (error) =>
# We cant read the file, so we GTFO on the watch
@unsubscribeFromNativeChangeEvents()
handled = false
handle = -> handled = true
error.eventType = eventType
@emitter.emit('will-throw-watch-error', {error, handle})
unless handled
newError = new Error("Cannot read file after file `#{eventType}` event: #{@path}")
newError.originalError = error
newError.code = "ENOENT"
newError.path
# I want to throw the error here, but it stops the event loop or
# something. No longer do interval or timeout methods get run!
# throw newError
console.error newError
try
@read(true).catch(handleReadError).done (newContents) =>
unless oldContents is newContents
@emit 'contents-changed' if Grim.includeDeprecatedAPIs
@emitter.emit 'did-change'
catch error
handleReadError(error)
detectResurrectionAfterDelay: ->
_.delay (=> @detectResurrection()), 50
detectResurrection: ->
@exists().then (exists) =>
if exists
@subscribeToNativeChangeEvents()
@handleNativeChangeEvent('resurrect', @getPath())
else
@cachedContents = null
@emit 'removed' if Grim.includeDeprecatedAPIs
@emitter.emit 'did-delete'
subscribeToNativeChangeEvents: ->
@watchSubscription ?= PathWatcher.watch @path, (args...) =>
@handleNativeChangeEvent(args...)
unsubscribeFromNativeChangeEvents: ->
if @watchSubscription?
@watchSubscription.close()
@watchSubscription = null
if Grim.includeDeprecatedAPIs
EmitterMixin = require('emissary').Emitter
EmitterMixin.includeInto(File)
File::on = (eventName) ->
switch eventName
when 'contents-changed'
Grim.deprecate("Use File::onDidChange instead")
when 'moved'
Grim.deprecate("Use File::onDidRename instead")
when 'removed'
Grim.deprecate("Use File::onDidDelete instead")
else
if @reportOnDeprecations
Grim.deprecate("Subscribing via ::on is deprecated. Use documented event subscription methods instead.")
EmitterMixin::on.apply(this, arguments)
else
File::hasSubscriptions = ->
@subscriptionCount > 0