# -*- coding: utf-8 -*-
#
# Copyright © Spyder Project Contributors
# Licensed under the terms of the MIT License
# (see spyder/__init__.py for details)
"""Project Explorer"""
# pylint: disable=C0103
# Standard library imports
from __future__ import print_function
import os
import os.path as osp
import shutil
# Third party imports
from qtpy.QtCore import QSortFilterProxyModel, Qt, Signal, Slot
from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QMessageBox
# Local imports
from spyder.api.translations import get_translation
from spyder.py3compat import to_text_string
from spyder.utils import misc
from spyder.plugins.explorer.widgets.explorer import DirView
_ = get_translation('spyder')
class ProxyModel(QSortFilterProxyModel):
"""Proxy model to filter tree view."""
PATHS_TO_HIDE = [
# Useful paths
'.spyproject',
'__pycache__',
'.ipynb_checkpoints',
# VCS paths
'.git',
'.hg',
'.svn',
# Others
'.pytest_cache',
'.DS_Store',
'Thumbs.db',
'.directory'
]
PATHS_TO_SHOW = [
'.github'
]
def __init__(self, parent):
"""Initialize the proxy model."""
super(ProxyModel, self).__init__(parent)
self.root_path = None
self.path_list = []
self.setDynamicSortFilter(True)
def setup_filter(self, root_path, path_list):
"""
Setup proxy model filter parameters.
Parameters
----------
root_path: str
Root path of the proxy model.
path_list: list
List with all the paths.
"""
self.root_path = osp.normpath(str(root_path))
self.path_list = [osp.normpath(str(p)) for p in path_list]
self.invalidateFilter()
def sort(self, column, order=Qt.AscendingOrder):
"""Reimplement Qt method."""
self.sourceModel().sort(column, order)
def filterAcceptsRow(self, row, parent_index):
"""Reimplement Qt method."""
if self.root_path is None:
return True
index = self.sourceModel().index(row, 0, parent_index)
path = osp.normcase(osp.normpath(
str(self.sourceModel().filePath(index))))
if osp.normcase(self.root_path).startswith(path):
# This is necessary because parent folders need to be scanned
return True
else:
for p in [osp.normcase(p) for p in self.path_list]:
if path == p or path.startswith(p + os.sep):
if not any([d in path for d in self.PATHS_TO_SHOW]):
if any([d in path for d in self.PATHS_TO_HIDE]):
return False
else:
return True
else:
return True
else:
return False
def data(self, index, role):
"""Show tooltip with full path only for the root directory."""
if role == Qt.ToolTipRole:
root_dir = self.path_list[0].split(osp.sep)[-1]
if index.data() == root_dir:
return osp.join(self.root_path, root_dir)
return QSortFilterProxyModel.data(self, index, role)
def type(self, index):
"""
Returns the type of file for the given index.
Parameters
----------
index: int
Given index to search its type.
"""
return self.sourceModel().type(self.mapToSource(index))
class FilteredDirView(DirView):
"""Filtered file/directory tree view."""
def __init__(self, parent=None):
"""Initialize the filtered dir view."""
super().__init__(parent)
self.proxymodel = None
self.setup_proxy_model()
self.root_path = None
# ---- Model
def setup_proxy_model(self):
"""Setup proxy model."""
self.proxymodel = ProxyModel(self)
self.proxymodel.setSourceModel(self.fsmodel)
def install_model(self):
"""Install proxy model."""
if self.root_path is not None:
self.setModel(self.proxymodel)
def set_root_path(self, root_path):
"""
Set root path.
Parameters
----------
root_path: str
New path directory.
"""
self.root_path = root_path
self.install_model()
index = self.fsmodel.setRootPath(root_path)
self.proxymodel.setup_filter(self.root_path, [])
self.setRootIndex(self.proxymodel.mapFromSource(index))
def get_index(self, filename):
"""
Return index associated with filename.
Parameters
----------
filename: str
String with the filename.
"""
index = self.fsmodel.index(filename)
if index.isValid() and index.model() is self.fsmodel:
return self.proxymodel.mapFromSource(index)
def set_folder_names(self, folder_names):
"""
Set folder names
Parameters
----------
folder_names: list
List with the folder names.
"""
assert self.root_path is not None
path_list = [osp.join(self.root_path, dirname)
for dirname in folder_names]
self.proxymodel.setup_filter(self.root_path, path_list)
def get_filename(self, index):
"""
Return filename from index
Parameters
----------
index: int
Index of the list of filenames
"""
if index:
path = self.fsmodel.filePath(self.proxymodel.mapToSource(index))
return osp.normpath(str(path))
def setup_project_view(self):
"""Setup view for projects."""
for i in [1, 2, 3]:
self.hideColumn(i)
self.setHeaderHidden(True)
# ---- Events
def directory_clicked(self, dirname, index):
if index and index.isValid():
if self.get_conf('single_click_to_open'):
state = not self.isExpanded(index)
else:
state = self.isExpanded(index)
self.setExpanded(index, state)
class ProjectExplorerTreeWidget(FilteredDirView):
"""Explorer tree widget"""
sig_delete_project = Signal()
def __init__(self, parent, show_hscrollbar=True):
FilteredDirView.__init__(self, parent)
self.last_folder = None
self.setSelectionMode(FilteredDirView.ExtendedSelection)
self.show_hscrollbar = show_hscrollbar
# Enable drag & drop events
self.setDragEnabled(True)
self.setDragDropMode(FilteredDirView.DragDrop)
# ------Public API---------------------------------------------------------
@Slot(bool)
def toggle_hscrollbar(self, checked):
"""Toggle horizontal scrollbar"""
self.set_conf('show_hscrollbar', checked)
self.show_hscrollbar = checked
self.header().setStretchLastSection(not checked)
self.header().setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
self.header().setSectionResizeMode(QHeaderView.ResizeToContents)
# ---- Internal drag & drop
def dragMoveEvent(self, event):
"""Reimplement Qt method"""
index = self.indexAt(event.pos())
if index:
dst = self.get_filename(index)
if osp.isdir(dst):
event.acceptProposedAction()
else:
event.ignore()
else:
event.ignore()
def dropEvent(self, event):
"""Reimplement Qt method"""
event.ignore()
action = event.dropAction()
if action not in (Qt.MoveAction, Qt.CopyAction):
return
# QTreeView must not remove the source items even in MoveAction mode:
# event.setDropAction(Qt.CopyAction)
dst = self.get_filename(self.indexAt(event.pos()))
yes_to_all, no_to_all = None, None
src_list = [to_text_string(url.toString())
for url in event.mimeData().urls()]
if len(src_list) > 1:
buttons = (QMessageBox.Yes | QMessageBox.YesToAll |
QMessageBox.No | QMessageBox.NoToAll |
QMessageBox.Cancel)
else:
buttons = QMessageBox.Yes | QMessageBox.No
for src in src_list:
if src == dst:
continue
dst_fname = osp.join(dst, osp.basename(src))
if osp.exists(dst_fname):
if yes_to_all is not None or no_to_all is not None:
if no_to_all:
continue
elif osp.isfile(dst_fname):
answer = QMessageBox.warning(
self,
_('Project explorer'),
_('File %s already exists.
'
'Do you want to overwrite it?') % dst_fname,
buttons
)
if answer == QMessageBox.No:
continue
elif answer == QMessageBox.Cancel:
break
elif answer == QMessageBox.YesToAll:
yes_to_all = True
elif answer == QMessageBox.NoToAll:
no_to_all = True
continue
else:
QMessageBox.critical(
self,
_('Project explorer'),
_('Folder %s already exists.') % dst_fname,
QMessageBox.Ok
)
event.setDropAction(Qt.CopyAction)
return
try:
if action == Qt.CopyAction:
if osp.isfile(src):
shutil.copy(src, dst)
else:
shutil.copytree(src, dst)
else:
if osp.isfile(src):
misc.move_file(src, dst)
else:
shutil.move(src, dst)
self.parent_widget.removed.emit(src)
except EnvironmentError as error:
if action == Qt.CopyAction:
action_str = _('copy')
else:
action_str = _('move')
QMessageBox.critical(
self,
_("Project Explorer"),
_("Unable to %s %s"
"
Error message:
%s") % (action_str, src,
str(error))
)
@Slot()
def delete(self, fnames=None):
"""Delete files"""
if fnames is None:
fnames = self.get_selected_filenames()
multiple = len(fnames) > 1
yes_to_all = None
for fname in fnames:
if fname == self.proxymodel.path_list[0]:
self.sig_delete_project.emit()
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