matthew-matvei/freeman

View on GitHub
src/renderer/components/panels/DirectoryList.tsx

Summary

Maintainability
D
2 days
Test Coverage
import autobind from "autobind-decorator";
import { remote } from "electron";
import { List } from "immutable";
import path from "path";
import * as PropTypes from "prop-types";
import * as React from "react";
import { HotKeys } from "react-hotkeys";
const dialog = remote.dialog;

import { DirectoryItem, InputItem } from "components/blocks";
import { Goto } from "components/modals";
import DirectoryError from "errors/DirectoryError";
import LoggedError from "errors/LoggedError";
import { IDirectoryItem, IHandlers } from "models";
import { DirectoryListModel } from "objects";
import { IDirectoryListProps } from "props/panels";
import { IDirectoryListState } from "states/panels";
import { ClipboardAction, DirectoryDirection, ItemType, ScrollToDirection } from "types";
import Utils from "Utils";

import "styles/panels/DirectoryList.scss";

/** The component for displaying a directory's list of items. */
class DirectoryList extends React.Component<IDirectoryListProps, IDirectoryListState> {

    /** Context available within a ScrollArea. */
    public context: { scrollArea: any };

    /** Validation for context types. */
    public static contextTypes = {
        scrollArea: PropTypes.object
    };

    /** Handler functions for the given events this component handles. */
    private handlers: IHandlers = {
        chooseItem: this.toggleItemChosen,
        copy: () => this.storeItemInClipboard("copy"),
        cut: () => this.storeItemInClipboard("cut"),
        delete: this.delete,
        moveBack: this.goBack,
        moveDown: () => this.move("down"),
        moveUp: () => this.move("up"),
        newFile: () => this.inputNewItem("file"),
        newFolder: () => this.inputNewItem("folder"),
        openGoto: this.openGoto,
        paste: this.pasteFromClipboard,
        rename: this.inputRenameItem,
        scrollToBottom: () => this.scrollTo("bottom"),
        scrollToTop: () => this.scrollTo("top"),
        sendToTrash: this.sendToTrash,
        toggleShowHidden: this.toggleShowHidden
    };

    /** The internal model of this DirectoryList. */
    private model: DirectoryListModel;

    /**
     * A trapper that can be given focus in cases where focus on directory items
     * is lost.
     */
    private keysTrapper?: HotKeys | null;

    /** Gets the keys trapper to invoke manual focusing of this DirectoryList. */
    public get KeysTrapper(): HotKeys | null | undefined {
        return this.keysTrapper;
    }

    /** Gets the directory items that are not currently hidden. */
    private get nonHiddenDirectoryItems(): IDirectoryItem[] {
        return this.state.directoryItems.filter(
            item => !item.isHidden || this.state.showHiddenItems);
    }

    /** Gets the currently selected item(s). */
    private get selectedItems(): IDirectoryItem[] {
        return this.state.chosenItems.length > 0 ?
            this.state.chosenItems : [this.nonHiddenDirectoryItems[this.state.selectedIndex]];
    }

    /**
     * Instantiates the DirectoryList component.
     *
     * @param props the properties for the DirectoryList component
     */
    public constructor(props: IDirectoryListProps, context: { scrollArea: any }) {
        super(props, context);

        this.context = context;

        this.state = {
            chosenItems: [],
            creatingNewItem: false,
            directoryItems: [],
            isFocused: this.props.isSelectedPane,
            isGotoOpen: false,
            itemDeleted: false,
            renamingItem: false,
            selectedIndex: 0,
            showHiddenItems: false
        };

        this.model = new DirectoryListModel();
    }

    /** Updates the directory contents after loading the component. */
    public async componentDidMount() {
        const { directoryManager, settingsManager } = this.props;

        this.startWatcher();

        const items = await directoryManager.listDirectory(
            this.props.path,
            { hideUnixStyleHiddenItems: settingsManager.settings.windows.hideUnixStyleHiddenItems });

        this.setState({ directoryItems: items } as IDirectoryListState);
    }

    /** Handles closing the watcher on unmounting the directory list. */
    public componentWillUnmount() {
        this.props.directoryManager.stopWatching();
    }

