Skip to content

Commit

Permalink
Merge pull request #451 from allusion-app/dnd-file-moving
Browse files Browse the repository at this point in the history
Move files to other (sub) locations with drag 'n drop
  • Loading branch information
hummingly authored May 1, 2022
2 parents 81a8a7e + 02e9b9e commit a5af173
Show file tree
Hide file tree
Showing 11 changed files with 216 additions and 62 deletions.
34 changes: 28 additions & 6 deletions src/Messaging.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { ipcRenderer, ipcMain, WebContents } from 'electron';
import { BrowserWindow, ipcMain, ipcRenderer, WebContents } from 'electron';
import path from 'path';

import { IImportItem } from './clipper/server';
import { ID } from './entities/ID';
import { ITag } from './entities/Tag';

import { IImportItem } from './clipper/server';
import { ViewMethod } from './frontend/stores/UiStore';

/**
Expand Down Expand Up @@ -32,6 +30,8 @@ const CLEAR_DATABASE = 'CLEAR_DATABASE';
const TOGGLE_DEV_TOOLS = 'TOGGLE_DEV_TOOLS';
const RELOAD = 'RELOAD';
const OPEN_DIALOG = 'OPEN_DIALOG';
const MESSAGE_BOX = 'MESSAGE_BOX';
const MESSAGE_BOX_SYNC = 'MESSAGE_BOX_SYNC';
const GET_PATH = 'GET_PATH';
const TRASH_FILE = 'TRASH_FILE';
const SET_FULL_SCREEN = 'SET_FULL_SCREEN';
Expand Down Expand Up @@ -133,10 +133,17 @@ export class RendererMessenger {

static reload = (frontEndOnly?: boolean) => ipcRenderer.send(RELOAD, frontEndOnly);

static openDialog = (
static showOpenDialog = (
options: Electron.OpenDialogOptions,
): Promise<Electron.OpenDialogReturnValue> => ipcRenderer.invoke(OPEN_DIALOG, options);

static showMessageBox = (
options: Electron.MessageBoxOptions,
): Promise<Electron.MessageBoxReturnValue> => ipcRenderer.invoke(MESSAGE_BOX, options);

static showMessageBoxSync = (options: Electron.MessageBoxSyncOptions): Promise<number> =>
ipcRenderer.invoke(MESSAGE_BOX_SYNC, options);

static getPath = (name: SYSTEM_PATHS): Promise<string> => ipcRenderer.invoke(GET_PATH, name);

static trashFile = (absolutePath: string): Promise<Error | undefined> =>
Expand Down Expand Up @@ -246,7 +253,22 @@ export class MainMessenger {
ipcMain.on(RELOAD, (_, frontEndOnly) => cb(frontEndOnly));

static onOpenDialog = (dialog: Electron.Dialog) =>
ipcMain.handle(OPEN_DIALOG, (_, options) => dialog.showOpenDialog(options));
ipcMain.handle(OPEN_DIALOG, (e, options) => {
const bw = BrowserWindow.fromWebContents(e.sender);
return bw ? dialog.showOpenDialog(bw, options) : dialog.showOpenDialog(options);
});

static onMessageBox = (dialog: Electron.Dialog) =>
ipcMain.handle(MESSAGE_BOX, (e, options) => {
const bw = BrowserWindow.fromWebContents(e.sender);
return bw ? dialog.showMessageBox(bw, options) : dialog.showMessageBox(options);
});

static onMessageBoxSync = (dialog: Electron.Dialog) =>
ipcMain.handle(MESSAGE_BOX_SYNC, (e, options) => {
const bw = BrowserWindow.fromWebContents(e.sender);
return bw ? dialog.showMessageBoxSync(bw, options) : dialog.showMessageBoxSync(options);
});

static onGetPath = (cb: (name: SYSTEM_PATHS) => string) =>
ipcMain.handle(GET_PATH, (_, name) => cb(name));
Expand Down
1 change: 1 addition & 0 deletions src/entities/File.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export async function getMetaData(stats: FileStats, exifIO: ExifIO): Promise<IMe
export function mergeMovedFile(oldFile: IFile, newFile: IFile): IFile {
return {
...oldFile,
ino: newFile.ino,
name: newFile.name,
extension: newFile.extension,
absolutePath: newFile.absolutePath,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { when } from 'mobx';
import { observer } from 'mobx-react-lite';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ClientLocation, ClientSubLocation } from 'src/entities/Location';
import { useStore } from 'src/frontend/contexts/StoreContext';
Expand Down Expand Up @@ -47,50 +48,46 @@ const LocationLabel = (nodeData: any, treeData: any) => (
<Location nodeData={nodeData} treeData={treeData} />
);

const SubLocation = ({
nodeData,
treeData,
}: {
nodeData: ClientSubLocation;
treeData: ITreeData;
}) => {
const { expansion, setExpansion } = treeData;
const subLocation = nodeData;
const SubLocation = observer(
({ nodeData, treeData }: { nodeData: ClientSubLocation; treeData: ITreeData }) => {
const { expansion, setExpansion } = treeData;
const subLocation = nodeData;

const toggleExclusion = () => {
subLocation.toggleExcluded();
// Need to update expansion to force a rerender of the tree
setExpansion({ ...expansion, [subLocation.path]: false });
};
const toggleExclusion = () => {
subLocation.toggleExcluded();
// Need to update expansion to force a rerender of the tree
setExpansion({ ...expansion, [subLocation.path]: false });
};

return (
<div className="tree-content-label" aria-disabled={subLocation.isExcluded}>
<Checkbox
// label looks nicer on the right
label=""
onChange={toggleExclusion}
// make it appear like it's an "include" option
checked={!subLocation.isExcluded}
/>
<span
style={{
marginLeft: '4px',
color: subLocation.isExcluded ? 'var(--text-color-muted)' : undefined,
}}
>
{subLocation.name}
</span>
</div>
);
};
return (
<div className="tree-content-label" aria-disabled={subLocation.isExcluded}>
<Checkbox
// label looks nicer on the right
label=""
onChange={toggleExclusion}
// make it appear like it's an "include" option
checked={!subLocation.isExcluded}
/>
<span
style={{
marginLeft: '4px',
color: subLocation.isExcluded ? 'var(--text-color-muted)' : undefined,
}}
>
{subLocation.name}
</span>
</div>
);
},
);

const Location = ({ nodeData }: { nodeData: ClientLocation; treeData: ITreeData }) => {
const Location = observer(({ nodeData }: { nodeData: ClientLocation; treeData: ITreeData }) => {
return (
<div className="tree-content-label">
<div>{nodeData.name}</div>
</div>
);
};
});

// eslint-disable-next-line @typescript-eslint/no-empty-function
const emptyFunction = () => {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ const LocationRecoveryDialog = () => {
};

const handleLocate = async () => {
const { filePaths: dirs } = await RendererMessenger.openDialog({
const { filePaths: dirs } = await RendererMessenger.showOpenDialog({
properties: ['openDirectory'],
defaultPath: location.path, // TODO: Maybe pick the parent dir of the original location by default?
});
Expand Down
23 changes: 18 additions & 5 deletions src/frontend/containers/Outliner/LocationsPanel/dnd.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
import fse from 'fs-extra';
import path from 'path';
import { IMG_EXTENSIONS } from 'src/entities/File';
import { ClientFile, IMG_EXTENSIONS } from 'src/entities/File';
import { ALLOWED_DROP_TYPES } from 'src/frontend/contexts/DropContext';
import { retainArray } from 'common/core';
import { timeoutPromise } from 'common/timeout';
import { IStoreFileMessage, RendererMessenger } from 'src/Messaging';
import { DnDAttribute } from 'src/frontend/contexts/TagDnDContext';
import FileStore from 'src/frontend/stores/FileStore';
import { action } from 'mobx';

const ALLOWED_FILE_DROP_TYPES = IMG_EXTENSIONS.map((ext) => `image/${ext}`);

export const isAcceptableType = (e: React.DragEvent) =>
e.dataTransfer.types.some((type) => ALLOWED_DROP_TYPES.includes(type));

/** Returns the IDs of the files that match those in Allusion given dropData. Returns false if one or files has no matches */
export const findDroppedFileMatches = action(
(dropData: (File | string)[], fs: FileStore): ClientFile[] | false => {
const matches = dropData.map(
(file) =>
typeof file !== 'string' &&
file.path &&
fs.fileList.find((f) => f.absolutePath === file.path),
);
return matches.every((m): m is ClientFile => m instanceof ClientFile) ? matches : false;
},
);

