-
Notifications
You must be signed in to change notification settings - Fork 1
/
Directory.ts
394 lines (362 loc) · 13.4 KB
/
Directory.ts
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
import { FileEntry } from './FileEntry'
import { Root } from './Root'
import { FileType, IWatcherEvent, WatchEvent, WatchTerminator } from './types'
import { ITreeSupervisor } from './types'
/**
* Like Array.prototype.splice except this method won't throw
* RangeError when given too many items (with spread operator as `...items`)
*
* Also items are concated straight up without having to use the spread operator
*
* Performance is more or less same as Array.prototype.splice
*
* @param arr Array to splice
* @param start Start index where splicing should begin
* @param deleteCount Items to delete (optionally replace with given items)
* @param items Items to insert (when deleteCount is same as items.length, it becomes a replace)
*/
function spliceTypedArray(arr: Uint32Array, start: number, deleteCount: number = 0, items?: Uint32Array) {
const a = new Uint32Array((arr.length - deleteCount) + (items ? items.length : 0))
a.set(arr.slice(0, start))
if (items) {
a.set(items, start)
}
a.set(arr.slice(start + deleteCount, arr.length), (start + (items ? items.length : 0)))
return a
}
export class Directory extends FileEntry {
private static defaultSortComparator(a: FileEntry | Directory, b: FileEntry | Directory) {
if (a.constructor === b.constructor) {
return a.fileName > b.fileName ? 1
: a.fileName < b.fileName ? -1
: 0
}
return a.constructor === Directory ? -1
: b.constructor === Directory ? 1
: 0
}
protected _children: Array<Directory | FileEntry>
/**
* Directory.children.length of self and all leafs (recursive) with isExpanded = true
*/
protected _branchSize: number
protected flattenedBranch: Uint32Array
private isExpanded: boolean
private watchTerminator: WatchTerminator
private hardReloadPromise: Promise<void>
private hardReloadPResolver: () => void
protected constructor(root: Root, tree: ITreeSupervisor, parent: Directory, dirName: string, optionalMetadata?: { [key: string]: any }) {
super(root, tree, parent, dirName, optionalMetadata)
this.isExpanded = false
this._branchSize = 0
this._children = null
}
get type(): FileType {
return FileType.Directory
}
get children() {
return this._children ? this._children.slice() : null
}
get expanded() {
return this.isExpanded
}
/**
* Number of *visible* flattened leaves this branch is incharge of (recursive)
*
* When a directory is expanded, its entire branch (recursively flattened) is owned by a branch higher up (either Root (at surface) or a branch in collapsed state (buried))
*
* When a directory is collapsed, its entire branch (recursively flattened) is "returned" back to it by one of its parent higher up
*/
get branchSize() {
return this._branchSize
}
/**
* Ensures the children of this `Directory` are loaded (without effecting the `expanded` state)
*
* If children are already loaded, returned `Promise` resolves immediately
*
* Otherwise a hard reload request is issued and returned `Promise` resolves when that process finishes.
*
* tl;dr: `Directory#children` are accessible once the returned `Promise` resolves
*/
public async ensureLoaded() {
if (this._children) {
return
}
return this.hardReloadChildren()
}
public async setExpanded(ensureVisible = true) {
if (this.isExpanded) {
return
}
this.isExpanded = true
if (this._children === null) {
await this.hardReloadChildren()
// check if still expanded; maybe setCollapsed was called in the meantime
if (!this.isExpanded) {
return
}
}
if (ensureVisible && this.parent && this.parent !== this.root) {
await this.parent.setExpanded(true)
}
// async (user might have changed their mind in the meantime)
if (this.isExpanded) {
this._superv.notifyWillChangeExpansionState(this, true)
this.expandBranch(this)
this._superv.notifyDidChangeExpansionState(this, true)
}
}
public setCollapsed() {
if (!this.isExpanded) {
return
}
if (this._children && this.parent) {
this._superv.notifyWillChangeExpansionState(this, false)
this.shrinkBranch(this)
}
this.isExpanded = false
this._superv.notifyDidChangeExpansionState(this, false)
}
/**
* Inserts the item into it's own parent (if not already)
*
* Gets called upon `IWatcherAddEvent` or `IWatcherMoveEvent`
*
* Calling this method directly WILL NOT trigger `onWillHandleWatchEvent` and `onDidHandleWatchEvent` events
*
* Prefer using `Root#inotify` instead
*/
public insertItem(item: FileEntry | Directory) {
if (item.parent !== this) {
item.mv(this, item.fileName)
return
}
if (this._children.indexOf(item) > -1) {
return
}
const branchSizeIncrease = 1 + ((item instanceof Directory && item.expanded) ? item._branchSize : 0)
this._children.push(item)
this._children.sort(this.root.host.sortComparator || Directory.defaultSortComparator)
this._branchSize += branchSizeIncrease
let master: Directory = this
while (!master.flattenedBranch) {
if (master.parent) {
master = master.parent
master._branchSize += branchSizeIncrease
}
}
let relativeInsertionIndex = this._children.indexOf(item)
const leadingSibling = this._children[relativeInsertionIndex - 1]
if (leadingSibling) {
const siblingIdx = master.flattenedBranch.indexOf(leadingSibling.id)
relativeInsertionIndex = siblingIdx + ((leadingSibling instanceof Directory && leadingSibling.expanded) ? leadingSibling._branchSize : 0)
} else {
relativeInsertionIndex = master.flattenedBranch.indexOf(this.id)
}
const absInsertionIndex = relativeInsertionIndex + 1 // +1 to accomodate for itself
const branch = new Uint32Array(branchSizeIncrease)
branch[0] = item.id
if (item instanceof Directory && item.expanded && item.flattenedBranch) {
branch.set(item.flattenedBranch, 1)
item.setFlattenedBranch(null)
}
master.setFlattenedBranch(spliceTypedArray(master.flattenedBranch, absInsertionIndex, 0, branch))
}
/**
* Removes the item from parent
*
* Gets called upon `IWatcherRemoveEvent` or `IWatcherMoveEvent`
*
* Calling this method directly WILL NOT trigger `onWillHandleWatchEvent` and `onDidHandleWatchEvent` events
*
* Prefer using `Root#inotify` instead
*/
public unlinkItem(item: FileEntry | Directory, reparenting: boolean = false): void {
const idx = this._children.indexOf(item)
if (idx === -1) {
return
}
this._children.splice(idx, 1)
const branchSizeDecrease = 1 + ((item instanceof Directory && item.expanded) ? item._branchSize : 0)
this._branchSize -= branchSizeDecrease
// find out first owner of topDownFlatLeaves struct
let master: Directory = this
while (!master.flattenedBranch) {
if (master.parent) {
master = master.parent
master._branchSize -= branchSizeDecrease
}
}
const removalBeginIdx = master.flattenedBranch.indexOf(item.id)
if (removalBeginIdx === -1) {
return
}
// is directory and DOES NOT owns its leaves (when directory is expanded, its leaves are owned by someone higher up) (except during transfers)
if (item instanceof Directory && item.expanded) {
item.setFlattenedBranch(master.flattenedBranch.slice(removalBeginIdx + 1, removalBeginIdx + branchSizeDecrease))
}
master.setFlattenedBranch(spliceTypedArray(
master.flattenedBranch,
removalBeginIdx,
branchSizeDecrease))
if (!reparenting && item.parent === this) {
item.mv(null)
}
}
public mv(to: Directory, newName: string = this.fileName) {
// get the old path before `super.mv` refreshes it
const prevPath = this.path
super.mv(to, newName)
if (typeof this.watchTerminator === 'function') {
this.watchTerminator(prevPath)
// If we got children, we gotta watch em'!
if (this._children) {
this.watchTerminator = this._superv.supervisedWatch(this.path, this.handleWatchEvent)
}
}
if (this._children) {
for (let i = 0; i < this._children.length; i++) {
const child = this._children[i]
// It'll reset the cached resolved path
child.mv(child.parent, child.fileName)
}
}
}
/**
* WARNING: This will only stop watchers and clear bookkeeping records
* To clean-up flattened branches and stuff, call Directory#removeItem in the parent
* Directory#removeItem will call Directory#dispose anyway
*/
protected dispose() {
if (typeof this.watchTerminator === 'function') {
this.watchTerminator(this.path)
}
if (this._children) {
this._children.forEach((child) => (child as Directory).dispose())
}
super.dispose()
}
/**
* Using setter as Root needs to capture when the root flat tree is altered
*/
protected setFlattenedBranch(leaves: Uint32Array) {
this.flattenedBranch = leaves
}
protected expandBranch(branch: Directory) {
if (this !== branch) {
this._branchSize += branch._branchSize
}
// when `this` itself is in collapsed state, it'll just "adopt" given branch's leaves without propagating any further up
if (this !== branch && this.flattenedBranch) {
const injectionStartIdx = this.flattenedBranch.indexOf(branch.id) + 1
this.setFlattenedBranch(spliceTypedArray(this.flattenedBranch, injectionStartIdx, 0, branch.flattenedBranch))
// [CRITICAL] take "away" the branch ownership
branch.setFlattenedBranch(null)
} else if (this.parent) {
this.parent.expandBranch(branch)
}
}
protected shrinkBranch(branch: Directory) {
if (this !== branch) {
// branch size for `this` hasn't changed, `this` still has same number of leaves, but from parents frame of reference, their branch has shrunk
this._branchSize -= branch._branchSize
}
if (this !== branch && this.flattenedBranch) {
const removalStartIdx = this.flattenedBranch.indexOf(branch.id) + 1
// [CRITICAL] "return" the branch ownership
branch.setFlattenedBranch(this.flattenedBranch.slice(removalStartIdx, removalStartIdx + branch._branchSize))
this.setFlattenedBranch(spliceTypedArray(this.flattenedBranch, removalStartIdx, branch.flattenedBranch.length))
} else if (this.parent) {
this.parent.shrinkBranch(branch)
}
}
protected async hardReloadChildren() {
if (this.hardReloadPromise) {
return this.hardReloadPromise
}
this.hardReloadPromise = new Promise((res) => this.hardReloadPResolver = res)
this.hardReloadPromise.then(() => {
this.hardReloadPromise = null
this.hardReloadPResolver = null
})
const rawItems = await this.root.host.getItems(this.path) || []
if (this._children) {
this.shrinkBranch(this) // VERY CRITICAL (we need the ownership of topDownFlatLeaves so we can reset it)
}
const flatTree = new Uint32Array(rawItems.length)
this._children = Array(rawItems.length)
for (let i = 0; i < rawItems.length; i++) {
const file = rawItems[i]
if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
FileEntry.checkRawFile(file)
}
const { type, name, metadata } = file
const child = new (type === FileType.Directory ? Directory : FileEntry)(this.root, this._superv, this, name, metadata)
this._children[i] = child
}
this._children.sort(this.root.host.sortComparator || Directory.defaultSortComparator)
for (let i = 0; i < rawItems.length; i++) {
flatTree[i] = this._children[i].id
}
this._branchSize = flatTree.length
this.setFlattenedBranch(flatTree)
if ( typeof this.watchTerminator === 'function') {
this.watchTerminator(this.path)
}
this.watchTerminator = this._superv.supervisedWatch(this.path, this.handleWatchEvent)
this.hardReloadPResolver()
}
private handleWatchEvent = async (event: IWatcherEvent) => {
this._superv.notifyWillProcessWatchEvent(this, event)
if (event.type === WatchEvent.Moved) {
const { oldPath, newPath } = event
if (typeof oldPath !== 'string') { throw new TypeError(`Expected oldPath to be a string`) }
if (typeof newPath !== 'string') { throw new TypeError(`Expected newPath to be a string`) }
if (this.root.pathfx.isRelative(oldPath)) { throw new TypeError(`oldPath must be absolute`) }
if (this.root.pathfx.isRelative(newPath)) { throw new TypeError(`newPath must be absolute`) }
this.transferItem(oldPath, newPath)
} else if (event.type === WatchEvent.Added) {
const { file } = event
FileEntry.checkRawFile(file)
const newItem = new (file.type === FileType.Directory ? Directory : FileEntry)(this.root, this._superv, this, file.name, file.metadata)
this.insertItem(newItem)
} else if (event.type === WatchEvent.Removed) {
const { path } = event
const dirName = this.root.pathfx.dirname(path)
const fileName = this.root.pathfx.basename(path)
if (dirName === this.path) {
const item = this._children.find((c) => c.fileName === fileName)
if (item) {
this.unlinkItem(item)
}
}
} else /* Maybe generic change event */ {
// TODO: Try to "rehydrate" tree instead of hard reset (if possible) (maybe IFileEntryItem can have optional `id` prop? hash of (ctime + [something])?)
for (let i = 0; i < this._children.length; i++) {
(this._children[i] as Directory).dispose()
}
this.hardReloadChildren()
}
this._superv.notifyDidProcessWatchEvent(this, event)
}
private transferItem(oldPath: string, newPath: string) {
const { dirname, basename } = this.root.pathfx
const from = dirname(oldPath)
if (from !== this.path) {
return
}
const fileName = basename(oldPath)
const item = this._children.find((c) => c.fileName === fileName)
if (!item) {
return
}
const to = dirname(newPath)
const destDir = to === from ? this : this.root.findFileEntryInLoadedTree(to)
if (!(destDir instanceof Directory)) {
this.unlinkItem(item)
return
}
item.mv(destDir, basename(newPath))
}
}