    /**
     * Handles setting the component to be focused on receiving new props.
     *
     * @param nextProps the next props object
     */
    public componentWillReceiveProps(nextProps: IDirectoryListProps) {
        if (!this.props.isSelectedPane && nextProps.isSelectedPane) {
            this.setState({ isFocused: true } as IDirectoryListState);
        }
    }

    /**
     * Updates the directory contents after updating the component.
     *
     * @param prevProps the previous props object
     * @param prevState the previous state object
     */
    public async componentDidUpdate(prevProps: IDirectoryListProps, prevState: IDirectoryListState) {
        const { directoryManager, settingsManager } = this.props;

        this.props.statusNotifier.setItemCount(this.nonHiddenDirectoryItems.length);
        this.props.statusNotifier.setChosenCount(this.state.chosenItems.length);

        if (this.nonHiddenDirectoryItems.length === 0 && this.keysTrapper && this.state.isFocused) {
            Utils.autoFocus(this.keysTrapper);
        }

        if (prevProps.path === this.props.path &&
            !prevState.creatingNewItem &&
            !prevState.renamingItem &&
            !this.state.itemDeleted) {

            return;
        }

        if (prevState.itemDeleted) {
            this.setState({ itemDeleted: false } as IDirectoryListState);
        }

        if (prevProps.path !== this.props.path) {
            this.startWatcher();
        }

        const directoryItems = await directoryManager.listDirectory(
            this.props.path,
            { hideUnixStyleHiddenItems: settingsManager.settings.windows.hideUnixStyleHiddenItems });
        const remainingChosenItems = this.state.chosenItems.filter(item => directoryItems.includes(item));
        this.setState(
            {
                chosenItems: remainingChosenItems,
                directoryItems
            } as IDirectoryListState);
    }

    /**
     * Whether the component should update.
     *
     * @param nextProps the next props
     * @param nextState the next state
     */
    public shouldComponentUpdate(nextProps: IDirectoryListProps, nextState: IDirectoryListState): boolean {
        return (this.model.stateChanged(this.state, nextState) ||
            this.model.propsChanged(this.props, nextProps) ||
            this.model.chosenItemsChanged(this.state.chosenItems, nextState.chosenItems) ||
            this.model.directoryItemsChanged(this.state.directoryItems, nextState.directoryItems));
    }

    /**
     * Defines how the directory list component is rendered.
     *
     * @returns a JSX element representing the directory list view
     */
    public render(): JSX.Element {
        const items = this.renderItems();

        return (
            <div className="DirectoryList">
                <HotKeys
                    handlers={this.handlers}
                    ref={component => {
                        this.keysTrapper = component;
                    }}
                    onFocus={this.setFocused}
                    onBlur={this.setUnFocused}>
                    <ul onKeyDown={this.handleKeyDown}>
                        {items}
                        {this.state.creatingNewItem &&
                            <InputItem
                                creatingItemType={this.state.creatingNewItem}
                                sendUpCreateItem={this.createNewItem}
                                otherItems={this.state.directoryItems}
                                theme={this.props.theme} />}
                    </ul>
                </HotKeys>
                <Goto
                    initialPath={this.props.path}
                    isOpen={this.state.isGotoOpen}
                    onClose={this.closeGoto}
                    navigateTo={this.navigateToPath}
                    directoryManager={this.props.directoryManager}
                    settingsManager={this.props.settingsManager}
                    theme={this.props.theme} />
            </div>);
    }

    /** Renders a list of directory items within the DirectoryList component. */
    private renderItems(): JSX.Element[] {
        return this.nonHiddenDirectoryItems
            .map((item, index) => {
                const isSelectedItem = this.props.isSelectedPane &&
                    !this.state.creatingNewItem && this.state.selectedIndex === index;

                if (this.state.renamingItem && isSelectedItem) {
                    const thisItem = this.nonHiddenDirectoryItems.find(i => i.name === item.name);
                    const otherItems = this.state.directoryItems.filter(i => i.name !== item.name);

                    return <InputItem
                        thisItem={thisItem}
                        otherItems={otherItems}
                        sendUpRenameItem={this.renameItem}
                        theme={this.props.theme} />;
                } else {
                    return <DirectoryItem
                        key={item.path}
                        model={item}
                        isSelected={this.state.isFocused && isSelectedItem}
                        isChosen={this.state.chosenItems.includes(item)}
                        sendPathUp={this.goIn}
                        sendSelectedItemUp={this.selectItem}
                        sendDeletionUp={this.refreshAfterDelete}
                        theme={this.props.theme}
                        columnSizes={this.props.columnSizes} />;
                }
            });
    }

