# -*- coding: utf-8 -*-
#
# Copyright © Spyder Project Contributors
# Licensed under the terms of the MIT License
# (see spyder/__init__.py for details)

"""Files and Directories Explorer"""

# pylint: disable=C0103
# pylint: disable=R0903
# pylint: disable=R0911
# pylint: disable=R0201

# Standard library imports
import os
import os.path as osp
import re
import shutil
import sys

# Third party imports
from qtpy import PYSIDE2
from qtpy.compat import getexistingdirectory
from qtpy.QtCore import (
    QDir,
    QFile,
    QMimeData,
    QSortFilterProxyModel,
    Qt,
    QTimer,
    QUrl,
    Signal,
    Slot,
)
from qtpy.QtGui import QClipboard, QDrag
from qtpy.QtWidgets import (
    QAbstractItemView,
    QApplication,
    QDialog,
    QDialogButtonBox,
    QFileSystemModel,
    QGridLayout,
    QInputDialog,
    QLabel,
    QLineEdit,
    QMessageBox,
    QProxyStyle,
    QStyle,
    QStyledItemDelegate,
    QStyleOptionViewItem,
    QToolTip,
    QTreeView,
    QVBoxLayout,
)

# Local imports
from spyder.api.config.decorators import on_conf_change
from spyder.api.translations import _
from spyder.api.widgets.comboboxes import SpyderComboBox
from spyder.api.widgets.dialogs import SpyderDialogButtonBox
from spyder.api.widgets.mixins import SpyderWidgetMixin
from spyder.config.base import get_home_dir
from spyder.plugins.explorer.widgets.utils import (
    create_script, fixpath, IconProvider, show_in_external_file_explorer)
from spyder.utils import encoding
from spyder.utils.icon_manager import ima
from spyder.utils import misc, programs, vcs
from spyder.utils.misc import getcwd_or_home
from spyder.utils.qthelpers import (
    file_uri, keyevent_to_keysequence_str, start_file)

try:
    from nbconvert import PythonExporter as nbexporter
except:
    nbexporter = None    # analysis:ignore


# ---- Constants
# ----------------------------------------------------------------------------
class DirViewColumns:
    Size = 1
    Type = 2
    Date = 3


class DirViewOpenWithSubMenuSections:
    Main = 'Main'


class DirViewActions:
    # Toggles
    ToggleDateColumn = 'toggle_date_column_action'
    ToggleSingleClick = 'toggle_single_click_to_open_action'
    ToggleSizeColumn = 'toggle_size_column_action'
    ToggleTypeColumn = 'toggle_type_column_action'
    ToggleHiddenFiles = 'toggle_show_hidden_action'

    # Triggers
    NewFile = 'new_file_action'
    NewModule = 'new_module_action'
    NewFolder = 'new_folder_action'
    NewPackage = 'new_package_action'
    OpenWithSpyder = 'open_with_spyder_action'
    OpenWithSystem = 'open_with_system_action'
    OpenWithSystem2 = 'open_with_system_2_action'
    Delete = 'delete_action'
    Rename = 'rename_action'
    Move = 'move_action'
    Copy = 'copy file'
    Paste = 'paste file'
    CopyAbsolutePath = 'copy absolute path'
    CopyRelativePath = 'copy relative path'
    ShowInSystemExplorer = 'show_system_explorer_action'
    VersionControlCommit = 'version_control_commit_action'
    VersionControlBrowse = 'version_control_browse_action'
    ConvertNotebook = 'convert_notebook_action'

    # TODO: Move this to the IPython Console
    OpenInterpreter = 'open_interpreter_action'
    Run = 'run_action'


class DirViewMenus:
    Context = 'context_menu'
    Header = 'header_menu'
    New = 'new_menu'
    OpenWith = 'open_with_menu'


class DirViewHeaderMenuSections:
    Main = 'main_section'


class DirViewNewSubMenuSections:
    General = 'general_section'
    Language = 'language_section'


class DirViewContextMenuSections:
    CopyPaste = 'copy_paste_section'
    Extras = 'extras_section'
    New = 'new_section'
    System = 'system_section'
    VersionControl = 'version_control_section'


# ---- Styles
# ----------------------------------------------------------------------------
class DirViewStyle(QProxyStyle):

    def styleHint(self, hint, option=None, widget=None, return_data=None):
        """
        To show tooltips with longer delays.

        From https://stackoverflow.com/a/59059919/438386
        """
        if hint == QStyle.SH_ToolTip_WakeUpDelay:
            return 1000  # 1 sec
        elif hint == QStyle.SH_ToolTip_FallAsleepDelay:
            # This removes some flickering when showing tooltips
            return 0

        return super().styleHint(hint, option, widget, return_data)


class DirViewItemDelegate(QStyledItemDelegate):

    def __init__(self, parent):
        super().__init__(parent)
        self._project_dir = ""

    def set_project_dir(self, project_dir):
        self._project_dir = project_dir

    def initStyleOption(self, option, index):
        """
        To change the item icon when expanding a folder.

        From https://stackoverflow.com/a/48531349/438386
        """
        super().initStyleOption(option, index)

        if isinstance(option, QStyleOptionViewItem):
            model = index.model()

            if isinstance(model, QSortFilterProxyModel):
                # This is necessary for Projects because it has a proxy model
                is_dir = model.sourceModel().isDir(model.mapToSource(index))
            else:
                is_dir = model.isDir(index)

            if is_dir:
                # This is necessary because Projects has a root directory and
                # we want to set a different icon for it.
                if isinstance(model, QSortFilterProxyModel):
                    dir_path = model.sourceModel().filePath(
                        model.mapToSource(index)
                    )
                else:
                    dir_path = None

                if dir_path == self._project_dir:
                    option.icon = ima.icon("project_spyder")
                elif (option.state & QStyle.State_Open):
                    option.icon = ima.icon("DirOpenIcon")


# ---- Widgets
# ----------------------------------------------------------------------------
class QInputDialogCombobox(QDialog):
    """
    Custom input dialog with a text edit and combobox.
    """

    def __init__(self, parent, title, label, items, label_combo, **kwargs):
        super().__init__(parent, **kwargs)

        if title is not None:
            self.setWindowTitle(title)

        self.setMinimumWidth(350)

        grid_layout = QGridLayout()

        self.text_edit = QLineEdit()
        grid_layout.addWidget(QLabel(label), 0, 0)
        grid_layout.addWidget(self.text_edit, 1, 0)

        combo_label = QLabel(label_combo)
        self.combo = SpyderComboBox(self)
        self.combo.addItems(items)
        grid_layout.addWidget(combo_label, 0, 1)
        grid_layout.addWidget(self.combo, 1, 1)

        bbox = SpyderDialogButtonBox(
            QDialogButtonBox.Ok | QDialogButtonBox.Cancel
        )
        bbox.accepted.connect(self.accept)
        bbox.rejected.connect(self.reject)

        layout = QVBoxLayout()
        layout.addLayout(grid_layout)
        layout.addWidget(bbox)
        self.setLayout(layout)
        self.text_edit.setFocus()

    @staticmethod
    def get_text_and_item(parent, title, label, items, label_combo):
        dialog = QInputDialogCombobox(parent, title, label, items, label_combo)

        ok = dialog.exec_()
        if ok:
            return dialog.text_edit.text(), dialog.combo.currentText(), True
        else:
            return '', '', False