/**
* Executed callback function while dragging over a target.
*
Expand Down Expand Up @@ -42,9 +57,7 @@ export function handleDragLeave(event: React.DragEvent<HTMLDivElement>) {
}
}

export async function storeDroppedImage(e: React.DragEvent, directory: string) {
const dropData = await getDropData(e);

export async function storeDroppedImage(dropData: (string | File)[], directory: string) {
for (const dataItem of dropData) {
let fileData: IStoreFileMessage | undefined;

Expand Down Expand Up @@ -122,7 +135,7 @@ function getFilenameFromUrl(url: string, fallback: string) {
return index !== -1 ? pathname.substring(index + 1) : pathname;
}

async function getDropData(e: React.DragEvent): Promise<Array<File | string>> {
export async function getDropData(e: React.DragEvent): Promise<Array<File | string>> {
// Using a set to filter out duplicates. For some reason, dropping URLs duplicates them 3 times (for me)
const dropItems = new Set<File | string>();

Expand Down
2 changes: 1 addition & 1 deletion src/frontend/containers/Outliner/LocationsPanel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ const LocationsPanel = observer((props: Partial<MultiSplitPaneProps>) => {
const handleChooseWatchedDir = useCallback(async () => {
let path: string;
try {
const { filePaths } = await RendererMessenger.openDialog({
const { filePaths } = await RendererMessenger.showOpenDialog({
properties: ['openDirectory'],
});
// multi-selection is disabled which means there can be at most 1 folder
Expand Down
116 changes: 113 additions & 3 deletions src/frontend/containers/Outliner/LocationsPanel/useFileDnD.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,111 @@
import { useState, useCallback } from 'react';
import { humanFileSize } from 'common/fmt';
import fse from 'fs-extra';
import path from 'path';
import { useCallback, useState } from 'react';
import { ClientFile } from 'src/entities/File';
import { ClientLocation } from 'src/entities/Location';
import { AppToaster } from 'src/frontend/components/Toaster';
import { useStore } from 'src/frontend/contexts/StoreContext';
import { DnDAttribute } from 'src/frontend/contexts/TagDnDContext';
import FileStore from 'src/frontend/stores/FileStore';
import { RendererMessenger } from 'src/Messaging';
import { IExpansionState } from '../../types';
import { onDragOver, isAcceptableType, storeDroppedImage, handleDragLeave } from './dnd';
import {
findDroppedFileMatches,
getDropData,
handleDragLeave,
isAcceptableType,
onDragOver,
storeDroppedImage,
} from './dnd';

export const HOVER_TIME_TO_EXPAND = 600;

/**
* Either moves or downloads a dropped file into the target directory
* @param fileStore
* @param matches
* @param dir
*/
const handleMove = async (
fileStore: FileStore,
matches: ClientFile[],
loc: ClientLocation,
dir: string,
) => {
let isReplaceAllActive = false;

// If it's a file being dropped that's already in Allusion, move it
for (const file of matches) {
const src = path.normalize(file.absolutePath);
const dst = path.normalize(path.join(dir, file.name));
if (src !== dst) {
const alreadyExists = await fse.pathExists(dst);

if (alreadyExists && !isReplaceAllActive) {
const srcStats = await fse.stat(src);
const dstStats = await fse.stat(dst);

// if the file is already in the target location, prompt the user to confirm the move
// TODO: could also add option to rename with a number suffix?
const res = await RendererMessenger.showMessageBox({
type: 'question',
title: 'Replace or skip file?',
message: `"${file.name}" already exists in this folder. Replace or skip?`,
detail: `From "${path.dirname(file.absolutePath)}" (${humanFileSize(
srcStats.size,
)}) \nTo "${dir}" (${humanFileSize(
dstStats.size,
)})\nNote: The tags on the replaced image will be lost.`,
buttons: ['&Replace', '&Skip', '&Cancel'],
normalizeAccessKeys: true,
defaultId: 0,
cancelId: 2,
checkboxLabel: matches.length > 1 ? 'Apply to all' : undefined,
});

if (res.response === 0 && res.checkboxChecked) {
isReplaceAllActive = true; // replace all
} else if ((res.response === 1 && res.checkboxChecked) || res.response === 2) {
break; // skip all
} else if (res.response === 1) {
continue; // skip this file
}
}

// When replacing an existing file, no change is detected when moving the file
// The target file needs to be removed from disk and the DB first
if (alreadyExists) {
// - Remove the target file from disk
await fse.remove(dst);

// - Remove the target file from the store
// TODO: This removes the target file and its tags. Could merge them, but that's a bit more work
const dstFile = fileStore.fileList.find((f) => f.absolutePath === dst);
if (dstFile) {
await fileStore.deleteFiles([dstFile]);
}

// - Move the source file to the target path
// Now the DB and internal state have been prepared to be able to detect the moving of the file
// Will be done with the move operation below
// We need to wait a second for the UI to update, otherwise it will cause render issues for some reason (old and new are rendered simultaneously)
await new Promise((res) => setTimeout(res, 1000));
} else {
// Else, the file watcher process will detect the changes and update the File entity accordingly
}
await fse.move(src, dst, { overwrite: true });
}
}
};