    /** Handles closing the GoTo modal. */
    @autobind
    private closeGoto() {
        if (this.state.isGotoOpen) {
            this.setState({ isGotoOpen: false } as IDirectoryListState);
        }
    }

    /**
     * Creates a new directory item if arguments are provided.
     *
     * @param itemName the name of the item to be created
     * @param itemTypeToCreate the type of the item to be created
     */
    @autobind
    private async createNewItem(itemName?: string, itemTypeToCreate?: ItemType) {
        if (itemName && itemTypeToCreate) {
            Utils.trace(`Requesting to create ${itemTypeToCreate} called ${itemName} at ${this.props.path}`);
            await this.props.directoryManager.createItem(itemName, this.props.path, itemTypeToCreate);
        }

        this.setState({ creatingNewItem: false } as IDirectoryListState);
    }

    /**
     * Handles providing a dialog to the user to confirm deletion of an item.
     */
    @autobind
    private async delete() {
        const chosenItems = this.selectedItems.length > 1 ? "the chosen items" : `'${this.selectedItems[0].name}'`;
        const confirmDelete = this.props.settingsManager.settings.confirmation.requiredBeforeDeletion ?
            confirmationDialog(`Are you sure you want to permanently delete ${chosenItems}?`) :
            true;

        this.keysTrapper && Utils.autoFocus(this.keysTrapper);

        if (confirmDelete) {
            Utils.trace(`Requesting to delete ${this.selectedItems.map(item => item.path).join(", ")}`);
            await this.props.directoryManager.deleteItems(this.selectedItems);

            this.refreshAfterDelete();
            this.props.statusNotifier.notify("Deleted items");
        }
    }

    /**
     * Begins the creation of a new directory item.
     *
     * @param itemTypeToCreate the type of the item to begin creating
     */
    @autobind
    private inputNewItem(itemTypeToCreate: ItemType) {
        this.setState({ creatingNewItem: itemTypeToCreate } as IDirectoryListState);
    }

    /** Begins the renaming of a directory item. */
    @autobind
    private inputRenameItem() {
        this.setState({ renamingItem: true } as IDirectoryListState);
    }

    /** Navigates back to the parent directory. */
    @autobind
    private goBack() {
        this.context.scrollArea.scrollTop();

        const cachedSelectedIndex = this.model.popSelectedIndex();

        const parentDirectory = path.join(this.props.path, "..");
        this.setState({ selectedIndex: cachedSelectedIndex || 0 } as IDirectoryListState);
        this.props.sendPathUp(parentDirectory);
    }

    /**
     * Updates the path held in the directory pane's state
     *
     * @param pathToDirectory the path to update to
     */
    @autobind
    private goIn(pathToDirectory: string) {
        this.context.scrollArea.scrollTop();

        this.model.cacheSelectedIndex(this.state.selectedIndex);

        this.setState({ selectedIndex: 0 } as IDirectoryListState);
        this.props.sendPathUp(pathToDirectory);
    }

    /**
     * Handles adding single alphanumeric characters to a search term to update
     * the currently-selected item.
     *
     * @param event an event raised on key down
     */
    @autobind
    private handleKeyDown(event: React.KeyboardEvent<HTMLUListElement>) {
        if (this.state.creatingNewItem || this.state.renamingItem) {
            return;
        }

        if (event.key.length === 1) {
            const indexToSelect = this.model.textFinder.addCharAndSearch(
                event.key, this.nonHiddenDirectoryItems);

            if (indexToSelect >= 0) {
                this.setState({ selectedIndex: indexToSelect } as IDirectoryListState);
            }
        }
    }

