Repository: warpdesign/react-explorer (ts)

Issue:
Copying a link will make a copy of the file pointed by the link
On macOS (Catalina), when copying a link pointing to a file, a copy of the target is actually created.

**Expected**: a new link pointing to the same file should be created.
Suggestion: show link target when hovering a symlink
Add symlink target when hovering a symlink.

Pull request description:
Fix copy symlink
Closes #91, closes #121 

Patch:
diff --git a/src/components/FileTable.tsx b/src/components/FileTable.tsx
index 3eacc59..2e21e67 100644
--- a/src/components/FileTable.tsx
+++ b/src/components/FileTable.tsx
@@ -69,6 +69,7 @@ interface ITableRow {
     isSelected: boolean;
     nodeData: File;
     className: string;
+    title: string;
 }
 
 interface IState {
@@ -256,9 +257,13 @@ export class FileTableClass extends React.Component<IProps, IState> {
             isSymlink: file.isSym
         });
 
+        // if (file.name.match(/link/))
+        //     debugger;
+
         const res: ITableRow = {
             icon: file.isDir && TYPE_ICONS['dir'] || (filetype && TYPE_ICONS[filetype] || TYPE_ICONS['any']),
             name: file.fullname,
+            title: file.isSym ? `${file.fullname} → ${file.target}` : file.fullname,
             nodeData: file,
             className: classes,
             isSelected: isSelected,
@@ -337,9 +342,9 @@ export class FileTableClass extends React.Component<IProps, IState> {
     }
 
     nameRenderer = (data: any) => {
-        const iconName = data.rowData.icon;
+        const { icon, title } = data.rowData;
 
-        return (<div className="name"><Icon icon={iconName}></Icon><span title={data.cellData} className="file-label">{data.cellData}</span></div>);
+        return (<div className="name"><Icon icon={icon}></Icon><span title={title} className="file-label">{data.cellData}</span></div>);
     }
 
     /*
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx
index 7282669..6fac1f7 100644
--- a/src/components/Toolbar.tsx
+++ b/src/components/Toolbar.tsx
@@ -1,7 +1,7 @@
 import * as React from "react";
 import { reaction, IReactionDisposer } from 'mobx';
 import { inject, observer } from 'mobx-react';
-import { InputGroup, ControlGroup, Button, ButtonGroup, Popover, Intent, Alert, ProgressBar, Classes, HotkeysTarget, Hotkeys, Hotkey, Tooltip, Position, IHotkeysProps } from '@blueprintjs/core';
+import { InputGroup, ControlGroup, Button, ButtonGroup, Popover, Intent, Alert, HotkeysTarget, Hotkeys, Hotkey, Tooltip, Position, IHotkeysProps } from '@blueprintjs/core';
 import { AppState } from "../state/appState";
 import { FileMenu } from "./FileMenu";
 import { MakedirDialog } from "./dialogs/MakedirDialog";
@@ -103,9 +103,7 @@ export class ToolbarClass extends React.Component<IProps, PathInputState> {
     }
 
     private onSubmit = () => {
-        console.log('onSubmit');
         if (this.cache.path !== this.state.path) {
-            console.log('onSubmit2', this.cache.cd);
             this.input.blur();
             const path = this.state.path;
             this.cache.cd(this.state.path)
@@ -397,9 +395,7 @@ export class ToolbarClass extends React.Component<IProps, PathInputState> {
         const { selected, history, current } = fileCache;
         const { t } = this.props;
 
-        if (fileCache) {
-            console.log('ok', fileCache);
-        } else {
+        if (!fileCache) {
             console.log('oops', fileCache);
             debugger;
         }
diff --git a/src/css/filetable.css b/src/css/filetable.css
index bd3ca3e..b77ac2a 100644
--- a/src/css/filetable.css
+++ b/src/css/filetable.css
@@ -90,14 +90,16 @@
     border:1px solid rgba(0,0,0,.4);
 }
 
-.tableRow.error, .tableRow.error .bp3-icon, body.bp3-dark .tableRow.error .bp3-icon{
-    color: rgb(160, 43, 43);
-}
-
 .tableRow.selected, .tableRow.selected .bp3-icon{
     color:#fff;
 }
 
+.tableRow.error,
+.tableRow.error .bp3-icon,
+body.bp3-dark .tableRow.error .bp3-icon{
+    color: rgb(160, 43, 43);
+}
+
 /* dark theme */
 body.bp3-dark .tableRow:hover{
     background-color:rgba(92, 112, 128, 0.3);
diff --git a/src/locale/lang/en.json b/src/locale/lang/en.json
index 7da7597..03140df 100644
--- a/src/locale/lang/en.json
+++ b/src/locale/lang/en.json
@@ -198,7 +198,8 @@
             "EEXIST": "A file with that name already exists",
             "NODEST": "copy target could not be accessed",
             "COPY_ERROR": "Couldn't copy files: {{message}}",
-            "COPY_UNKNOWN_ERROR": "Error while copying files."
+            "COPY_UNKNOWN_ERROR": "Error while copying files.",
+            "SHELL_OPEN_FAILED": "Could not open file: {{path}}"
         },
         "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 b10c31c..7b30dc6 100644
--- a/src/locale/lang/fr.json
+++ b/src/locale/lang/fr.json
@@ -198,7 +198,8 @@
             "EEXIST": "Un fichier avec ce nom existe déjà",
             "NODEST": "la destination de la copie n'est pas accessible",
             "COPY_ERROR": "La copie n'a pu s'effectuer: {{message}}",
-            "COPY_UNKNOWN_ERROR": "Impossible de copier les fichiers demandés."
+            "COPY_UNKNOWN_ERROR": "Impossible de copier les fichiers demandés.",
+            "SHELL_OPEN_FAILED": "Impossible d'ouvrir le fichier: {{path}}"
         },
         "MAIN_PROCESS": {
             "PRESS_TO_EXIT": "Maintenez la touche ⌘Q enfoncée pour quitter"
diff --git a/src/services/Fs.ts b/src/services/Fs.ts
index 6acda7b..b6be5c5 100644
--- a/src/services/Fs.ts
+++ b/src/services/Fs.ts
@@ -38,6 +38,7 @@ export interface File {
     name: string;
     fullname: string;
     extension: string;
+    target: string;
     cDate: Date;
     mDate: Date;
     bDate: Date;
@@ -50,6 +51,10 @@ export interface File {
     id: FileID;
 }
 
+export interface FsOptions {
+    needsRefresh: boolean;
+}
+
 export interface Fs {
     // runtime api
     API: (new (path: string, onFsChange: (filename: string) => void) => FsApi);
@@ -61,6 +66,7 @@ export interface Fs {
     name: string;
     description: string;
     icon: string;
+    options: FsOptions;
 }
 
 export const Extensions: { [index: string]: RegExp } = {
@@ -93,7 +99,7 @@ function isModeExe(mode: number, gid: number, uid: number): Boolean {
     const isGroup = gid ? process.getgid && gid === process.getgid() : false;
     const isUser = uid ? process.getuid && uid === process.getuid() : false;
 
-    return !!((mode & ExeMaskAll) || ((mode & ExeMaskUser) && isGroup) || ((mode & ExeMaskGroup) && isUser));
+    return !!((mode !== -1 && (mode & ExeMaskAll)) || ((mode & ExeMaskUser) && isGroup) || ((mode & ExeMaskGroup) && isUser));
 }
 
 export function filetype(mode: number, gid: number, uid: number, extension: string): FileType {
@@ -128,6 +134,7 @@ export interface FsApi {
     isDir(path: string, transferId?: number): Promise<boolean>;
     exists(path: string, transferId?: number): Promise<boolean>;
     size(source: string, files: string[], transferId?: number): Promise<number>;
+    makeSymlink(targetPath: string, path: string, transferId?: number): Promise<boolean>;
     getStream(path: string, file: string, transferId?: number): Promise<Readable>;
     putStream(readStream: Readable, dstPath: string, progress: (bytesRead: number) => void, transferId?: number): Promise<void>;
     getParentTree(dir: string): Array<{ dir: string, fullname: string }>;
diff --git a/src/services/plugins/FsFtp.ts b/src/services/plugins/FsFtp.ts
index e9a9aa2..cc7bc7a 100644
--- a/src/services/plugins/FsFtp.ts
+++ b/src/services/plugins/FsFtp.ts
@@ -238,6 +238,7 @@ class Client {
                             readonly: false,
                             type: ftpFile.type !== 'd' && filetype(0, 0, 0, ext) || '',
                             isSym: false,
+                            target: null,
                             id: {
                                 ino: mDate.getTime(),
                                 dev: new Date().getTime()
@@ -586,6 +587,11 @@ class FtpAPI implements FsApi {
         return this.master.list(dir);
     };
 
+    async makeSymlink(targetPath: string, path: string, transferId?: number): Promise<boolean> {
+        console.log("FsFtp.makeSymlink")
+        return true;
+    }
+
     async stat(fullPath: string): Promise<File> {
         console.warn('FsFtp.stat: TODO');
         return Promise.resolve({
@@ -752,6 +758,9 @@ export const FsFtp: Fs = {
     icon: 'globe-network',
     name: 'ftp',
     description: 'Fs that just implements fs over ftp',
+    options: {
+        needsRefresh: true
+    },
     canread(str: string): boolean {
         const info = new URL(str);
         console.log('FsFtp.canread', str, info.protocol, info.protocol === 'ftp:');
diff --git a/src/services/plugins/FsGeneric.ts b/src/services/plugins/FsGeneric.ts
index 42647ed..3d0398b 100644
--- a/src/services/plugins/FsGeneric.ts
+++ b/src/services/plugins/FsGeneric.ts
@@ -65,6 +65,11 @@ class GenericApi implements FsApi {
         return Promise.resolve(true);
     }
 
+    async makeSymlink(targetPath: string, path: string, transferId?: number): Promise<boolean> {
+        console.log("FsGeneric.makeSymlink")
+        return true;
+    }
+
     stat(fullPath: string, transferId = -1): Promise<File> {
         return Promise.resolve({
             dir: "",
@@ -139,6 +144,9 @@ export const FsGeneric: Fs = {
     icon: "database",
     name: "generic",
     description: "Fs that just implements the FsInterface but does nothing",
+    options: {
+        needsRefresh: false
+    },
     canread(str: string): boolean {
         return true;
     },
diff --git a/src/services/plugins/FsLocal.ts b/src/services/plugins/FsLocal.ts
index 17f73d0..d93f751 100644
--- a/src/services/plugins/FsLocal.ts
+++ b/src/services/plugins/FsLocal.ts
@@ -83,7 +83,7 @@ export class LocalApi implements FsApi {
         });
     }
 
-    makedir(source: string, dirName: string, transferId = -1): Promise<string> {
+    async makedir(source: string, dirName: string, transferId = -1): Promise<string> {
         return new Promise((resolve, reject) => {
             const unixPath = path.join(source, dirName).replace(/\\/g, "/");
             try {
@@ -132,7 +132,7 @@ export class LocalApi implements FsApi {
             console.log("valid !", oldPath, newPath);
             return new Promise((resolve, reject) => {
                 // since node's fs.rename will overwrite the destination
-                // path if it exists, first check that file dones't exists
+                // path if it exists, first check that file doesn't exist
                 this.exists(newPath).then(exists => {
                     if (exists) {
                         reject({
@@ -165,68 +165,72 @@ export class LocalApi implements FsApi {
         }
     }
 
-    isDir(path: string, transferId = -1): Promise<boolean> {
-        return new Promise((resolve, reject) => {
-            try {
-                const lstat = fs.lstatSync(path);
-                const stat = fs.statSync(path);
-                resolve(stat.isDirectory() || lstat.isDirectory());
-            } catch (err) {
+    async makeSymlink(targetPath: string, path: string, transferId = -1): Promise<boolean> {
+        console.log('creating symlink', targetPath, path);
+        return new Promise<boolean>((resolve, reject) => fs.symlink(targetPath, path, (err) => {
+            if (err) {
+                console.log('error creating symlink', err);
                 reject(err);
+            } else {
+                console.log('link created');
+                resolve(true);
             }
-        });
+        }))
     }
 
-    exists(path: string, transferId = -1): Promise<boolean> {
-        return new Promise((resolve, reject) => {
-            try {
-                fs.statSync(path);
-                resolve(true);
-            } catch (err) {
-                if (err.code === "ENOENT") {
-                    resolve(false);
-                } else {
-                    reject(err);
-                }
+    async isDir(path: string, transferId = -1): Promise<boolean> {
+        const lstat = fs.lstatSync(path);
+        const stat = fs.statSync(path);
+        return stat.isDirectory() || lstat.isDirectory();
+    }
+
+    async exists(path: string, transferId = -1): Promise<boolean> {
+        try {
+            fs.statSync(path);
+            return true;
+        } catch (err) {
+            if (err.code === "ENOENT") {
+                return false;
+            } else {
+                throw err;
             }
-        });
+        }
     }
 
     async stat(fullPath: string, transferId = -1): Promise<File> {
-        return new Promise<File>((resolve, reject) => {
-            try {
-                const format = path.parse(fullPath);
-                const stats = fs.statSync(fullPath);
-                const file: File = {
-                    dir: format.dir,
-                    fullname: format.base,
-                    name: format.name,
-                    extension: format.ext.toLowerCase(),
-                    cDate: stats.ctime,
-                    mDate: stats.mtime,
-                    bDate: stats.birthtime,
-                    length: stats.size,
-                    mode: stats.mode,
-                    isDir: stats.isDirectory(),
-                    readonly: false,
-                    type:
-                        (!stats.isDirectory() &&
-                            filetype(
-                                stats.mode,
-                                stats.gid,
-                                stats.uid,
-                                format.ext.toLowerCase()
-                            )) ||
-                        "",
-                    isSym: stats.isSymbolicLink(),
-                    id: MakeId(stats)
-                };
-
-                resolve(file);
-            } catch (err) {
-                reject(err);
-            }
-        });
+        try {
+            const format = path.parse(fullPath);
+            const stats = fs.lstatSync(fullPath);
+            const file: File = {
+                dir: format.dir,
+                fullname: format.base,
+                name: format.name,
+                extension: format.ext.toLowerCase(),
+                cDate: stats.ctime,
+                mDate: stats.mtime,
+                bDate: stats.birthtime,
+                length: stats.size,
+                mode: stats.mode,
+                isDir: stats.isDirectory(),
+                readonly: false,
+                type:
+                    (!stats.isDirectory() &&
+                        filetype(
+                            stats.mode,
+                            stats.gid,
+                            stats.uid,
+                            format.ext.toLowerCase()
+                        )) ||
+                    "",
+                isSym: stats.isSymbolicLink(),
+                target: stats.isSymbolicLink() && fs.readlinkSync(fullPath) || null,
+                id: MakeId(stats)
+            };
+
+            return file;
+        } catch (err) {
+            return err;
+        }
     }
 
     login(server?: string, credentials?: ICredentials): Promise<void> {
@@ -277,29 +281,35 @@ export class LocalApi implements FsApi {
     static fileFromPath(fullPath: string): File {
         const format = path.parse(fullPath);
         let name = fullPath;
-        let stats = null;
+        let stats: Partial<fs.Stats> = null;
         let target_stats = null;
 
         try {
+            // do not follow symlinks first
             stats = fs.lstatSync(fullPath);
             if (stats.isSymbolicLink()) {
-                target_stats = fs.statSync(fullPath);
+                // get link target path first
                 name = fs.readlinkSync(fullPath);
+                target_stats = fs.statSync(fullPath);
             }
         } catch (err) {
             console.warn(
                 "error getting stats for",
-                /*path.join(dirPath, items[i])*/ fullPath,
+                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: 0,
-                isDirectory: () => true,
+                size: stats.size,
+                isDirectory: () => isDir,
                 mode: -1,
-                isSymbolicLink: () => false,
+                isSymbolicLink: () => isSymLink,
                 ino: 0,
                 dev: 0
             };
@@ -329,6 +339,7 @@ export class LocalApi implements FsApi {
                     filetype(mode, 0, 0, extension)) ||
                 "",
             isSym: stats.isSymbolicLink(),
+            target: stats.isSymbolicLink() && name || null,
             id: MakeId(stats)
         };
 
@@ -486,6 +497,9 @@ export const FsLocal: Fs = {
     icon: "database",
     name: "local",
     description: "Local Filesystem",
+    options: {
+        needsRefresh: false
+    },
     canread(str: string): boolean {
         // console.log('FsLocal.canread', str, !!str.match(localStart));
         return !!str.match(localStart);
diff --git a/src/services/plugins/FsSimpleFtp.ts b/src/services/plugins/FsSimpleFtp.ts
index 6943e8a..bc2af73 100644
--- a/src/services/plugins/FsSimpleFtp.ts
+++ b/src/services/plugins/FsSimpleFtp.ts
@@ -242,7 +242,7 @@ class SimpleFtpApi implements FsApi {
 
     isDirectoryNameValid(dirName: string): boolean {
         debugger;
-        console.log('GenericFs.isDirectoryNameValid');
+        console.log('SimpleFtpFs.isDirectoryNameValid');
         return true;
     }
 
@@ -280,7 +280,7 @@ class SimpleFtpApi implements FsApi {
     }
 
     size(source: string, files: string[], transferId = -1): Promise<number> {
-        console.log('GenericFs.size');
+        console.log('SimpleFtpFs.size');
         return Promise.resolve(10);
     }
 
@@ -300,30 +300,35 @@ class SimpleFtpApi implements FsApi {
     }
 
     makedir(parent: string, dirName: string, transferId = -1): Promise<string> {
-        console.log('FsGeneric.makedir');
+        console.log('FsSimpleFtp.makedir');
         return Promise.resolve('');
     }
 
     delete(src: string, files: File[], transferId = -1): Promise<number> {
-        console.log('FsGeneric.delete');
+        console.log('FsSimpleFtp.delete');
         return Promise.resolve(files.length);
     }
 
     rename(source: string, file: File, newName: string, transferId = -1): Promise<string> {
-        console.log('FsGeneric.rename');
+        console.log('FsSimpleFtp.rename');
         return Promise.resolve(newName);
     }
 
     isDir(path: string, transferId = -1): Promise<boolean> {
-        console.log('FsGeneric.isDir');
+        console.log('FsSimpleFtp.isDir');
         return Promise.resolve(true);
     }
 
     exists(path: string, transferId = -1): Promise<boolean> {
-        console.log('FsGeneric.exists');
+        console.log('FsSimpleFtp.exists');
         return Promise.resolve(true);
     }
 
+    async makeSymlink(targetPath: string, path: string, transferId?: number): Promise<boolean> {
+        console.log("FsSimpleFtp.makeSymlink")
+        return true;
+    }
+
     async stat(fullPath: string, transferId = -1): Promise<File> {
         return Promise.resolve({
             dir: '',
@@ -366,6 +371,7 @@ class SimpleFtpApi implements FsApi {
                         readonly: false,
                         type: !ftpFile.isDirectory && filetype(0, 0, 0, ext) || '',
                         isSym: false,
+                        target: null,
                         id: {
                             ino: mDate.getTime(),
                             dev: new Date().getTime()
@@ -463,6 +469,9 @@ export const FsSimpleFtp: Fs = {
     icon: 'globe-network',
     name: 'simple-ftp',
     description: 'Fs that implements ft connection on top of simple-ftp',
+    options: {
+        needsRefresh: true
+    },
     canread(str: string): boolean {
         const info = new URL(str);
         console.log('FsFtp.canread', str, info.protocol, info.protocol === 'ftp:');
diff --git a/src/state/appState.ts b/src/state/appState.ts
index acd2276..dcce0d3 100644
--- a/src/state/appState.ts
+++ b/src/state/appState.ts
@@ -181,9 +181,11 @@ export class AppState {
 
         return this.addTransfer(options)
             .then(res => {
+                const fs = dstCache.getFS()
                 if (
+                    fs.options.needsRefresh &&
                     options.dstPath === dstCache.path &&
-                    options.dstFsName === dstCache.getFS().name
+                    options.dstFsName === fs.name
                 ) {
                     dstCache.reload();
                 }
@@ -337,6 +339,7 @@ export class AppState {
             options.dstFs,
             options.dstPath
         );
+        debugger;
         const batch = new Batch(
             options.srcFs,
             options.dstFs,
diff --git a/src/state/fileState.ts b/src/state/fileState.ts
index b033164..a60e295 100644
--- a/src/state/fileState.ts
+++ b/src/state/fileState.ts
@@ -529,19 +529,20 @@ export class FileState {
     async openFile(appState: AppState, cache: FileState, file: File) {
         try {
             const path = await appState.prepareLocalTransfer(cache, [file]);
-            this.shellOpenFile(path);
+            if (this.shellOpenFile(path) !== true) {
+                throw {
+                    message: i18next.t('ERRORS.SHELL_OPEN_FAILED', { path }),
+                    code: 'NO_CODE'
+                }
+            }
         } catch (err) {
             return Promise.reject(err);
         }
     }
 
-    shellOpenFile(path: string) {
+    shellOpenFile(path: string):boolean {
         console.log('need to open file', path);
-        shell.openItem(path);
-        // return this.get(file).then((tmpPath: string) => {
-        //     console.log('opening file', tmpPath);
-        //     shell.openItem(tmpPath);
-        // });
+        return shell.openItem(path);
     }
 
     openDirectory(file: { dir: string, fullname: string }) {
diff --git a/src/transfers/batch.ts b/src/transfers/batch.ts
index 685f0bf..d200844 100644
--- a/src/transfers/batch.ts
+++ b/src/transfers/batch.ts
@@ -184,11 +184,15 @@ export class Batch {
         const srcPath = srcFs.join(this.srcPath, transfer.subDirectory);
         const wantedName = transfer.file.fullname;
         const isDir = transfer.file.isDir;
+        const isSym = transfer.file.isSym;
 
         let newFilename = '';
         let stream = null;
 
         try {
+            if (transfer.file.isSym) {
+                debugger;
+            }
             newFilename = await this.renameOrCreateDir(transfer, fullDstPath);
         } catch (err) {
             console.log('error creating directory', err);
@@ -200,47 +204,61 @@ export class Batch {
         }
 
         if (!isDir) {
-            try {
-                // console.log('getting stream', srcPath, wantedName);
-                stream = await srcFs.getStream(srcPath, wantedName, this.id);
-
-                this.streams.push(stream);
-                // console.log('sending to stream', dstFs.join(fullDstPath, newFilename));
-                // we have to listen for errors that may appear during the transfer: socket closed, timeout,...
-                // and throw an error in this case because the putStream won't throw in this case:
-                // it will just stall
-                // stream.on('error', (err) => {
-                //     console.log('error on read stream');
-                //     this.onTransferError(transfer, err);
-                //     // destroy stream so that put stream resolves ?
-                //     stream.destroy();
-                // });
-
-                await dstFs.putStream(stream, dstFs.join(fullDstPath, newFilename), (bytesRead: number) => {
-                    this.onData(transfer, bytesRead);
-                }, this.id);
-
-                this.removeStream(stream);
-                transfer.status = 'done';
-            } catch (err) {
-                console.log('error with streams', err);
-                this.removeStream(stream);
-                // transfer.status = 'error';
-                // return Promise.reject(err);
-                // generate transfer error, but do not stop transfer unless errors > MAX_TRANSFER_ERRORS
-                // TODO: transfer.errors++
-                // if (transfer.errors > MAX) {
-                // set remaining transfers to cancel
-                // return ?
-                //}
-                this.onTransferError(transfer, err);
-                if (this.errors > MAX_ERRORS) {
-                    console.warn('maximum errors occurred: cancelling upcoming file transfers');
-                    this.status = 'error';
-                    this.cancelFiles();
+            if (isSym) {
+                const linkPath = dstFs.join(fullDstPath, newFilename)
+                try {
+                    debugger;
+                    await srcFs.makeSymlink(transfer.file.target, linkPath, this.id)
+                    transfer.status = 'done';
+                } catch(err) {
+                    this.onTransferError(transfer, err);
+                    debugger;
+                }
+            } else {
+                try {
+                    if (transfer.file.isSym) {
+                        debugger;
+                    }
+                    // console.log('getting stream', srcPath, wantedName);
+                    stream = await srcFs.getStream(srcPath, wantedName, this.id);
+    
+                    this.streams.push(stream);
+                    // console.log('sending to stream', dstFs.join(fullDstPath, newFilename));
+                    // we have to listen for errors that may appear during the transfer: socket closed, timeout,...
+                    // and throw an error in this case because the putStream won't throw in this case:
+                    // it will just stall
+                    // stream.on('error', (err) => {
+                    //     console.log('error on read stream');
+                    //     this.onTransferError(transfer, err);
+                    //     // destroy stream so that put stream resolves ?
+                    //     stream.destroy();
+                    // });
+    
+                    await dstFs.putStream(stream, dstFs.join(fullDstPath, newFilename), (bytesRead: number) => {
+                        this.onData(transfer, bytesRead);
+                    }, this.id);
+    
+                    this.removeStream(stream);
+                    transfer.status = 'done';
+                } catch (err) {
+                    console.log('error with streams', err);
+                    this.removeStream(stream);
+                    // transfer.status = 'error';
+                    // return Promise.reject(err);
+                    // generate transfer error, but do not stop transfer unless errors > MAX_TRANSFER_ERRORS
+                    // TODO: transfer.errors++
+                    // if (transfer.errors > MAX) {
+                    // set remaining transfers to cancel
+                    // return ?
+                    //}
+                    this.onTransferError(transfer, err);
+                    if (this.errors > MAX_ERRORS) {
+                        console.warn('maximum errors occurred: cancelling upcoming file transfers');
+                        this.status = 'error';
+                        this.cancelFiles();
+                    }
                 }
             }
-
         } else {
             transfer.status = 'done';
             // make transfers with this directory ready
@@ -285,7 +303,7 @@ export class Batch {
     async renameOrCreateDir(transfer: FileTransfer, dstPath: string): Promise<string> {
         const isDir = transfer.file.isDir;
         const dstFs = this.dstFs;
-        // create directory if not exists
+        // create directory if does not exist
         const wantedName = transfer.file.fullname;
         let dirPath = dstFs.join(dstPath, wantedName);
         let newName = wantedName;
@@ -307,7 +325,7 @@ export class Batch {
             // directory already exists: for now, simply use it
             if (!exists) {
                 try {
-                    const newDir = await dstFs.makedir(dstPath, newName, this.id);
+                    await dstFs.makedir(dstPath, newName, this.id);
                 } catch (err) {
                     return Promise.reject(err);
                 }


Test patch:
diff --git a/src/services/FsSort.test.ts b/src/services/__tests__/FsSort.test.ts
similarity index 94%
rename from src/services/FsSort.test.ts
rename to src/services/__tests__/FsSort.test.ts
index 3298745..a58094a 100644
--- a/src/services/FsSort.test.ts
+++ b/src/services/__tests__/FsSort.test.ts
@@ -1,5 +1,5 @@
-import { getSortMethod } from './FsSort';
-import { File } from './Fs';
+import { getSortMethod } from '../FsSort';
+import { File } from '../Fs';
 
 const files: Array<File> = [{
     "dir": "/",
@@ -18,6 +18,7 @@ const files: Array<File> = [{
         "dev": 1
     },
     isSym: false,
+    target: null,
     type: ''
 },
 {
@@ -37,6 +38,7 @@ const files: Array<File> = [{
         "dev": 1
     },
     isSym: false,
+    target: null,
     type: ''
 },
 {
@@ -56,6 +58,7 @@ const files: Array<File> = [{
         "dev": 1
     },
     isSym: false,
+    target: null,
     type: ''
 },
 {
@@ -75,6 +78,7 @@ const files: Array<File> = [{
         "dev": 1
     },
     isSym: false,
+    target: null,
     type: ''
 }];
 
diff --git a/src/services/plugins/FsLocal.test.ts b/src/services/plugins/__tests__/FsLocal.test.ts
similarity index 98%
rename from src/services/plugins/FsLocal.test.ts
rename to src/services/plugins/__tests__/FsLocal.test.ts
index 792ed5e..4bfcfe4 100644
--- a/src/services/plugins/FsLocal.test.ts
+++ b/src/services/plugins/__tests__/FsLocal.test.ts
@@ -1,10 +1,10 @@
-import { FsLocal } from "./FsLocal";
+import { FsLocal } from "../FsLocal";
 
 import {
     describeUnix,
     getPath,
     prepareTmpTestFiles
-} from "../../utils/test/helpers";
+} from "../../../utils/test/helpers";
 
 let localAPI: any;
 
diff --git a/src/utils/fileUtils.test.ts b/src/utils/__tests__/fileUtils.test.ts
similarity index 94%
rename from src/utils/fileUtils.test.ts
rename to src/utils/__tests__/fileUtils.test.ts
index cff069b..2421b1c 100644
--- a/src/utils/fileUtils.test.ts
+++ b/src/utils/__tests__/fileUtils.test.ts
@@ -1,4 +1,4 @@
-import { getSelectionRange, getExtensionIndex } from './fileUtils';
+import { getSelectionRange, getExtensionIndex } from '../fileUtils';
 
 describe('fileUtils', () => {
     it('getSelectionRange: should exclude both extensions for archive.tar.gz', () => {