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', }, 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 6ffd0f2d..597fe7ea 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", @@ -50,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", @@ -3942,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", @@ -10987,6 +10998,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", @@ -18242,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", @@ -23614,6 +23646,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..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", @@ -132,6 +133,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-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/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/FileView.tsx b/src/components/FileView.tsx index 7dee3ff2..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) @@ -183,15 +187,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/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/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/__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/Toolbar/index.tsx b/src/components/Toolbar/index.tsx index 0ec3008c..1647f54e 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 { 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' @@ -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 @@ -222,7 +226,7 @@ export const Toolbar = observer(({ active }: Props) => { @@ -237,6 +241,7 @@ export const Toolbar = observer(({ active }: Props) => { onChange={onPathChange} onKeyUp={onKeyUp} placeholder={t('COMMON.PATH_PLACEHOLDER')} + leftElement={} rightElement={reloadButton} value={path} inputRef={inputRef} @@ -246,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 && ( { expect(appState.openDirectory).toHaveBeenCalledWith( expect.objectContaining({ - dir: cache.join(file.dir, file.fullname), - fullname: '', + dir: file.dir, + fullname: file.fullname, }), true, ) 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/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/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/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/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 1ff45531..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" @@ -262,6 +263,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..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" @@ -262,6 +263,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..9559d9fe 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 = [] @@ -40,13 +38,18 @@ 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(str: string): boolean + canread(basePath: string, subPath: string): boolean serverpart(str: string): string credentials(str: string): Credentials displaypath(str: string): { fullPath: string; shortPath: string } @@ -114,17 +117,17 @@ 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 - getStream(path: string, file: string, transferId?: number): Promise - putStream( - readStream: Readable, + makeSymlink?(targetPath: string, path: string, transferId?: number): Promise + getStream(path: string, file: string, transferId?: number): Promise + putStream?( + readStream: NodeJS.ReadableStream, dstPath: string, progress: (bytesRead: number) => void, transferId?: number, @@ -143,8 +146,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/FsFtp.ts b/src/services/plugins/FsFtp.ts index dc531c15..aae9030b 100644 --- a/src/services/plugins/FsFtp.ts +++ b/src/services/plugins/FsFtp.ts @@ -763,6 +763,8 @@ export const FsFtp: Fs = { description: 'Fs that just implements fs over ftp', 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 bc967351..1e91120a 100644 --- a/src/services/plugins/FsGeneric.ts +++ b/src/services/plugins/FsGeneric.ts @@ -144,6 +144,8 @@ export const FsGeneric: Fs = { description: 'Fs that just implements the FsInterface but does nothing', 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 8b992c58..1fb548f4 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 @@ -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 } @@ -498,9 +498,12 @@ export const FsLocal: Fs = { description: 'Local Filesystem', options: { needsRefresh: false, + readonly: false, + indirect: false, }, - canread(str: string): boolean { - return !!str.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/services/plugins/FsVirtual.ts b/src/services/plugins/FsVirtual.ts index 420cb307..1ff15b42 100644 --- a/src/services/plugins/FsVirtual.ts +++ b/src/services/plugins/FsVirtual.ts @@ -492,9 +492,11 @@ export const FsVirtual: Fs = { description: 'Virtual Filesystem', options: { needsRefresh: false, + readonly: false, + indirect: 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 fad57f55..2cc834d0 100644 --- a/src/services/plugins/FsWsl.ts +++ b/src/services/plugins/FsWsl.ts @@ -122,9 +122,11 @@ export const FsWsl: Fs = { description: 'Local WSL Filesystem', options: { needsRefresh: false, + readonly: false, + indirect: 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 new file mode 100644 index 00000000..aacad8c8 --- /dev/null +++ b/src/services/plugins/FsZip.ts @@ -0,0 +1,342 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import StreamZip, { StreamZipAsync, ZipEntry } from 'node-stream-zip' +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 +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]\:)(\\)*)|(\\\\))$/) || /^\/$/ + +export const checkDirectoryName = (dirName: string) => !!!dirName.match(invalidDirChars) && dirName !== '/' + +export interface ZipMethods { + getEntries: (path: string) => Promise + getRelativePath: (path: string) => string + 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 = null + zipEntries: ZipEntry[] = [] + zipPath = '' + + constructor(path: string) { + this.setup(path) + } + + 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() + } + + /** + * Get path relative to zip path + */ + getRelativePath(path: string) { + return path.replace(this.zipPath, '').replace(/^\//, '') + } + + async setup(path: string) { + const zipPath = path.replace(getZipPathRegEx, '') + if (zipPath !== this.zipPath) { + this.zip && this.close() + this.zip = new StreamZip.async({ file: zipPath }) + this.zipPath = 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 if needed. + this.setup(path) + 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 dirsInRoot: string[] = [] + const entries: ZipEntry[] = [] + const dirPos = !pathInZip.length ? 0 : pathInZip.split('/').length + this.zipEntries.forEach((entry) => { + const { name } = entry + // 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 + if (dirsInRoot.indexOf(dir) !== -1 || !dir.length) return + + if (paths.length === dirPos + 1) { + entries.push(entry) + } else { + dirsInRoot.push(dir) + entries.push(this.getFakeDirDescriptor(pathInZip.length ? `${pathInZip}/${dir}` : dir)) + } + } + }) + console.log(entries) + + return entries + } + + isDir(path: string) { + const pathInZip = this.getRelativePath(path) + + // 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) { + 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 + + const file = { + dir: path.parse(`${this.zipPath}/${name}`).dir, + fullname: parsed.base, + 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 + } + + getFileStream(path: string): Promise { + const relativePath = this.getRelativePath(path) + return this.zip.stream(relativePath) + } +} + +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) + } + + // 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 { + // gh#44: replace ~ with userpath + 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(path) + } catch (e) { + console.error('error getting zip file entries', e) + throw e?.code === 'EACCES' ? e : { code: 'EBADFILE' } + } + + const isDir = await this.isDir(resolvedPath) + if (isDir) { + return resolvedPath + } else { + throw { code: 'ENOTDIR' } + } + } + + async size(source: string, files: string[], transferId = -1): Promise { + throw 'FsZip:size not implemented!' + } + + async isDir(path: string, transferId = -1): Promise { + return this.zip.isDir(path) + } + + async exists(path: string, transferId = -1): Promise { + throw 'TODO: FsZip.Exists not implemented' + } + + async stat(fullPath: string, transferId = -1): Promise { + throw 'TODO: FsZip.stat not implemented' + } + + login(server?: string, credentials?: Credentials): Promise { + return Promise.resolve() + } + + onList(dir: string): void { + console.warn('FsZop.onList not implemented') + } + + async list(dir: string, watchDir = false, transferId = -1): Promise { + const entries = await this.zip.getEntries(dir) + const list = entries.map((entry) => this.zip.getFileDescriptor(entry)) + console.log(list) + return list + } + + isRoot(path: string): boolean { + return !!path.match(isRoot) + } + + off(): void { + this.zip.close() + } + + getStream(path: string, file: string, transferId = -1): Promise { + return this.zip.getFileStream(this.join(path, file)) + } + + 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, + readonly: true, + indirect: true, + }, + canread(basePath: string, subPath: string): boolean { + const fullPath = path.join(basePath, subPath) + const matches = fullPath.match(/\.zip/gi) + + return matches && matches.length === 1 + }, + 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/state/appState.tsx b/src/state/appState.tsx index 5fea8cba..6eb207df 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() { @@ -152,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, @@ -162,6 +170,8 @@ export class AppState { dstFsName: destCache.getFS().name, } this.copy(options) + } else { + shell.beep() } } @@ -179,11 +189,29 @@ 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) { + if (e.code === 'ENOTDIR') { + const cache = this.getActiveCache() + cache.openFile(this, file) + } else { + AppAlert.show(`${e.message} (${e.code})`, { + intent: 'danger', + }) + } + } + } + + 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] @@ -230,7 +258,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 } @@ -292,32 +321,53 @@ 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 '' } + const { dir, fullname } = files[0] + // 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(dir, 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, + } + 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, () => { // // // }) - // 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 +414,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 +438,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 13b288cd..2db1947b 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' @@ -218,7 +219,7 @@ export class FileState { this.viewId = viewId this.path = path - this.getNewFS(path) + this.getNewFS(path, '') } private saveContext(): void { @@ -250,16 +251,18 @@ 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 && newfs.name !== this.fs.name)) { !skipContext && this.api && this.saveContext() // 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() } @@ -526,13 +529,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.length) { + 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', @@ -658,13 +663,12 @@ export class FileState { } } - openDirectory(file: { dir: string; fullname: string }): Promise { - console.log(file.dir, file.fullname) + openDirectory(file: Pick): Promise { return this.cd(file.dir, file.fullname) } openTerminal(path: string): Promise { - if (this.getFS().name === 'local') { + if (!this.getFS().options.indirect) { return ipcRenderer.invoke('openTerminal', path) } } @@ -718,4 +722,8 @@ export class FileState { setViewMode(newViewMode: ViewModeName) { this.viewmode = newViewMode } + + get options() { + return this.fs.options + } } diff --git a/src/state/transferState.ts b/src/state/transferState.ts index 37da64bb..a49e5b2c 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) @@ -222,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) @@ -414,7 +410,9 @@ export class TransferState { destroyRunningStreams(): void { for (const stream of this.streams) { - stream.destroy() + // stream.destroy() + // stream.close() + console.warn('need to detroy stream') } } @@ -484,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)) @@ -492,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') } } diff --git a/src/utils/initFS.ts b/src/utils/initFS.ts index 352ad626..bc0d9933 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() { @@ -14,5 +15,6 @@ export default function initFS() { // TODO: there should be an easy way to automatically register new FS registerFs(FsWsl) registerFs(FsLocal) + registerFs(FsZip) } }