    /**
     * Navigates the currently-selected item in the given direction.
     *
     * @param direction the direction to navigate in
     */
    @autobind
    private move(direction: DirectoryDirection) {
        if (direction === "up") {
            if (this.state.selectedIndex > 0) {
                this.setState(prevState => ({ selectedIndex: prevState.selectedIndex - 1 } as IDirectoryListState));
            }
        } else {
            if (this.state.selectedIndex < this.nonHiddenDirectoryItems.length - 1) {
                this.setState(prevState => ({ selectedIndex: prevState.selectedIndex + 1 } as IDirectoryListState));
            }
        }
    }

    /**
     * Handles navigating the user to the given path by sending it up to the
     * parent component.
     *
     * @param filePath the path to navigate to
     */
    @autobind
    private navigateToPath(filePath: string) {
        if (filePath !== this.props.path) {
            this.props.sendPathUp(filePath);
        }
    }

    /** Handles opening the Goto modal window. */
    @autobind
    private openGoto() {
        if (!this.state.isGotoOpen) {
            this.setState({ isGotoOpen: true } as IDirectoryListState);
        }
    }

    /**
     * Pastes an item stored in the internal clipboard according to the
     * ClipboardAction previously recorded.
     */
    @autobind
    private async pasteFromClipboard() {
        const { directoryManager, settingsManager } = this.props;
        const { clipboardAction, clipboardItems } = this.model;

        if (clipboardAction === "copy") {
            if (!clipboardItems) {
                throw new LoggedError("Clipboard items is undefined");
            }

            Utils.trace(`Requesting to copy ${clipboardItems.map(item => item.path).join(", ")} to ${this.props.path}`);
            await directoryManager.copyItems(clipboardItems, this.props.path);

            this.setState(
                {
                    directoryItems: await directoryManager.listDirectory(
                        this.props.path,
                        { hideUnixStyleHiddenItems: settingsManager.settings.windows.hideUnixStyleHiddenItems })
                } as IDirectoryListState);

            this.props.statusNotifier.notify("Copied items");
        } else if (clipboardAction === "cut") {
            if (!clipboardItems) {
                throw new LoggedError("Clipboard items is undefined");
            }

            Utils.trace(`Requesting to move ${clipboardItems.map(item => item.path).join(", ")} to ${path}`);
            await directoryManager.moveItems(clipboardItems, this.props.path);

            this.setState(
                {
                    directoryItems: await directoryManager.listDirectory(
                        this.props.path,
                        { hideUnixStyleHiddenItems: settingsManager.settings.windows.hideUnixStyleHiddenItems })
                } as IDirectoryListState);

            this.props.statusNotifier.notify("Cut items");
        }
    }

    /** Handles refreshing the page after a delete. */
    @autobind
    private refreshAfterDelete() {
        this.setState({ itemDeleted: true, selectedIndex: 0 } as IDirectoryListState);
    }

    /**
     * Renames a directory item if arguments are provided.
     *
     * @param oldName the previous name
     * @param newName the new name
     */
    @autobind
    private async renameItem(oldName?: string, newName?: string) {
        if (oldName && newName) {
            Utils.trace(`Requesting to rename item from ${oldName} to ${newName}`);
            await this.props.directoryManager.renameItem(oldName, newName, this.props.path);
        }

        this.setState({ renamingItem: false } as IDirectoryListState);
    }

    /**
     * Handles scrolling in the given scrollToDirection.
     *
     * @param scrollToDirection the direction in which to scroll to
     */
    @autobind
    private scrollTo(scrollToDirection: ScrollToDirection) {
        if (scrollToDirection === "top") {
            this.setState({ selectedIndex: 0 } as IDirectoryListState, () => {
                this.context.scrollArea.scrollTop();
            });
        } else {
            this.setState(
                {
                    selectedIndex: this.nonHiddenDirectoryItems.length - 1
                } as IDirectoryListState, () => {
                    this.context.scrollArea.scrollBottom();
                });
        }
    }

    /**
     * Handles selecting the given item in the directory pane.
     *
     * @param itemToSelect the item to select
     */
    @autobind
    private selectItem(itemToSelect: IDirectoryItem) {
        const index = this.nonHiddenDirectoryItems
            .findIndex(item => item.name === itemToSelect.name);
        this.setState({ selectedIndex: index } as IDirectoryListState);
        this.props.sendSelectedPaneUp(this.props.id);
    }

