From c769729390a1d64a0f7f770d1277e534293462b4 Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Tue, 21 Feb 2023 22:00:18 +0100 Subject: [PATCH 01/20] FsZip: added new Zip filesystem Note: shows a very simple zip only. Lots of work needed: - need to add readonly + checks on Fs so that read+delete+paste+rename operations are disabled - need to allow browsing into a dir inside the zip - need to allow browsing a zip by double-clicking in it - need to handle opening a file from zip (by extracking the file in /tmp) - need to handle copying files from zip --- package-lock.json | 22 +- package.json | 1 + src/components/Toolbar/index.tsx | 3 +- src/services/plugins/FsZip.ts | 624 +++++++++++++++++++++++++++++++ src/utils/initFS.ts | 2 + 5 files changed, 649 insertions(+), 3 deletions(-) create mode 100644 src/services/plugins/FsZip.ts diff --git a/package-lock.json b/package-lock.json index 6ffd0f2d..7174a9e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-explorer", - "version": "3.0.0", + "version": "3.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "react-explorer", - "version": "3.0.0", + "version": "3.1.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -26,6 +26,7 @@ "mobx": "^6.6.2", "mobx-react": "^7.5.3", "node-disk-info": "git+https://git@github.com/warpdesign/node-disk-info.git", + "node-stream-zip": "^1.15.0", "react": "^16.9.0", "react-dnd": "^14.0.5", "react-dnd-html5-backend": "^14.1.0", @@ -10987,6 +10988,18 @@ "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==", "dev": true }, + "node_modules/node-stream-zip": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", + "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", + "engines": { + "node": ">=0.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/antelle" + } + }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -23614,6 +23627,11 @@ "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==", "dev": true }, + "node-stream-zip": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", + "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==" + }, "normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", diff --git a/package.json b/package.json index ff821d72..6279d0a0 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "mobx": "^6.6.2", "mobx-react": "^7.5.3", "node-disk-info": "git+https://git@github.com/warpdesign/node-disk-info.git", + "node-stream-zip": "^1.15.0", "react": "^16.9.0", "react-dnd": "^14.0.5", "react-dnd-html5-backend": "^14.1.0", diff --git a/src/components/Toolbar/index.tsx b/src/components/Toolbar/index.tsx index 0ec3008c..5b02ba17 100644 --- a/src/components/Toolbar/index.tsx +++ b/src/components/Toolbar/index.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react' import { observer } from 'mobx-react' import { InputGroup, ControlGroup, Button, ButtonGroup, Intent, HotkeysTarget2, Classes } from '@blueprintjs/core' -import { IconNames } from '@blueprintjs/icons' +import { IconName, IconNames } from '@blueprintjs/icons' import { Popover2 } from '@blueprintjs/popover2' import { useTranslation } from 'react-i18next' @@ -237,6 +237,7 @@ export const Toolbar = observer(({ active }: Props) => { onChange={onPathChange} onKeyUp={onKeyUp} placeholder={t('COMMON.PATH_PLACEHOLDER')} + leftIcon={cache.getFS().icon as IconName} rightElement={reloadButton} value={path} inputRef={inputRef} diff --git a/src/services/plugins/FsZip.ts b/src/services/plugins/FsZip.ts new file mode 100644 index 00000000..941114e1 --- /dev/null +++ b/src/services/plugins/FsZip.ts @@ -0,0 +1,624 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import StreamZip, { StreamZipAsync, ZipEntry } from 'node-stream-zip' +import type { ReadStream, BigIntStats } from 'fs' +import { Transform, TransformCallback } from 'stream' +import * as path from 'path' + +import { FsApi, FileDescriptor, Credentials, Fs, filetype, MakeId } from '$src/services/Fs' +import { throttle } from '$src/utils/throttle' +import { isWin, HOME_DIR } from '$src/utils/platform' + +const invalidDirChars = (isWin && /[\*:<>\?|"]+/gi) || /^[\.]+[\/]+(.)*$/gi +const invalidFileChars = (isWin && /[\*:<>\?|"]+/gi) || /\// +const SEP = path.sep + +// Since nodeJS will translate unix like paths to windows path, when running under Windows +// we accept Windows style paths (eg. C:\foo...) and unix paths (eg. /foo or ./foo) +const isRoot = (isWin && /((([a-zA-Z]\:)(\\)*)|(\\\\))$/) || /^\/$/ + +const progressFunc = throttle((progress: (bytes: number) => void, bytesRead: number) => { + progress(bytesRead) +}, 400) + +export const checkDirectoryName = (dirName: string) => !!!dirName.match(invalidDirChars) && dirName !== '/' + +export interface ZipMethods { + isZipRoot: (path: string) => boolean + getEntries: () => Promise +} + +export class Zip { + ready = false + zip: StreamZipAsync + zipEntries: ZipEntry[] + zipPath: string + zipFilename: string + + constructor(path: string) { + this.zip = new StreamZip.async({ file: path }) + this.zipEntries = [] + this.zipPath = path.replace(/(\/$)*/, '') + this.zipFilename = '' + } + + isZipRoot(path: string) { + return true + } + + async getEntries(path: string) { + if (!this.ready) { + const entries = await this.zip.entries() + this.zipEntries = Object.values(entries) + this.ready = true + } + + const pathInZip = path.replace(this.zipPath, '') + const isZipRoot = !!!pathInZip + + // TODO: get path inside zip + return this.zipEntries.filter((entry) => { + if (isZipRoot) { + const name = entry.name.replace(/(\/$)*/g, '') + // root: include files in root zip + return !name.match(/\//) + } + }) + } + + getFileDescriptor(entry: ZipEntry) { + const name = entry.name.replace(/(\/$)*/g, '') + const parsed = path.parse(name) + const extension = parsed.ext.toLowerCase() + const mDate = new Date(entry.time) + const mode = entry.attr ? ((entry.attr >>> 0) | 0) >> 16 : 0 + + // TODO: build file + const file = { + dir: path.parse(`${this.zipPath}/${name}`).dir, + fullname: name, + name: parsed.name, + extension, + cDate: mDate, + mDate, + bDate: mDate, + length: entry.size, + mode, + isDir: entry.isDirectory, + readonly: true, + type: (!entry.isDirectory && filetype(mode, 0, 0, extension)) || '', + // CHECKME: can we have links inside a zip file? + isSym: false, + target: null, + id: MakeId({ + ino: BigInt(entry.crc + (entry as any).headerOffset), + dev: BigInt(entry.offset + +(entry as any).headerOffset), + }), + } as FileDescriptor + + return file + // dir: format.dir, + // fullname: format.base, + // name: format.name, + // extension: format.ext.toLowerCase(), + // cDate: stats.ctime, + // mDate: stats.mtime, + // bDate: stats.birthtime, + // length: Number(stats.size), + // mode: Number(stats.mode), + // isDir: stats.isDirectory(), + // readonly: false, + // type: + // (!stats.isDirectory() && + // filetype(Number(stats.mode), Number(stats.gid), Number(stats.uid), format.ext.toLowerCase())) || + // '', + // isSym: stats.isSymbolicLink(), + // target: (stats.isSymbolicLink() && vol.readlinkSync(fullPath)) || null, + // id: MakeId({ ino: stats.ino, dev: stats.dev }), + } +} + +export class ZipApi implements FsApi { + type = 0 + // current path + path: string + loginOptions: Credentials = null + onFsChange: (filename: string) => void + zip: Zip + + constructor(path: string, onFsChange: (filename: string) => void) { + this.path = '' + this.onFsChange = onFsChange + this.zip = new Zip(path) + debugger + } + + // local fs doesn't require login + isConnected(): boolean { + return true + } + + isDirectoryNameValid(dirName: string): boolean { + return checkDirectoryName(dirName) + } + + join(...paths: string[]): string { + return path.join(...paths) + } + + resolve(newPath: string): string { + // TODO: + // gh#44: replace ~ with userpath + debugger + const dir = newPath.replace(/^~/, HOME_DIR) + return path.resolve(dir) + } + + async cd(path: string, transferId = -1): Promise { + const resolvedPath = this.resolve(path) + try { + const isDir = await this.isDir(resolvedPath) + if (isDir) { + return resolvedPath + } else { + throw { code: 'ENOTDIR' } + } + } catch {} + } + + size(source: string, files: string[], transferId = -1): Promise { + return Promise.reject('FsZip:size not implemented!') + // return new Promise(async (resolve, reject) => { + // try { + // let bytes = 0 + // for (const file of files) { + // bytes += await size(path.join(source, file)) + // } + // resolve(bytes) + // } catch (err) { + // reject(err) + // } + // }) + } + + async makedir(source: string, dirName: string, transferId = -1): Promise { + // return new Promise((resolve, reject) => { + // console.log('makedir, source:', source, 'dirName:', dirName) + // const unixPath = path.join(source, dirName).replace(/\\/g, '/') + // console.log('unixPath', unixPath) + // try { + // console.log('calling mkdir') + // reject('FsVirtual:makedir not implemented!') + // // mkdir(unixPath, (err: NodeJS.ErrnoException) => { + // // if (err) { + // // console.log('error creating dir', err) + // // reject(err) + // // } else { + // // console.log('successfully created dir', err) + // // resolve(path.join(source, dirName)) + // // } + // // }) + // } catch (err) { + // console.log('error execing mkdir()', err) + // reject(err) + // } + // }) + throw 'TODO: FsZip.makedir' + } + + delete(source: string, files: FileDescriptor[], transferId = -1): Promise { + const toDelete = files.map((file) => path.join(source, file.fullname)) + + return Promise.reject('TODO: FsZip.delete not implemented!') + // return new Promise(async (resolve, reject) => { + // try { + // const deleted = await del(toDelete, { + // force: true, + // noGlob: true, + // }) + // resolve(deleted.length) + // } catch (err) { + // reject(err) + // } + // }) + } + + rename(source: string, file: FileDescriptor, newName: string, transferId = -1): Promise { + throw 'TODO: FsZip.rename' + // const oldPath = path.join(source, file.fullname) + // const newPath = path.join(source, newName) + + // if (!newName.match(invalidFileChars)) { + // return new Promise((resolve, reject) => { + // // since node's fs.rename will overwrite the destination + // // path if it exists, first check that file doesn't exist + // this.exists(newPath) + // .then((exists) => { + // if (exists) { + // reject({ + // code: 'EEXIST', + // oldName: file.fullname, + // }) + // } else { + // vol.rename(oldPath, newPath, (err) => { + // if (err) { + // reject({ + // code: err.code, + // message: err.message, + // newName: newName, + // oldName: file.fullname, + // }) + // } else { + // resolve(newName) + // } + // }) + // } + // }) + // .catch((err) => { + // reject({ + // code: err.code, + // message: err.message, + // newName: newName, + // oldName: file.fullname, + // }) + // }) + // }) + // } else { + // // reject promise with previous name in case of invalid chars + // return Promise.reject({ + // oldName: file.fullname, + // newName: newName, + // code: 'BAD_FILENAME', + // }) + // } + } + + async makeSymlink(targetPath: string, path: string, transferId = -1): Promise { + throw 'TODO: FsZip.makeSymLink' + // return new Promise((resolve, reject) => + // vol.symlink(targetPath, path, (err) => { + // if (err) { + // reject(err) + // } else { + // resolve(true) + // } + // }), + // ) + } + + async isDir(path: string, transferId = -1): Promise { + console.warn('TODO: FsZip.isDir => check that file inside dir is a directory') + return true + } + + async exists(path: string, transferId = -1): Promise { + throw 'TODO: FsZip.Exists not implemented' + // try { + // await vol.promises.access(path) + // return true + // } catch (err) { + // if (err.code === 'ENOENT') { + // return false + // } else { + // throw err + // } + // } + } + + async stat(fullPath: string, transferId = -1): Promise { + throw 'TODO: FsZip.stat not implemented' + // try { + // const format = path.parse(fullPath) + // const stats = vol.lstatSync(fullPath, { bigint: true }) + // const file: FileDescriptor = { + // dir: format.dir, + // fullname: format.base, + // name: format.name, + // extension: format.ext.toLowerCase(), + // cDate: stats.ctime, + // mDate: stats.mtime, + // bDate: stats.birthtime, + // length: Number(stats.size), + // mode: Number(stats.mode), + // isDir: stats.isDirectory(), + // readonly: false, + // type: + // (!stats.isDirectory() && + // filetype(Number(stats.mode), Number(stats.gid), Number(stats.uid), format.ext.toLowerCase())) || + // '', + // isSym: stats.isSymbolicLink(), + // target: (stats.isSymbolicLink() && vol.readlinkSync(fullPath)) || null, + // id: MakeId({ ino: stats.ino, dev: stats.dev }), + // } + + // return file + // } catch (err) { + // throw err + // } + } + + login(server?: string, credentials?: Credentials): Promise { + return Promise.resolve() + } + + onList(dir: string): void { + console.warn('FsZop.onList not implemented') + // if (dir !== this.path) { + // // console.log('stopWatching', this.path) + // try { + // VirtualWatch.stopWatchingPath(this.path, this.onFsChange) + // VirtualWatch.watchPath(dir, this.onFsChange) + // } catch (e) { + // console.warn('Could not watch path', dir, e) + // } + // // console.log('watchPath', dir) + // this.path = dir + // } + } + + async list(dir: string, watchDir = false, transferId = -1): Promise { + const entries = await this.zip.getEntries(dir) + // return [] + return entries.map((entry) => this.zip.getFileDescriptor(entry)) + debugger + throw 'TODO: FsZip.list not implemented' + // try { + // await this.isDir(dir) + // return new Promise((resolve, reject) => { + // vol.readdir(dir, (err, items) => { + // if (err) { + // reject(err) + // } else { + // const dirPath = path.resolve(dir) + + // const files: FileDescriptor[] = [] + + // for (let i = 0; i < items.length; i++) { + // const file = ZipApi.fileFromPath(path.join(dirPath, items[i] as string)) + // files.push(file) + // } + + // watchDir && this.onList(dirPath) + + // resolve(files) + // } + // }) + // }) + // } catch (err) { + // throw { + // code: err.code, + // message: `Could not access path: ${dir}`, + // } + // } + } + + static fileFromPath(fullPath: string): FileDescriptor { + const format = path.parse(fullPath) + const name = fullPath + const stats: Partial = null + + debugger + + return { + name: `${fullPath}`, + } as FileDescriptor + // try { + // // do not follow symlinks first + // stats = vol.lstatSync(fullPath, { bigint: true }) + // if (stats.isSymbolicLink()) { + // // get link target path first + // name = vol.readlinkSync(fullPath) as string + // targetStats = vol.statSync(fullPath, { bigint: true }) + // } + // } catch (err) { + // console.warn('error getting stats for', fullPath, err) + + // const isDir = stats ? stats.isDirectory() : false + // const isSymLink = stats ? stats.isSymbolicLink() : false + + // stats = { + // ctime: new Date(), + // mtime: new Date(), + // birthtime: new Date(), + // size: stats ? stats.size : 0n, + // isDirectory: (): boolean => isDir, + // mode: -1n, + // isSymbolicLink: (): boolean => isSymLink, + // ino: 0n, + // dev: 0n, + // } + // } + + // const extension = path.parse(name).ext.toLowerCase() + // const mode = targetStats ? targetStats.mode : stats.mode + + // const file: FileDescriptor = { + // dir: format.dir, + // fullname: format.base, + // name: format.name, + // extension: extension, + // cDate: stats.ctime, + // mDate: stats.mtime, + // bDate: stats.birthtime, + // length: Number(stats.size), + // mode: Number(mode), + // isDir: targetStats ? targetStats.isDirectory() : stats.isDirectory(), + // readonly: false, + // type: + // (!(targetStats ? targetStats.isDirectory() : stats.isDirectory()) && + // filetype(Number(mode), 0, 0, extension)) || + // '', + // isSym: stats.isSymbolicLink(), + // target: (stats.isSymbolicLink() && name) || null, + // id: MakeId({ ino: stats.ino, dev: stats.dev }), + // } + + // return file + } + + isRoot(path: string): boolean { + return !!path.match(isRoot) + } + + off(): void { + // + } + + // TODO add error handling + async getStream(path: string, file: string, transferId = -1): Promise { + // try { + // const stream = fs.createReadStream(this.join(path, file)) + // return Promise.resolve(stream) + // } catch (err) { + // console.log('FsVirtual.getStream error', err) + // return Promise.reject(err) + // } + return Promise.reject('TODO: getStream') + } + + putStream( + readStream: ReadStream, + dstPath: string, + progress: (bytes: number) => void, + transferId = -1, + ): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + // return new Promise((resolve: (val?: any) => void, reject: (val?: any) => void) => { + // let finished = false + // let readError = false + // let bytesRead = 0 + + // const reportProgress = new Transform({ + // // eslint-disable-next-line @typescript-eslint/no-explicit-any + // transform(chunk: any, encoding: any, callback: TransformCallback) { + // bytesRead += chunk.length + // progressFunc(progress, bytesRead) + // callback(null, chunk) + // }, + // highWaterMark: 16384 * 31, + // }) + + // const writeStream = fs.createWriteStream(dstPath) + + // readStream.once('error', (err) => { + // console.log('error on read stream') + // readError = true + // readStream.destroy() + // writeStream.destroy(err) + // }) + + // readStream.pipe(reportProgress).pipe(writeStream) + + // writeStream.once('finish', (...args) => { + // progress(writeStream.bytesWritten) + // finished = true + // }) + + // writeStream.once('error', (err) => { + // // remove created file if it's empty and there was a problem + // // accessing the source file: we will report an error to the + // // user so there's no need to leave an empty file + // if (readError && !bytesRead && !writeStream.bytesWritten) { + // console.log('cleaning up fs') + // fs.unlink(dstPath, (err) => { + // if (!err) { + // console.log('cleaned-up fs') + // } else { + // console.log('error cleaning-up fs', err) + // } + // }) + // } + // reject(err) + // }) + + // writeStream.once('close', () => { + // if (finished) { + // resolve() + // } else { + // reject() + // } + // }) + + // writeStream.once('error', (err) => { + // reject(err) + // }) + // }) + throw 'TODO: FsZip.putStream not implemented' + } + + getParentTree(dir: string): Array<{ dir: string; fullname: string; name: string }> { + const parts = dir.split(SEP) + const max = parts.length - 1 + let fullname = '' + + if (dir.length === 1) { + return [ + { + dir, + fullname: '', + name: dir, + }, + ] + } else { + const folders = [] + + for (let i = 0; i <= max; ++i) { + folders.push({ + dir, + fullname, + name: parts[max - i] || SEP, + }) + + if (!i) { + fullname += '..' + } else { + fullname += '/..' + } + } + + return folders + } + } + + sanityze(path: string): string { + return isWin ? (path.match(/\\$/) ? path : path + '\\') : path + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string, cb: (data: any) => void): void { + // + } +} + +export function FolderExists(path: string): boolean { + return false +} + +export const FsZip: Fs = { + icon: 'archive', + name: 'zip', + description: 'Zip Filesystem (Readonly)', + options: { + needsRefresh: false, + }, + canread(str: string): boolean { + return str.replace(/\/$/, '').split(/\.zip/gi).length === 2 + }, + serverpart(str: string): string { + return 'zip' + }, + credentials(str: string): Credentials { + return { + user: '', + password: '', + port: 0, + } + }, + displaypath(str: string): { shortPath: string; fullPath: string } { + const split = str.split(SEP) + return { + fullPath: str, + shortPath: split.slice(-1)[0] || str, + } + }, + API: ZipApi, +} diff --git a/src/utils/initFS.ts b/src/utils/initFS.ts index 352ad626..cbeefca5 100644 --- a/src/utils/initFS.ts +++ b/src/utils/initFS.ts @@ -2,6 +2,7 @@ import { registerFs } from '$src/services/Fs' import { FsWsl } from '$src/services/plugins/FsWsl' import { FsLocal } from '$src/services/plugins/FsLocal' import { FsVirtual } from '$src/services/plugins/FsVirtual' +import { FsZip } from '$src/services/plugins/FsZip' import virtualVolume from '$src/utils/test/virtualVolume' export default function initFS() { @@ -13,6 +14,7 @@ export default function initFS() { } else { // TODO: there should be an easy way to automatically register new FS registerFs(FsWsl) + registerFs(FsZip) registerFs(FsLocal) } } From 9b7a39a0819ecae6c4752fcc340ca5590630253d Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Wed, 22 Feb 2023 10:48:07 +0100 Subject: [PATCH 02/20] Fs: added options.readonly Also added new icons: - Icon inside toolbar input that shows current FS's icon - Lock icon when filesystem is readonly --- src/components/Statusbar.tsx | 16 +++++++++++++++- src/components/Toolbar/index.tsx | 4 ++-- src/css/main.css | 2 ++ src/locale/lang/en.json | 3 +++ src/locale/lang/fr.json | 3 +++ src/services/Fs.ts | 1 + src/services/plugins/FsFtp.ts | 1 + src/services/plugins/FsGeneric.ts | 1 + src/services/plugins/FsLocal.ts | 1 + src/services/plugins/FsVirtual.ts | 1 + src/services/plugins/FsWsl.ts | 1 + src/services/plugins/FsZip.ts | 1 + 12 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/components/Statusbar.tsx b/src/components/Statusbar.tsx index 4ff3593e..7807abe4 100644 --- a/src/components/Statusbar.tsx +++ b/src/components/Statusbar.tsx @@ -1,9 +1,10 @@ import * as React from 'react' -import { Button, Intent } from '@blueprintjs/core' +import { Button, Colors, Icon, Intent } from '@blueprintjs/core' import { IconNames } from '@blueprintjs/icons' import { Tooltip2 } from '@blueprintjs/popover2' import { observer } from 'mobx-react' import { useTranslation } from 'react-i18next' + import { useStores } from '$src/hooks/useStores' import { filterDirs, filterFiles } from '$src/utils/fileUtils' @@ -33,6 +34,7 @@ const Statusbar = observer(() => { const { t } = useTranslation() const fileCache = viewState.getVisibleCache() const { files, showHiddenFiles } = fileCache + const showReadOnlyIcon = fileCache.getFS().options.readonly const numDirs = filterDirs(files).length const numFiles = filterFiles(files).length @@ -48,6 +50,18 @@ const Statusbar = observer(() => { {`${t('STATUS.FILES', { count: numFiles })}, ${t('STATUS.FOLDERS', { count: numDirs, })}`} + {showReadOnlyIcon && ( +
+ +
+ )} ) }) diff --git a/src/components/Toolbar/index.tsx b/src/components/Toolbar/index.tsx index 5b02ba17..27142936 100644 --- a/src/components/Toolbar/index.tsx +++ b/src/components/Toolbar/index.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from 'react' import { observer } from 'mobx-react' -import { InputGroup, ControlGroup, Button, ButtonGroup, Intent, HotkeysTarget2, Classes } from '@blueprintjs/core' +import { InputGroup, ControlGroup, Button, ButtonGroup, Intent, HotkeysTarget2, Classes, Icon } from '@blueprintjs/core' import { IconName, IconNames } from '@blueprintjs/icons' import { Popover2 } from '@blueprintjs/popover2' import { useTranslation } from 'react-i18next' @@ -237,7 +237,7 @@ export const Toolbar = observer(({ active }: Props) => { onChange={onPathChange} onKeyUp={onKeyUp} placeholder={t('COMMON.PATH_PLACEHOLDER')} - leftIcon={cache.getFS().icon as IconName} + leftElement={} rightElement={reloadButton} value={path} inputRef={inputRef} diff --git a/src/css/main.css b/src/css/main.css index 497643db..e43630ff 100644 --- a/src/css/main.css +++ b/src/css/main.css @@ -359,6 +359,8 @@ body.bp4-dark .app-loader.active{ } .status-bar { + display: flex; + align-items: center; background: rgba(206, 217, 224, 0.5); border-top: 1px solid rgb(189, 195, 199); padding-top: 4px; diff --git a/src/locale/lang/en.json b/src/locale/lang/en.json index 1ff45531..e37df50a 100644 --- a/src/locale/lang/en.json +++ b/src/locale/lang/en.json @@ -262,6 +262,9 @@ "GO_BACK": "Back", "GO_FORWARD": "Forward", "TOGGLE_HIDDEN_FILES": "Show/Hide Hidden Files" + }, + "FS": { + "READONLY": "You are not allowed to alter this folder" } } } \ No newline at end of file diff --git a/src/locale/lang/fr.json b/src/locale/lang/fr.json index c5c8dba5..ab757097 100644 --- a/src/locale/lang/fr.json +++ b/src/locale/lang/fr.json @@ -262,6 +262,9 @@ "GO_BACK": "Précédent", "GO_FORWARD": "Suivant", "TOGGLE_HIDDEN_FILES": "Voir/Cacher Fichiers Cachés" + }, + "FS": { + "READONLY": "Vous ne pouvez pas modifier ce dossier" } } } \ No newline at end of file diff --git a/src/services/Fs.ts b/src/services/Fs.ts index 759023c9..5e99b678 100644 --- a/src/services/Fs.ts +++ b/src/services/Fs.ts @@ -40,6 +40,7 @@ export interface FileDescriptor { export interface FsOptions { needsRefresh: boolean + readonly: boolean } export interface Fs { diff --git a/src/services/plugins/FsFtp.ts b/src/services/plugins/FsFtp.ts index dc531c15..1846a203 100644 --- a/src/services/plugins/FsFtp.ts +++ b/src/services/plugins/FsFtp.ts @@ -763,6 +763,7 @@ export const FsFtp: Fs = { description: 'Fs that just implements fs over ftp', options: { needsRefresh: true, + readonly: false, }, canread(str: string): boolean { const info = new URL(str) diff --git a/src/services/plugins/FsGeneric.ts b/src/services/plugins/FsGeneric.ts index bc967351..35a639b7 100644 --- a/src/services/plugins/FsGeneric.ts +++ b/src/services/plugins/FsGeneric.ts @@ -144,6 +144,7 @@ export const FsGeneric: Fs = { description: 'Fs that just implements the FsInterface but does nothing', options: { needsRefresh: false, + readonly: false, }, canread(str: string): boolean { return true diff --git a/src/services/plugins/FsLocal.ts b/src/services/plugins/FsLocal.ts index 8b992c58..d0c7ec86 100644 --- a/src/services/plugins/FsLocal.ts +++ b/src/services/plugins/FsLocal.ts @@ -498,6 +498,7 @@ export const FsLocal: Fs = { description: 'Local Filesystem', options: { needsRefresh: false, + readonly: false, }, canread(str: string): boolean { return !!str.match(localStart) diff --git a/src/services/plugins/FsVirtual.ts b/src/services/plugins/FsVirtual.ts index 420cb307..c3f4c89a 100644 --- a/src/services/plugins/FsVirtual.ts +++ b/src/services/plugins/FsVirtual.ts @@ -492,6 +492,7 @@ export const FsVirtual: Fs = { description: 'Virtual Filesystem', options: { needsRefresh: false, + readonly: false, }, canread(str: string): boolean { return !!str.match(virtualStart) diff --git a/src/services/plugins/FsWsl.ts b/src/services/plugins/FsWsl.ts index fad57f55..df089dcc 100644 --- a/src/services/plugins/FsWsl.ts +++ b/src/services/plugins/FsWsl.ts @@ -122,6 +122,7 @@ export const FsWsl: Fs = { description: 'Local WSL Filesystem', options: { needsRefresh: false, + readonly: false, }, canread(str: string): boolean { return isWin && !!str.match(wslStart) diff --git a/src/services/plugins/FsZip.ts b/src/services/plugins/FsZip.ts index 941114e1..bc79c2ae 100644 --- a/src/services/plugins/FsZip.ts +++ b/src/services/plugins/FsZip.ts @@ -599,6 +599,7 @@ export const FsZip: Fs = { description: 'Zip Filesystem (Readonly)', options: { needsRefresh: false, + readonly: true, }, canread(str: string): boolean { return str.replace(/\/$/, '').split(/\.zip/gi).length === 2 From c67c887cfe330336af7ee3a0ff2b5d3875c5e3e1 Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Wed, 22 Feb 2023 15:47:28 +0100 Subject: [PATCH 03/20] Fs: added subPath parameter to getNewFs This is needed since we may switch to different Fs in the middle of a path. --- src/components/dialogs/PrefsDialog.tsx | 4 ++-- src/services/Fs.ts | 6 +++--- src/services/plugins/FsLocal.ts | 4 ++-- src/services/plugins/FsVirtual.ts | 4 ++-- src/services/plugins/FsWsl.ts | 4 ++-- src/services/plugins/FsZip.ts | 8 +++++--- src/state/fileState.ts | 21 ++++++++++++--------- 7 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/components/dialogs/PrefsDialog.tsx b/src/components/dialogs/PrefsDialog.tsx index d3abe34e..d472a762 100644 --- a/src/components/dialogs/PrefsDialog.tsx +++ b/src/components/dialogs/PrefsDialog.tsx @@ -41,11 +41,11 @@ const PrefsDialog = observer(({ isOpen, onClose }: PrefsProps) => { // TODO: we could have a default folder that's not using FsLocal const [isFolderValid, setIsFolderValid] = useState( - () => FsLocal.canread(defaultFolder) && FolderExists(defaultFolder), + () => FsLocal.canread(defaultFolder, '') && FolderExists(defaultFolder), ) const checkPath: (path: string) => void = debounce((path: string) => { - const isValid = FsLocal.canread(path) && FolderExists(path) + const isValid = FsLocal.canread(path, '') && FolderExists(path) if (path !== settingsState.defaultFolder) { setIsFolderValid(isValid) diff --git a/src/services/Fs.ts b/src/services/Fs.ts index 5e99b678..2fc26f08 100644 --- a/src/services/Fs.ts +++ b/src/services/Fs.ts @@ -47,7 +47,7 @@ export interface Fs { // runtime api API: new (path: string, onFsChange: (filename: string) => void) => FsApi // static members - canread(str: string): boolean + canread(basePath: string, subPath: string): boolean serverpart(str: string): string credentials(str: string): Credentials displaypath(str: string): { fullPath: string; shortPath: string } @@ -144,8 +144,8 @@ export interface FsApi { loginOptions: Credentials } -export function getFS(path: string): Fs { - const newfs = interfaces.find((filesystem) => filesystem.canread(path)) +export function getFS(basePath: string, subPath: string): Fs { + const newfs = interfaces.find((filesystem) => filesystem.canread(basePath, subPath)) // if (!newfs) { // newfs = FsGeneric; // ` diff --git a/src/services/plugins/FsLocal.ts b/src/services/plugins/FsLocal.ts index d0c7ec86..c0258956 100644 --- a/src/services/plugins/FsLocal.ts +++ b/src/services/plugins/FsLocal.ts @@ -500,8 +500,8 @@ export const FsLocal: Fs = { needsRefresh: false, readonly: false, }, - canread(str: string): boolean { - return !!str.match(localStart) + canread(basePath: string): boolean { + return !!basePath.match(localStart) }, serverpart(str: string): string { return 'local' diff --git a/src/services/plugins/FsVirtual.ts b/src/services/plugins/FsVirtual.ts index c3f4c89a..3a8205ce 100644 --- a/src/services/plugins/FsVirtual.ts +++ b/src/services/plugins/FsVirtual.ts @@ -494,8 +494,8 @@ export const FsVirtual: Fs = { needsRefresh: false, readonly: false, }, - canread(str: string): boolean { - return !!str.match(virtualStart) + canread(basePath: string): boolean { + return !!basePath.match(virtualStart) }, serverpart(str: string): string { return 'virtual' diff --git a/src/services/plugins/FsWsl.ts b/src/services/plugins/FsWsl.ts index df089dcc..9eb32dc8 100644 --- a/src/services/plugins/FsWsl.ts +++ b/src/services/plugins/FsWsl.ts @@ -124,8 +124,8 @@ export const FsWsl: Fs = { needsRefresh: false, readonly: false, }, - canread(str: string): boolean { - return isWin && !!str.match(wslStart) + canread(basePath: string): boolean { + return isWin && !!basePath.match(wslStart) }, // eslint-disable-next-line @typescript-eslint/no-unused-vars serverpart(str: string): string { diff --git a/src/services/plugins/FsZip.ts b/src/services/plugins/FsZip.ts index bc79c2ae..c3749335 100644 --- a/src/services/plugins/FsZip.ts +++ b/src/services/plugins/FsZip.ts @@ -129,7 +129,6 @@ export class ZipApi implements FsApi { this.path = '' this.onFsChange = onFsChange this.zip = new Zip(path) - debugger } // local fs doesn't require login @@ -601,8 +600,11 @@ export const FsZip: Fs = { needsRefresh: false, readonly: true, }, - canread(str: string): boolean { - return str.replace(/\/$/, '').split(/\.zip/gi).length === 2 + canread(basePath: string, subPath: string): boolean { + return ( + basePath.replace(/\/$/, '').split(/\.zip/gi).length === 2 && + (subPath !== '..' || !basePath.match(/\.zip$/i)) + ) }, serverpart(str: string): string { return 'zip' diff --git a/src/state/fileState.ts b/src/state/fileState.ts index 13b288cd..42926124 100644 --- a/src/state/fileState.ts +++ b/src/state/fileState.ts @@ -218,7 +218,7 @@ export class FileState { this.viewId = viewId this.path = path - this.getNewFS(path) + this.getNewFS(path, '') } private saveContext(): void { @@ -250,10 +250,12 @@ export class FileState { this.reload() } - private getNewFS(path: string, skipContext = false): Fs { - const newfs = getFS(path) + private getNewFS(path: string, newDir: string, skipContext = false): Fs { + const newfs = getFS(path, newDir) - if (newfs) { + // only create a new FS if supported FS is different + // than current one + if (!this.fs || newfs.name !== this.fs.name) { !skipContext && this.api && this.saveContext() // we need to free events in any case @@ -526,13 +528,15 @@ export class FileState { } cd(path: string, path2 = '', skipHistory = false, skipContext = false): Promise { - // first updates fs (eg. was local fs, is now ftp) - if (this.path !== path) { - if (this.getNewFS(path, skipContext)) { + // Since we may change Fs in the middle of a path (for example: + // path == '/foo/archive/zip', path2 == '..', fs == FsZip) + // In this particular case, going up a directory should + // switch to FsLocal. + if (this.path !== path || path2 === '..') { + if (this.getNewFS(path, path2, skipContext)) { this.server = this.fs.serverpart(path) this.credentials = this.fs.credentials(path) } else { - // this.navHistory(0); return Promise.reject({ message: i18n.i18next.t('ERRORS.CANNOT_READ_FOLDER', { folder: path }), code: 'NO_FS', @@ -659,7 +663,6 @@ export class FileState { } openDirectory(file: { dir: string; fullname: string }): Promise { - console.log(file.dir, file.fullname) return this.cd(file.dir, file.fullname) } From c2c9994586ce39fc959e16d1a5a5019f20f8eb3a Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Fri, 24 Feb 2023 10:38:04 +0100 Subject: [PATCH 04/20] FsZip: fixed open directory inside zip file Also fixed FileDescriptor.name which was containing full file path. --- src/services/plugins/FsZip.ts | 162 ++++++++++------------------------ src/state/fileState.ts | 2 +- 2 files changed, 48 insertions(+), 116 deletions(-) diff --git a/src/services/plugins/FsZip.ts b/src/services/plugins/FsZip.ts index c3749335..4dbb28ac 100644 --- a/src/services/plugins/FsZip.ts +++ b/src/services/plugins/FsZip.ts @@ -24,10 +24,14 @@ export const checkDirectoryName = (dirName: string) => !!!dirName.match(invalidD export interface ZipMethods { isZipRoot: (path: string) => boolean - getEntries: () => Promise + getEntries: (path: string) => Promise + getRelativePath: (path: string) => string + prepareEntries: () => Promise + getFileDescriptor: (entry: ZipEntry) => FileDescriptor + isDir: (path: string) => boolean } -export class Zip { +export class Zip implements ZipMethods { ready = false zip: StreamZipAsync zipEntries: ZipEntry[] @@ -35,9 +39,9 @@ export class Zip { zipFilename: string constructor(path: string) { - this.zip = new StreamZip.async({ file: path }) + this.zipPath = path.replace(/(?<=\.zip).*/i, '') + this.zip = new StreamZip.async({ file: this.zipPath }) this.zipEntries = [] - this.zipPath = path.replace(/(\/$)*/, '') this.zipFilename = '' } @@ -45,24 +49,39 @@ export class Zip { return true } - async getEntries(path: string) { + /** + * Get path relative to zip path + */ + getRelativePath(path: string) { + return path.replace(this.zipPath, '').replace(/^\//, '') + } + + async prepareEntries() { if (!this.ready) { const entries = await this.zip.entries() this.zipEntries = Object.values(entries) this.ready = true } + } + + async getEntries(path: string) { + const pathInZip = this.getRelativePath(path) + // const isZipRoot = !pathInZip.length - const pathInZip = path.replace(this.zipPath, '') - const isZipRoot = !!!pathInZip + // const longestPath = pathInZip.replace(/([^\/]*)$/, '') + const regExp = pathInZip.length ? new RegExp(`^${pathInZip}\/([^\/]+)[\/]?$`, 'g') : /^([^\/])*[\/]?$/g + return this.zipEntries.filter((entry) => !!entry.name.match(regExp)) + } - // TODO: get path inside zip - return this.zipEntries.filter((entry) => { - if (isZipRoot) { - const name = entry.name.replace(/(\/$)*/g, '') - // root: include files in root zip - return !name.match(/\//) - } - }) + isDir(path: string) { + const pathInZip = this.getRelativePath(path) + const longestPath = pathInZip.replace(/([^\/]*)$/, '') + + if (!longestPath.length) { + return true + } else { + return this.zipEntries.some((entry) => entry.isDirectory && !!entry.name.match(pathInZip)) + } } getFileDescriptor(entry: ZipEntry) { @@ -72,10 +91,9 @@ export class Zip { const mDate = new Date(entry.time) const mode = entry.attr ? ((entry.attr >>> 0) | 0) >> 16 : 0 - // TODO: build file const file = { dir: path.parse(`${this.zipPath}/${name}`).dir, - fullname: name, + fullname: parsed.base, name: parsed.name, extension, cDate: mDate, @@ -145,20 +163,27 @@ export class ZipApi implements FsApi { } resolve(newPath: string): string { - // TODO: // gh#44: replace ~ with userpath - debugger const dir = newPath.replace(/^~/, HOME_DIR) return path.resolve(dir) } async cd(path: string, transferId = -1): Promise { const resolvedPath = this.resolve(path) + + try { + await this.zip.prepareEntries() + } catch (e) { + console.error('error getting zip file entries', e) + throw { code: 'ENOTDIR' } + } + try { const isDir = await this.isDir(resolvedPath) if (isDir) { return resolvedPath } else { + debugger throw { code: 'ENOTDIR' } } } catch {} @@ -286,7 +311,7 @@ export class ZipApi implements FsApi { async isDir(path: string, transferId = -1): Promise { console.warn('TODO: FsZip.isDir => check that file inside dir is a directory') - return true + return this.zip.isDir(path) } async exists(path: string, transferId = -1): Promise { @@ -356,102 +381,9 @@ export class ZipApi implements FsApi { async list(dir: string, watchDir = false, transferId = -1): Promise { const entries = await this.zip.getEntries(dir) - // return [] - return entries.map((entry) => this.zip.getFileDescriptor(entry)) - debugger - throw 'TODO: FsZip.list not implemented' - // try { - // await this.isDir(dir) - // return new Promise((resolve, reject) => { - // vol.readdir(dir, (err, items) => { - // if (err) { - // reject(err) - // } else { - // const dirPath = path.resolve(dir) - - // const files: FileDescriptor[] = [] - - // for (let i = 0; i < items.length; i++) { - // const file = ZipApi.fileFromPath(path.join(dirPath, items[i] as string)) - // files.push(file) - // } - - // watchDir && this.onList(dirPath) - // resolve(files) - // } - // }) - // }) - // } catch (err) { - // throw { - // code: err.code, - // message: `Could not access path: ${dir}`, - // } - // } - } - - static fileFromPath(fullPath: string): FileDescriptor { - const format = path.parse(fullPath) - const name = fullPath - const stats: Partial = null - - debugger - - return { - name: `${fullPath}`, - } as FileDescriptor - // try { - // // do not follow symlinks first - // stats = vol.lstatSync(fullPath, { bigint: true }) - // if (stats.isSymbolicLink()) { - // // get link target path first - // name = vol.readlinkSync(fullPath) as string - // targetStats = vol.statSync(fullPath, { bigint: true }) - // } - // } catch (err) { - // console.warn('error getting stats for', fullPath, err) - - // const isDir = stats ? stats.isDirectory() : false - // const isSymLink = stats ? stats.isSymbolicLink() : false - - // stats = { - // ctime: new Date(), - // mtime: new Date(), - // birthtime: new Date(), - // size: stats ? stats.size : 0n, - // isDirectory: (): boolean => isDir, - // mode: -1n, - // isSymbolicLink: (): boolean => isSymLink, - // ino: 0n, - // dev: 0n, - // } - // } - - // const extension = path.parse(name).ext.toLowerCase() - // const mode = targetStats ? targetStats.mode : stats.mode - - // const file: FileDescriptor = { - // dir: format.dir, - // fullname: format.base, - // name: format.name, - // extension: extension, - // cDate: stats.ctime, - // mDate: stats.mtime, - // bDate: stats.birthtime, - // length: Number(stats.size), - // mode: Number(mode), - // isDir: targetStats ? targetStats.isDirectory() : stats.isDirectory(), - // readonly: false, - // type: - // (!(targetStats ? targetStats.isDirectory() : stats.isDirectory()) && - // filetype(Number(mode), 0, 0, extension)) || - // '', - // isSym: stats.isSymbolicLink(), - // target: (stats.isSymbolicLink() && name) || null, - // id: MakeId({ ino: stats.ino, dev: stats.dev }), - // } - - // return file + return entries.map((entry) => this.zip.getFileDescriptor(entry)) + // FIXME: what should we do about watch dir? } isRoot(path: string): boolean { diff --git a/src/state/fileState.ts b/src/state/fileState.ts index 42926124..968090fc 100644 --- a/src/state/fileState.ts +++ b/src/state/fileState.ts @@ -255,7 +255,7 @@ export class FileState { // only create a new FS if supported FS is different // than current one - if (!this.fs || newfs.name !== this.fs.name) { + if (!this.fs || (newfs && newfs.name !== this.fs.name)) { !skipContext && this.api && this.saveContext() // we need to free events in any case From 3d2c58a363b95b80fd35803851391a22d7b311a4 Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Fri, 24 Feb 2023 10:56:53 +0100 Subject: [PATCH 05/20] FsZip: fixed crash when opening a non-existing dir inside zip --- src/services/plugins/FsZip.ts | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/services/plugins/FsZip.ts b/src/services/plugins/FsZip.ts index 4dbb28ac..302c0461 100644 --- a/src/services/plugins/FsZip.ts +++ b/src/services/plugins/FsZip.ts @@ -75,13 +75,11 @@ export class Zip implements ZipMethods { isDir(path: string) { const pathInZip = this.getRelativePath(path) - const longestPath = pathInZip.replace(/([^\/]*)$/, '') - if (!longestPath.length) { - return true - } else { - return this.zipEntries.some((entry) => entry.isDirectory && !!entry.name.match(pathInZip)) - } + return ( + pathInZip.length === 0 || + this.zipEntries.some((entry) => entry.isDirectory && !!entry.name.match(pathInZip)) + ) } getFileDescriptor(entry: ZipEntry) { @@ -178,15 +176,13 @@ export class ZipApi implements FsApi { throw { code: 'ENOTDIR' } } - try { - const isDir = await this.isDir(resolvedPath) - if (isDir) { - return resolvedPath - } else { - debugger - throw { code: 'ENOTDIR' } - } - } catch {} + const isDir = await this.isDir(resolvedPath) + if (isDir) { + return resolvedPath + } else { + debugger + throw { code: 'ENOTDIR' } + } } size(source: string, files: string[], transferId = -1): Promise { @@ -310,7 +306,6 @@ export class ZipApi implements FsApi { } async isDir(path: string, transferId = -1): Promise { - console.warn('TODO: FsZip.isDir => check that file inside dir is a directory') return this.zip.isDir(path) } From e2536ad98c25433e3fe9dd9faaf45a83e977881e Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Mon, 27 Feb 2023 15:58:45 +0100 Subject: [PATCH 06/20] FsZip: fixed reading entries when dir is not listed Some zip have a specific entry for directories inside the archive: 'foo/' but some zips don't have it, so we have a manually add 'foo/' if the archive contains for example 'foo/bar'. Also added new 'indirect' option on Fs that will be used when opening a file needs first to uncompress (eg. zip archive) or transfer (eg. FTP) file before it can be opened by the OS. Last but not least, onFsChange was made optional since archive Fs don't have it (at least not in the initial version). --- src/components/FileView.tsx | 19 +++---- src/components/Toolbar/index.tsx | 6 ++- src/gui/index.tsx | 4 +- src/services/Fs.ts | 6 ++- src/services/plugins/FsFtp.ts | 1 + src/services/plugins/FsGeneric.ts | 1 + src/services/plugins/FsLocal.ts | 21 ++++---- src/services/plugins/FsVirtual.ts | 1 + src/services/plugins/FsWsl.ts | 1 + src/services/plugins/FsZip.ts | 70 ++++++++++++++++++++----- src/state/appState.tsx | 85 +++++++++++++++++++------------ src/state/fileState.ts | 7 +-- 12 files changed, 151 insertions(+), 71 deletions(-) diff --git a/src/components/FileView.tsx b/src/components/FileView.tsx index 7dee3ff2..3e31b63e 100644 --- a/src/components/FileView.tsx +++ b/src/components/FileView.tsx @@ -183,15 +183,16 @@ const FileView = observer(({ hide }: Props) => { } const openFileOrDirectory = (file: FileDescriptor, useInactiveCache: boolean): void => { - if (!file.isDir) { - cache.openFile(appState, file) - } else { - const dir = { - dir: cache.join(file.dir, file.fullname), - fullname: '', - } - appState.openDirectory(dir, !useInactiveCache) - } + // if (!file.isDir) { + // cache.openFile(appState, file) + // } else { + // // const dir = { + // // dir: cache.join(file.dir, file.fullname), + // // fullname: '', + // // } + // appState.openDirectory(file, !useInactiveCache) + // } + appState.openFileOrDirectory(file, useInactiveCache) } const onOpenFile = (e: KeyboardEvent): void => { diff --git a/src/components/Toolbar/index.tsx b/src/components/Toolbar/index.tsx index 27142936..b0a7b752 100644 --- a/src/components/Toolbar/index.tsx +++ b/src/components/Toolbar/index.tsx @@ -58,7 +58,11 @@ export const Toolbar = observer(({ active }: Props) => { const onSubmit = async (shouldSelectTextOnError = false): Promise => { if (cache.path !== path) { try { - await cache.cd(path) + // await cache.cd(path) + await appState.openDirectory({ + dir: path, + fullname: '', + }) inputRef.current.blur() } catch (e) { const err = e as LocalizedError diff --git a/src/gui/index.tsx b/src/gui/index.tsx index b876b21c..1c4a5602 100644 --- a/src/gui/index.tsx +++ b/src/gui/index.tsx @@ -22,10 +22,10 @@ configure({ }) const bootstrap = async () => { - const appState = new AppState() - initFS() + const appState = new AppState() + await appState.loadSettingsAndPrepareViews() await i18n.promise diff --git a/src/services/Fs.ts b/src/services/Fs.ts index 2fc26f08..696b5db9 100644 --- a/src/services/Fs.ts +++ b/src/services/Fs.ts @@ -41,11 +41,15 @@ export interface FileDescriptor { export interface FsOptions { needsRefresh: boolean readonly: boolean + // do we have direct access to files + // or do we need to first copy them to local fs + // to open them (like in zip, remote,...) + indirect: boolean } export interface Fs { // runtime api - API: new (path: string, onFsChange: (filename: string) => void) => FsApi + API: new (path: string, onFsChange?: (filename: string) => void) => FsApi // static members canread(basePath: string, subPath: string): boolean serverpart(str: string): string diff --git a/src/services/plugins/FsFtp.ts b/src/services/plugins/FsFtp.ts index 1846a203..aae9030b 100644 --- a/src/services/plugins/FsFtp.ts +++ b/src/services/plugins/FsFtp.ts @@ -764,6 +764,7 @@ export const FsFtp: Fs = { options: { needsRefresh: true, readonly: false, + indirect: true, }, canread(str: string): boolean { const info = new URL(str) diff --git a/src/services/plugins/FsGeneric.ts b/src/services/plugins/FsGeneric.ts index 35a639b7..1e91120a 100644 --- a/src/services/plugins/FsGeneric.ts +++ b/src/services/plugins/FsGeneric.ts @@ -145,6 +145,7 @@ export const FsGeneric: Fs = { options: { needsRefresh: false, readonly: false, + indirect: false, }, canread(str: string): boolean { return true diff --git a/src/services/plugins/FsLocal.ts b/src/services/plugins/FsLocal.ts index c0258956..e959f712 100644 --- a/src/services/plugins/FsLocal.ts +++ b/src/services/plugins/FsLocal.ts @@ -31,9 +31,9 @@ export class LocalApi implements FsApi { // current path path: string loginOptions: Credentials = null - onFsChange: (filename: string) => void + onFsChange?: (filename: string) => void - constructor(_: string, onFsChange: (filename: string) => void) { + constructor(_: string, onFsChange?: (filename: string) => void) { this.path = '' this.onFsChange = onFsChange } @@ -242,14 +242,14 @@ export class LocalApi implements FsApi { onList(dir: string): void { if (dir !== this.path) { - // console.log('stopWatching', this.path) - try { - LocalWatch.stopWatchingPath(this.path, this.onFsChange) - LocalWatch.watchPath(dir, this.onFsChange) - } catch (e) { - console.warn('Could not watch path', dir, e) + if (this.onFsChange) { + try { + LocalWatch.stopWatchingPath(this.path, this.onFsChange) + LocalWatch.watchPath(dir, this.onFsChange) + } catch (e) { + console.warn('Could not watch path', dir, e) + } } - // console.log('watchPath', dir) this.path = dir } } @@ -354,7 +354,7 @@ export class LocalApi implements FsApi { off(): void { // console.log("off", this.path) // console.log("stopWatchingPath", this.path) - LocalWatch.stopWatchingPath(this.path, this.onFsChange) + this.onFsChange && LocalWatch.stopWatchingPath(this.path, this.onFsChange) } // TODO add error handling @@ -499,6 +499,7 @@ export const FsLocal: Fs = { options: { needsRefresh: false, readonly: false, + indirect: false, }, canread(basePath: string): boolean { return !!basePath.match(localStart) diff --git a/src/services/plugins/FsVirtual.ts b/src/services/plugins/FsVirtual.ts index 3a8205ce..1ff15b42 100644 --- a/src/services/plugins/FsVirtual.ts +++ b/src/services/plugins/FsVirtual.ts @@ -493,6 +493,7 @@ export const FsVirtual: Fs = { options: { needsRefresh: false, readonly: false, + indirect: false, }, canread(basePath: string): boolean { return !!basePath.match(virtualStart) diff --git a/src/services/plugins/FsWsl.ts b/src/services/plugins/FsWsl.ts index 9eb32dc8..2cc834d0 100644 --- a/src/services/plugins/FsWsl.ts +++ b/src/services/plugins/FsWsl.ts @@ -123,6 +123,7 @@ export const FsWsl: Fs = { options: { needsRefresh: false, readonly: false, + indirect: false, }, canread(basePath: string): boolean { return isWin && !!basePath.match(wslStart) diff --git a/src/services/plugins/FsZip.ts b/src/services/plugins/FsZip.ts index 302c0461..e045d6ce 100644 --- a/src/services/plugins/FsZip.ts +++ b/src/services/plugins/FsZip.ts @@ -66,20 +66,56 @@ export class Zip implements ZipMethods { async getEntries(path: string) { const pathInZip = this.getRelativePath(path) - // const isZipRoot = !pathInZip.length - // const longestPath = pathInZip.replace(/([^\/]*)$/, '') - const regExp = pathInZip.length ? new RegExp(`^${pathInZip}\/([^\/]+)[\/]?$`, 'g') : /^([^\/])*[\/]?$/g - return this.zipEntries.filter((entry) => !!entry.name.match(regExp)) + // const regExp = pathInZip.length ? new RegExp(`^${pathInZip}\/([^\/]+)[\/]?$`, 'g') : /^([^\/])*[\/]?$/g + const dirsInRoot: string[] = [] + const entries: ZipEntry[] = [] + const dirPos = !pathInZip.length ? 0 : pathInZip.split('/').length + this.zipEntries.forEach((entry) => { + const { name } = entry + if (name.startsWith(pathInZip)) { + // const paths = pathInZip.length ? name.replace(new RegExp(`${pathInZip}[^\/]?`, 'g'), '').split('/') : name.split('/') + const paths = name.split('/') + const dir = paths[dirPos] + // do not add current path or already added path to the list + if (dirsInRoot.indexOf(dir) !== -1 || !dir.length) return + + if (paths.length === dirPos + 1) { + entries.push(entry) + } else { + dirsInRoot.push(dir) + entries.push(this.getFakeDirDescriptor(dir)) + } + console.log(dir, paths) + } + }) + console.log(entries) + debugger + return entries } isDir(path: string) { const pathInZip = this.getRelativePath(path) - return ( - pathInZip.length === 0 || - this.zipEntries.some((entry) => entry.isDirectory && !!entry.name.match(pathInZip)) - ) + debugger + + // will match 'pathInZip/' & 'pathInZip/foo': even though the second one is not necessarily + // a directory, it means that pathInZip is itself a directory. + return pathInZip.length === 0 || this.zipEntries.some((entry) => !!entry.name.match(`${pathInZip}/`)) + } + + getFakeDirDescriptor(name: string): ZipEntry { + const time = new Date().getTime() + return { + name, + size: 0, + attr: 0, + isDirectory: true, + time, + crc: time, + offset: time, + headerOffset: time, + } as any } getFileDescriptor(entry: ZipEntry) { @@ -172,15 +208,16 @@ export class ZipApi implements FsApi { try { await this.zip.prepareEntries() } catch (e) { + debugger console.error('error getting zip file entries', e) throw { code: 'ENOTDIR' } } + debugger const isDir = await this.isDir(resolvedPath) if (isDir) { return resolvedPath } else { - debugger throw { code: 'ENOTDIR' } } } @@ -526,12 +563,19 @@ export const FsZip: Fs = { options: { needsRefresh: false, readonly: true, + indirect: true, }, canread(basePath: string, subPath: string): boolean { - return ( - basePath.replace(/\/$/, '').split(/\.zip/gi).length === 2 && - (subPath !== '..' || !basePath.match(/\.zip$/i)) - ) + // console.log(basePath.replace(/\/$/, '').split(/\.zip/gi).length, (subPath !== '..' || !basePath.match(/\.zip$/i))) + // debugger + // return ( + // basePath.replace(/\/$/, '').split(/\.zip/gi).length === 2 && + // (subPath !== '..' || !basePath.match(/\.zip$/i)) + // ) + const fullPath = path.join(basePath, subPath) + const matches = fullPath.match(/\.zip/gi) + + return matches && matches.length === 1 }, serverpart(str: string): string { return 'zip' diff --git a/src/state/appState.tsx b/src/state/appState.tsx index 5fea8cba..4f64539a 100644 --- a/src/state/appState.tsx +++ b/src/state/appState.tsx @@ -4,7 +4,7 @@ import { action, observable, computed, makeObservable } from 'mobx' import type { TFunction } from 'i18next' import { shell } from 'electron' -import { FileDescriptor, sameID } from '$src/services/Fs' +import { FileDescriptor, FsApi, getFS, sameID } from '$src/services/Fs' import { FileState } from '$src/state/fileState' import { TransferOptions } from '$src/state/transferState' import { ViewDescriptor } from '$src/types' @@ -21,6 +21,7 @@ import { TransferListState } from '$src/state/transferListState' import { DraggedObject } from '$src/types' import { SettingsState } from './settingsState' import { CustomSettings } from '$src/electron/windowSettings' +import { DOWNLOADS_DIR } from '$src/electron/osSupport' // wait 1 sec before showing badge: this avoids // flashing (1) badge when the transfer is very fast @@ -57,6 +58,9 @@ export class AppState { // reference to current i18n's instance translate function t: TFunction + localFsApi: FsApi + localFsName: string + constructor() { makeObservable(this, { isExplorer: observable, @@ -68,6 +72,7 @@ export class AppState { refreshActiveView: action, addView: action, openDirectory: action, + openFileOrDirectory: action, options: observable, togglePrefsDialog: action, toggleShortcutsDialog: action, @@ -75,6 +80,9 @@ export class AppState { }) this.t = i18n.i18next.t + const { API, name } = getFS(DOWNLOADS_DIR, '') + this.localFsApi = new API(DOWNLOADS_DIR) + this.localFsName = name } async loadSettingsAndPrepareViews() { @@ -179,11 +187,23 @@ export class AppState { } } - openDirectory(file: { dir: string; fullname: string }, sameView = true) { + /** + * Attempts to browse directory, falls back to opening the file otherwise + */ + async openFileOrDirectory(file: FileDescriptor, useInactiveCache: boolean) { + try { + await this.openDirectory(file, !useInactiveCache) + } catch (e) { + const cache = this.getActiveCache() + cache.openFile(this, file) + } + } + + openDirectory(file: Pick, sameView = true) { if (sameView) { const activeCache = this.getActiveCache() if (activeCache && activeCache.status === 'ok') { - activeCache.openDirectory(file) + return activeCache.openDirectory(file) } } else { const winState = this.winStates[0] @@ -292,32 +312,31 @@ export class AppState { * * @returns {Promise} */ - prepareLocalTransfer(srcCache: FileState, files: FileDescriptor[]): Promise { + async prepareLocalTransfer(srcCache: FileState, files: FileDescriptor[]): Promise { if (!files.length) { - return Promise.resolve('') + return '' } // Simply open the file if src is a local FS - if (srcCache.getFS().name.startsWith('local')) { + if (!srcCache.getFS().options.indirect) { const api = srcCache.getAPI() - return Promise.resolve(api.join(files[0].dir, files[0].fullname)) + return api.join(files[0].dir, files[0].fullname) } else { console.error('TODO: prepareLocalTransfer for non-local FS') - // TODO once we support non local FS - + const options = { + files, + srcFs: srcCache.getAPI(), + srcPath: srcCache.path, + dstFs: this.localFsApi, + dstPath: DOWNLOADS_DIR, + dstFsName: this.localFsName, + } + debugger // first we need to get a FS for local // const fs = getFS(DOWNLOADS_DIR) // const api = new fs.API(DOWNLOADS_DIR, () => { // // // }) - // const options = { - // files, - // srcFs: srcCache.getAPI(), - // srcPath: srcCache.path, - // dstFs: api, - // dstPath: DOWNLOADS_DIR, - // dstFsName: fs.name, - // } // // TODO: use a temporary filename for destination file? // return this.addTransfer(options) @@ -364,17 +383,18 @@ export class AppState { return view.caches } - async copy(options: TransferOptions) { + async copy(options: TransferOptions, silent = false) { try { const transfer = await this.transferListState.addTransfer(options) await transfer.start() - AppToaster.show({ - message: this.t('COMMON.COPY_FINISHED'), - icon: 'tick', - intent: Intent.SUCCESS, - timeout: SUCCESS_COPY_TIMEOUT, - }) + !silent && + AppToaster.show({ + message: this.t('COMMON.COPY_FINISHED'), + icon: 'tick', + intent: Intent.SUCCESS, + timeout: SUCCESS_COPY_TIMEOUT, + }) // get visible caches: the ones that are not visible will be automatically refreshed // when set visible @@ -387,14 +407,15 @@ export class AppState { debugger console.log(err, err.files) const successCount = err.files - err.errors - AppToaster.show({ - message: this.t('COMMON.COPY_WARNING', { - count: successCount, - }), - icon: 'warning-sign', - intent: !successCount ? Intent.DANGER : Intent.WARNING, - timeout: ERROR_MESSAGE_TIMEOUT, - }) + !silent && + AppToaster.show({ + message: this.t('COMMON.COPY_WARNING', { + count: successCount, + }), + icon: 'warning-sign', + intent: !successCount ? Intent.DANGER : Intent.WARNING, + timeout: ERROR_MESSAGE_TIMEOUT, + }) } else { // TODOCOPY debugger diff --git a/src/state/fileState.ts b/src/state/fileState.ts index 968090fc..e67caf66 100644 --- a/src/state/fileState.ts +++ b/src/state/fileState.ts @@ -1,6 +1,7 @@ import { observable, action, runInAction, makeObservable } from 'mobx' import { shell, ipcRenderer } from 'electron' +import * as nodePath from 'path' import { FsApi, Fs, getFS, FileDescriptor, Credentials, withConnection, FileID, sameID } from '$src/services/Fs' import { Deferred } from '$src/utils/deferred' import { i18n } from '$src/locale/i18n' @@ -261,7 +262,7 @@ export class FileState { // we need to free events in any case this.freeFsEvents() this.fs = newfs - this.api = new newfs.API(path, this.onFSChange) + this.api = new newfs.API(nodePath.join(path, newDir), this.onFSChange) this.bindFsEvents() } @@ -532,7 +533,7 @@ export class FileState { // path == '/foo/archive/zip', path2 == '..', fs == FsZip) // In this particular case, going up a directory should // switch to FsLocal. - if (this.path !== path || path2 === '..') { + if (this.path !== path || path2.length) { if (this.getNewFS(path, path2, skipContext)) { this.server = this.fs.serverpart(path) this.credentials = this.fs.credentials(path) @@ -662,7 +663,7 @@ export class FileState { } } - openDirectory(file: { dir: string; fullname: string }): Promise { + openDirectory(file: Pick): Promise { return this.cd(file.dir, file.fullname) } From 823133879f5567cc05239dcb352b65177b59b488 Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Tue, 28 Feb 2023 10:29:29 +0100 Subject: [PATCH 07/20] Fs: prevent rename/delete/paste/drop on a readonly filesystem --- src/components/FileView.tsx | 4 ++++ src/components/SideView.tsx | 7 ++++++- src/components/Toolbar/index.tsx | 2 +- src/services/plugins/FsZip.ts | 2 +- src/state/appState.tsx | 7 +++++-- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/components/FileView.tsx b/src/components/FileView.tsx index 3e31b63e..88d30b66 100644 --- a/src/components/FileView.tsx +++ b/src/components/FileView.tsx @@ -163,6 +163,10 @@ const FileView = observer(({ hide }: Props) => { } const onInlineEdit = ({ action, data }: InlineEditEvent) => { + if (cache.getFS().options.readonly) { + return + } + switch (action) { case 'validate': appState.renameEditingFile(cache, data as string) diff --git a/src/components/SideView.tsx b/src/components/SideView.tsx index ac55c81a..0a04f617 100644 --- a/src/components/SideView.tsx +++ b/src/components/SideView.tsx @@ -27,7 +27,12 @@ export const SideView = observer(({ hide, viewState }: SideViewProps) => { canDrop: ({ fileState }: DraggedObject) => { const sourceViewId = fileState.viewId const fileCache = viewState.getVisibleCache() - return viewState.viewId !== sourceViewId && fileCache.status !== 'busy' && !fileCache.error + return ( + viewState.viewId !== sourceViewId && + fileCache.status !== 'busy' && + !fileCache.error && + !fileCache.getFS().options.readonly + ) }, drop: (item) => appState.drop(item, viewState.getVisibleCache()), collect: (monitor) => ({ diff --git a/src/components/Toolbar/index.tsx b/src/components/Toolbar/index.tsx index b0a7b752..eb85958e 100644 --- a/src/components/Toolbar/index.tsx +++ b/src/components/Toolbar/index.tsx @@ -226,7 +226,7 @@ export const Toolbar = observer(({ active }: Props) => { diff --git a/src/services/plugins/FsZip.ts b/src/services/plugins/FsZip.ts index e045d6ce..3865a1fe 100644 --- a/src/services/plugins/FsZip.ts +++ b/src/services/plugins/FsZip.ts @@ -435,7 +435,7 @@ export class ZipApi implements FsApi { // console.log('FsVirtual.getStream error', err) // return Promise.reject(err) // } - return Promise.reject('TODO: getStream') + return Promise.reject('TODO: FsZip.getStream') } putStream( diff --git a/src/state/appState.tsx b/src/state/appState.tsx index 4f64539a..3b4cdff2 100644 --- a/src/state/appState.tsx +++ b/src/state/appState.tsx @@ -160,7 +160,7 @@ export class AppState { } paste(destCache: FileState): void { - if (destCache && !destCache.error && this.clipboard.files.length) { + if (destCache && !destCache.error && !destCache.getFS().options.readonly && this.clipboard.files.length) { const options = { files: this.clipboard.files, srcFs: this.clipboard.srcFs, @@ -170,6 +170,8 @@ export class AppState { dstFsName: destCache.getFS().name, } this.copy(options) + } else { + shell.beep() } } @@ -250,7 +252,8 @@ export class AppState { const cache = this.getActiveCache() const toDelete = files || cache.selected - if (!toDelete.length) { + if (!toDelete.length || cache.getFS().options.readonly) { + shell.beep() return } From 162d818168185ac0652473562bdda370a2719254 Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Tue, 28 Feb 2023 10:54:44 +0100 Subject: [PATCH 08/20] FsZip: close zip when exiting Fs --- src/services/plugins/FsZip.ts | 30 ++++++------------------------ 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/src/services/plugins/FsZip.ts b/src/services/plugins/FsZip.ts index 3865a1fe..5f66938f 100644 --- a/src/services/plugins/FsZip.ts +++ b/src/services/plugins/FsZip.ts @@ -23,12 +23,12 @@ const progressFunc = throttle((progress: (bytes: number) => void, bytesRead: num export const checkDirectoryName = (dirName: string) => !!!dirName.match(invalidDirChars) && dirName !== '/' export interface ZipMethods { - isZipRoot: (path: string) => boolean getEntries: (path: string) => Promise getRelativePath: (path: string) => string prepareEntries: () => Promise getFileDescriptor: (entry: ZipEntry) => FileDescriptor isDir: (path: string) => boolean + close(): void } export class Zip implements ZipMethods { @@ -45,8 +45,8 @@ export class Zip implements ZipMethods { this.zipFilename = '' } - isZipRoot(path: string) { - return true + close() { + this.zip.close() } /** @@ -148,24 +148,6 @@ export class Zip implements ZipMethods { } as FileDescriptor return file - // dir: format.dir, - // fullname: format.base, - // name: format.name, - // extension: format.ext.toLowerCase(), - // cDate: stats.ctime, - // mDate: stats.mtime, - // bDate: stats.birthtime, - // length: Number(stats.size), - // mode: Number(stats.mode), - // isDir: stats.isDirectory(), - // readonly: false, - // type: - // (!stats.isDirectory() && - // filetype(Number(stats.mode), Number(stats.gid), Number(stats.uid), format.ext.toLowerCase())) || - // '', - // isSym: stats.isSymbolicLink(), - // target: (stats.isSymbolicLink() && vol.readlinkSync(fullPath)) || null, - // id: MakeId({ ino: stats.ino, dev: stats.dev }), } } @@ -262,10 +244,10 @@ export class ZipApi implements FsApi { throw 'TODO: FsZip.makedir' } - delete(source: string, files: FileDescriptor[], transferId = -1): Promise { + async delete(source: string, files: FileDescriptor[], transferId = -1): Promise { const toDelete = files.map((file) => path.join(source, file.fullname)) - return Promise.reject('TODO: FsZip.delete not implemented!') + throw 'TODO: FsZip.delete not implemented!' // return new Promise(async (resolve, reject) => { // try { // const deleted = await del(toDelete, { @@ -423,7 +405,7 @@ export class ZipApi implements FsApi { } off(): void { - // + this.zip.close() } // TODO add error handling From 4310d4904f68df287c2200015e035672b43105d1 Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Tue, 28 Feb 2023 14:26:17 +0100 Subject: [PATCH 09/20] FsZip: implement getStream --- .../services/plugins/FsSimpleFtp.ts | 6 ++-- src/services/Fs.ts | 6 ++-- src/services/plugins/FsZip.ts | 31 ++++++------------- src/state/appState.tsx | 28 +++++++++++++++-- src/state/transferState.ts | 9 +++--- 5 files changed, 45 insertions(+), 35 deletions(-) diff --git a/src-experimental/services/plugins/FsSimpleFtp.ts b/src-experimental/services/plugins/FsSimpleFtp.ts index 07af9553..2d6d150e 100644 --- a/src-experimental/services/plugins/FsSimpleFtp.ts +++ b/src-experimental/services/plugins/FsSimpleFtp.ts @@ -5,7 +5,7 @@ import * as fs from 'fs' import { Transform, Readable, Writable } from 'stream' import { EventEmitter } from 'events' import * as nodePath from 'path' -import { isWin } from '../../utils/platform' +import { isWin } from '$src/utils/platform' function serverPart(str: string, lowerCase = true): string { const info = new URL(str) @@ -192,7 +192,7 @@ class Client { } @canTimeout - getStream(path: string, writeStream: Writable): Promise { + getStream(path: string, writeStream: Writable): Promise { this.ftpClient.download(writeStream, path) return Promise.resolve(this.ftpClient.ftp.dataSocket) } @@ -418,7 +418,7 @@ class SimpleFtpApi implements FsApi { } } - async getStream(path: string, file: string, transferId = -1): Promise { + async getStream(path: string, file: string, transferId = -1): Promise { try { // create a duplex stream const transform = new Transform({ diff --git a/src/services/Fs.ts b/src/services/Fs.ts index 696b5db9..efc92119 100644 --- a/src/services/Fs.ts +++ b/src/services/Fs.ts @@ -1,6 +1,4 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { Readable } from 'stream' - import { isWin } from '$src/utils/platform' const interfaces: Array = [] @@ -127,9 +125,9 @@ export interface FsApi { exists(path: string, transferId?: number): Promise size(source: string, files: string[], transferId?: number): Promise makeSymlink(targetPath: string, path: string, transferId?: number): Promise - getStream(path: string, file: string, transferId?: number): Promise + getStream(path: string, file: string, transferId?: number): Promise putStream( - readStream: Readable, + readStream: NodeJS.ReadableStream, dstPath: string, progress: (bytesRead: number) => void, transferId?: number, diff --git a/src/services/plugins/FsZip.ts b/src/services/plugins/FsZip.ts index 5f66938f..63e87b97 100644 --- a/src/services/plugins/FsZip.ts +++ b/src/services/plugins/FsZip.ts @@ -27,6 +27,7 @@ export interface ZipMethods { getRelativePath: (path: string) => string prepareEntries: () => Promise getFileDescriptor: (entry: ZipEntry) => FileDescriptor + getFileStream: (path: string) => any isDir: (path: string) => boolean close(): void } @@ -90,15 +91,13 @@ export class Zip implements ZipMethods { } }) console.log(entries) - debugger + return entries } isDir(path: string) { const pathInZip = this.getRelativePath(path) - debugger - // will match 'pathInZip/' & 'pathInZip/foo': even though the second one is not necessarily // a directory, it means that pathInZip is itself a directory. return pathInZip.length === 0 || this.zipEntries.some((entry) => !!entry.name.match(`${pathInZip}/`)) @@ -149,6 +148,11 @@ export class Zip implements ZipMethods { return file } + + getFileStream(path: string): Promise { + const relativePath = this.getRelativePath(path) + return this.zip.stream(relativePath) + } } export class ZipApi implements FsApi { @@ -194,7 +198,6 @@ export class ZipApi implements FsApi { console.error('error getting zip file entries', e) throw { code: 'ENOTDIR' } } - debugger const isDir = await this.isDir(resolvedPath) if (isDir) { @@ -408,20 +411,12 @@ export class ZipApi implements FsApi { this.zip.close() } - // TODO add error handling - async getStream(path: string, file: string, transferId = -1): Promise { - // try { - // const stream = fs.createReadStream(this.join(path, file)) - // return Promise.resolve(stream) - // } catch (err) { - // console.log('FsVirtual.getStream error', err) - // return Promise.reject(err) - // } - return Promise.reject('TODO: FsZip.getStream') + getStream(path: string, file: string, transferId = -1): Promise { + return this.zip.getFileStream(this.join(path, file)) } putStream( - readStream: ReadStream, + readStream: NodeJS.ReadableStream, dstPath: string, progress: (bytes: number) => void, transferId = -1, @@ -548,12 +543,6 @@ export const FsZip: Fs = { indirect: true, }, canread(basePath: string, subPath: string): boolean { - // console.log(basePath.replace(/\/$/, '').split(/\.zip/gi).length, (subPath !== '..' || !basePath.match(/\.zip$/i))) - // debugger - // return ( - // basePath.replace(/\/$/, '').split(/\.zip/gi).length === 2 && - // (subPath !== '..' || !basePath.match(/\.zip$/i)) - // ) const fullPath = path.join(basePath, subPath) const matches = fullPath.match(/\.zip/gi) diff --git a/src/state/appState.tsx b/src/state/appState.tsx index 3b4cdff2..d0a32c80 100644 --- a/src/state/appState.tsx +++ b/src/state/appState.tsx @@ -320,12 +320,13 @@ export class AppState { return '' } + const { dir, fullname } = files[0] + // Simply open the file if src is a local FS if (!srcCache.getFS().options.indirect) { const api = srcCache.getAPI() - return api.join(files[0].dir, files[0].fullname) + return api.join(dir, fullname) } else { - console.error('TODO: prepareLocalTransfer for non-local FS') const options = { files, srcFs: srcCache.getAPI(), @@ -334,7 +335,28 @@ export class AppState { dstPath: DOWNLOADS_DIR, dstFsName: this.localFsName, } - debugger + + try { + debugger + const transfer = await this.transferListState.addTransfer(options) + await transfer.start() + debugger + return this.localFsApi.join(DOWNLOADS_DIR, fullname) + } catch (e) { + debugger + console.log('oops error copying file') + // TODO: add a new error for failed transfers + throw { + code: 'SHELL_OPEN_FAILED', + } + } + // return this.addTransfer(options) + // .then(() => { + // return api.join(DOWNLOADS_DIR, files[0].fullname) + // }) + // .catch((err) => { + // return Promise.reject(err) + // }) // first we need to get a FS for local // const fs = getFS(DOWNLOADS_DIR) // const api = new fs.API(DOWNLOADS_DIR, () => { diff --git a/src/state/transferState.ts b/src/state/transferState.ts index 37da64bb..3ae870fb 100644 --- a/src/state/transferState.ts +++ b/src/state/transferState.ts @@ -1,5 +1,4 @@ import { observable, action, computed, makeObservable, runInAction } from 'mobx' -import type { Readable } from 'stream' import { FsApi, FileDescriptor } from '$src/services/Fs' import { Deferred } from '$src/utils/deferred' @@ -53,7 +52,7 @@ export class TransferState { public elements = observable([]) - public streams = new Array() + public streams = new Array() public status: Status = 'queued' @@ -199,7 +198,7 @@ export class TransferState { this.errors++ } - removeStream(stream: Readable): void { + removeStream(stream: NodeJS.ReadableStream): void { const index = this.streams.findIndex((item) => item === stream) if (index > -1) { this.streams.splice(index, 1) @@ -414,7 +413,9 @@ export class TransferState { destroyRunningStreams(): void { for (const stream of this.streams) { - stream.destroy() + // stream.destroy() + // stream.close() + console.warn('need to detroy stream') } } From 08ec18bd607af090feea8ea3b60da53a05e5848f Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Tue, 28 Feb 2023 14:49:11 +0100 Subject: [PATCH 10/20] FsZip: fixed directory entry path in sub directory When entering a zip subdirectory, the FileDescriptor's dir was missing the current directory relative to the zip. --- src/services/plugins/FsZip.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/services/plugins/FsZip.ts b/src/services/plugins/FsZip.ts index 63e87b97..dc265d05 100644 --- a/src/services/plugins/FsZip.ts +++ b/src/services/plugins/FsZip.ts @@ -85,9 +85,8 @@ export class Zip implements ZipMethods { entries.push(entry) } else { dirsInRoot.push(dir) - entries.push(this.getFakeDirDescriptor(dir)) + entries.push(this.getFakeDirDescriptor(pathInZip.length ? `${pathInZip}/${dir}` : dir)) } - console.log(dir, paths) } }) console.log(entries) @@ -398,9 +397,9 @@ export class ZipApi implements FsApi { async list(dir: string, watchDir = false, transferId = -1): Promise { const entries = await this.zip.getEntries(dir) - - return entries.map((entry) => this.zip.getFileDescriptor(entry)) - // FIXME: what should we do about watch dir? + const list = entries.map((entry) => this.zip.getFileDescriptor(entry)) + console.log(list) + return list } isRoot(path: string): boolean { From 52d37f4f7b316eb9957d680113cefa23b5b8d7d5 Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Tue, 28 Feb 2023 14:59:30 +0100 Subject: [PATCH 11/20] Tools: added zlib polyfill for e2e builds --- e2e/webpack.config.e2e.ts | 1 + package-lock.json | 19 +++++++++++++++++++ package.json | 1 + 3 files changed, 21 insertions(+) diff --git a/e2e/webpack.config.e2e.ts b/e2e/webpack.config.e2e.ts index 4d77fd0f..08a121f9 100644 --- a/e2e/webpack.config.e2e.ts +++ b/e2e/webpack.config.e2e.ts @@ -77,6 +77,7 @@ const baseConfig = { assert: require.resolve('assert/'), util: require.resolve('util/'), path: require.resolve('path-browserify'), + zlib: require.resolve('browserify-zlib'), }, }, module: { diff --git a/package-lock.json b/package-lock.json index 7174a9e3..597fe7ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", "aws-sdk": "^2.514.0", + "browserify-zlib": "^0.2.0", "clean-webpack-plugin": "^4.0.0", "copy-webpack-plugin": "^11.0.0", "css-loader": "^6.7.1", @@ -3943,6 +3944,15 @@ "node": ">=8" } }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dev": true, + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.21.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz", @@ -18255,6 +18265,15 @@ "fill-range": "^7.0.1" } }, + "browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dev": true, + "requires": { + "pako": "~1.0.5" + } + }, "browserslist": { "version": "4.21.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz", diff --git a/package.json b/package.json index 6279d0a0..6aff9e08 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", "aws-sdk": "^2.514.0", + "browserify-zlib": "^0.2.0", "clean-webpack-plugin": "^4.0.0", "copy-webpack-plugin": "^11.0.0", "css-loader": "^6.7.1", From 8b08fe3473cdf334b731aa4982bf1d158b16a773 Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Tue, 28 Feb 2023 15:11:10 +0100 Subject: [PATCH 12/20] Tools: fix process mock typo --- e2e/cypress/mocks/process.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/cypress/mocks/process.js b/e2e/cypress/mocks/process.js index 882acfa5..6f2b4334 100644 --- a/e2e/cypress/mocks/process.js +++ b/e2e/cypress/mocks/process.js @@ -2,7 +2,7 @@ module.exports = { process: 'React-Explorer', platform: __PLATFORM__, version: __NODE__, - verisons: { + versions: { chrome: 'cypress chrome', electron: 'cypress electron', }, From 44b1c5e92cf21d81a706549a063ac6227ddcfd31 Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Tue, 28 Feb 2023 15:20:01 +0100 Subject: [PATCH 13/20] Tests: fixed tests that were broken after adding zip support --- src/components/Toolbar/__tests__/index.test.tsx | 6 +++--- src/components/__tests__/FileView.test.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/Toolbar/__tests__/index.test.tsx b/src/components/Toolbar/__tests__/index.test.tsx index 8a5b6dce..4832a14d 100644 --- a/src/components/Toolbar/__tests__/index.test.tsx +++ b/src/components/Toolbar/__tests__/index.test.tsx @@ -232,7 +232,7 @@ describe('Toolbar', () => { await user.paste('/foo') await user.keyboard('{Enter}') - expect(cache.cd).toHaveBeenCalledWith('/foo') + expect(cache.cd).toHaveBeenCalledWith('/foo', '') expect(input).not.toHaveFocus() }) @@ -249,7 +249,7 @@ describe('Toolbar', () => { await user.paste('/foo') await user.click(container.querySelector('[data-icon="arrow-right"]')) - expect(cache.cd).toHaveBeenCalledWith('/foo') + expect(cache.cd).toHaveBeenCalledWith('/foo', '') }) it('should show an alert then restore previous value when the user clicked on the submit button and the cache failed to change directory', async () => { @@ -269,7 +269,7 @@ describe('Toolbar', () => { await user.paste('/foo') await user.click(container.querySelector('[data-icon="arrow-right"]')) - expect(cache.cd).toHaveBeenCalledWith('/foo') + expect(cache.cd).toHaveBeenCalledWith('/foo', '') await screen.findByText(`${error.message} (${error.code})`) diff --git a/src/components/__tests__/FileView.test.tsx b/src/components/__tests__/FileView.test.tsx index e0983868..571a8023 100644 --- a/src/components/__tests__/FileView.test.tsx +++ b/src/components/__tests__/FileView.test.tsx @@ -129,8 +129,8 @@ describe('FileView', () => { expect(appState.openDirectory).toHaveBeenCalledWith( expect.objectContaining({ - dir: cache.join(file.dir, file.fullname), - fullname: '', + dir: file.dir, + fullname: file.fullname, }), true, ) From e9b2cc29812a4f9aa46a9c08f44397dc04dcc029 Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Fri, 3 Mar 2023 10:20:10 +0100 Subject: [PATCH 14/20] FsZip: instanciate a new StreamZip from cd() if zipPath has changed Also: cleaned-up write-only methods (these should be optionals) --- src/locale/error.ts | 5 +- src/locale/lang/en.json | 3 +- src/locale/lang/fr.json | 3 +- src/services/plugins/FsZip.ts | 268 +++++----------------------------- src/state/appState.tsx | 10 +- 5 files changed, 49 insertions(+), 240 deletions(-) diff --git a/src/locale/error.ts b/src/locale/error.ts index 7a2925b9..bf6a2852 100644 --- a/src/locale/error.ts +++ b/src/locale/error.ts @@ -13,7 +13,7 @@ export interface IOError { export function getLocalizedError(error: string | IOError): LocalizedError { const supportedErrors = - /(ENOTFOUND|ECONNREFUSED|ENOENT|EPERM|EACCES|EIO|ENOSPC|EEXIST|EHOSTDOWN|ENOTDIR|NODEST|530|550|EROS|SHELL_OPEN_FAILED)$/ + /(ENOTFOUND|ECONNREFUSED|ENOENT|EPERM|EACCES|EIO|ENOSPC|EEXIST|EHOSTDOWN|ENOTDIR|NODEST|530|550|EROS|SHELL_OPEN_FAILED|EBADFILE)$/ const niceError: LocalizedError = {} const { i18next: { t }, @@ -32,9 +32,10 @@ export function getLocalizedError(error: string | IOError): LocalizedError { break } } else { - niceError.code = (error as IOError).code + niceError.code = (error as IOError).code || error.toString() } + debugger switch (supportedErrors.test(niceError.code.toString())) { case true: niceError.message = t(`ERRORS.${niceError.code.toString()}`) diff --git a/src/locale/lang/en.json b/src/locale/lang/en.json index e37df50a..37c4c0eb 100644 --- a/src/locale/lang/en.json +++ b/src/locale/lang/en.json @@ -209,7 +209,8 @@ "COPY_ERROR": "Couldn't copy files: {{message}}", "COPY_UNKNOWN_ERROR": "Error while copying files.", "SHELL_OPEN_FAILED": "Could not open file: no application to open it.", - "OPEN_TERMINAL_FAILED": "Could not open terminal. Check that the path is correct in the settings." + "OPEN_TERMINAL_FAILED": "Could not open terminal. Check that the path is correct in the settings.", + "EBADFILE": "Corrupted File" }, "MAIN_PROCESS": { "PRESS_TO_EXIT": "Keep pressing ⌘Q to exit" diff --git a/src/locale/lang/fr.json b/src/locale/lang/fr.json index ab757097..e9e26169 100644 --- a/src/locale/lang/fr.json +++ b/src/locale/lang/fr.json @@ -209,7 +209,8 @@ "COPY_ERROR": "La copie n'a pu s'effectuer: {{message}}", "COPY_UNKNOWN_ERROR": "Impossible de copier les fichiers demandés.", "SHELL_OPEN_FAILED": "Impossible d'ouvrir le fichier: aucune application connue pour l'ouvrir.", - "OPEN_TERMINAL_FAILED": "Impossible d'ouvrir le terminal. Vérifiez le chemin du terminal dans les paramètres." + "OPEN_TERMINAL_FAILED": "Impossible d'ouvrir le terminal. Vérifiez le chemin du terminal dans les paramètres.", + "EBADFILE": "Fichier Endommagé" }, "MAIN_PROCESS": { "PRESS_TO_EXIT": "Maintenez la touche ⌘Q enfoncée pour quitter" diff --git a/src/services/plugins/FsZip.ts b/src/services/plugins/FsZip.ts index dc265d05..f8929e45 100644 --- a/src/services/plugins/FsZip.ts +++ b/src/services/plugins/FsZip.ts @@ -11,7 +11,7 @@ import { isWin, HOME_DIR } from '$src/utils/platform' const invalidDirChars = (isWin && /[\*:<>\?|"]+/gi) || /^[\.]+[\/]+(.)*$/gi const invalidFileChars = (isWin && /[\*:<>\?|"]+/gi) || /\// const SEP = path.sep - +const getZipPathRegEx = /(?<=\.zip).*/i // Since nodeJS will translate unix like paths to windows path, when running under Windows // we accept Windows style paths (eg. C:\foo...) and unix paths (eg. /foo or ./foo) const isRoot = (isWin && /((([a-zA-Z]\:)(\\)*)|(\\\\))$/) || /^\/$/ @@ -25,25 +25,22 @@ export const checkDirectoryName = (dirName: string) => !!!dirName.match(invalidD export interface ZipMethods { getEntries: (path: string) => Promise getRelativePath: (path: string) => string - prepareEntries: () => Promise + prepareEntries: (path: string) => Promise getFileDescriptor: (entry: ZipEntry) => FileDescriptor getFileStream: (path: string) => any isDir: (path: string) => boolean + setup: (path: string) => void close(): void } export class Zip implements ZipMethods { ready = false - zip: StreamZipAsync - zipEntries: ZipEntry[] - zipPath: string - zipFilename: string + zip: StreamZipAsync = null + zipEntries: ZipEntry[] = [] + zipPath = '' constructor(path: string) { - this.zipPath = path.replace(/(?<=\.zip).*/i, '') - this.zip = new StreamZip.async({ file: this.zipPath }) - this.zipEntries = [] - this.zipFilename = '' + this.setup(path) } close() { @@ -57,7 +54,22 @@ export class Zip implements ZipMethods { return path.replace(this.zipPath, '').replace(/^\//, '') } - async prepareEntries() { + async setup(path: string) { + const zipPath = path.replace(getZipPathRegEx, '') + if (zipPath !== this.zipPath) { + this.zipPath = zipPath + this.zip && this.close() + this.zip = new StreamZip.async({ file: zipPath }) + this.ready = false + } + } + + async prepareEntries(path: string) { + // If the user first attempted to open an invalid zip archive + // then clicks on another zip file, we will keep the same fs + // so here we re-run setup which will open a new zip stream + // with the new path is needed. + this.setup(path) if (!this.ready) { const entries = await this.zip.entries() this.zipEntries = Object.values(entries) @@ -67,15 +79,13 @@ export class Zip implements ZipMethods { async getEntries(path: string) { const pathInZip = this.getRelativePath(path) - // const longestPath = pathInZip.replace(/([^\/]*)$/, '') - // const regExp = pathInZip.length ? new RegExp(`^${pathInZip}\/([^\/]+)[\/]?$`, 'g') : /^([^\/])*[\/]?$/g + const dirsInRoot: string[] = [] const entries: ZipEntry[] = [] const dirPos = !pathInZip.length ? 0 : pathInZip.split('/').length this.zipEntries.forEach((entry) => { const { name } = entry if (name.startsWith(pathInZip)) { - // const paths = pathInZip.length ? name.replace(new RegExp(`${pathInZip}[^\/]?`, 'g'), '').split('/') : name.split('/') const paths = name.split('/') const dir = paths[dirPos] // do not add current path or already added path to the list @@ -97,7 +107,7 @@ export class Zip implements ZipMethods { isDir(path: string) { const pathInZip = this.getRelativePath(path) - // will match 'pathInZip/' & 'pathInZip/foo': even though the second one is not necessarily + // Will match 'pathInZip/' & 'pathInZip/foo': even though the second one is not necessarily // a directory, it means that pathInZip is itself a directory. return pathInZip.length === 0 || this.zipEntries.some((entry) => !!entry.name.match(`${pathInZip}/`)) } @@ -191,11 +201,11 @@ export class ZipApi implements FsApi { const resolvedPath = this.resolve(path) try { - await this.zip.prepareEntries() + await this.zip.prepareEntries(path) } catch (e) { debugger console.error('error getting zip file entries', e) - throw { code: 'ENOTDIR' } + throw { code: 'EBADFILE' } } const isDir = await this.isDir(resolvedPath) @@ -206,124 +216,24 @@ export class ZipApi implements FsApi { } } - size(source: string, files: string[], transferId = -1): Promise { - return Promise.reject('FsZip:size not implemented!') - // return new Promise(async (resolve, reject) => { - // try { - // let bytes = 0 - // for (const file of files) { - // bytes += await size(path.join(source, file)) - // } - // resolve(bytes) - // } catch (err) { - // reject(err) - // } - // }) + async size(source: string, files: string[], transferId = -1): Promise { + throw 'FsZip:size not implemented!' } async makedir(source: string, dirName: string, transferId = -1): Promise { - // return new Promise((resolve, reject) => { - // console.log('makedir, source:', source, 'dirName:', dirName) - // const unixPath = path.join(source, dirName).replace(/\\/g, '/') - // console.log('unixPath', unixPath) - // try { - // console.log('calling mkdir') - // reject('FsVirtual:makedir not implemented!') - // // mkdir(unixPath, (err: NodeJS.ErrnoException) => { - // // if (err) { - // // console.log('error creating dir', err) - // // reject(err) - // // } else { - // // console.log('successfully created dir', err) - // // resolve(path.join(source, dirName)) - // // } - // // }) - // } catch (err) { - // console.log('error execing mkdir()', err) - // reject(err) - // } - // }) - throw 'TODO: FsZip.makedir' + throw 'FsZip.makedir not supported' } async delete(source: string, files: FileDescriptor[], transferId = -1): Promise { - const toDelete = files.map((file) => path.join(source, file.fullname)) - - throw 'TODO: FsZip.delete not implemented!' - // return new Promise(async (resolve, reject) => { - // try { - // const deleted = await del(toDelete, { - // force: true, - // noGlob: true, - // }) - // resolve(deleted.length) - // } catch (err) { - // reject(err) - // } - // }) + throw 'FsZip.delete not supported' } rename(source: string, file: FileDescriptor, newName: string, transferId = -1): Promise { - throw 'TODO: FsZip.rename' - // const oldPath = path.join(source, file.fullname) - // const newPath = path.join(source, newName) - - // if (!newName.match(invalidFileChars)) { - // return new Promise((resolve, reject) => { - // // since node's fs.rename will overwrite the destination - // // path if it exists, first check that file doesn't exist - // this.exists(newPath) - // .then((exists) => { - // if (exists) { - // reject({ - // code: 'EEXIST', - // oldName: file.fullname, - // }) - // } else { - // vol.rename(oldPath, newPath, (err) => { - // if (err) { - // reject({ - // code: err.code, - // message: err.message, - // newName: newName, - // oldName: file.fullname, - // }) - // } else { - // resolve(newName) - // } - // }) - // } - // }) - // .catch((err) => { - // reject({ - // code: err.code, - // message: err.message, - // newName: newName, - // oldName: file.fullname, - // }) - // }) - // }) - // } else { - // // reject promise with previous name in case of invalid chars - // return Promise.reject({ - // oldName: file.fullname, - // newName: newName, - // code: 'BAD_FILENAME', - // }) - // } + throw 'FsZip.rename not supported' } async makeSymlink(targetPath: string, path: string, transferId = -1): Promise { - throw 'TODO: FsZip.makeSymLink' - // return new Promise((resolve, reject) => - // vol.symlink(targetPath, path, (err) => { - // if (err) { - // reject(err) - // } else { - // resolve(true) - // } - // }), - // ) + throw 'FsZip.makeSymLink not supported' } async isDir(path: string, transferId = -1): Promise { @@ -332,48 +242,10 @@ export class ZipApi implements FsApi { async exists(path: string, transferId = -1): Promise { throw 'TODO: FsZip.Exists not implemented' - // try { - // await vol.promises.access(path) - // return true - // } catch (err) { - // if (err.code === 'ENOENT') { - // return false - // } else { - // throw err - // } - // } } async stat(fullPath: string, transferId = -1): Promise { throw 'TODO: FsZip.stat not implemented' - // try { - // const format = path.parse(fullPath) - // const stats = vol.lstatSync(fullPath, { bigint: true }) - // const file: FileDescriptor = { - // dir: format.dir, - // fullname: format.base, - // name: format.name, - // extension: format.ext.toLowerCase(), - // cDate: stats.ctime, - // mDate: stats.mtime, - // bDate: stats.birthtime, - // length: Number(stats.size), - // mode: Number(stats.mode), - // isDir: stats.isDirectory(), - // readonly: false, - // type: - // (!stats.isDirectory() && - // filetype(Number(stats.mode), Number(stats.gid), Number(stats.uid), format.ext.toLowerCase())) || - // '', - // isSym: stats.isSymbolicLink(), - // target: (stats.isSymbolicLink() && vol.readlinkSync(fullPath)) || null, - // id: MakeId({ ino: stats.ino, dev: stats.dev }), - // } - - // return file - // } catch (err) { - // throw err - // } } login(server?: string, credentials?: Credentials): Promise { @@ -382,17 +254,6 @@ export class ZipApi implements FsApi { onList(dir: string): void { console.warn('FsZop.onList not implemented') - // if (dir !== this.path) { - // // console.log('stopWatching', this.path) - // try { - // VirtualWatch.stopWatchingPath(this.path, this.onFsChange) - // VirtualWatch.watchPath(dir, this.onFsChange) - // } catch (e) { - // console.warn('Could not watch path', dir, e) - // } - // // console.log('watchPath', dir) - // this.path = dir - // } } async list(dir: string, watchDir = false, transferId = -1): Promise { @@ -420,68 +281,7 @@ export class ZipApi implements FsApi { progress: (bytes: number) => void, transferId = -1, ): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - // return new Promise((resolve: (val?: any) => void, reject: (val?: any) => void) => { - // let finished = false - // let readError = false - // let bytesRead = 0 - - // const reportProgress = new Transform({ - // // eslint-disable-next-line @typescript-eslint/no-explicit-any - // transform(chunk: any, encoding: any, callback: TransformCallback) { - // bytesRead += chunk.length - // progressFunc(progress, bytesRead) - // callback(null, chunk) - // }, - // highWaterMark: 16384 * 31, - // }) - - // const writeStream = fs.createWriteStream(dstPath) - - // readStream.once('error', (err) => { - // console.log('error on read stream') - // readError = true - // readStream.destroy() - // writeStream.destroy(err) - // }) - - // readStream.pipe(reportProgress).pipe(writeStream) - - // writeStream.once('finish', (...args) => { - // progress(writeStream.bytesWritten) - // finished = true - // }) - - // writeStream.once('error', (err) => { - // // remove created file if it's empty and there was a problem - // // accessing the source file: we will report an error to the - // // user so there's no need to leave an empty file - // if (readError && !bytesRead && !writeStream.bytesWritten) { - // console.log('cleaning up fs') - // fs.unlink(dstPath, (err) => { - // if (!err) { - // console.log('cleaned-up fs') - // } else { - // console.log('error cleaning-up fs', err) - // } - // }) - // } - // reject(err) - // }) - - // writeStream.once('close', () => { - // if (finished) { - // resolve() - // } else { - // reject() - // } - // }) - - // writeStream.once('error', (err) => { - // reject(err) - // }) - // }) - throw 'TODO: FsZip.putStream not implemented' + throw 'FsZip.putStream not supported' } getParentTree(dir: string): Array<{ dir: string; fullname: string; name: string }> { diff --git a/src/state/appState.tsx b/src/state/appState.tsx index d0a32c80..6eb207df 100644 --- a/src/state/appState.tsx +++ b/src/state/appState.tsx @@ -196,8 +196,14 @@ export class AppState { try { await this.openDirectory(file, !useInactiveCache) } catch (e) { - const cache = this.getActiveCache() - cache.openFile(this, file) + if (e.code === 'ENOTDIR') { + const cache = this.getActiveCache() + cache.openFile(this, file) + } else { + AppAlert.show(`${e.message} (${e.code})`, { + intent: 'danger', + }) + } } } From 0f54d9f6ae950e8249a201213a4f54b0aa10e911 Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Fri, 3 Mar 2023 14:50:45 +0100 Subject: [PATCH 15/20] FsZip: show EACCES error if read access is denied --- src/services/Fs.ts | 10 +++++----- src/services/plugins/FsZip.ts | 30 ++---------------------------- 2 files changed, 7 insertions(+), 33 deletions(-) diff --git a/src/services/Fs.ts b/src/services/Fs.ts index efc92119..9559d9fe 100644 --- a/src/services/Fs.ts +++ b/src/services/Fs.ts @@ -117,16 +117,16 @@ export interface FsApi { // async methods that may require server access list(dir: string, watchDir?: boolean, transferId?: number): Promise cd(path: string, transferId?: number): Promise - delete(parent: string, files: FileDescriptor[], transferId?: number): Promise - makedir(parent: string, name: string, transferId?: number): Promise - rename(parent: string, file: FileDescriptor, name: string, transferId?: number): Promise + delete?(parent: string, files: FileDescriptor[], transferId?: number): Promise + makedir?(parent: string, name: string, transferId?: number): Promise + rename?(parent: string, file: FileDescriptor, name: string, transferId?: number): Promise stat(path: string, transferId?: number): Promise isDir(path: string, transferId?: number): Promise exists(path: string, transferId?: number): Promise size(source: string, files: string[], transferId?: number): Promise - makeSymlink(targetPath: string, path: string, transferId?: number): Promise + makeSymlink?(targetPath: string, path: string, transferId?: number): Promise getStream(path: string, file: string, transferId?: number): Promise - putStream( + putStream?( readStream: NodeJS.ReadableStream, dstPath: string, progress: (bytesRead: number) => void, diff --git a/src/services/plugins/FsZip.ts b/src/services/plugins/FsZip.ts index f8929e45..a261ed95 100644 --- a/src/services/plugins/FsZip.ts +++ b/src/services/plugins/FsZip.ts @@ -68,7 +68,7 @@ export class Zip implements ZipMethods { // If the user first attempted to open an invalid zip archive // then clicks on another zip file, we will keep the same fs // so here we re-run setup which will open a new zip stream - // with the new path is needed. + // with the new path if needed. this.setup(path) if (!this.ready) { const entries = await this.zip.entries() @@ -203,9 +203,8 @@ export class ZipApi implements FsApi { try { await this.zip.prepareEntries(path) } catch (e) { - debugger console.error('error getting zip file entries', e) - throw { code: 'EBADFILE' } + throw e?.code === 'EACCES' ? e : { code: 'EBADFILE' } } const isDir = await this.isDir(resolvedPath) @@ -220,22 +219,6 @@ export class ZipApi implements FsApi { throw 'FsZip:size not implemented!' } - async makedir(source: string, dirName: string, transferId = -1): Promise { - throw 'FsZip.makedir not supported' - } - - async delete(source: string, files: FileDescriptor[], transferId = -1): Promise { - throw 'FsZip.delete not supported' - } - - rename(source: string, file: FileDescriptor, newName: string, transferId = -1): Promise { - throw 'FsZip.rename not supported' - } - - async makeSymlink(targetPath: string, path: string, transferId = -1): Promise { - throw 'FsZip.makeSymLink not supported' - } - async isDir(path: string, transferId = -1): Promise { return this.zip.isDir(path) } @@ -275,15 +258,6 @@ export class ZipApi implements FsApi { return this.zip.getFileStream(this.join(path, file)) } - putStream( - readStream: NodeJS.ReadableStream, - dstPath: string, - progress: (bytes: number) => void, - transferId = -1, - ): Promise { - throw 'FsZip.putStream not supported' - } - getParentTree(dir: string): Array<{ dir: string; fullname: string; name: string }> { const parts = dir.split(SEP) const max = parts.length - 1 From de1fce36fae0212a9d25de271050fe2a4f06da14 Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Tue, 7 Mar 2023 09:30:47 +0100 Subject: [PATCH 16/20] FsZip: reopen the zip if needed This PR fixes two things: - when copying files from a zip directory, closing the zip tab, then pasting the files in another tab, the cache's zip file was closed, so we re-open it. - FsZip file listing had a bug where a directory partially matching another directory would return non-existing folders, for eg. `/foo` & `/foo.bar`. --- src/services/plugins/FsZip.ts | 12 +++++++++--- src/state/transferState.ts | 6 ++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/services/plugins/FsZip.ts b/src/services/plugins/FsZip.ts index a261ed95..154ca74f 100644 --- a/src/services/plugins/FsZip.ts +++ b/src/services/plugins/FsZip.ts @@ -44,6 +44,10 @@ export class Zip implements ZipMethods { } close() { + // Reset zipPath so that zip is reopened if needed: this can happen + // if files from this zip are added into clipboard, then tab is closed + // and user pastes files. + this.zipPath = '' this.zip.close() } @@ -57,9 +61,9 @@ export class Zip implements ZipMethods { async setup(path: string) { const zipPath = path.replace(getZipPathRegEx, '') if (zipPath !== this.zipPath) { - this.zipPath = zipPath this.zip && this.close() this.zip = new StreamZip.async({ file: zipPath }) + this.zipPath = zipPath this.ready = false } } @@ -79,13 +83,15 @@ export class Zip implements ZipMethods { async getEntries(path: string) { const pathInZip = this.getRelativePath(path) - const dirsInRoot: string[] = [] const entries: ZipEntry[] = [] const dirPos = !pathInZip.length ? 0 : pathInZip.split('/').length this.zipEntries.forEach((entry) => { const { name } = entry - if (name.startsWith(pathInZip)) { + // skip name partially matching pathInZip, for eg. + // pathInZip === '/foo' + // name === '/foo.bar' + if (name.startsWith(pathInZip.length ? `${pathInZip}/` : '')) { const paths = name.split('/') const dir = paths[dirPos] // do not add current path or already added path to the list diff --git a/src/state/transferState.ts b/src/state/transferState.ts index 3ae870fb..a49e5b2c 100644 --- a/src/state/transferState.ts +++ b/src/state/transferState.ts @@ -221,9 +221,6 @@ export class TransferState { let stream = null try { - // if (transfer.file.isSym) { - // debugger; - // } newFilename = await this.renameOrCreateDir(transfer, fullDstPath) } catch (err) { console.log('error creating directory', err) @@ -485,6 +482,7 @@ export class TransferState { // /note try { await this.srcFs.cd(currentPath) + subFiles = await this.srcFs.list(currentPath) const subDir = this.srcFs.join(subDirectory, dir.fullname) transfers = transfers.concat(await this.getFileList(subFiles, subDir)) @@ -493,7 +491,7 @@ export class TransferState { // then, simply skip it when doing the transfer this.onTransferError(transfer, { code: 'ENOENT' }) this.transfersDone++ - console.log('could not get directory content for', currentPath) + console.log('could not get directory content for', currentPath, err) console.log('directory was still added to transfer list for consistency') } } From c0ca48b8c374dc983bf3c345b06bc1d7f4aad53a Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Tue, 7 Mar 2023 09:51:15 +0100 Subject: [PATCH 17/20] Toolbar: disable spellcheck on path input --- src/components/Toolbar/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Toolbar/index.tsx b/src/components/Toolbar/index.tsx index eb85958e..1647f54e 100644 --- a/src/components/Toolbar/index.tsx +++ b/src/components/Toolbar/index.tsx @@ -251,6 +251,7 @@ export const Toolbar = observer(({ active }: Props) => { // allows input shrinking to a very low width: // without it, it would refuse shrinking below 100px size={1} + spellCheck={false} /> {isMakedirDialogOpen && ( Date: Tue, 7 Mar 2023 18:03:40 +0100 Subject: [PATCH 18/20] FileCache: do not attempt to open a terminal if fs.options is indirect --- src/state/fileState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/state/fileState.ts b/src/state/fileState.ts index e67caf66..366aa7e2 100644 --- a/src/state/fileState.ts +++ b/src/state/fileState.ts @@ -668,7 +668,7 @@ export class FileState { } openTerminal(path: string): Promise { - if (this.getFS().name === 'local') { + if (!this.getFS().options.indirect) { return ipcRenderer.invoke('openTerminal', path) } } From 7bfe5aa6211e0c80f9364b16bb38341253b45187 Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Thu, 9 Mar 2023 09:59:37 +0100 Subject: [PATCH 19/20] FsZip: do not attempt to open folder 'foo.zip' as an archive --- src/services/plugins/FsLocal.ts | 7 ++++--- src/utils/initFS.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/services/plugins/FsLocal.ts b/src/services/plugins/FsLocal.ts index e959f712..1fb548f4 100644 --- a/src/services/plugins/FsLocal.ts +++ b/src/services/plugins/FsLocal.ts @@ -486,7 +486,7 @@ export class LocalApi implements FsApi { export function FolderExists(path: string): boolean { try { - return fs.existsSync(path) && fs.lstatSync(path).isDirectory() + return fs.existsSync(path) && fs.statSync(path).isDirectory() } catch (err) { return false } @@ -501,8 +501,9 @@ export const FsLocal: Fs = { readonly: false, indirect: false, }, - canread(basePath: string): boolean { - return !!basePath.match(localStart) + canread(basePath: string, subDir: string): boolean { + const fullPath = path.join(basePath, subDir) + return !!basePath.match(localStart) && FolderExists(fullPath) }, serverpart(str: string): string { return 'local' diff --git a/src/utils/initFS.ts b/src/utils/initFS.ts index cbeefca5..bc0d9933 100644 --- a/src/utils/initFS.ts +++ b/src/utils/initFS.ts @@ -14,7 +14,7 @@ export default function initFS() { } else { // TODO: there should be an easy way to automatically register new FS registerFs(FsWsl) - registerFs(FsZip) registerFs(FsLocal) + registerFs(FsZip) } } From 166bf17ed82a3fb60111f512c5540ce65e56c6c3 Mon Sep 17 00:00:00 2001 From: Nicolas Ramz Date: Thu, 9 Mar 2023 17:55:52 +0100 Subject: [PATCH 20/20] FileContextMenu: disable paste/delete when cache is readonly Also simplified paste enable checks: ignore file under mouse for now since we paste on current cache & not on the folder under the mouse anyway. --- src/components/App.tsx | 8 ++------ src/components/menus/FileContextMenu.tsx | 9 ++++++--- src/services/plugins/FsZip.ts | 6 ------ src/state/fileState.ts | 4 ++++ 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 23e1829d..aecbfdd6 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -62,14 +62,11 @@ const App = observer(() => { status: activeCache.status, path: activeCache.path, selectedLength: activeCache.selected.length, - // enable when FsZip is merged - isReadonly: false, - isIndirect: false, historyLength: activeCache.history.length, historyCurrent: activeCache.current, isRoot: API.isRoot(activeCache.path), - // isReadonly: activeCache.getFS().options.readonly, - // isIndirect: activeCache.getFS().options.indirect, + isReadonly: fs.options.readonly, + isIndirect: fs.options.indirect, isOverlayOpen: refIsOverlayOpen.current, activeViewTabNums: activeView.caches.length, isExplorer: appState.isExplorer, @@ -77,7 +74,6 @@ const App = observer(() => { filesLength: activeCache.files.length, clipboardLength: appState.clipboard.files.length, activeViewId: activeView.viewId, - // missing: about opened, tab: is it needed? } }, [appState]) diff --git a/src/components/menus/FileContextMenu.tsx b/src/components/menus/FileContextMenu.tsx index b93bae35..6b12b89b 100644 --- a/src/components/menus/FileContextMenu.tsx +++ b/src/components/menus/FileContextMenu.tsx @@ -14,11 +14,14 @@ const FileContextMenu = ({ fileUnderMouse }: Props) => { const { appState } = useStores('appState') const clipboard = appState.clipboard const cache = appState.getActiveCache() + const isReadonly = cache.options.readonly - // TODO: disable delete/paste when cahce.fs.readonly is true const numFilesInClipboard = clipboard.files.length const isInSelection = fileUnderMouse && !!cache.selected.find((file) => sameID(file.id, fileUnderMouse.id)) - const isPasteEnabled = numFilesInClipboard && ((!fileUnderMouse && !cache.error) || fileUnderMouse?.isDir) + // FIXME: if fileUnderMouse is a folder, we could paste inside that folder. Right now paste doesn't care about + // where click happens: it just uses current cache as target. + // ((!fileUnderMouse && !cache.error) || fileUnderMouse?.isDir) + const isPasteEnabled = numFilesInClipboard && !isReadonly const onCopy = () => { clipboard.setClipboard(cache, !isInSelection ? [fileUnderMouse] : undefined) @@ -59,7 +62,7 @@ const FileContextMenu = ({ fileUnderMouse }: Props) => { icon="delete" intent={Intent.DANGER} text={t('APP_MENUS.DELETE')} - disabled={!fileUnderMouse} + disabled={!fileUnderMouse || isReadonly} onClick={onDelete} /> diff --git a/src/services/plugins/FsZip.ts b/src/services/plugins/FsZip.ts index 154ca74f..aacad8c8 100644 --- a/src/services/plugins/FsZip.ts +++ b/src/services/plugins/FsZip.ts @@ -1,7 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import StreamZip, { StreamZipAsync, ZipEntry } from 'node-stream-zip' -import type { ReadStream, BigIntStats } from 'fs' -import { Transform, TransformCallback } from 'stream' import * as path from 'path' import { FsApi, FileDescriptor, Credentials, Fs, filetype, MakeId } from '$src/services/Fs' @@ -16,10 +14,6 @@ const getZipPathRegEx = /(?<=\.zip).*/i // we accept Windows style paths (eg. C:\foo...) and unix paths (eg. /foo or ./foo) const isRoot = (isWin && /((([a-zA-Z]\:)(\\)*)|(\\\\))$/) || /^\/$/ -const progressFunc = throttle((progress: (bytes: number) => void, bytesRead: number) => { - progress(bytesRead) -}, 400) - export const checkDirectoryName = (dirName: string) => !!!dirName.match(invalidDirChars) && dirName !== '/' export interface ZipMethods { diff --git a/src/state/fileState.ts b/src/state/fileState.ts index 366aa7e2..2db1947b 100644 --- a/src/state/fileState.ts +++ b/src/state/fileState.ts @@ -722,4 +722,8 @@ export class FileState { setViewMode(newViewMode: ViewModeName) { this.viewmode = newViewMode } + + get options() { + return this.fs.options + } }