farjs-app
Version:
FAR.js - Cross-platform File and Archive Manager app in your terminal
289 lines (249 loc) • 7.95 kB
JavaScript
/**
* @import { FileSource, FileTarget } from "@farjs/filelist/api/FileListApi.mjs"
* @typedef {import("@farjs/filelist/api/FileListItem.mjs").FileListItem} FileListItem
* @typedef {import("./FSService.mjs").FSService} FSService
*/
import path from "path";
import nodeFs from "fs";
import fsPromises from "fs/promises";
import FileListApi from "@farjs/filelist/api/FileListApi.mjs";
import FileListItem from "@farjs/filelist/api/FileListItem.mjs";
import FileListCapability from "@farjs/filelist/api/FileListCapability.mjs";
import FSService from "./FSService.mjs";
/**
* @typedef {{
* lstatSync(path: string): nodeFs.Stats;
* rmdirSync(path: string): void;
* unlinkSync(path: string): void;
* mkdir(path: string): Promise<void>;
* readdir(path: string): Promise<readonly string[]>;
* open(path: string, flags: number): Promise<fsPromises.FileHandle>;
* }} FS
*/
class FSFileListApi extends FileListApi {
/**
* @param {FS} fs
* @param {FSService} fsService
*/
constructor(
fs = { ...nodeFs, ...fsPromises },
fsService = FSService.instance
) {
super(
true,
new Set([
FileListCapability.read,
FileListCapability.write,
FileListCapability.delete,
FileListCapability.mkDirs,
FileListCapability.copyInplace,
FileListCapability.moveInplace,
])
);
/** @private @readonly @type {FS} */
this.fs = fs;
/** @private @readonly @type {FSService} */
this.fsService = fsService;
}
/** @type {FileListApi['readDir']} */
async readDir(parent, dir) {
const targetDir = path.resolve(parent, dir ?? "");
const files = await this.fs.readdir(targetDir);
const items = files.map((name) => {
return this.readFileListItem(targetDir, name);
});
return {
path: targetDir,
isRoot: FSFileListApi._isRoot(targetDir),
items,
};
}
/** @type {FileListApi['delete']} */
delete(parent, items) {
const self = this;
/** @type {(parent: string, items: [string, boolean][]) => Promise<void>} */
function loop(parent, items) {
return items.reduce(async (resP, [name, isDir]) => {
await resP;
if (isDir) {
const dir = path.join(parent, name);
const files = await fsPromises.readdir(dir);
/** @type {[string, boolean][]} */
const items = files.map((name) => {
const stats = self.fs.lstatSync(path.join(dir, name));
return [name, stats.isDirectory()];
});
await loop(dir, items);
self.fs.rmdirSync(dir);
} else {
self.fs.unlinkSync(path.join(parent, name));
}
}, Promise.resolve());
}
return loop(
parent,
items.map((i) => [i.name, i.isDir])
);
}
/** @type {FileListApi['mkDirs']} */
mkDirs(dirs) {
const self = this;
/** @type {(parent: string, names: readonly string[]) => Promise<string>} */
async function loop(parent, names) {
if (names.length === 0) {
return parent;
}
const [name, ...tail] = names;
const dir = await (async () => {
if (name.length === 0) {
return parent;
}
const dir = path.join(parent, name);
if (parent.length > 0 || !FSFileListApi._isRoot(dir)) {
await self.fs.mkdir(dir).catch((error) => {
if (error.code === "EEXIST") {
//skip
} else throw error;
});
}
return dir;
})();
return loop(dir, tail);
}
return loop("", dirs);
}
/** @type {FileListApi['readFile']} */
async readFile(parent, file, position) {
const filePath = path.join(parent, file.name);
const handle = await this.fs.open(filePath, FSConstants.O_RDONLY);
let pos = position;
/** @type {FileSource} */
const source = {
file: filePath,
readNextBytes: async (buff) => {
const { bytesRead } = await handle.read(buff, 0, buff.length, pos);
pos += bytesRead;
return bytesRead;
},
close: handle.close,
};
return source;
}
/** @type {FileListApi['writeFile']} */
async writeFile(parent, fileName, onExists) {
const self = this;
const filePath = path.join(parent, fileName);
/** @type {[fsPromises.FileHandle | undefined, number]} */
const [handle, position] = await this.fs
.open(
filePath,
FSConstants.O_CREAT | FSConstants.O_WRONLY | FSConstants.O_EXCL
)
.then(
(handle) => [handle, 0],
async (error) => {
if (error.code === "EEXIST") {
const existing = self.readFileListItem(parent, fileName);
const overwrite = await onExists(existing);
if (overwrite === undefined) {
return [undefined, 0];
}
const handle = await self.fs.open(filePath, FSConstants.O_WRONLY);
const position = overwrite ? 0 : existing.size;
return [handle, position];
}
throw error;
}
);
if (handle) {
let pos = position;
/** @type {FileTarget} */
const target = {
file: filePath,
async writeNextBytes(buff, length) {
const { bytesWritten } = await handle.write(buff, 0, length, pos);
if (bytesWritten !== length) {
throw Error(
`File write error: bytesWritten(${bytesWritten}) != expected(${length}), file: ${filePath}`
);
}
pos += bytesWritten;
return pos;
},
async setAttributes(src) {
await handle.truncate(pos);
await handle.utimes(src.atimeMs / 1000, src.mtimeMs / 1000);
},
close: handle.close,
delete: async () => self.fs.unlinkSync(filePath),
};
return target;
}
return undefined;
}
/** @type {FileListApi['getDriveRoot']} */
async getDriveRoot(path) {
const disk = await this.fsService.readDisk(path);
return disk?.root;
}
/** @private @type {(targetDir: string, name: string) => FileListItem} */
readFileListItem(targetDir, name) {
try {
const stats = this.fs.lstatSync(path.join(targetDir, name));
return FSFileListApi._toFileListItem(name, stats);
} catch (_) {
return FileListItem(name);
}
}
}
/** @type {(name: string, stats: nodeFs.Stats) => FileListItem} */
FSFileListApi._toFileListItem = (name, stats) => {
const isDir = stats.isDirectory();
return {
...FileListItem(name, isDir),
isSymLink: stats.isSymbolicLink(),
size: isDir ? 0 : stats.size,
atimeMs: stats.atimeMs,
mtimeMs: stats.mtimeMs,
ctimeMs: stats.ctimeMs,
birthtimeMs: stats.birthtimeMs,
permissions: FSFileListApi._getPermissions(stats.mode),
};
};
const FSConstants = nodeFs.constants;
const S_IFDIR = FSConstants.S_IFDIR;
const S_IRUSR = FSConstants.S_IRUSR;
const S_IWUSR = FSConstants.S_IWUSR;
const S_IXUSR = FSConstants.S_IXUSR;
const S_IRGRP = FSConstants.S_IRGRP;
const S_IWGRP = FSConstants.S_IWGRP;
const S_IXGRP = FSConstants.S_IXGRP;
const S_IROTH = FSConstants.S_IROTH;
const S_IWOTH = FSConstants.S_IWOTH;
const S_IXOTH = FSConstants.S_IXOTH;
/** @type {(dir: string) => boolean} */
FSFileListApi._isRoot = (dir) => {
const pathObj = path.parse(dir);
return pathObj.root === pathObj.dir && pathObj.base.length === 0;
};
/** @type {(mode: number) => string} */
FSFileListApi._getPermissions = (mode) => {
/** @type {(c: string, f: number) => string} */
function flag(c, f) {
return (mode & f) !== 0 ? c : "-";
}
const chars = [
flag("d", S_IFDIR),
flag("r", S_IRUSR),
flag("w", S_IWUSR),
flag("x", S_IXUSR),
flag("r", S_IRGRP),
flag("w", S_IWGRP),
flag("x", S_IXGRP),
flag("r", S_IROTH),
flag("w", S_IWOTH),
flag("x", S_IXOTH),
];
return chars.join("");
};
export default FSFileListApi;