    /**
     * Handles providing a dialog to the user to confirm sending an item to the
     * trash.
     */
    @autobind
    private async sendToTrash() {
        const { settingsManager, directoryManager, statusNotifier } = this.props;

        const chosenItems = this.selectedItems.length > 1 ? "the chosen items" : `'${this.selectedItems[0].name}'`;
        const confirmTrash = settingsManager.settings.confirmation.requiredBeforeTrash ?
            confirmationDialog(`Are you sure you want to send ${chosenItems} to the trash?`) :
            true;

        this.keysTrapper && Utils.autoFocus(this.keysTrapper);

        if (confirmTrash) {
            Utils.trace(`Requesting to trash ${this.selectedItems.map(item => item.path).join(", ")}`);
            await directoryManager.sendItemsToTrash(this.selectedItems);
            this.refreshAfterDelete();
            statusNotifier.notify("Sent items to trash");
        }
    }

    /** Handles setting the focus of the directory list. */
    @autobind
    private setFocused() {
        this.setState({ isFocused: true } as IDirectoryListState);
    }

    /** Handles clearing the focus of the directory list. */
    @autobind
    private setUnFocused() {
        this.setState({ isFocused: false } as IDirectoryListState);
    }

    /** Starts watching the current directory for changes to update the list of directory items. */
    private startWatcher() {
        const { directoryManager, settingsManager } = this.props;

        try {
            directoryManager.startWatching(this.props.path, async () => {
                this.setState(
                    {
                        directoryItems: await directoryManager.listDirectory(
                            this.props.path,
                            { hideUnixStyleHiddenItems: settingsManager.settings.windows.hideUnixStyleHiddenItems })
                    } as IDirectoryListState);
            });
        } catch {
            throw new DirectoryError("Could not set watcher", this.props.path);
        }
    }

    /**
     * Stores the currently selected item within the internal clipboard.
     *
     * @param action the action to take when pasting, "cut" or "copy"
     */
    @autobind
    private storeItemInClipboard(action: ClipboardAction) {
        this.model.itemClipboard = {
            clipboardAction: action,
            directoryItems: this.selectedItems
        };

        if (action === "copy") {
            this.props.statusNotifier.notify("Copying item(s)");
        } else {
            this.props.statusNotifier.notify("Cutting item(s)");
        }
    }

    /** Toggles whether the currently selected item is chosen or not. */
    @autobind
    private toggleItemChosen() {
        const selectedItem = this.nonHiddenDirectoryItems[this.state.selectedIndex];

        if (this.state.chosenItems.includes(selectedItem)) {
            this.setState((currentState) => (
                {
                    chosenItems: currentState.chosenItems.filter(item => item.name !== selectedItem.name)
                } as IDirectoryListState));
        } else {
            const chosenItems = List(this.state.chosenItems).withMutations(list => list.push(selectedItem));
            this.setState((currentState) => ({ chosenItems: chosenItems.toArray() } as IDirectoryListState));
        }
    }

    /** Handles toggling whether hidden files should be shown. */
    @autobind
    private toggleShowHidden() {
        if (this.state.showHiddenItems) {
            this.props.statusNotifier.notify("Hiding hidden items");
        } else {
            this.props.statusNotifier.notify("Showing hidden items");
        }

        this.setState(prevState => (
            {
                showHiddenItems: !prevState.showHiddenItems
            } as IDirectoryListState));
    }
}

/**
 * Displays a dialog and returns whether the user confirmed the action described
 * in the given message.
 *
 * @param message the message to display to the user
 *
 * @returns whether the user confirmed the described action
 */
function confirmationDialog(message: string): boolean {
    const confirmIndex = 0;
    const cancelIndex = 1;
    const confirmation = dialog.showMessageBox({
        buttons: ["OK", "Cancel"],
        cancelId: cancelIndex,
        defaultId: cancelIndex,
        message,
        title: "Confirm deletion",
        type: "warning"
    });

    return confirmation === confirmIndex;
}

export default DirectoryList;