| 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 | } |