export const useFileDropHandling = (
expansionId: string,
fullPath: string,
expansion: IExpansionState,
setExpansion: (s: IExpansionState) => void,
) => {
const { fileStore, locationStore } = useStore();
// Don't expand immediately, only after hovering over it for a second or so
const [expandTimeoutId, setExpandTimeoutId] = useState<number>();
const expandDelayed = useCallback(() => {
Expand Down Expand Up @@ -47,7 +141,23 @@ export const useFileDropHandling = (
if (isAcceptableType(event)) {
event.dataTransfer.dropEffect = 'none';
try {
await storeDroppedImage(event, fullPath);
const dropData = await getDropData(event);

// if this is a local file (it has matches to the files in the DB),
// it should be moved instead of copied
const matches = findDroppedFileMatches(dropData, fileStore);
if (matches) {
const loc = locationStore.locationList.find((l) => fullPath.startsWith(l.path));
if (!loc) {
throw new Error('Location not found for path ' + fullPath);
}
await handleMove(fileStore, matches, loc, fullPath);
setTimeout(() => fileStore.refetch(), 500);
} else {
// Otherwise it's an external file (e.g. from the web or a folder not set up as a Location in Allusion)
// -> download it and "copy" it to the target folder
await storeDroppedImage(dropData, fullPath);
}
} catch (e) {
console.error(e);
AppToaster.show({
Expand Down
6 changes: 3 additions & 3 deletions src/frontend/containers/Settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ const ImportExport = observer(() => {
}, []);

const handleChooseImportDir = async () => {
const { filePaths } = await RendererMessenger.openDialog({
const { filePaths } = await RendererMessenger.showOpenDialog({
properties: ['openFile'],
filters: [{ extensions: ['json'], name: 'JSON' }],
defaultPath: backupDir,
Expand Down Expand Up @@ -449,7 +449,7 @@ const BackgroundProcesses = observer(() => {

const importDirectory = uiStore.importDirectory;
const browseImportDirectory = async () => {
const { filePaths: dirs } = await RendererMessenger.openDialog({
const { filePaths: dirs } = await RendererMessenger.showOpenDialog({
properties: ['openDirectory'],
defaultPath: importDirectory,
});
Expand Down Expand Up @@ -617,7 +617,7 @@ const Advanced = observer(() => {
};

const browseThumbnailDirectory = async () => {
const { filePaths: dirs } = await RendererMessenger.openDialog({
const { filePaths: dirs } = await RendererMessenger.showOpenDialog({
properties: ['openDirectory'],
defaultPath: thumbnailDirectory,
});
Expand Down
Loading

0 comments on commit a5af173

Please sign in to comment.