main
ts 148 lines 6.38 KB
Raw
1 import { makeQ } from './makeQ'
2 import { opendir } from 'fs/promises'
3 import { IS_WINDOWS } from './const'
4 import { join } from 'path'
5 import { pendingPromise, Promisable } from './cross'
6 import { Stats, Dirent, Dir } from 'node:fs'
7 import events from './events'
8 import _ from 'lodash'
9 import { Context } from 'koa'
10 import fswin from 'fswin'
11 import { isDirectory, statWithTimeout } from './util-files'
12
13 interface DirStreamEntry extends Dirent {
14 closingBranch?: Promise<string>
15 stats?: Stats
16 }
17
18 const dirQ = makeQ(3)
19
20 // cb returns void = just go on, null = stop, false = go on but don't recur (in case of depth)
21 export function walkDir(path: string, { depth = 0, hidden = true, parallelizeRecursion = false, ctx }: {
22 depth?: number,
23 hidden?: boolean,
24 parallelizeRecursion?: boolean,
25 ctx?: Context
26 }, cb: (e: DirStreamEntry) => Promisable<void | null | false>) {
27 let stopped = false
28 const closingQ: string[] = []
29 return new Promise(async (resolve, reject) => {
30 if (!await isDirectory(path))
31 return reject(Error('ENOTDIR'))
32 dirQ.add(() => readDir('', depth)
33 .then(res => { // don't make the job await for it, but use it to know it's over
34 Promise.resolve(res?.branchDone).then(resolve)
35 }, reject)
36 )
37 })
38
39 async function readDir(relativePath: string, depth: number) {
40 if (stopped) return
41 const base = join(path, relativePath)
42 const subDirsDone: Promise<any>[] = []
43 let n = 0
44 let last: DirStreamEntry | undefined
45
46 const res = (await events.emitAsync('listDiskFolder', { path: base, ctx, hidden }))?.[0] // consider only first result
47 const pluginReceiver = _.isFunction(res) && res || null
48 const pluginIterator = _.isFunction(res?.[Symbol.asyncIterator] || res?.[Symbol.iterator]) && res as Dir
49
50 if (IS_WINDOWS && !pluginIterator) { // use native apis to read the 'hidden' attribute
51 const direntMethods = {
52 isDir: false,
53 isFile(){ return !this.isDir },
54 isDirectory(){ return this.isDir },
55 isBlockDevice(){ return false },
56 isCharacterDevice() { return false },
57 }
58 await new Promise<void>(res => fswin.find(base + '\\*', (event, f) => {
59 if (event !== 'FOUND') return res()
60 if (stopped) return true // stop signal
61 if (!hidden && f.IS_HIDDEN) return
62 work(Object.assign(Object.create(direntMethods), {
63 isDir: f.IS_DIRECTORY,
64 name: f.LONG_NAME,
65 stats: {
66 size: f.SIZE,
67 birthtime: f.CREATION_TIME, birthtimeMs: f.CREATION_TIME.getTime(),
68 mtime: f.LAST_WRITE_TIME, mtimeMs: f.LAST_WRITE_TIME.getTime(),
69 isFile: () => !f.IS_DIRECTORY,
70 isDirectory: () => f.IS_DIRECTORY,
71 } as Stats
72 }))
73 }, true))
74 }
75 else for await (let entry of (pluginIterator || await opendir(base))) {
76 if (stopped) break
77 if (!hidden && !IS_WINDOWS && entry.name[0] === '.')
78 continue
79 const stats = entry.isSymbolicLink?.() && await statWithTimeout(join(base, entry.name)).catch(() => null)
80 if (stats === null) continue
81 if (stats)
82 entry = new DirentFromStats(entry.name, stats)
83 const expanded: DirStreamEntry = entry
84 if (stats)
85 expanded.stats = stats
86 await work(expanded)
87 }
88 pluginReceiver?.(!stopped)
89 const branchDone = Promise.allSettled(subDirsDone)
90 if (last) // using streams, we don't know when the entries are received, so we need to notify on last item
91 last.closingBranch = branchDone.then(() => relativePath)
92 else
93 closingQ.push(relativePath) // ok, we'll ask next one to carry this info
94 // don't return the promise directly, as this job ends here, but communicate to caller the promise for the whole branch
95 return { branchDone, n }
96
97 async function work(entry: DirStreamEntry) {
98 entry.path = (relativePath && relativePath + '/') + entry.name
99 pluginReceiver?.(entry)
100 if (last && closingQ.length) // pending entries
101 last.closingBranch = Promise.resolve(closingQ.shift()!)
102 last = entry
103 const res = await cb(entry)
104 if (res === null) return stopped = true
105 if (res === false) return
106 n++
107 if (!depth || !entry.isDirectory()) return
108 const branchDone = pendingPromise() // per-job
109 subDirsDone.push(branchDone)
110 const job = () =>
111 readDir(entry.path, depth - 1) // recur
112 .then(x => x, () => {}) // mute errors
113 .then(res => { // don't await, as readDir must resolve without branch being done
114 if (!res?.n)
115 closingQ.push(entry.path) // no children to tell i'm done
116 Promise.resolve(res?.branchDone).then(() =>
117 branchDone.resolve())
118 })
119 if (parallelizeRecursion)
120 dirQ.add(job)
121 else
122 await job()
123 }
124 }
125 }
126
127 type DirentStatsKeysIntersection = keyof Dirent & keyof Stats;
128 const kStats = Symbol('stats')
129 // Adapting an internal class in Node.js to mimic the behavior of `Dirent` when creating it manually from `Stats`.
130 // https://github.com/nodejs/node/blob/a4cf6b204f0b160480153dc293ae748bf15225f9/lib/internal/fs/utils.js#L199C1-L213
131 export class DirentFromStats extends Dirent {
132 private readonly [kStats]: Stats;
133 constructor(name: string, stats: Stats) {
134 // @ts-expect-error The constructor has parameters, but they are not represented in types.
135 // https://github.com/nodejs/node/blob/a4cf6b204f0b160480153dc293ae748bf15225f9/lib/internal/fs/utils.js#L164
136 super(name, null);
137 this[kStats] = stats;
138 }
139 }
140
141 for (const key of Reflect.ownKeys(Dirent.prototype)) {
142 const name = key as DirentStatsKeysIntersection | 'constructor';
143 if (name === 'constructor' || typeof name === 'symbol')
144 continue;
145 DirentFromStats.prototype[name] = function () {
146 return this[kStats][name]();
147 };
148 }