class DirView(QTreeView, SpyderWidgetMixin):
    """Base file/directory tree view."""

    # Signals
    sig_file_created = Signal(str)
    """
    This signal is emitted when a file is created

    Parameters
    ----------
    module: str
        Path to the created file.
    """

    sig_open_interpreter_requested = Signal(str)
    """
    This signal is emitted when the interpreter opened is requested

    Parameters
    ----------
    module: str
        Path to use as working directory of interpreter.
    """

    sig_module_created = Signal(str)
    """
    This signal is emitted when a new python module is created.

    Parameters
    ----------
    module: str
        Path to the new module created.
    """

    sig_redirect_stdio_requested = Signal(bool)
    """
    This signal is emitted when redirect stdio is requested.

    Parameters
    ----------
    enable: bool
        Enable/Disable standard input/output redirection.
    """

    sig_removed = Signal(str)
    """
    This signal is emitted when a file is removed.

    Parameters
    ----------
    path: str
        File path removed.
    """

    sig_renamed = Signal(str, str)
    """
    This signal is emitted when a file is renamed.

    Parameters
    ----------
    old_path: str
        Old path for renamed file.
    new_path: str
        New path for renamed file.
    """

    sig_run_requested = Signal(str)
    """
    This signal is emitted to request running a file.

    Parameters
    ----------
    path: str
        File path to run.
    """

    sig_tree_removed = Signal(str)
    """
    This signal is emitted when a folder is removed.

    Parameters
    ----------
    path: str
        Folder to remove.
    """

    sig_tree_renamed = Signal(str, str)
    """
    This signal is emitted when a folder is renamed.

    Parameters
    ----------
    old_path: str
        Old path for renamed folder.
    new_path: str
        New path for renamed folder.
    """

    sig_open_file_requested = Signal(str)
    """
    This signal is emitted to request opening a new file with Spyder.

    Parameters
    ----------
    path: str
        File path to run.
    """

    def __init__(self, parent=None):
        """Initialize the DirView.

        Parameters
        ----------
        parent: QWidget
            Parent QWidget of the widget.
        """
        if not PYSIDE2:
            super().__init__(parent=parent, class_parent=parent)
        else:
            QTreeView.__init__(self, parent)
            SpyderWidgetMixin.__init__(self, class_parent=parent)

        # Attributes
        self._parent = parent
        self._last_column = 0
        self._last_order = True
        self._scrollbar_positions = None
        self._to_be_loaded = None
        self.__expanded_state = None
        self.common_actions = None
        self.filter_on = False
        self.expanded_or_colapsed_by_mouse = False

        # Widgets
        self.fsmodel = None
        self.menu = None
        self.header_menu = None
        header = self.header()

        # Signals
        header.customContextMenuRequested.connect(self.show_header_menu)

        # Style adjustments
        self._style = DirViewStyle(None)
        self._style.setParent(self)
        self.setStyle(self._style)
        self.setItemDelegate(DirViewItemDelegate(self))

        # Setup
        self.setup_fs_model()
        self.setSelectionMode(
            QAbstractItemView.SelectionMode.ExtendedSelection
        )
        header.setContextMenuPolicy(Qt.CustomContextMenu)

        # Track mouse movements. This activates the mouseMoveEvent declared
        # below.
        self.setMouseTracking(True)

    # ---- SpyderWidgetMixin API
    # ------------------------------------------------------------------------
    def setup(self):
        self.setup_view()

        # New actions
        new_file_action = self.create_action(
            DirViewActions.NewFile,
            text=_("File..."),
            icon=self.create_icon('TextFileIcon'),
            triggered=self.new_file,
        )

        new_module_action = self.create_action(
            DirViewActions.NewModule,
            text=_("Python file..."),
            icon=self.create_icon('python'),
            triggered=self.new_module,
        )

        new_folder_action = self.create_action(
            DirViewActions.NewFolder,
            text=_("Folder..."),
            icon=self.create_icon('folder_new'),
            triggered=self.new_folder,
        )

        new_package_action = self.create_action(
            DirViewActions.NewPackage,
            text=_("Python package..."),
            icon=self.create_icon('package_new'),
            triggered=self.new_package,
        )

        # Open actions
        self.open_with_spyder_action = self.create_action(
            DirViewActions.OpenWithSpyder,
            text=_("Open in Spyder"),
            icon=self.create_icon('edit'),
            triggered=self.open,
        )

        self.open_external_action = self.create_action(
            DirViewActions.OpenWithSystem,
            text=_("Open externally"),
            triggered=self.open_external,
        )

        self.open_external_action_2 = self.create_action(
            DirViewActions.OpenWithSystem2,
            text=_("Default external application"),
            triggered=self.open_external,
            register_shortcut=False,
        )

        # File management actions
        delete_action = self.create_action(
            DirViewActions.Delete,
            text=_("Delete..."),
            icon=self.create_icon('editdelete'),
            triggered=self.delete,
        )

        rename_action = self.create_action(
            DirViewActions.Rename,
            text=_("Rename..."),
            icon=self.create_icon('rename'),
            triggered=self.rename,
        )

        self.move_action = self.create_action(
            DirViewActions.Move,
            text=_("Move..."),
            icon=self.create_icon('move'),
            triggered=self.move,
        )

        # Copy/Paste actions
        self.copy_action = self.create_action(
            DirViewActions.Copy,
            text=_("Copy"),
            icon=self.create_icon('editcopy'),
            triggered=self.copy_file_clipboard,
            register_shortcut=True
        )

        self.paste_action = self.create_action(
            DirViewActions.Paste,
            text=_("Paste"),
            icon=self.create_icon('editpaste'),
            triggered=self.save_file_clipboard,
            register_shortcut=True,
        )

        self.copy_absolute_path_action = self.create_action(
            DirViewActions.CopyAbsolutePath,
            text=_("Copy absolute path"),
            triggered=self.copy_absolute_path,
            register_shortcut=True,
        )

        self.copy_relative_path_action = self.create_action(
            DirViewActions.CopyRelativePath,
            text=_("Copy relative path"),
            triggered=self.copy_relative_path,
            register_shortcut=True
        )

        # Show actions
        if sys.platform == 'darwin':
            show_in_finder_text = _("Show in Finder")
        else:
            show_in_finder_text = _("Show in folder")

        show_in_system_explorer_action = self.create_action(
            DirViewActions.ShowInSystemExplorer,
            text=show_in_finder_text,
            triggered=self.show_in_external_file_explorer,
        )

        # Version control actions
        self.vcs_commit_action = self.create_action(
            DirViewActions.VersionControlCommit,
            # Don't translate this text because it makes little sense in
            # languages other than English.
            # Fixes spyder-ide/spyder#21959
            text="Git commit",
            icon=self.create_icon('vcs_commit'),
            triggered=lambda: self.vcs_command('commit'),
        )
        self.vcs_log_action = self.create_action(
            DirViewActions.VersionControlBrowse,
            text=_("Browse Git repository"),
            icon=self.create_icon('vcs_browse'),
            triggered=lambda: self.vcs_command('browse'),
        )

        # Common actions
        self.hidden_action = self.create_action(
            DirViewActions.ToggleHiddenFiles,
            text=_("Show hidden files"),
            toggled=True,
            initial=self.get_conf('show_hidden'),
            option='show_hidden'
        )

        self.create_action(
            DirViewActions.ToggleSingleClick,
            text=_("Single click to open"),
            toggled=True,
            initial=self.get_conf('single_click_to_open'),
            option='single_click_to_open'
        )

        # IPython console actions
        # TODO: Move this option to the ipython console setup
        self.open_interpreter_action = self.create_action(
            DirViewActions.OpenInterpreter,
            text=_("Open IPython console here"),
            icon=self.create_icon('ipython_console'),
            triggered=self.open_interpreter,
        )

        # TODO: Move this option to the ipython console setup
        run_action = self.create_action(
            DirViewActions.Run,
            text=_("Run"),
            icon=self.create_icon('run'),
            triggered=self.run,
        )

        # Notebook Actions
        ipynb_convert_action = self.create_action(
            DirViewActions.ConvertNotebook,
            _("Convert to Python file"),
            icon=ima.icon('python'),
            triggered=self.convert_notebooks
        )

        # Header Actions
        size_column_action = self.create_action(
            DirViewActions.ToggleSizeColumn,
            text=_('Size'),
            toggled=True,
            initial=self.get_conf('size_column'),
            register_shortcut=False,
            option='size_column'
        )
        type_column_action = self.create_action(
            DirViewActions.ToggleTypeColumn,
            text=_('Type') if sys.platform == 'darwin' else _('Type'),
            toggled=True,
            initial=self.get_conf('type_column'),
            register_shortcut=False,
            option='type_column'
        )
        date_column_action = self.create_action(
            DirViewActions.ToggleDateColumn,
            text=_("Date modified"),
            toggled=True,
            initial=self.get_conf('date_column'),
            register_shortcut=False,
            option='date_column'
        )

        # Header Context Menu
        self.header_menu = self.create_menu(DirViewMenus.Header)
        for item in [size_column_action, type_column_action,
                     date_column_action]:
            self.add_item_to_menu(
                item,
                menu=self.header_menu,
                section=DirViewHeaderMenuSections.Main,
            )

        # New submenu
        new_submenu = self.create_menu(
            DirViewMenus.New,
            _('New'),
        )
        for item in [new_file_action, new_folder_action]:
            self.add_item_to_menu(
                item,
                menu=new_submenu,
                section=DirViewNewSubMenuSections.General,
            )

        for item in [new_module_action, new_package_action]:
            self.add_item_to_menu(
                item,
                menu=new_submenu,
                section=DirViewNewSubMenuSections.Language,
            )

        # Open with submenu
        self.open_with_submenu = self.create_menu(
            DirViewMenus.OpenWith,
            _('Open with'),
        )

        # Context submenu
        self.context_menu = self.create_menu(DirViewMenus.Context)
        for item in [new_submenu, run_action,
                     self.open_with_spyder_action,
                     self.open_with_submenu,
                     self.open_external_action,
                     delete_action, rename_action, self.move_action]:
            self.add_item_to_menu(
                item,
                menu=self.context_menu,
                section=DirViewContextMenuSections.New,
            )

        # Copy/Paste section
        for item in [self.copy_action, self.paste_action,
                     self.copy_absolute_path_action,
                     self.copy_relative_path_action]:
            self.add_item_to_menu(
                item,
                menu=self.context_menu,
                section=DirViewContextMenuSections.CopyPaste,
            )

        self.add_item_to_menu(
            show_in_system_explorer_action,
            menu=self.context_menu,
            section=DirViewContextMenuSections.System,
        )

        # Version control section
        for item in [self.vcs_commit_action, self.vcs_log_action]:
            self.add_item_to_menu(
                item,
                menu=self.context_menu,
                section=DirViewContextMenuSections.VersionControl
            )

        for item in [self.open_interpreter_action, ipynb_convert_action]:
            self.add_item_to_menu(
                item,
                menu=self.context_menu,
                section=DirViewContextMenuSections.Extras,
            )

        # Signals
        self.context_menu.aboutToShow.connect(self.update_actions)

    @on_conf_change(option=['size_column', 'type_column', 'date_column',
                            'name_filters', 'show_hidden',
                            'single_click_to_open'])
    def on_conf_update(self, option, value):
        if option == 'size_column':
            self.setColumnHidden(DirViewColumns.Size, not value)
        elif option == 'type_column':
            self.setColumnHidden(DirViewColumns.Type, not value)
        elif option == 'date_column':
            self.setColumnHidden(DirViewColumns.Date, not value)
        elif option == 'name_filters':
            if self.filter_on:
                self.filter_files(value)
        elif option == 'show_hidden':
            self.set_show_hidden(value)
        elif option == 'single_click_to_open':
            self.set_single_click_to_open(value)

    def update_actions(self):
        fnames = self.get_selected_filenames()
        if fnames:
            if osp.isdir(fnames[0]):
                dirname = fnames[0]
            else:
                dirname = osp.dirname(fnames[0])

            basedir = fixpath(osp.dirname(fnames[0]))
            only_dirs = fnames and all([osp.isdir(fname) for fname in fnames])
            only_files = all([osp.isfile(fname) for fname in fnames])
            only_valid = all([encoding.is_text_file(fna) for fna in fnames])
        else:
            only_files = False
            only_valid = False
            only_dirs = False
            dirname = ''
            basedir = ''

        vcs_visible = vcs.is_vcs_repository(dirname)

        # Make actions visible conditionally
        self.move_action.setVisible(
            all(
                [fixpath(osp.dirname(fname)) == basedir for fname in fnames])
                and only_files
            )
        self.open_external_action.setVisible(False)
        self.open_interpreter_action.setVisible(only_dirs)
        self.open_with_spyder_action.setVisible(only_files and only_valid)
        self.open_with_submenu.menuAction().setVisible(False)
        clipboard = QApplication.clipboard()
        has_urls = clipboard.mimeData().hasUrls()
        self.paste_action.setDisabled(not has_urls)

        # VCS support is quite limited for now, so we are enabling the VCS
        # related actions only when a single file/folder is selected:
        self.vcs_commit_action.setVisible(vcs_visible)
        self.vcs_log_action.setVisible(vcs_visible)

        if only_files:
            if len(fnames) == 1:
                assoc = self.get_file_associations(fnames[0])
            elif len(fnames) > 1:
                assoc = self.get_common_file_associations(fnames)

            if len(assoc) >= 1:
                actions = self._create_file_associations_actions()
                self.open_with_submenu.menuAction().setVisible(True)
                self.open_with_submenu.clear_actions()
                for action in actions:
                    self.add_item_to_menu(
                        action,
                        menu=self.open_with_submenu,
                        section=DirViewOpenWithSubMenuSections.Main,
                    )
            else:
                self.open_external_action.setVisible(True)

        fnames = self.get_selected_filenames()
        only_notebooks = all([osp.splitext(fname)[1] == '.ipynb'
                              for fname in fnames])
        only_modules = all([osp.splitext(fname)[1] in ('.py', '.pyw', '.ipy')
                            for fname in fnames])

        nb_visible = only_notebooks and nbexporter is not None
        self.get_action(DirViewActions.ConvertNotebook).setVisible(nb_visible)
        self.get_action(DirViewActions.Run).setVisible(only_modules)

    def _create_file_associations_actions(self, fnames=None):
        """
        Create file association actions.
        """
        if fnames is None:
            fnames = self.get_selected_filenames()

        actions = []
        only_files = all([osp.isfile(fname) for fname in fnames])
        if only_files:
            if len(fnames) == 1:
                assoc = self.get_file_associations(fnames[0])
            elif len(fnames) > 1:
                assoc = self.get_common_file_associations(fnames)

            if len(assoc) >= 1:
                for app_name, fpath in assoc:
                    text = app_name
                    if not (os.path.isfile(fpath) or os.path.isdir(fpath)):
                        text += _(' (Application not found!)')

                    try:
                        # Action might have been created already
                        open_assoc = self.open_association
                        open_with_action = self.create_action(
                            app_name,
                            text=text,
                            triggered=lambda x, y=fpath: open_assoc(y),
                            register_shortcut=False,
                        )
                    except Exception:
                        open_with_action = self.get_action(app_name)

                        # Disconnect previous signal in case the app path
                        # changed
                        try:
                            open_with_action.triggered.disconnect()
                        except Exception:
                            pass

                        # Reconnect the trigger signal
                        open_with_action.triggered.connect(
                            lambda x, y=fpath: self.open_association(y)
                        )

                    if not (os.path.isfile(fpath) or os.path.isdir(fpath)):
                        open_with_action.setDisabled(True)

                    actions.append(open_with_action)

                actions.append(self.open_external_action_2)

        return actions

    # ---- Qt overrides
    # ------------------------------------------------------------------------
    def sortByColumn(self, column, order=Qt.AscendingOrder):
        """Override Qt method."""
        header = self.header()
        header.setSortIndicatorShown(True)
        QTreeView.sortByColumn(self, column, order)
        header.setSortIndicator(0, order)
        self._last_column = column
        self._last_order = not self._last_order

    def viewportEvent(self, event):
        """Reimplement Qt method"""

        # Prevent Qt from crashing or showing warnings like:
        # "QSortFilterProxyModel: index from wrong model passed to
        # mapFromSource", probably due to the fact that the file system model
        # is being built. See spyder-ide/spyder#1250.
        #
        # This workaround was inspired by the following KDE bug:
        # https://bugs.kde.org/show_bug.cgi?id=172198
        #
        # Apparently, this is a bug from Qt itself.
        self.executeDelayedItemsLayout()

        return QTreeView.viewportEvent(self, event)

    def contextMenuEvent(self, event):
        """Override Qt method"""
        # Needed to handle not initialized menu.
        # See spyder-ide/spyder#6975
        try:
            self.context_menu.popup(event.globalPos())
        except AttributeError:
            pass

    def keyPressEvent(self, event):
        """Handle keyboard shortcuts and special keys."""
        key_seq = keyevent_to_keysequence_str(event)

        if event.key() in (Qt.Key_Enter, Qt.Key_Return):
            self.clicked()
        elif event.key() == Qt.Key_F2:
            self.rename()
        elif event.key() == Qt.Key_Delete:
            self.delete()
        elif event.key() == Qt.Key_Backspace:
            self.go_to_parent_directory()
        elif key_seq == self.copy_action.shortcut().toString():
            self.copy_file_clipboard()
        elif key_seq == self.paste_action.shortcut().toString():
            self.save_file_clipboard()
        elif key_seq == self.copy_absolute_path_action.shortcut().toString():
            self.copy_absolute_path()
        elif key_seq == self.copy_relative_path_action.shortcut().toString():
            self.copy_relative_path()
        else:
            QTreeView.keyPressEvent(self, event)

    def mouseDoubleClickEvent(self, event):
        """Handle double clicks."""
        super().mouseDoubleClickEvent(event)
        if not self.get_conf('single_click_to_open'):
            self.clicked(index=self.indexAt(event.pos()))

    def mousePressEvent(self, event):
        """
        Detect when a directory was expanded or collapsed by clicking
        on its arrow.

        Taken from https://stackoverflow.com/a/13142586/438386
        """
        if event.button() == Qt.RightButton:
            return
        clicked_index = self.indexAt(event.pos())
        if clicked_index.isValid():
            vrect = self.visualRect(clicked_index)
            item_identation = vrect.x() - self.visualRect(self.rootIndex()).x()
            if event.pos().x() < item_identation:
                self.expanded_or_colapsed_by_mouse = True
            else:
                self.expanded_or_colapsed_by_mouse = False
        else:
            # Clear selection if users click on an empty region. This improves
            # the context menu UX because it makes the current directory to be
            # used for its operations (i.e. creating a new folder or directory,
            # copying its path, etc).
            self.selectionModel().clear()

        super().mousePressEvent(event)

    def mouseReleaseEvent(self, event):
        """Handle single clicks."""
        super().mouseReleaseEvent(event)
        if self.get_conf('single_click_to_open'):
            self.clicked(index=self.indexAt(event.pos()))

    def mouseMoveEvent(self, event):
        """Actions to take with mouse movements."""
        # To hide previous tooltip
        QToolTip.hideText()

        index = self.indexAt(event.pos())
        if index.isValid():
            if self.get_conf('single_click_to_open'):
                vrect = self.visualRect(index)
                item_identation = (
                    vrect.x() - self.visualRect(self.rootIndex()).x()
                )

                if event.pos().x() > item_identation:
                    # When hovering over directories or files
                    self.setCursor(Qt.PointingHandCursor)
                else:
                    # On every other element
                    self.setCursor(Qt.ArrowCursor)

            self.setToolTip(self.get_filename(index))

        super().mouseMoveEvent(event)

    def dragEnterEvent(self, event):
        """Drag and Drop - Enter event"""
        event.setAccepted(event.mimeData().hasFormat("text/plain"))

    def dragMoveEvent(self, event):
        """Drag and Drop - Move event"""
        if (event.mimeData().hasFormat("text/plain")):
            event.setDropAction(Qt.MoveAction)
            event.accept()
        else:
            event.ignore()

    def startDrag(self, dropActions):
        """Reimplement Qt Method - handle drag event"""
        data = QMimeData()
        data.setUrls(
            [
                QUrl.fromLocalFile(fname)
                for fname in self.get_selected_filenames()
            ]
        )
        drag = QDrag(self)
        drag.setMimeData(data)
        drag.exec_()

    # ---- Model
    # ------------------------------------------------------------------------
    def setup_fs_model(self):
        """Setup filesystem model"""
        self.fsmodel = QFileSystemModel(self)
        self.fsmodel.setNameFilterDisables(False)

    def install_model(self):
        """Install filesystem model"""
        self.setModel(self.fsmodel)

    def setup_view(self):
        """Setup view"""
        self.install_model()
        self.fsmodel.directoryLoaded.connect(
            lambda: self.resizeColumnToContents(0))
        self.setAnimated(False)
        self.setSortingEnabled(True)
        self.sortByColumn(0, Qt.AscendingOrder)
        self.fsmodel.modelReset.connect(self.reset_icon_provider)
        self.reset_icon_provider()

    # ---- File/Dir Helpers
    # ------------------------------------------------------------------------
    def get_filename(self, index):
        """Return filename associated with *index*"""
        if index:
            return osp.normpath(str(self.fsmodel.filePath(index)))
        else:
            return osp.normpath(str(self.fsmodel.rootPath()))

    def get_index(self, filename):
        """Return index associated with filename"""
        return self.fsmodel.index(filename)

    def get_selected_filenames(self):
        """Return selected filenames"""
        fnames = []
        if (
            self.selectionMode()
            == QAbstractItemView.SelectionMode.ExtendedSelection
        ):
            if self.selectionModel() is not None:
                fnames = [self.get_filename(idx) for idx in
                          self.selectionModel().selectedRows()]
        else:
            fnames = [self.get_filename(self.currentIndex())]

        if not fnames:
            fnames = [self.get_filename(self.currentIndex())]

        return fnames

    def get_dirname(self, index):
        """Return dirname associated with *index*"""
        fname = self.get_filename(index)
        if fname:
            if osp.isdir(fname):
                return fname
            else:
                return osp.dirname(fname)

    # ---- General actions API
    # ------------------------------------------------------------------------
    def show_header_menu(self, pos):
        """Display header menu."""
        self.header_menu.popup(self.mapToGlobal(pos))

    def clicked(self, index=None):
        """
        Selected item was single/double-clicked or enter/return was pressed.
        """
        fnames = self.get_selected_filenames()

        # Don't do anything when clicking on the arrow next to a directory
        # to expand/collapse it. If clicking on its name, use it as `fnames`.
        if index and index.isValid():
            fname = self.get_filename(index)
            if osp.isdir(fname):
                if self.expanded_or_colapsed_by_mouse:
                    return
                else:
                    fnames = [fname]

        # Open files or directories
        for fname in fnames:
            if osp.isdir(fname):
                self.directory_clicked(fname, index)
            else:
                if len(fnames) == 1:
                    assoc = self.get_file_associations(fnames[0])
                elif len(fnames) > 1:
                    assoc = self.get_common_file_associations(fnames)

                if assoc:
                    self.open_association(assoc[0][-1])
                else:
                    self.open([fname])

    def directory_clicked(self, dirname, index):
        """
        Handle directories being clicked.

        Parameters
        ----------
        dirname: str
            Path to the clicked directory.
        index: QModelIndex
            Index of the directory.
        """
        raise NotImplementedError('To be implemented by subclasses')

    @Slot()
    def open(self, fnames=None):
        """Open files with the appropriate application"""
        if fnames is None or isinstance(fnames, bool):
            fnames = self.get_selected_filenames()
        for fname in fnames:
            if osp.isfile(fname) and encoding.is_text_file(fname):
                self.sig_open_file_requested.emit(fname)
            else:
                self.open_outside_spyder([fname])

    @Slot()
    def open_association(self, app_path):
        """Open files with given application executable path."""
        if not (os.path.isdir(app_path) or os.path.isfile(app_path)):
            return_codes = {app_path: 1}
            app_path = None
        else:
            return_codes = {}

        if app_path:
            fnames = self.get_selected_filenames()
            return_codes = programs.open_files_with_application(app_path,
                                                                fnames)
        self.check_launch_error_codes(return_codes)

    @Slot()
    def open_external(self, fnames=None):
        """Open files with default application"""
        if fnames is None or isinstance(fnames, bool):
            fnames = self.get_selected_filenames()
        for fname in fnames:
            self.open_outside_spyder([fname])

    def open_outside_spyder(self, fnames):
        """
        Open file outside Spyder with the appropriate application.

        If this does not work, opening unknown file in Spyder, as text file.
        """
        for path in sorted(fnames):
            path = file_uri(path)
            ok = start_file(path)
            if not ok and encoding.is_text_file(path):
                self.sig_open_file_requested.emit(path)

    def remove_tree(self, dirname):
        """
        Remove whole directory tree

        Reimplemented in project explorer widget
        """
        while osp.exists(dirname):
            QFile.moveToTrash(dirname)

    def delete_file(self, fname, multiple, yes_to_all):
        """Delete file"""
        if multiple:
            buttons = (QMessageBox.Yes | QMessageBox.YesToAll |
                       QMessageBox.No | QMessageBox.Cancel)
        else:
            buttons = QMessageBox.Yes | QMessageBox.No
        if yes_to_all is None:
            answer = QMessageBox.warning(
                self, _("Delete"),
                _("Do you really want to delete <b>%s</b>?\n"
                  "<br><br>"
                  "<b>Note</b>: This file or directory will be moved to the "
                  "trash can."
                  ) % osp.basename(fname), buttons)
            if answer == QMessageBox.No:
                return yes_to_all
            elif answer == QMessageBox.Cancel:
                return False
            elif answer == QMessageBox.YesToAll:
                yes_to_all = True
        try:
            if osp.isfile(fname):
                misc.remove_file(fname)
                self.sig_removed.emit(fname)
            else:
                self.remove_tree(fname)
                self.sig_tree_removed.emit(fname)
            return yes_to_all
        except EnvironmentError as error:
            action_str = _('delete')
            QMessageBox.critical(
                self, _("Project Explorer"),
                _("<b>Unable to %s <i>%s</i></b><br><br>Error message:<br>%s"
                  ) % (action_str, fname, str(error)))
        return False

    @Slot()
    def delete(self, fnames=None):
        """Delete files"""
        if fnames is None or isinstance(fnames, bool):
            fnames = self.get_selected_filenames()
        multiple = len(fnames) > 1
        yes_to_all = None
        for fname in fnames:
            spyproject_path = osp.join(fname, '.spyproject')
            if osp.isdir(fname) and osp.exists(spyproject_path):
                QMessageBox.information(
                    self, _('File Explorer'),
                    _("The current directory contains a project.<br><br>"
                      "If you want to delete the project, please go to "
                      "<b>Projects</b> &raquo; <b>Delete Project</b>"))
            else:
                yes_to_all = self.delete_file(fname, multiple, yes_to_all)
                if yes_to_all is not None and not yes_to_all:
                    # Canceled
                    break

    def rename_file(self, fname):
        """Rename file"""
        path, valid = QInputDialog.getText(
            self, _('Rename'), _('New name:'), QLineEdit.Normal,
            osp.basename(fname))

        if valid:
            path = osp.join(osp.dirname(fname), str(path))
            if path == fname:
                return
            if osp.exists(path):
                answer = QMessageBox.warning(
                    self, _("Rename"),
                    _("Do you really want to rename <b>%s</b> and "
                      "overwrite the existing file <b>%s</b>?"
                      ) % (osp.basename(fname), osp.basename(path)),
                    QMessageBox.Yes | QMessageBox.No)
                if answer == QMessageBox.No:
                    return
            try:
                misc.rename_file(fname, path)
                if osp.isfile(path):
                    self.sig_renamed.emit(fname, path)
                else:
                    self.sig_tree_renamed.emit(fname, path)
                return path
            except EnvironmentError as error:
                QMessageBox.critical(
                    self, _("Rename"),
                    _("<b>Unable to rename file <i>%s</i></b>"
                      "<br><br>Error message:<br>%s"
                      ) % (osp.basename(fname), str(error)))

    @Slot()
    def show_in_external_file_explorer(self, fnames=None):
        """Show file in external file explorer"""
        if fnames is None or isinstance(fnames, bool):
            fnames = self.get_selected_filenames()

        try:
            show_in_external_file_explorer(fnames)
        except FileNotFoundError as error:
            if "xdg-open" in str(error):
                msg_title = _("Error")
                msg = _(
                    "Spyder can't show this file in the external file "
                    "explorer because the <tt>xdg-utils</tt> package is not "
                    "available on your system."
                )
                QMessageBox.critical(
                    self._parent, msg_title, msg, QMessageBox.Ok
                )

    @Slot()
    def rename(self, fnames=None):
        """Rename files"""
        if fnames is None or isinstance(fnames, bool):
            fnames = self.get_selected_filenames()
        if not isinstance(fnames, (tuple, list)):
            fnames = [fnames]
        for fname in fnames:
            self.rename_file(fname)

    @Slot()
    def move(self, fnames=None, directory=None):
        """Move files/directories"""
        if fnames is None or isinstance(fnames, bool):
            fnames = self.get_selected_filenames()
        orig = fixpath(osp.dirname(fnames[0]))
        while True:
            self.sig_redirect_stdio_requested.emit(False)
            if directory is None:
                folder = getexistingdirectory(
                    self, _("Select directory"), orig)
            else:
                folder = directory
            self.sig_redirect_stdio_requested.emit(True)
            if folder:
                folder = fixpath(folder)
                if folder != orig:
                    break
            else:
                return
        for fname in fnames:
            basename = osp.basename(fname)
            try:
                misc.move_file(fname, osp.join(folder, basename))
            except EnvironmentError as error:
                QMessageBox.critical(
                    self, _("Error"),
                    _("<b>Unable to move <i>%s</i></b>"
                      "<br><br>Error message:<br>%s"
                      ) % (basename, str(error)))

    def create_new_folder(self, current_path, title, subtitle, is_package):
        """Create new folder"""
        if current_path is None:
            current_path = ''
        if osp.isfile(current_path):
            current_path = osp.dirname(current_path)
        name, valid = QInputDialog.getText(
            self, title, subtitle, QLineEdit.Normal, ""
        )

        if valid:
            dirname = osp.join(current_path, str(name))
            try:
                os.mkdir(dirname)
            except OSError as error:
                QMessageBox.critical(
                    self,
                    title,
                    _(
                        "<b>Unable to create folder <i>%s</i></b>"
                        "<br><br>Error message:<br>%s"
                    )
                    % (dirname, str(error)),
                )
            finally:
                if is_package:
                    fname = osp.join(dirname, '__init__.py')
                    try:
                        with open(fname, 'wb') as f:
                            f.write(b'#')
                    except OSError as error:
                        QMessageBox.critical(
                            self,
                            title,
                            _(
                                "<b>Unable to create file <i>%s</i></b>"
                                "<br><br>Error message:<br>%s"
                            )
                            % (fname, str(error)),
                        )

    def get_selected_dir(self):
        """ Get selected dir
        If file is selected the directory containing file is returned.
        If multiple items are selected, first item is chosen.
        """
        selected_path = self.get_selected_filenames()[0]
        if osp.isfile(selected_path):
            selected_path = osp.dirname(selected_path)
        return fixpath(selected_path)

    @Slot()
    def new_folder(self, basedir=None):
        """New folder."""

        if basedir is None or isinstance(basedir, bool):
            basedir = self.get_selected_dir()

        title = _('New folder')
        subtitle = _('Folder name:')
        self.create_new_folder(basedir, title, subtitle, is_package=False)

    def create_new_file(self, current_path, title, subtitle, ext, create_func):
        """Create new file
        Returns True if successful"""
        if current_path is None:
            current_path = ''
        if osp.isfile(current_path):
            current_path = osp.dirname(current_path)

        self.sig_redirect_stdio_requested.emit(False)

        if not ext:
            name, valid = QInputDialog.getText(
                self, title, subtitle, QLineEdit.Normal, ""
            )
            fname = osp.join(current_path, str(name))
        else:
            name, ext, valid = QInputDialogCombobox.get_text_and_item(
                self,
                title,
                label=subtitle,
                items=ext,
                label_combo=_("Extension:"),
            )
            fname = osp.join(current_path, str(name) + str(ext))

        self.sig_redirect_stdio_requested.emit(True)

        if fname and valid:
            try:
                create_func(fname)
                return fname
            except EnvironmentError as error:
                QMessageBox.critical(
                    self, _("New file"),
                    _("<b>Unable to create file <i>%s</i>"
                      "</b><br><br>Error message:<br>%s"
                      ) % (fname, str(error)))

    @Slot()
    def new_file(self, basedir=None):
        """New file"""

        if basedir is None or isinstance(basedir, bool):
            basedir = self.get_selected_dir()

        title = _("New file")
        subtitle = _('File name:')

        def create_func(fname):
            """File creation callback"""
            if osp.splitext(fname)[1] in ('.py', '.pyw', '.ipy'):
                create_script(fname)
            else:
                with open(fname, 'wb') as f:
                    f.write(b'')

        fname = self.create_new_file(
            basedir, title, subtitle, None, create_func
        )
        if fname is not None:
            self.open([fname])

    @Slot()
    def run(self, fnames=None):
        """Run Python scripts"""
        if fnames is None or isinstance(fnames, bool):
            fnames = self.get_selected_filenames()
        for fname in fnames:
            self.sig_run_requested.emit(fname)

    def copy_path(self, fnames=None, method="absolute"):
        """Copy absolute or relative path to given file(s)/folders(s)."""
        cb = QApplication.clipboard()
        explorer_dir = self.fsmodel.rootPath()
        if fnames is None:
            fnames = self.get_selected_filenames()
        if not isinstance(fnames, (tuple, list)):
            fnames = [fnames]
        fnames = [_fn.replace(os.sep, "/") for _fn in fnames]
        if len(fnames) > 1:
            if method == "absolute":
                clipboard_files = ',\n'.join('"' + _fn + '"' for _fn in fnames)
            elif method == "relative":
                clipboard_files = ',\n'.join('"' +
                                             osp.relpath(_fn, explorer_dir).
                                             replace(os.sep, "/") + '"'
                                             for _fn in fnames)
        else:
            if method == "absolute":
                clipboard_files = fnames[0]
            elif method == "relative":
                clipboard_files = (osp.relpath(fnames[0], explorer_dir).
                                   replace(os.sep, "/"))
        copied_from = self._parent.__class__.__name__
        if copied_from == 'ProjectExplorerWidget' and method == 'relative':
            clipboard_files = [path.strip(',"') for path in
                               clipboard_files.splitlines()]
            clipboard_files = ['/'.join(path.strip('/').split('/')[1:]) for
                               path in clipboard_files]
            if len(clipboard_files) > 1:
                clipboard_files = ',\n'.join('"' + _fn + '"' for _fn in
                                             clipboard_files)
            else:
                clipboard_files = clipboard_files[0]
        cb.setText(clipboard_files, mode=QClipboard.Mode.Clipboard)

    @Slot()
    def copy_absolute_path(self):
        """Copy absolute paths of named files/directories to the clipboard."""
        self.copy_path(method="absolute")

    @Slot()
    def copy_relative_path(self):
        """Copy relative paths of named files/directories to the clipboard."""
        self.copy_path(method="relative")

    @Slot()
    def copy_file_clipboard(self, fnames=None):
        """Copy file(s)/folders(s) to clipboard."""
        if fnames is None or isinstance(fnames, bool):
            fnames = self.get_selected_filenames()
        if not isinstance(fnames, (tuple, list)):
            fnames = [fnames]
        try:
            file_content = QMimeData()
            file_content.setUrls([QUrl.fromLocalFile(_fn) for _fn in fnames])
            cb = QApplication.clipboard()
            cb.setMimeData(file_content, mode=QClipboard.Mode.Clipboard)
        except Exception as e:
            QMessageBox.critical(
                self, _('File/Folder copy error'),
                _("Cannot copy this type of file(s) or "
                  "folder(s). The error was:\n\n") + str(e))

    @Slot()
    def save_file_clipboard(self, fnames=None):
        """Paste file from clipboard into file/project explorer directory."""
        if fnames is None or isinstance(fnames, bool):
            fnames = self.get_selected_filenames()
        if not isinstance(fnames, (tuple, list)):
            fnames = [fnames]
        if len(fnames) >= 1:
            selected_item = osp.commonpath(fnames)
            if osp.isfile(selected_item):
                parent_path = osp.dirname(selected_item)
            else:
                parent_path = osp.normpath(selected_item)

            cb_data = QApplication.clipboard().mimeData()
            if cb_data.hasUrls():
                urls = cb_data.urls()
                for url in urls:
                    source_name = url.toLocalFile()
                    base_name = osp.basename(source_name)
                    if osp.isfile(source_name):
                        try:
                            while base_name in os.listdir(parent_path):
                                file_no_ext, file_ext = osp.splitext(base_name)
                                end_number = re.search(r'\d+$', file_no_ext)
                                if end_number:
                                    new_number = int(end_number.group()) + 1
                                else:
                                    new_number = 1
                                left_string = re.sub(r'\d+$', '', file_no_ext)
                                left_string += str(new_number)
                                base_name = left_string + file_ext
                                destination = osp.join(parent_path, base_name)
                            else:
                                destination = osp.join(parent_path, base_name)
                            shutil.copy(source_name, destination)
                        except Exception as e:
                            QMessageBox.critical(self, _('Error pasting file'),
                                                 _("Unsupported copy operation"
                                                   ". The error was:\n\n")
                                                 + str(e))
                    else:
                        try:
                            while base_name in os.listdir(parent_path):
                                end_number = re.search(r'\d+$', base_name)
                                if end_number:
                                    new_number = int(end_number.group()) + 1
                                else:
                                    new_number = 1
                                left_string = re.sub(r'\d+$', '', base_name)
                                base_name = left_string + str(new_number)
                                destination = osp.join(parent_path, base_name)
                            else:
                                destination = osp.join(parent_path, base_name)
                            if osp.realpath(destination).startswith(
                                    osp.realpath(source_name) + os.sep):
                                QMessageBox.critical(self,
                                                     _('Recursive copy'),
                                                     _("Source is an ancestor"
                                                       " of destination"
                                                       " folder."))
                                continue
                            shutil.copytree(source_name, destination)
                        except Exception as e:
                            QMessageBox.critical(self,
                                                 _('Error pasting folder'),
                                                 _("Unsupported copy"
                                                   " operation. The error was:"
                                                   "\n\n") + str(e))
            else:
                QMessageBox.critical(self, _("No file in clipboard"),
                                     _("No file in the clipboard. Please copy"
                                       " a file to the clipboard first."))
        else:
            if QApplication.clipboard().mimeData().hasUrls():
                QMessageBox.critical(self, _('Blank area'),
                                     _("Cannot paste in the blank area."))
            else:
                pass

    @Slot()
    def open_interpreter(self, fnames=None):
        """Open interpreter"""
        if fnames is None or isinstance(fnames, bool):
            fnames = self.get_selected_filenames()
        for path in sorted(fnames):
            self.sig_open_interpreter_requested.emit(path)

    def filter_files(self, name_filters=None):
        """Filter files given the defined list of filters."""
        if name_filters is None:
            name_filters = self.get_conf('name_filters')

        if self.filter_on:
            self.fsmodel.setNameFilters(name_filters)
        else:
            self.fsmodel.setNameFilters([])

    # ---- File Associations
    # ------------------------------------------------------------------------
    def get_common_file_associations(self, fnames):
        """
        Return the list of common matching file associations for all fnames.
        """
        all_values = []
        for fname in fnames:
            values = self.get_file_associations(fname)
            all_values.append(values)

        common = set(all_values[0])
        for index in range(1, len(all_values)):
            common = common.intersection(all_values[index])
        return list(sorted(common))

    def get_file_associations(self, fname):
        """Return the list of matching file associations for `fname`."""
        for exts, values in self.get_conf('file_associations', {}).items():
            clean_exts = [ext.strip() for ext in exts.split(',')]
            for ext in clean_exts:
                if fname.endswith((ext, ext[1:])):
                    values = values
                    break
            else:
                continue  # Only excecuted if the inner loop did not break
            break  # Only excecuted if the inner loop did break
        else:
            values = []

        return values

    # ---- File/Directory actions
    # ------------------------------------------------------------------------
    def check_launch_error_codes(self, return_codes):
        """Check return codes and display message box if errors found."""
        errors = [cmd for cmd, code in return_codes.items() if code != 0]
        if errors:
            if len(errors) == 1:
                msg = _('The following command did not launch successfully:')
            else:
                msg = _('The following commands did not launch successfully:')

            msg += '<br><br>' if len(errors) == 1 else '<br><br><ul>'
            for error in errors:
                if len(errors) == 1:
                    msg += '<code>{}</code>'.format(error)
                else:
                    msg += '<li><code>{}</code></li>'.format(error)
            msg += '' if len(errors) == 1 else '</ul>'

            QMessageBox.warning(self, 'Application', msg, QMessageBox.Ok)

        return not bool(errors)

    # ---- VCS actions
    # ------------------------------------------------------------------------
    def vcs_command(self, action):
        """VCS action (commit, browse)"""
        fnames = self.get_selected_filenames()

        # Get dirname of selection
        if osp.isdir(fnames[0]):
            dirname = fnames[0]
        else:
            dirname = osp.dirname(fnames[0])

        # Run action
        try:
            for path in sorted(fnames):
                vcs.run_vcs_tool(dirname, action)
        except vcs.ActionToolNotFound as error:
            msg = _("For %s support, please install one of the<br/> "
                    "following tools:<br/><br/>  %s")\
                        % (error.vcsname, ', '.join(error.tools))
            QMessageBox.critical(
                self, _("Error"),
                _("""<b>Unable to find external program.</b><br><br>%s"""
                  ) % str(msg))

    # ---- Settings
    # ------------------------------------------------------------------------
    def get_scrollbar_position(self):
        """Return scrollbar positions"""
        return (self.horizontalScrollBar().value(),
                self.verticalScrollBar().value())

    def set_scrollbar_position(self, position):
        """Set scrollbar positions"""
        # Scrollbars will be restored after the expanded state
        self._scrollbar_positions = position
        if self._to_be_loaded is not None and len(self._to_be_loaded) == 0:
            self.restore_scrollbar_positions()

    def restore_scrollbar_positions(self):
        """Restore scrollbar positions once tree is loaded"""
        hor, ver = self._scrollbar_positions
        self.horizontalScrollBar().setValue(hor)
        self.verticalScrollBar().setValue(ver)

    def get_expanded_state(self):
        """Return expanded state"""
        self.save_expanded_state()
        return self.__expanded_state

    def set_expanded_state(self, state):
        """Set expanded state"""
        self.__expanded_state = state
        self.restore_expanded_state()

    def save_expanded_state(self):
        """Save all items expanded state"""
        model = self.model()
        # If model is not installed, 'model' will be None: this happens when
        # using the Project Explorer without having selected a workspace yet
        if model is not None:
            self.__expanded_state = []
            for idx in model.persistentIndexList():
                if self.isExpanded(idx):
                    self.__expanded_state.append(self.get_filename(idx))

    def restore_directory_state(self, fname):
        """Restore directory expanded state"""
        root = osp.normpath(str(fname))
        if not osp.exists(root):
            # Directory has been (re)moved outside Spyder
            return
        for basename in os.listdir(root):
            path = osp.normpath(osp.join(root, basename))
            if osp.isdir(path) and path in self.__expanded_state:
                self.__expanded_state.pop(self.__expanded_state.index(path))
                if self._to_be_loaded is None:
                    self._to_be_loaded = []
                self._to_be_loaded.append(path)
                self.setExpanded(self.get_index(path), True)
        if not self.__expanded_state:
            self.fsmodel.directoryLoaded.disconnect(
                self.restore_directory_state)

    def follow_directories_loaded(self, fname):
        """Follow directories loaded during startup"""
        if self._to_be_loaded is None:
            return
        path = osp.normpath(str(fname))
        if path in self._to_be_loaded:
            self._to_be_loaded.remove(path)
        if self._to_be_loaded is not None and len(self._to_be_loaded) == 0:
            self.fsmodel.directoryLoaded.disconnect(
                self.follow_directories_loaded)
            if self._scrollbar_positions is not None:
                # The tree view need some time to render branches:
                QTimer.singleShot(50, self.restore_scrollbar_positions)

    def restore_expanded_state(self):
        """Restore all items expanded state"""
        if self.__expanded_state is not None:
            # In the old project explorer, the expanded state was a
            # dictionary:
            if isinstance(self.__expanded_state, list):
                self.fsmodel.directoryLoaded.connect(
                    self.restore_directory_state)
                self.fsmodel.directoryLoaded.connect(
                    self.follow_directories_loaded)

    # ---- Options
    # ------------------------------------------------------------------------
    def set_single_click_to_open(self, value):
        """Set single click to open items."""
        # Reset cursor shape
        if not value:
            self.unsetCursor()

    def set_file_associations(self, value):
        """Set file associations open items."""
        self.set_conf('file_associations', value)

    def set_name_filters(self, name_filters):
        """Set name filters"""
        if self.get_conf('name_filters') == ['']:
            self.set_conf('name_filters', [])
        else:
            self.set_conf('name_filters', name_filters)

    def set_show_hidden(self, state):
        """Toggle 'show hidden files' state"""
        filters = (QDir.AllDirs | QDir.Files | QDir.Drives |
                   QDir.NoDotAndDotDot)
        if state:
            filters = (QDir.AllDirs | QDir.Files | QDir.Drives |
                       QDir.NoDotAndDotDot | QDir.Hidden)
        self.fsmodel.setFilter(filters)

    def reset_icon_provider(self):
        """Reset file system model icon provider
        The purpose of this is to refresh files/directories icons"""
        self.fsmodel.setIconProvider(IconProvider())

    def convert_notebook(self, fname):
        """Convert an IPython notebook to a Python script in editor"""
        try:
            script = nbexporter().from_filename(fname)[0]
        except Exception as e:
            QMessageBox.critical(
                self, _('Conversion error'),
                _("It was not possible to convert this "
                  "notebook. The error is:\n\n") + str(e))
            return
        self.sig_file_created.emit(script)

    @Slot()
    def convert_notebooks(self):
        """Convert IPython notebooks to Python scripts in editor"""
        fnames = self.get_selected_filenames()
        if not isinstance(fnames, (tuple, list)):
            fnames = [fnames]
        for fname in fnames:
            self.convert_notebook(fname)

    @Slot()
    def new_package(self, basedir=None):
        """New package"""

        if basedir is None or isinstance(basedir, bool):
            basedir = self.get_selected_dir()

        title = _('New package')
        subtitle = _('Package name:')
        self.create_new_folder(basedir, title, subtitle, is_package=True)

    @Slot()
    def new_module(self, basedir=None):
        """New module"""

        if basedir is None or isinstance(basedir, bool):
            basedir = self.get_selected_dir()

        title = _("New module")
        subtitle = _('Module name:')
        filters = ['.py', '.pyw', '.ipy']

        def create_func(fname):
            self.sig_module_created.emit(fname)

        self.create_new_file(basedir, title, subtitle, filters, create_func)

    def go_to_parent_directory(self):
        pass


class ExplorerTreeWidget(DirView):
    """
    File/directory explorer tree widget.
    """

    sig_dir_opened = Signal(str, str)
    """
    This signal is emitted when the current directory of the explorer tree
    has changed.

    Parameters
    ----------
    new_root_directory: str
        The new root directory path.
    server_id: str
        The server identification from where the new root directory is reachable.

    Notes
    -----
    This happens when clicking (or double clicking depending on the option)
    a folder, turning this folder in the new root parent of the tree.
    """

    def __init__(self, parent=None):
        """Initialize the widget.

        Parameters
        ----------
        parent: PluginMainWidget, optional
            Parent widget of the explorer tree widget.
        """
        super().__init__(parent=parent)

        # Attributes
        self._parent = parent
        self.__last_folder = None
        self.__original_root_index = None
        self.history = []
        self.histindex = None

        # Enable drag events
        self.setDragEnabled(True)

    # ---- SpyderWidgetMixin API
    # ------------------------------------------------------------------------
    def update_actions(self):
        """Update the widget actions."""
        super().update_actions()

    # ---- API
    # ------------------------------------------------------------------------
    def change_filter_state(self):
        """Handle the change of the filter state."""
        self.filter_on = not self.filter_on
        self.filter_button.setChecked(self.filter_on)
        self.filter_button.setToolTip(_("Filter filenames"))
        self.filter_files()

    # ---- Refreshing widget
    def set_current_folder(self, folder):
        """
        Set current folder and return associated model index

        Parameters
        ----------
        folder: str
            New path to the selected folder.
        """
        index = self.fsmodel.setRootPath(folder)
        self.__last_folder = folder
        self.setRootIndex(index)
        return index

    def get_current_folder(self):
        return self.__last_folder

    def refresh(self, new_path=None, force_current=False):
        """
        Refresh widget

        Parameters
        ----------
        new_path: str, optional
            New path to refresh the widget.
        force_current: bool, optional
            If False, it won't refresh widget if path has not changed.
        """
        if new_path is None:
            new_path = getcwd_or_home()
        if force_current:
            index = self.set_current_folder(new_path)
            self.expand(index)
            self.setCurrentIndex(index)

        self.previous_action.setEnabled(False)
        self.next_action.setEnabled(False)

        if self.histindex is not None:
            self.previous_action.setEnabled(self.histindex > 0)
            self.next_action.setEnabled(self.histindex < len(self.history) - 1)

    # ---- Events
    def directory_clicked(self, dirname, index):
        if dirname:
            self.chdir(directory=dirname)

    # ---- Files/Directories Actions
    @Slot()
    def go_to_parent_directory(self):
        """Go to parent directory"""
        self.chdir(osp.abspath(osp.join(getcwd_or_home(), os.pardir)))

    @Slot()
    def go_to_previous_directory(self):
        """Back to previous directory"""
        self.histindex -= 1
        self.chdir(browsing_history=True)

    @Slot()
    def go_to_next_directory(self):
        """Return to next directory"""
        self.histindex += 1
        self.chdir(browsing_history=True)

    def update_history(self, directory):
        """
        Update browse history.

        Parameters
        ----------
        directory: str
            The new working directory.
        """
        try:
            directory = osp.abspath(str(directory))
            if directory in self.history:
                self.histindex = self.history.index(directory)
        except Exception:
            user_directory = get_home_dir()
            self.chdir(directory=user_directory, browsing_history=True)

    def chdir(self, directory=None, browsing_history=False, emit=True):
        """
        Set directory as working directory.

        Parameters
        ----------
        directory: str
            The new working directory.
        browsing_history: bool, optional
            Add the new `directory`to the browsing history. Default is False.
        emit: bool, optional
            Emit a signal when changing the working directpory.
            Default is True.
        """
        if directory is not None:
            directory = osp.abspath(str(directory))
        if browsing_history:
            directory = self.history[self.histindex]
        elif directory in self.history:
            self.histindex = self.history.index(directory)
        else:
            if self.histindex is None:
                self.history = []
            else:
                self.history = self.history[:self.histindex+1]
            if len(self.history) == 0 or \
               (self.history and self.history[-1] != directory):
                self.history.append(directory)
            self.histindex = len(self.history)-1
        directory = str(directory)

        try:
            os.chdir(directory)
            self.refresh(new_path=directory, force_current=True)
            if emit:
                self.sig_dir_opened.emit(directory, None)
        except PermissionError:
            QMessageBox.critical(self._parent, "Error",
                                 _("You don't have the right permissions to "
                                   "open this directory"))
        except FileNotFoundError:
            # Handle renaming directories on the fly.
            # See spyder-ide/spyder#5183
            self.history.pop(self.histindex)
