예제 #1
0
파일: plugin.py 프로젝트: wkx228/spyder
class Projects(SpyderPluginWidget):
    """Projects plugin."""

    CONF_SECTION = 'project_explorer'
    CONF_FILE = False

    # This is required for the new API
    NAME = 'project_explorer'
    REQUIRES = []
    OPTIONAL = [Plugins.Completions, Plugins.IPythonConsole, Plugins.Explorer]

    # Signals
    sig_project_created = Signal(str, str, object)
    """
    This signal is emitted to request the Projects plugin the creation of a
    project.

    Parameters
    ----------
    project_path: str
        Location of project.
    project_type: str
        Type of project as defined by project types.
    project_packages: object
        Package to install. Currently not in use.
    """

    sig_project_loaded = Signal(object)
    sig_project_closed = Signal(object)
    sig_pythonpath_changed = Signal()

    def __init__(self, parent=None):
        """Initialization."""
        SpyderPluginWidget.__init__(self, parent)

        self.explorer = ProjectExplorerWidget(
            self,
            name_filters=self.get_option('name_filters'),
            show_hscrollbar=self.get_option('show_hscrollbar'),
            options_button=self.options_button,
            single_click_to_open=CONF.get('explorer', 'single_click_to_open'),
        )

        layout = QVBoxLayout()
        layout.addWidget(self.explorer)
        self.setLayout(layout)

        self.recent_projects = self.get_option('recent_projects', default=[])
        self.current_active_project = None
        self.latest_project = None
        self.watcher = WorkspaceWatcher(self)
        self.completions_available = False
        self.explorer.setup_project(self.get_active_project_path())
        self.watcher.connect_signals(self)
        self._project_types = OrderedDict()

    #------ SpyderPluginWidget API ---------------------------------------------
    def get_plugin_title(self):
        """Return widget title"""
        return _("Project")

    def get_focus_widget(self):
        """
        Return the widget to give focus to when
        this plugin's dockwidget is raised on top-level
        """
        return self.explorer.treewidget

    def get_plugin_actions(self):
        """Return a list of actions related to plugin"""
        self.new_project_action = create_action(
            self, _("New Project..."), triggered=self.create_new_project)
        self.open_project_action = create_action(
            self,
            _("Open Project..."),
            triggered=lambda v: self.open_project())
        self.close_project_action = create_action(self,
                                                  _("Close Project"),
                                                  triggered=self.close_project)
        self.delete_project_action = create_action(
            self, _("Delete Project"), triggered=self.delete_project)
        self.clear_recent_projects_action = create_action(
            self, _("Clear this list"), triggered=self.clear_recent_projects)
        self.recent_project_menu = QMenu(_("Recent Projects"), self)

        self.max_recent_action = create_action(
            self,
            _("Maximum number of recent projects..."),
            triggered=self.change_max_recent_projects)

        if self.main is not None:
            self.main.projects_menu_actions += [
                self.new_project_action, MENU_SEPARATOR,
                self.open_project_action, self.close_project_action,
                self.delete_project_action, MENU_SEPARATOR,
                self.recent_project_menu, self._toggle_view_action
            ]

        self.setup_menu_actions()
        return []

    def register_plugin(self):
        """Register plugin in Spyder's main window"""
        ipyconsole = self.main.ipyconsole
        treewidget = self.explorer.treewidget
        lspmgr = self.main.completions

        self.add_dockwidget()
        self.explorer.sig_open_file_requested.connect(self.main.open_file)

        treewidget.sig_delete_project.connect(self.delete_project)
        treewidget.sig_open_file_requested.connect(self.main.editor.load)
        treewidget.sig_removed.connect(self.main.editor.removed)
        treewidget.sig_tree_removed.connect(self.main.editor.removed_tree)
        treewidget.sig_renamed.connect(self.main.editor.renamed)
        treewidget.sig_tree_renamed.connect(self.main.editor.renamed_tree)
        treewidget.sig_module_created.connect(self.main.editor.new)
        treewidget.sig_file_created.connect(
            lambda t: self.main.editor.new(text=t))
        treewidget.sig_open_interpreter_requested.connect(
            ipyconsole.create_client_from_path)
        treewidget.sig_redirect_stdio_requested.connect(
            self.main.redirect_internalshell_stdio)
        treewidget.sig_run_requested.connect(
            lambda fname: ipyconsole.run_script(fname, osp.dirname(
                fname), '', False, False, False, True, False))

        # TODO: This is not necessary anymore due to us starting workspace
        # services in the editor. However, we could restore it in the future.
        #lspmgr.sig_language_completions_available.connect(
        #    lambda settings, language:
        #        self.start_workspace_services())
        lspmgr.sig_stop_completions.connect(self.stop_workspace_services)

        # New project connections. Order matters!
        self.sig_project_loaded.connect(
            lambda path: self.main.workingdirectory.chdir(directory=path,
                                                          sender_plugin=self))
        self.sig_project_loaded.connect(lambda v: self.main.set_window_title())
        self.sig_project_loaded.connect(
            functools.partial(lspmgr.project_path_update,
                              update_kind=WorkspaceUpdateKind.ADDITION,
                              instance=self))
        self.sig_project_loaded.connect(
            lambda v: self.main.editor.setup_open_files())
        self.sig_project_loaded.connect(self.update_explorer)
        self.sig_project_loaded.connect(
            lambda v: self.main.outlineexplorer.update_all_editors())
        self.sig_project_closed[object].connect(
            lambda path: self.main.workingdirectory.chdir(
                directory=self.get_last_working_dir(), sender_plugin=self))
        self.sig_project_closed.connect(lambda v: self.main.set_window_title())
        self.sig_project_closed.connect(
            functools.partial(lspmgr.project_path_update,
                              update_kind=WorkspaceUpdateKind.DELETION,
                              instance=self))
        self.sig_project_closed.connect(
            lambda v: self.main.editor.setup_open_files())
        self.sig_project_closed.connect(
            lambda v: self.main.outlineexplorer.update_all_editors())
        self.recent_project_menu.aboutToShow.connect(self.setup_menu_actions)

        self.main.restore_scrollbar_position.connect(
            self.restore_scrollbar_position)
        self.sig_pythonpath_changed.connect(self.main.pythonpath_changed)
        self.main.editor.set_projects(self)

        self.sig_project_loaded.connect(
            lambda v: self.main.editor.set_current_project_path(v))
        self.sig_project_closed.connect(
            lambda v: self.main.editor.set_current_project_path())

        # Connect to file explorer to keep single click to open files in sync
        # TODO: Remove this once projects is migrated
        CONF.observe_configuration(self, 'explorer', 'single_click_to_open')
        self.register_project_type(self, EmptyProject)

    def on_configuration_change(self, option, section, value):
        """Set single click to open files and directories."""
        if option == 'single_click_to_open':
            self.explorer.treewidget.set_single_click_to_open(value)

    def closing_plugin(self, cancelable=False):
        """Perform actions before parent main window is closed"""
        self.save_config()
        self.explorer.closing_widget()
        return True

    def unmaximize(self):
        """Unmaximize the currently maximized plugin, if not self."""
        if (self.main.last_plugin is not None
                and self.main.last_plugin._ismaximized
                and self.main.last_plugin is not self):
            self.main.maximize_dockwidget()

    def build_opener(self, project):
        """Build function opening passed project"""
        def opener(*args, **kwargs):
            self.open_project(path=project)

        return opener

    # ------ Public API -------------------------------------------------------
    def on_first_registration(self):
        """Action to be performed on first plugin registration"""
        # TODO: Uncomment for Spyder 5
        # self.tabify(self.main.explorer)

    def setup_menu_actions(self):
        """Setup and update the menu actions."""
        self.recent_project_menu.clear()
        self.recent_projects_actions = []
        if self.recent_projects:
            for project in self.recent_projects:
                if self.is_valid_project(project):
                    name = project.replace(get_home_dir(), '~')
                    action = create_action(
                        self,
                        name,
                        icon=ima.icon('project'),
                        triggered=self.build_opener(project),
                    )
                    self.recent_projects_actions.append(action)
                else:
                    self.recent_projects.remove(project)
            self.recent_projects_actions += [
                None, self.clear_recent_projects_action, self.max_recent_action
            ]
        else:
            self.recent_projects_actions = [
                self.clear_recent_projects_action, self.max_recent_action
            ]
        add_actions(self.recent_project_menu, self.recent_projects_actions)
        self.update_project_actions()

    def update_project_actions(self):
        """Update actions of the Projects menu"""
        if self.recent_projects:
            self.clear_recent_projects_action.setEnabled(True)
        else:
            self.clear_recent_projects_action.setEnabled(False)

        active = bool(self.get_active_project_path())
        self.close_project_action.setEnabled(active)
        self.delete_project_action.setEnabled(active)

    @Slot()
    def create_new_project(self):
        """Create new project."""
        self.unmaximize()
        active_project = self.current_active_project
        dlg = ProjectDialog(self, project_types=self.get_project_types())
        result = dlg.exec_()
        data = dlg.project_data
        root_path = data.get("root_path", None)
        project_type = data.get("project_type", EmptyProject.ID)

        if result:
            # A project was not open before
            if active_project is None:
                if self.get_option('visible_if_project_open'):
                    self.show_explorer()
            else:
                # We are switching projects.
                # TODO: Don't emit sig_project_closed when we support
                # multiple workspaces.
                self.sig_project_closed.emit(active_project.root_path)

            self._create_project(root_path, project_type_id=project_type)
            self.sig_pythonpath_changed.emit()
            self.restart_consoles()
            dlg.close()

    def _create_project(self,
                        root_path,
                        project_type_id=EmptyProject.ID,
                        packages=None):
        """Create a new project."""
        project_types = self.get_project_types()
        if project_type_id in project_types:
            project_type_class = project_types[project_type_id]
            project = project_type_class(
                root_path=root_path,
                parent_plugin=project_type_class._PARENT_PLUGIN,
            )

            created_succesfully, message = project.create_project()
            if not created_succesfully:
                QMessageBox.warning(self, "Project creation", message)
                shutil.rmtree(root_path, ignore_errors=True)
                return

            # TODO: In a subsequent PR return a value and emit based on that
            self.sig_project_created.emit(root_path, project_type_id, packages)
            self.open_project(path=root_path, project=project)
        else:
            if not running_under_pytest():
                QMessageBox.critical(
                    self, _('Error'),
                    _("<b>{}</b> is not a registered Spyder project "
                      "type!").format(project_type_id))

    def open_project(self,
                     path=None,
                     project=None,
                     restart_consoles=True,
                     save_previous_files=True,
                     workdir=None):
        """Open the project located in `path`."""
        self.unmaximize()
        if path is None:
            basedir = get_home_dir()
            path = getexistingdirectory(parent=self,
                                        caption=_("Open project"),
                                        basedir=basedir)
            path = encoding.to_unicode_from_fs(path)
            if not self.is_valid_project(path):
                if path:
                    QMessageBox.critical(
                        self,
                        _('Error'),
                        _("<b>%s</b> is not a Spyder project!") % path,
                    )
                return
        else:
            path = encoding.to_unicode_from_fs(path)
        if project is None:
            project_type_class = self._load_project_type_class(path)
            project = project_type_class(
                root_path=path,
                parent_plugin=project_type_class._PARENT_PLUGIN,
            )

        # A project was not open before
        if self.current_active_project is None:
            if save_previous_files and self.main.editor is not None:
                self.main.editor.save_open_files()

            if self.main.editor is not None:
                self.main.editor.set_option('last_working_dir',
                                            getcwd_or_home())

            if self.get_option('visible_if_project_open'):
                self.show_explorer()
        else:
            # We are switching projects
            if self.main.editor is not None:
                self.set_project_filenames(
                    self.main.editor.get_open_filenames())

            # TODO: Don't emit sig_project_closed when we support
            # multiple workspaces.
            self.sig_project_closed.emit(self.current_active_project.root_path)

        self.current_active_project = project
        self.latest_project = project
        self.add_to_recent(path)

        self.set_option('current_project_path', self.get_active_project_path())

        self.setup_menu_actions()
        if workdir and osp.isdir(workdir):
            self.sig_project_loaded.emit(workdir)
        else:
            self.sig_project_loaded.emit(path)
        self.sig_pythonpath_changed.emit()
        self.watcher.start(path)

        if restart_consoles:
            self.restart_consoles()

        open_successfully, message = project.open_project()
        if not open_successfully:
            QMessageBox.warning(self, "Project open", message)

    def close_project(self):
        """
        Close current project and return to a window without an active
        project
        """
        if self.current_active_project:
            self.unmaximize()
            if self.main.editor is not None:
                self.set_project_filenames(
                    self.main.editor.get_open_filenames())
            path = self.current_active_project.root_path
            closed_sucessfully, message = (
                self.current_active_project.close_project())
            if not closed_sucessfully:
                QMessageBox.warning(self, "Project close", message)

            self.current_active_project = None
            self.set_option('current_project_path', None)
            self.setup_menu_actions()

            self.sig_project_closed.emit(path)
            self.sig_pythonpath_changed.emit()

            if self.dockwidget is not None:
                self.set_option('visible_if_project_open',
                                self.dockwidget.isVisible())
                self.dockwidget.close()

            self.explorer.clear()
            self.restart_consoles()
            self.watcher.stop()

    def delete_project(self):
        """
        Delete the current project without deleting the files in the directory.
        """
        if self.current_active_project:
            self.unmaximize()
            path = self.current_active_project.root_path
            buttons = QMessageBox.Yes | QMessageBox.No
            answer = QMessageBox.warning(
                self, _("Delete"),
                _("Do you really want to delete <b>{filename}</b>?<br><br>"
                  "<b>Note:</b> This action will only delete the project. "
                  "Its files are going to be preserved on disk.").format(
                      filename=osp.basename(path)), buttons)
            if answer == QMessageBox.Yes:
                try:
                    self.close_project()
                    shutil.rmtree(osp.join(path, '.spyproject'))
                except EnvironmentError as error:
                    QMessageBox.critical(
                        self, _("Project Explorer"),
                        _("<b>Unable to delete <i>{varpath}</i></b>"
                          "<br><br>The error message was:<br>{error}").format(
                              varpath=path, error=to_text_string(error)))

    def clear_recent_projects(self):
        """Clear the list of recent projects"""
        self.recent_projects = []
        self.setup_menu_actions()

    def change_max_recent_projects(self):
        """Change max recent projects entries."""

        mrf, valid = QInputDialog.getInt(
            self, _('Projects'), _('Maximum number of recent projects'),
            self.get_option('max_recent_projects'), 1, 35)

        if valid:
            self.set_option('max_recent_projects', mrf)

    def get_active_project(self):
        """Get the active project"""
        return self.current_active_project

    def reopen_last_project(self):
        """
        Reopen the active project when Spyder was closed last time, if any
        """
        current_project_path = self.get_option('current_project_path',
                                               default=None)

        # Needs a safer test of project existence!
        if (current_project_path
                and self.is_valid_project(current_project_path)):
            self.open_project(path=current_project_path,
                              restart_consoles=False,
                              save_previous_files=False)
            self.load_config()

    def get_project_filenames(self):
        """Get the list of recent filenames of a project"""
        recent_files = []
        if self.current_active_project:
            recent_files = self.current_active_project.get_recent_files()
        elif self.latest_project:
            recent_files = self.latest_project.get_recent_files()
        return recent_files

    def set_project_filenames(self, recent_files):
        """Set the list of open file names in a project"""
        if (self.current_active_project and self.is_valid_project(
                self.current_active_project.root_path)):
            self.current_active_project.set_recent_files(recent_files)

    def get_active_project_path(self):
        """Get path of the active project"""
        active_project_path = None
        if self.current_active_project:
            active_project_path = self.current_active_project.root_path
        return active_project_path

    def get_pythonpath(self, at_start=False):
        """Get project path as a list to be added to PYTHONPATH"""
        if at_start:
            current_path = self.get_option('current_project_path',
                                           default=None)
        else:
            current_path = self.get_active_project_path()
        if current_path is None:
            return []
        else:
            return [current_path]

    def get_last_working_dir(self):
        """Get the path of the last working directory"""
        return self.main.editor.get_option('last_working_dir',
                                           default=getcwd_or_home())

    def save_config(self):
        """
        Save configuration: opened projects & tree widget state.

        Also save whether dock widget is visible if a project is open.
        """
        self.set_option('recent_projects', self.recent_projects)
        self.set_option('expanded_state',
                        self.explorer.treewidget.get_expanded_state())
        self.set_option('scrollbar_position',
                        self.explorer.treewidget.get_scrollbar_position())
        if self.current_active_project and self.dockwidget:
            self.set_option('visible_if_project_open',
                            self.dockwidget.isVisible())

    def load_config(self):
        """Load configuration: opened projects & tree widget state"""
        expanded_state = self.get_option('expanded_state', None)
        # Sometimes the expanded state option may be truncated in .ini file
        # (for an unknown reason), in this case it would be converted to a
        # string by 'userconfig':
        if is_text_string(expanded_state):
            expanded_state = None
        if expanded_state is not None:
            self.explorer.treewidget.set_expanded_state(expanded_state)

    def restore_scrollbar_position(self):
        """Restoring scrollbar position after main window is visible"""
        scrollbar_pos = self.get_option('scrollbar_position', None)
        if scrollbar_pos is not None:
            self.explorer.treewidget.set_scrollbar_position(scrollbar_pos)

    def update_explorer(self):
        """Update explorer tree"""
        self.explorer.setup_project(self.get_active_project_path())

    def show_explorer(self):
        """Show the explorer"""
        if self.dockwidget is not None:
            if self.dockwidget.isHidden():
                self.dockwidget.show()
            self.dockwidget.raise_()
            self.dockwidget.update()

    def restart_consoles(self):
        """Restart consoles when closing, opening and switching projects"""
        if self.main.ipyconsole is not None:
            self.main.ipyconsole.restart()

    def is_valid_project(self, path):
        """Check if a directory is a valid Spyder project"""
        spy_project_dir = osp.join(path, '.spyproject')
        return osp.isdir(path) and osp.isdir(spy_project_dir)

    def add_to_recent(self, project):
        """
        Add an entry to recent projetcs

        We only maintain the list of the 10 most recent projects
        """
        if project not in self.recent_projects:
            self.recent_projects.insert(0, project)
        if len(self.recent_projects) > self.get_option('max_recent_projects'):
            self.recent_projects.pop(-1)

    def start_workspace_services(self):
        """Enable LSP workspace functionality."""
        self.completions_available = True
        if self.current_active_project:
            path = self.get_active_project_path()
            self.notify_project_open(path)

    def stop_workspace_services(self, _language):
        """Disable LSP workspace functionality."""
        self.completions_available = False

    def emit_request(self, method, params, requires_response):
        """Send request/notification/response to all LSP servers."""
        params['requires_response'] = requires_response
        params['response_instance'] = self
        self.main.completions.broadcast_notification(method, params)

    @Slot(str, dict)
    def handle_response(self, method, params):
        """Method dispatcher for LSP requests."""
        if method in self.handler_registry:
            handler_name = self.handler_registry[method]
            handler = getattr(self, handler_name)
            handler(params)

    @Slot(str, str, bool)
    @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE,
             requires_response=False)
    def file_moved(self, src_file, dest_file, is_dir):
        """Notify LSP server about a file that is moved."""
        # LSP specification only considers file updates
        if is_dir:
            return

        deletion_entry = {'file': src_file, 'kind': FileChangeType.DELETED}

        addition_entry = {'file': dest_file, 'kind': FileChangeType.CREATED}

        entries = [addition_entry, deletion_entry]
        params = {'params': entries}
        return params

    @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE,
             requires_response=False)
    @Slot(str, bool)
    def file_created(self, src_file, is_dir):
        """Notify LSP server about file creation."""
        if is_dir:
            return

        params = {
            'params': [{
                'file': src_file,
                'kind': FileChangeType.CREATED
            }]
        }
        return params

    @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE,
             requires_response=False)
    @Slot(str, bool)
    def file_deleted(self, src_file, is_dir):
        """Notify LSP server about file deletion."""
        if is_dir:
            return

        params = {
            'params': [{
                'file': src_file,
                'kind': FileChangeType.DELETED
            }]
        }
        return params

    @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE,
             requires_response=False)
    @Slot(str, bool)
    def file_modified(self, src_file, is_dir):
        """Notify LSP server about file modification."""
        if is_dir:
            return

        params = {
            'params': [{
                'file': src_file,
                'kind': FileChangeType.CHANGED
            }]
        }
        return params

    @request(method=CompletionRequestTypes.WORKSPACE_FOLDERS_CHANGE,
             requires_response=False)
    def notify_project_open(self, path):
        """Notify LSP server about project path availability."""
        params = {'folder': path, 'instance': self, 'kind': 'addition'}
        return params

    @request(method=CompletionRequestTypes.WORKSPACE_FOLDERS_CHANGE,
             requires_response=False)
    def notify_project_close(self, path):
        """Notify LSP server to unregister project path."""
        params = {'folder': path, 'instance': self, 'kind': 'deletion'}
        return params

    @handles(CompletionRequestTypes.WORKSPACE_APPLY_EDIT)
    @request(method=CompletionRequestTypes.WORKSPACE_APPLY_EDIT,
             requires_response=False)
    def handle_workspace_edit(self, params):
        """Apply edits to multiple files and notify server about success."""
        edits = params['params']
        response = {
            'applied': False,
            'error': 'Not implemented',
            'language': edits['language']
        }
        return response

    # --- New API:
    # ------------------------------------------------------------------------
    def _load_project_type_class(self, path):
        """
        Load a project type class from the config project folder directly.

        Notes
        -----
        This is done directly, since using the EmptyProject would rewrite the
        value in the constructor. If the project found has not been registered
        as a valid project type, the EmptyProject type will be returned.

        Returns
        -------
        spyder.plugins.projects.api.BaseProjectType
            Loaded project type class.
        """
        fpath = osp.join(path, get_project_config_folder(), 'config',
                         WORKSPACE + ".ini")

        project_type_id = EmptyProject.ID
        if osp.isfile(fpath):
            config = configparser.ConfigParser()
            config.read(fpath)
            project_type_id = config[WORKSPACE].get("project_type",
                                                    EmptyProject.ID)

        EmptyProject._PARENT_PLUGIN = self
        project_types = self.get_project_types()
        project_type_class = project_types.get(project_type_id, EmptyProject)
        return project_type_class

    def register_project_type(self, parent_plugin, project_type):
        """
        Register a new project type.

        Parameters
        ----------
        parent_plugin: spyder.plugins.api.plugins.SpyderPluginV2
            The parent plugin instance making the project type registration.
        project_type: spyder.plugins.projects.api.BaseProjectType
            Project type to register.
        """
        if not issubclass(project_type, BaseProjectType):
            raise SpyderAPIError("A project type must subclass "
                                 "BaseProjectType!")

        project_id = project_type.ID
        if project_id in self._project_types:
            raise SpyderAPIError("A project type id '{}' has already been "
                                 "registered!".format(project_id))

        project_type._PARENT_PLUGIN = parent_plugin
        self._project_types[project_id] = project_type

    def get_project_types(self):
        """
        Return available registered project types.

        Returns
        -------
        dict
            Project types dictionary. Keys are project type IDs and values
            are project type classes.
        """
        return self._project_types

    # TODO: To be removed after migration
    def get_plugin(self, plugin_name):
        """
        Return a plugin instance by providing the plugin's NAME.
        """
        PLUGINS = self.main._PLUGINS
        if plugin_name in PLUGINS:
            return PLUGINS[plugin_name]
예제 #2
0
class Projects(SpyderDockablePlugin):
    """Projects plugin."""
    NAME = 'project_explorer'
    CONF_SECTION = NAME
    CONF_FILE = False
    REQUIRES = []
    OPTIONAL = [Plugins.Completions, Plugins.IPythonConsole, Plugins.Editor,
                Plugins.MainMenu]
    WIDGET_CLASS = ProjectExplorerWidget

    # Signals
    sig_project_created = Signal(str, str, object)
    """
    This signal is emitted to request the Projects plugin the creation of a
    project.

    Parameters
    ----------
    project_path: str
        Location of project.
    project_type: str
        Type of project as defined by project types.
    project_packages: object
        Package to install. Currently not in use.
    """

    sig_project_loaded = Signal(object)
    """
    This signal is emitted when a project is loaded.

    Parameters
    ----------
    project_path: object
        Loaded project path.
    """

    sig_project_closed = Signal((object,), (bool,))
    """
    This signal is emitted when a project is closed.

    Parameters
    ----------
    project_path: object
        Closed project path (signature 1).
    close_project: bool
        This is emitted only when closing a project but not when switching
        between projects (signature 2).
    """

    sig_pythonpath_changed = Signal()
    """
    This signal is emitted when the Python path has changed.
    """

    def __init__(self, parent=None, configuration=None):
        """Initialization."""
        super().__init__(parent, configuration)
        self.recent_projects = self.get_conf('recent_projects', [])
        self.current_active_project = None
        self.latest_project = None
        self.watcher = WorkspaceWatcher(self)
        self.completions_available = False
        self.get_widget().setup_project(self.get_active_project_path())
        self.watcher.connect_signals(self)
        self._project_types = OrderedDict()

    # ---- SpyderDockablePlugin API
    # ------------------------------------------------------------------------
    @staticmethod
    def get_name():
        return _("Projects")

    def get_description(self):
        return _("Create Spyder projects and manage their files.")

    def get_icon(self):
        return self.create_icon('project')

    def on_initialize(self):
        """Register plugin in Spyder's main window"""
        widget = self.get_widget()
        treewidget = widget.treewidget

        self.ipyconsole = None
        self.editor = None
        self.completions = None

        treewidget.sig_delete_project.connect(self.delete_project)
        treewidget.sig_redirect_stdio_requested.connect(
            self.sig_redirect_stdio_requested)
        self.sig_switch_to_plugin_requested.connect(
            lambda plugin, check: self.show_explorer())
        self.sig_project_loaded.connect(self.update_explorer)

        if self.main:
            widget.sig_open_file_requested.connect(self.main.open_file)
            self.main.project_path = self.get_pythonpath(at_start=True)
            self.sig_project_loaded.connect(
                lambda v: self.main.set_window_title())
            self.sig_project_closed.connect(
                lambda v: self.main.set_window_title())
            self.main.restore_scrollbar_position.connect(
                self.restore_scrollbar_position)
            self.sig_pythonpath_changed.connect(self.main.pythonpath_changed)

        self.register_project_type(self, EmptyProject)
        self.setup()

    @on_plugin_available(plugin=Plugins.Editor)
    def on_editor_available(self):
        self.editor = self.get_plugin(Plugins.Editor)
        widget = self.get_widget()
        treewidget = widget.treewidget

        treewidget.sig_open_file_requested.connect(self.editor.load)
        treewidget.sig_removed.connect(self.editor.removed)
        treewidget.sig_tree_removed.connect(self.editor.removed_tree)
        treewidget.sig_renamed.connect(self.editor.renamed)
        treewidget.sig_tree_renamed.connect(self.editor.renamed_tree)
        treewidget.sig_module_created.connect(self.editor.new)
        treewidget.sig_file_created.connect(self._new_editor)

        self.sig_project_loaded.connect(self._setup_editor_files)
        self.sig_project_closed[bool].connect(self._setup_editor_files)

        self.editor.set_projects(self)
        self.sig_project_loaded.connect(self._set_path_in_editor)
        self.sig_project_closed.connect(self._unset_path_in_editor)

    @on_plugin_available(plugin=Plugins.Completions)
    def on_completions_available(self):
        self.completions = self.get_plugin(Plugins.Completions)

        # TODO: This is not necessary anymore due to us starting workspace
        # services in the editor. However, we could restore it in the future.
        # completions.sig_language_completions_available.connect(
        #     lambda settings, language:
        #         self.start_workspace_services())
        self.completions.sig_stop_completions.connect(
            self.stop_workspace_services)
        self.sig_project_loaded.connect(self._add_path_to_completions)
        self.sig_project_closed.connect(self._remove_path_from_completions)

    @on_plugin_available(plugin=Plugins.IPythonConsole)
    def on_ipython_console_available(self):
        self.ipyconsole = self.get_plugin(Plugins.IPythonConsole)
        widget = self.get_widget()
        treewidget = widget.treewidget
        treewidget.sig_open_interpreter_requested.connect(
            self.ipyconsole.create_client_from_path)
        treewidget.sig_run_requested.connect(self._run_file_in_ipyconsole)

    @on_plugin_available(plugin=Plugins.MainMenu)
    def on_main_menu_available(self):
        main_menu = self.get_plugin(Plugins.MainMenu)
        new_project_action = self.get_action(ProjectsActions.NewProject)
        open_project_action = self.get_action(ProjectsActions.OpenProject)

        projects_menu = main_menu.get_application_menu(
            ApplicationMenus.Projects)
        projects_menu.aboutToShow.connect(self.is_invalid_active_project)

        main_menu.add_item_to_application_menu(
            new_project_action,
            menu_id=ApplicationMenus.Projects,
            section=ProjectsMenuSections.New)

        for item in [open_project_action, self.close_project_action,
                     self.delete_project_action]:
            main_menu.add_item_to_application_menu(
                item,
                menu_id=ApplicationMenus.Projects,
                section=ProjectsMenuSections.Open)

        main_menu.add_item_to_application_menu(
            self.recent_project_menu,
            menu_id=ApplicationMenus.Projects,
            section=ProjectsMenuSections.Extras)

    @on_plugin_teardown(plugin=Plugins.Editor)
    def on_editor_teardown(self):
        self.editor = self.get_plugin(Plugins.Editor)
        widget = self.get_widget()
        treewidget = widget.treewidget

        treewidget.sig_open_file_requested.disconnect(self.editor.load)
        treewidget.sig_removed.disconnect(self.editor.removed)
        treewidget.sig_tree_removed.disconnect(self.editor.removed_tree)
        treewidget.sig_renamed.disconnect(self.editor.renamed)
        treewidget.sig_tree_renamed.disconnect(self.editor.renamed_tree)
        treewidget.sig_module_created.disconnect(self.editor.new)
        treewidget.sig_file_created.disconnect(self._new_editor)

        self.sig_project_loaded.disconnect(self._setup_editor_files)
        self.sig_project_closed[bool].disconnect(self._setup_editor_files)
        self.editor.set_projects(None)
        self.sig_project_loaded.disconnect(self._set_path_in_editor)
        self.sig_project_closed.disconnect(self._unset_path_in_editor)

        self.editor = None

    @on_plugin_teardown(plugin=Plugins.Completions)
    def on_completions_teardown(self):
        self.completions = self.get_plugin(Plugins.Completions)

        self.completions.sig_stop_completions.disconnect(
            self.stop_workspace_services)

        self.sig_project_loaded.disconnect(self._add_path_to_completions)
        self.sig_project_closed.disconnect(self._remove_path_from_completions)

        self.completions = None

    @on_plugin_teardown(plugin=Plugins.IPythonConsole)
    def on_ipython_console_teardown(self):
        self.ipyconsole = self.get_plugin(Plugins.IPythonConsole)
        widget = self.get_widget()
        treewidget = widget.treewidget

        treewidget.sig_open_interpreter_requested.disconnect(
            self.ipyconsole.create_client_from_path)
        treewidget.sig_run_requested.disconnect(self._run_file_in_ipyconsole)

        self._ipython_run_script = None
        self.ipyconsole = None

    @on_plugin_teardown(plugin=Plugins.MainMenu)
    def on_main_menu_teardown(self):
        main_menu = self.get_plugin(Plugins.MainMenu)
        main_menu.remove_application_menu(ApplicationMenus.Projects)

    def setup(self):
        """Setup the plugin actions."""
        self.create_action(
            ProjectsActions.NewProject,
            text=_("New Project..."),
            triggered=self.create_new_project)

        self.create_action(
            ProjectsActions.OpenProject,
            text=_("Open Project..."),
            triggered=lambda v: self.open_project())

        self.close_project_action = self.create_action(
            ProjectsActions.CloseProject,
            text=_("Close Project"),
            triggered=self.close_project)

        self.delete_project_action = self.create_action(
            ProjectsActions.DeleteProject,
            text=_("Delete Project"),
            triggered=self.delete_project)

        self.clear_recent_projects_action = self.create_action(
            ProjectsActions.ClearRecentProjects,
            text=_("Clear this list"),
            triggered=self.clear_recent_projects)

        self.max_recent_action = self.create_action(
            ProjectsActions.MaxRecent,
            text=_("Maximum number of recent projects..."),
            triggered=self.change_max_recent_projects)

        self.recent_project_menu = self.get_widget().create_menu(
            ProjectsMenuSubmenus.RecentProjects,
            _("Recent Projects")
        )
        self.recent_project_menu.aboutToShow.connect(self.setup_menu_actions)
        self.setup_menu_actions()

    def setup_menu_actions(self):
        """Setup and update the menu actions."""
        if self.recent_projects:
            for project in self.recent_projects:
                if self.is_valid_project(project):
                    if os.name == 'nt':
                        name = project
                    else:
                        name = project.replace(get_home_dir(), '~')
                    try:
                        action = self.get_action(name)
                    except KeyError:
                        action = self.create_action(
                            name,
                            text=name,
                            icon=ima.icon('project'),
                            triggered=self.build_opener(project),
                        )
                    self.get_widget().add_item_to_menu(
                        action,
                        menu=self.recent_project_menu,
                        section=RecentProjectsMenuSections.Recent)

        for item in [self.clear_recent_projects_action,
                     self.max_recent_action]:
            self.get_widget().add_item_to_menu(
                item,
                menu=self.recent_project_menu,
                section=RecentProjectsMenuSections.Extras)
        self.update_project_actions()

    def update_project_actions(self):
        """Update actions of the Projects menu"""
        if self.recent_projects:
            self.clear_recent_projects_action.setEnabled(True)
        else:
            self.clear_recent_projects_action.setEnabled(False)

        active = bool(self.get_active_project_path())
        self.close_project_action.setEnabled(active)
        self.delete_project_action.setEnabled(active)

    def on_close(self, cancelable=False):
        """Perform actions before parent main window is closed"""
        self.save_config()
        self.watcher.stop()
        return True

    def unmaximize(self):
        """Unmaximize the currently maximized plugin, if not self."""
        if self.main:
            if (self.main.last_plugin is not None and
                    self.main.last_plugin._ismaximized and
                    self.main.last_plugin is not self):
                self.main.maximize_dockwidget()

    def build_opener(self, project):
        """Build function opening passed project"""
        def opener(*args, **kwargs):
            self.open_project(path=project)
        return opener

    def on_mainwindow_visible(self):
        # Open project passed on the command line or reopen last one.
        cli_options = self.get_command_line_options()
        initial_cwd = self._main.get_initial_working_directory()

        if cli_options.project is not None:
            # This doesn't work for our Mac app
            if not running_in_mac_app():
                logger.debug('Opening project from the command line')
                project = osp.normpath(
                    osp.join(initial_cwd, cli_options.project)
                )
                self.open_project(
                    project,
                    workdir=cli_options.working_directory
                )
        else:
            logger.debug('Reopening project from last session')
            self.reopen_last_project()

    # ------ Public API -------------------------------------------------------
    @Slot()
    def create_new_project(self):
        """Create new project."""
        self.unmaximize()
        dlg = ProjectDialog(self.get_widget(),
                            project_types=self.get_project_types())
        result = dlg.exec_()
        data = dlg.project_data
        root_path = data.get("root_path", None)
        project_type = data.get("project_type", EmptyProject.ID)

        if result:
            self._create_project(root_path, project_type_id=project_type)
            dlg.close()

    def _create_project(self, root_path, project_type_id=EmptyProject.ID,
                        packages=None):
        """Create a new project."""
        project_types = self.get_project_types()
        if project_type_id in project_types:
            project_type_class = project_types[project_type_id]
            project = project_type_class(
                root_path=root_path,
                parent_plugin=project_type_class._PARENT_PLUGIN,
            )

            created_succesfully, message = project.create_project()
            if not created_succesfully:
                QMessageBox.warning(
                    self.get_widget(), "Project creation", message)
                shutil.rmtree(root_path, ignore_errors=True)
                return

            # TODO: In a subsequent PR return a value and emit based on that
            self.sig_project_created.emit(root_path, project_type_id, packages)
            self.open_project(path=root_path, project=project)
        else:
            if not running_under_pytest():
                QMessageBox.critical(
                    self.get_widget(),
                    _('Error'),
                    _("<b>{}</b> is not a registered Spyder project "
                      "type!").format(project_type_id)
                )

    def open_project(self, path=None, project=None, restart_consoles=True,
                     save_previous_files=True, workdir=None):
        """Open the project located in `path`."""
        self.unmaximize()
        if path is None:
            basedir = get_home_dir()
            path = getexistingdirectory(parent=self.get_widget(),
                                        caption=_("Open project"),
                                        basedir=basedir)
            path = encoding.to_unicode_from_fs(path)
            if not self.is_valid_project(path):
                if path:
                    QMessageBox.critical(
                        self.get_widget(),
                        _('Error'),
                        _("<b>%s</b> is not a Spyder project!") % path,
                    )
                return
        else:
            path = encoding.to_unicode_from_fs(path)

        logger.debug(f'Opening project located at {path}')

        if project is None:
            project_type_class = self._load_project_type_class(path)
            project = project_type_class(
                root_path=path,
                parent_plugin=project_type_class._PARENT_PLUGIN,
            )

        # A project was not open before
        if self.current_active_project is None:
            if save_previous_files and self.editor is not None:
                self.editor.save_open_files()

            if self.editor is not None:
                self.set_conf('last_working_dir', getcwd_or_home(),
                              section='editor')

            if self.get_conf('visible_if_project_open'):
                self.show_explorer()
        else:
            # We are switching projects
            if self.editor is not None:
                self.set_project_filenames(self.editor.get_open_filenames())

            # TODO: Don't emit sig_project_closed when we support
            # multiple workspaces.
            self.sig_project_closed.emit(
                self.current_active_project.root_path)
            self.watcher.stop()

        self.current_active_project = project
        self.latest_project = project
        self.add_to_recent(path)

        self.set_conf('current_project_path', self.get_active_project_path())

        self.setup_menu_actions()
        if workdir and osp.isdir(workdir):
            self.sig_project_loaded.emit(workdir)
        else:
            self.sig_project_loaded.emit(path)
        self.sig_pythonpath_changed.emit()
        self.watcher.start(path)

        if restart_consoles:
            self.restart_consoles()

        open_successfully, message = project.open_project()
        if not open_successfully:
            QMessageBox.warning(self.get_widget(), "Project open", message)

    def close_project(self):
        """
        Close current project and return to a window without an active
        project
        """
        if self.current_active_project:
            self.unmaximize()
            if self.editor is not None:
                self.set_project_filenames(
                    self.editor.get_open_filenames())
            path = self.current_active_project.root_path
            closed_sucessfully, message = (
                self.current_active_project.close_project())
            if not closed_sucessfully:
                QMessageBox.warning(
                    self.get_widget(), "Project close", message)

            self.current_active_project = None
            self.set_conf('current_project_path', None)
            self.setup_menu_actions()

            self.sig_project_closed.emit(path)
            self.sig_project_closed[bool].emit(True)
            self.sig_pythonpath_changed.emit()

            # Hide pane.
            self.set_conf('visible_if_project_open',
                          self.get_widget().isVisible())
            self.toggle_view(False)

            self.get_widget().clear()
            self.restart_consoles()
            self.watcher.stop()

    def delete_project(self):
        """
        Delete the current project without deleting the files in the directory.
        """
        if self.current_active_project:
            self.unmaximize()
            path = self.current_active_project.root_path
            buttons = QMessageBox.Yes | QMessageBox.No
            answer = QMessageBox.warning(
                self.get_widget(),
                _("Delete"),
                _("Do you really want to delete <b>{filename}</b>?<br><br>"
                  "<b>Note:</b> This action will only delete the project. "
                  "Its files are going to be preserved on disk."
                  ).format(filename=osp.basename(path)),
                buttons)
            if answer == QMessageBox.Yes:
                try:
                    self.close_project()
                    shutil.rmtree(osp.join(path, '.spyproject'))
                except EnvironmentError as error:
                    QMessageBox.critical(
                        self.get_widget(),
                        _("Project Explorer"),
                        _("<b>Unable to delete <i>{varpath}</i></b>"
                          "<br><br>The error message was:<br>{error}"
                          ).format(varpath=path, error=to_text_string(error)))

    def clear_recent_projects(self):
        """Clear the list of recent projects"""
        self.recent_projects = []
        self.set_conf('recent_projects', self.recent_projects)
        self.setup_menu_actions()

    def change_max_recent_projects(self):
        """Change max recent projects entries."""

        mrf, valid = QInputDialog.getInt(
            self.get_widget(),
            _('Projects'),
            _('Maximum number of recent projects'),
            self.get_conf('max_recent_projects'),
            1,
            35)

        if valid:
            self.set_conf('max_recent_projects', mrf)

    def get_active_project(self):
        """Get the active project"""
        return self.current_active_project

    def reopen_last_project(self):
        """
        Reopen the active project when Spyder was closed last time, if any
        """
        current_project_path = self.get_conf('current_project_path',
                                             default=None)

        # Needs a safer test of project existence!
        if (
            current_project_path and
            self.is_valid_project(current_project_path)
        ):
            cli_options = self.get_command_line_options()
            self.open_project(
                path=current_project_path,
                restart_consoles=True,
                save_previous_files=False,
                workdir=cli_options.working_directory
            )
            self.load_config()

    def get_project_filenames(self):
        """Get the list of recent filenames of a project"""
        recent_files = []
        if self.current_active_project:
            recent_files = self.current_active_project.get_recent_files()
        elif self.latest_project:
            recent_files = self.latest_project.get_recent_files()
        return recent_files

    def set_project_filenames(self, recent_files):
        """Set the list of open file names in a project"""
        if (self.current_active_project
                and self.is_valid_project(
                        self.current_active_project.root_path)):
            self.current_active_project.set_recent_files(recent_files)

    def get_active_project_path(self):
        """Get path of the active project"""
        active_project_path = None
        if self.current_active_project:
            active_project_path = self.current_active_project.root_path
        return active_project_path

    def get_pythonpath(self, at_start=False):
        """Get project path as a list to be added to PYTHONPATH"""
        if at_start:
            current_path = self.get_conf('current_project_path',
                                         default=None)
        else:
            current_path = self.get_active_project_path()
        if current_path is None:
            return []
        else:
            return [current_path]

    def get_last_working_dir(self):
        """Get the path of the last working directory"""
        return self.get_conf(
            'last_working_dir', section='editor', default=getcwd_or_home())

    def save_config(self):
        """
        Save configuration: opened projects & tree widget state.

        Also save whether dock widget is visible if a project is open.
        """
        self.set_conf('recent_projects', self.recent_projects)
        self.set_conf('expanded_state',
                      self.get_widget().treewidget.get_expanded_state())
        self.set_conf('scrollbar_position',
                      self.get_widget().treewidget.get_scrollbar_position())
        if self.current_active_project:
            self.set_conf('visible_if_project_open',
                          self.get_widget().isVisible())

    def load_config(self):
        """Load configuration: opened projects & tree widget state"""
        expanded_state = self.get_conf('expanded_state', None)
        # Sometimes the expanded state option may be truncated in .ini file
        # (for an unknown reason), in this case it would be converted to a
        # string by 'userconfig':
        if is_text_string(expanded_state):
            expanded_state = None
        if expanded_state is not None:
            self.get_widget().treewidget.set_expanded_state(expanded_state)

    def restore_scrollbar_position(self):
        """Restoring scrollbar position after main window is visible"""
        scrollbar_pos = self.get_conf('scrollbar_position', None)
        if scrollbar_pos is not None:
            self.get_widget().treewidget.set_scrollbar_position(scrollbar_pos)

    def update_explorer(self):
        """Update explorer tree"""
        self.get_widget().setup_project(self.get_active_project_path())

    def show_explorer(self):
        """Show the explorer"""
        if self.get_widget() is not None:
            self.toggle_view(True)
            self.get_widget().setVisible(True)
            self.get_widget().raise_()
            self.get_widget().update()

    def restart_consoles(self):
        """Restart consoles when closing, opening and switching projects"""
        if self.ipyconsole is not None:
            self.ipyconsole.restart()

    def is_valid_project(self, path):
        """Check if a directory is a valid Spyder project"""
        spy_project_dir = osp.join(path, '.spyproject')
        return osp.isdir(path) and osp.isdir(spy_project_dir)

    def is_invalid_active_project(self):
        """Handle an invalid active project."""
        try:
            path = self.get_active_project_path()
        except AttributeError:
            return

        if bool(path):
            if not self.is_valid_project(path):
                if path:
                    QMessageBox.critical(
                        self.get_widget(),
                        _('Error'),
                        _("<b>{}</b> is no longer a valid Spyder project! "
                          "Since it is the current active project, it will "
                          "be closed automatically.").format(path)
                    )
                self.close_project()

    def add_to_recent(self, project):
        """
        Add an entry to recent projetcs

        We only maintain the list of the 10 most recent projects
        """
        if project not in self.recent_projects:
            self.recent_projects.insert(0, project)
        if len(self.recent_projects) > self.get_conf('max_recent_projects'):
            self.recent_projects.pop(-1)

    def start_workspace_services(self):
        """Enable LSP workspace functionality."""
        self.completions_available = True
        if self.current_active_project:
            path = self.get_active_project_path()
            self.notify_project_open(path)

    def stop_workspace_services(self, _language):
        """Disable LSP workspace functionality."""
        self.completions_available = False

    def emit_request(self, method, params, requires_response):
        """Send request/notification/response to all LSP servers."""
        params['requires_response'] = requires_response
        params['response_instance'] = self
        if self.completions:
            self.completions.broadcast_notification(method, params)

    @Slot(str, dict)
    def handle_response(self, method, params):
        """Method dispatcher for LSP requests."""
        if method in self.handler_registry:
            handler_name = self.handler_registry[method]
            handler = getattr(self, handler_name)
            handler(params)

    @Slot(str, str, bool)
    @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE,
             requires_response=False)
    def file_moved(self, src_file, dest_file, is_dir):
        """Notify LSP server about a file that is moved."""
        # LSP specification only considers file updates
        if is_dir:
            return

        deletion_entry = {
            'file': src_file,
            'kind': FileChangeType.DELETED
        }

        addition_entry = {
            'file': dest_file,
            'kind': FileChangeType.CREATED
        }

        entries = [addition_entry, deletion_entry]
        params = {
            'params': entries
        }
        return params

    @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE,
             requires_response=False)
    @Slot(str, bool)
    def file_created(self, src_file, is_dir):
        """Notify LSP server about file creation."""
        if is_dir:
            return

        params = {
            'params': [{
                'file': src_file,
                'kind': FileChangeType.CREATED
            }]
        }
        return params

    @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE,
             requires_response=False)
    @Slot(str, bool)
    def file_deleted(self, src_file, is_dir):
        """Notify LSP server about file deletion."""
        if is_dir:
            return

        params = {
            'params': [{
                'file': src_file,
                'kind': FileChangeType.DELETED
            }]
        }
        return params

    @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE,
             requires_response=False)
    @Slot(str, bool)
    def file_modified(self, src_file, is_dir):
        """Notify LSP server about file modification."""
        if is_dir:
            return

        params = {
            'params': [{
                'file': src_file,
                'kind': FileChangeType.CHANGED
            }]
        }
        return params

    @request(method=CompletionRequestTypes.WORKSPACE_FOLDERS_CHANGE,
             requires_response=False)
    def notify_project_open(self, path):
        """Notify LSP server about project path availability."""
        params = {
            'folder': path,
            'instance': self,
            'kind': 'addition'
        }
        return params

    @request(method=CompletionRequestTypes.WORKSPACE_FOLDERS_CHANGE,
             requires_response=False)
    def notify_project_close(self, path):
        """Notify LSP server to unregister project path."""
        params = {
            'folder': path,
            'instance': self,
            'kind': 'deletion'
        }
        return params

    @handles(CompletionRequestTypes.WORKSPACE_APPLY_EDIT)
    @request(method=CompletionRequestTypes.WORKSPACE_APPLY_EDIT,
             requires_response=False)
    def handle_workspace_edit(self, params):
        """Apply edits to multiple files and notify server about success."""
        edits = params['params']
        response = {
            'applied': False,
            'error': 'Not implemented',
            'language': edits['language']
        }
        return response

    # --- New API:
    # ------------------------------------------------------------------------
    def _load_project_type_class(self, path):
        """
        Load a project type class from the config project folder directly.

        Notes
        -----
        This is done directly, since using the EmptyProject would rewrite the
        value in the constructor. If the project found has not been registered
        as a valid project type, the EmptyProject type will be returned.

        Returns
        -------
        spyder.plugins.projects.api.BaseProjectType
            Loaded project type class.
        """
        fpath = osp.join(
            path, get_project_config_folder(), 'config', WORKSPACE + ".ini")

        project_type_id = EmptyProject.ID
        if osp.isfile(fpath):
            config = configparser.ConfigParser()

            # Catch any possible error when reading the workspace config file.
            # Fixes spyder-ide/spyder#17621
            try:
                config.read(fpath, encoding='utf-8')
            except Exception:
                pass

            # This is necessary to catch an error for projects created in
            # Spyder 4 or older versions.
            # Fixes spyder-ide/spyder17097
            try:
                project_type_id = config[WORKSPACE].get(
                    "project_type", EmptyProject.ID)
            except KeyError:
                pass

        EmptyProject._PARENT_PLUGIN = self
        project_types = self.get_project_types()
        project_type_class = project_types.get(project_type_id, EmptyProject)
        return project_type_class

    def register_project_type(self, parent_plugin, project_type):
        """
        Register a new project type.

        Parameters
        ----------
        parent_plugin: spyder.plugins.api.plugins.SpyderPluginV2
            The parent plugin instance making the project type registration.
        project_type: spyder.plugins.projects.api.BaseProjectType
            Project type to register.
        """
        if not issubclass(project_type, BaseProjectType):
            raise SpyderAPIError("A project type must subclass "
                                 "BaseProjectType!")

        project_id = project_type.ID
        if project_id in self._project_types:
            raise SpyderAPIError("A project type id '{}' has already been "
                                 "registered!".format(project_id))

        project_type._PARENT_PLUGIN = parent_plugin
        self._project_types[project_id] = project_type

    def get_project_types(self):
        """
        Return available registered project types.

        Returns
        -------
        dict
            Project types dictionary. Keys are project type IDs and values
            are project type classes.
        """
        return self._project_types

    # --- Private API
    # -------------------------------------------------------------------------
    def _new_editor(self, text):
        self.editor.new(text=text)

    def _setup_editor_files(self, __unused):
        self.editor.setup_open_files()

    def _set_path_in_editor(self, path):
        self.editor.set_current_project_path(path)

    def _unset_path_in_editor(self, __unused):
        self.editor.set_current_project_path()

    def _add_path_to_completions(self, path):
        self.completions.project_path_update(
            path,
            update_kind=WorkspaceUpdateKind.ADDITION,
            instance=self
        )

    def _remove_path_from_completions(self, path):
        self.completions.project_path_update(
            path,
            update_kind=WorkspaceUpdateKind.DELETION,
            instance=self
        )

    def _run_file_in_ipyconsole(self, fname):
        self.ipyconsole.run_script(
            fname, osp.dirname(fname), '', False, False, False, True,
            False
        )
예제 #3
0
class Projects(SpyderPluginWidget):
    """Projects plugin."""

    CONF_SECTION = 'project_explorer'
    CONF_FILE = False
    sig_pythonpath_changed = Signal()
    sig_project_created = Signal(object, object, object)
    sig_project_loaded = Signal(object)
    sig_project_closed = Signal(object)

    def __init__(self, parent=None):
        """Initialization."""
        SpyderPluginWidget.__init__(self, parent)

        self.explorer = ProjectExplorerWidget(
            self,
            name_filters=self.get_option('name_filters'),
            show_all=self.get_option('show_all'),
            show_hscrollbar=self.get_option('show_hscrollbar'),
            options_button=self.options_button,
            single_click_to_open=CONF.get('explorer', 'single_click_to_open'),
        )

        layout = QVBoxLayout()
        layout.addWidget(self.explorer)
        self.setLayout(layout)

        self.recent_projects = self.get_option('recent_projects', default=[])
        self.current_active_project = None
        self.latest_project = None
        self.watcher = WorkspaceWatcher(self)
        self.completions_available = False
        self.explorer.setup_project(self.get_active_project_path())
        self.watcher.connect_signals(self)

    #------ SpyderPluginWidget API ---------------------------------------------
    def get_plugin_title(self):
        """Return widget title"""
        return _("Project")

    def get_focus_widget(self):
        """
        Return the widget to give focus to when
        this plugin's dockwidget is raised on top-level
        """
        return self.explorer.treewidget

    def get_plugin_actions(self):
        """Return a list of actions related to plugin"""
        self.new_project_action = create_action(
            self, _("New Project..."), triggered=self.create_new_project)
        self.open_project_action = create_action(
            self,
            _("Open Project..."),
            triggered=lambda v: self.open_project())
        self.close_project_action = create_action(self,
                                                  _("Close Project"),
                                                  triggered=self.close_project)
        self.delete_project_action = create_action(
            self, _("Delete Project"), triggered=self.delete_project)
        self.clear_recent_projects_action =\
            create_action(self, _("Clear this list"),
                          triggered=self.clear_recent_projects)
        self.recent_project_menu = QMenu(_("Recent Projects"), self)

        if self.main is not None:
            self.main.projects_menu_actions += [
                self.new_project_action, MENU_SEPARATOR,
                self.open_project_action, self.close_project_action,
                self.delete_project_action, MENU_SEPARATOR,
                self.recent_project_menu, self._toggle_view_action
            ]

        self.setup_menu_actions()
        return []

    def register_plugin(self):
        """Register plugin in Spyder's main window"""
        ipyconsole = self.main.ipyconsole
        treewidget = self.explorer.treewidget
        lspmgr = self.main.completions

        self.add_dockwidget()
        self.explorer.sig_open_file.connect(self.main.open_file)
        self.register_widget_shortcuts(treewidget)

        treewidget.sig_delete_project.connect(self.delete_project)
        treewidget.sig_edit.connect(self.main.editor.load)
        treewidget.sig_removed.connect(self.main.editor.removed)
        treewidget.sig_removed_tree.connect(self.main.editor.removed_tree)
        treewidget.sig_renamed.connect(self.main.editor.renamed)
        treewidget.sig_renamed_tree.connect(self.main.editor.renamed_tree)
        treewidget.sig_create_module.connect(self.main.editor.new)
        treewidget.sig_new_file.connect(lambda t: self.main.editor.new(text=t))
        treewidget.sig_open_interpreter.connect(
            ipyconsole.create_client_from_path)
        treewidget.redirect_stdio.connect(
            self.main.redirect_internalshell_stdio)
        treewidget.sig_run.connect(lambda fname: ipyconsole.run_script(
            fname, osp.dirname(fname), '', False, False, False, True))

        # New project connections. Order matters!
        self.sig_project_loaded.connect(
            lambda v: self.main.workingdirectory.chdir(v))
        self.sig_project_loaded.connect(lambda v: self.main.set_window_title())
        self.sig_project_loaded.connect(
            functools.partial(lspmgr.project_path_update,
                              update_kind='addition'))
        self.sig_project_loaded.connect(
            lambda v: self.main.editor.setup_open_files())
        self.sig_project_loaded.connect(self.update_explorer)
        self.sig_project_closed[object].connect(
            lambda v: self.main.workingdirectory.chdir(self.
                                                       get_last_working_dir()))
        self.sig_project_closed.connect(lambda v: self.main.set_window_title())
        self.sig_project_closed.connect(
            functools.partial(lspmgr.project_path_update,
                              update_kind='deletion'))
        self.sig_project_closed.connect(
            lambda v: self.main.editor.setup_open_files())
        self.recent_project_menu.aboutToShow.connect(self.setup_menu_actions)

        self.main.pythonpath_changed()
        self.main.restore_scrollbar_position.connect(
            self.restore_scrollbar_position)
        self.sig_pythonpath_changed.connect(self.main.pythonpath_changed)
        self.main.editor.set_projects(self)

        # Connect to file explorer to keep single click to open files in sync
        self.main.explorer.fileexplorer.sig_option_changed.connect(
            self.set_single_click_to_open)

    def set_single_click_to_open(self, option, value):
        """Set single click to open files and directories."""
        if option == 'single_click_to_open':
            self.explorer.treewidget.set_single_click_to_open(value)

    def closing_plugin(self, cancelable=False):
        """Perform actions before parent main window is closed"""
        self.save_config()
        self.explorer.closing_widget()
        return True

    def switch_to_plugin(self):
        """Switch to plugin."""
        # Unmaxizime currently maximized plugin
        if (self.main.last_plugin is not None
                and self.main.last_plugin._ismaximized
                and self.main.last_plugin is not self):
            self.main.maximize_dockwidget()

        # Show plugin only if it was already visible
        if self.get_option('visible_if_project_open'):
            if not self._toggle_view_action.isChecked():
                self._toggle_view_action.setChecked(True)
            self._visibility_changed(True)

    def build_opener(self, project):
        """Build function opening passed project"""
        def opener(*args, **kwargs):
            self.open_project(path=project)

        return opener

    # ------ Public API -------------------------------------------------------
    def on_first_registration(self):
        """Action to be performed on first plugin registration"""
        # TODO: Uncomment for Spyder 5
        # self.tabify(self.main.explorer)

    def setup_menu_actions(self):
        """Setup and update the menu actions."""
        self.recent_project_menu.clear()
        self.recent_projects_actions = []
        if self.recent_projects:
            for project in self.recent_projects:
                if self.is_valid_project(project):
                    name = project.replace(get_home_dir(), '~')
                    action = create_action(
                        self,
                        name,
                        icon=ima.icon('project'),
                        triggered=self.build_opener(project),
                    )
                    self.recent_projects_actions.append(action)
                else:
                    self.recent_projects.remove(project)
            self.recent_projects_actions += [
                None,
                self.clear_recent_projects_action,
            ]
        else:
            self.recent_projects_actions = [self.clear_recent_projects_action]
        add_actions(self.recent_project_menu, self.recent_projects_actions)
        self.update_project_actions()

    def update_project_actions(self):
        """Update actions of the Projects menu"""
        if self.recent_projects:
            self.clear_recent_projects_action.setEnabled(True)
        else:
            self.clear_recent_projects_action.setEnabled(False)

        active = bool(self.get_active_project_path())
        self.close_project_action.setEnabled(active)
        self.delete_project_action.setEnabled(active)

    @Slot()
    def create_new_project(self):
        """Create new project"""
        self.switch_to_plugin()
        active_project = self.current_active_project
        dlg = ProjectDialog(self)
        dlg.sig_project_creation_requested.connect(self._create_project)
        dlg.sig_project_creation_requested.connect(self.sig_project_created)
        if dlg.exec_():
            if (active_project is None
                    and self.get_option('visible_if_project_open')):
                self.show_explorer()
            self.sig_pythonpath_changed.emit()
            self.restart_consoles()

    def _create_project(self, path):
        """Create a new project."""
        self.open_project(path=path)

    def open_project(self,
                     path=None,
                     restart_consoles=True,
                     save_previous_files=True):
        """Open the project located in `path`"""
        self.notify_project_open(path)
        self.switch_to_plugin()
        if path is None:
            basedir = get_home_dir()
            path = getexistingdirectory(parent=self,
                                        caption=_("Open project"),
                                        basedir=basedir)
            path = encoding.to_unicode_from_fs(path)
            if not self.is_valid_project(path):
                if path:
                    QMessageBox.critical(
                        self, _('Error'),
                        _("<b>%s</b> is not a Spyder project!") % path)
                return
        else:
            path = encoding.to_unicode_from_fs(path)

        self.add_to_recent(path)

        # A project was not open before
        if self.current_active_project is None:
            if save_previous_files and self.main.editor is not None:
                self.main.editor.save_open_files()
            if self.main.editor is not None:
                self.main.editor.set_option('last_working_dir',
                                            getcwd_or_home())
            if self.get_option('visible_if_project_open'):
                self.show_explorer()
        else:
            # We are switching projects
            if self.main.editor is not None:
                self.set_project_filenames(
                    self.main.editor.get_open_filenames())

        project = EmptyProject(path)
        self.current_active_project = project
        self.latest_project = project
        self.set_option('current_project_path', self.get_active_project_path())

        self.setup_menu_actions()
        self.sig_project_loaded.emit(path)
        self.sig_pythonpath_changed.emit()
        self.watcher.start(path)

        if restart_consoles:
            self.restart_consoles()

    def close_project(self):
        """
        Close current project and return to a window without an active
        project
        """
        if self.current_active_project:
            self.switch_to_plugin()
            if self.main.editor is not None:
                self.set_project_filenames(
                    self.main.editor.get_open_filenames())
            path = self.current_active_project.root_path
            self.current_active_project = None
            self.set_option('current_project_path', None)
            self.setup_menu_actions()

            self.sig_project_closed.emit(path)
            self.sig_pythonpath_changed.emit()

            if self.dockwidget is not None:
                self.set_option('visible_if_project_open',
                                self.dockwidget.isVisible())
                self.dockwidget.close()

            self.explorer.clear()
            self.restart_consoles()
            self.watcher.stop()
            self.notify_project_close(path)

    def delete_project(self):
        """
        Delete the current project without deleting the files in the directory.
        """
        if self.current_active_project:
            self.switch_to_plugin()
            path = self.current_active_project.root_path
            buttons = QMessageBox.Yes | QMessageBox.No
            answer = QMessageBox.warning(
                self, _("Delete"),
                _("Do you really want to delete <b>{filename}</b>?<br><br>"
                  "<b>Note:</b> This action will only delete the project. "
                  "Its files are going to be preserved on disk.").format(
                      filename=osp.basename(path)), buttons)
            if answer == QMessageBox.Yes:
                try:
                    self.close_project()
                    shutil.rmtree(osp.join(path, '.spyproject'))
                except EnvironmentError as error:
                    QMessageBox.critical(
                        self, _("Project Explorer"),
                        _("<b>Unable to delete <i>{varpath}</i></b>"
                          "<br><br>The error message was:<br>{error}").format(
                              varpath=path, error=to_text_string(error)))

    def clear_recent_projects(self):
        """Clear the list of recent projects"""
        self.recent_projects = []
        self.setup_menu_actions()

    def get_active_project(self):
        """Get the active project"""
        return self.current_active_project

    def reopen_last_project(self):
        """
        Reopen the active project when Spyder was closed last time, if any
        """
        current_project_path = self.get_option('current_project_path',
                                               default=None)

        # Needs a safer test of project existence!
        if current_project_path and \
          self.is_valid_project(current_project_path):
            self.open_project(path=current_project_path,
                              restart_consoles=False,
                              save_previous_files=False)
            self.load_config()

    def get_project_filenames(self):
        """Get the list of recent filenames of a project"""
        recent_files = []
        if self.current_active_project:
            recent_files = self.current_active_project.get_recent_files()
        elif self.latest_project:
            recent_files = self.latest_project.get_recent_files()
        return recent_files

    def set_project_filenames(self, recent_files):
        """Set the list of open file names in a project"""
        if (self.current_active_project and self.is_valid_project(
                self.current_active_project.root_path)):
            self.current_active_project.set_recent_files(recent_files)

    def get_active_project_path(self):
        """Get path of the active project"""
        active_project_path = None
        if self.current_active_project:
            active_project_path = self.current_active_project.root_path
        return active_project_path

    def get_pythonpath(self, at_start=False):
        """Get project path as a list to be added to PYTHONPATH"""
        if at_start:
            current_path = self.get_option('current_project_path',
                                           default=None)
        else:
            current_path = self.get_active_project_path()
        if current_path is None:
            return []
        else:
            return [current_path]

    def get_last_working_dir(self):
        """Get the path of the last working directory"""
        return self.main.editor.get_option('last_working_dir',
                                           default=getcwd_or_home())

    def save_config(self):
        """
        Save configuration: opened projects & tree widget state.

        Also save whether dock widget is visible if a project is open.
        """
        self.set_option('recent_projects', self.recent_projects)
        self.set_option('expanded_state',
                        self.explorer.treewidget.get_expanded_state())
        self.set_option('scrollbar_position',
                        self.explorer.treewidget.get_scrollbar_position())
        if self.current_active_project and self.dockwidget:
            self.set_option('visible_if_project_open',
                            self.dockwidget.isVisible())

    def load_config(self):
        """Load configuration: opened projects & tree widget state"""
        expanded_state = self.get_option('expanded_state', None)
        # Sometimes the expanded state option may be truncated in .ini file
        # (for an unknown reason), in this case it would be converted to a
        # string by 'userconfig':
        if is_text_string(expanded_state):
            expanded_state = None
        if expanded_state is not None:
            self.explorer.treewidget.set_expanded_state(expanded_state)

    def restore_scrollbar_position(self):
        """Restoring scrollbar position after main window is visible"""
        scrollbar_pos = self.get_option('scrollbar_position', None)
        if scrollbar_pos is not None:
            self.explorer.treewidget.set_scrollbar_position(scrollbar_pos)

    def update_explorer(self):
        """Update explorer tree"""
        self.explorer.setup_project(self.get_active_project_path())

    def show_explorer(self):
        """Show the explorer"""
        if self.dockwidget is not None:
            if self.dockwidget.isHidden():
                self.dockwidget.show()
            self.dockwidget.raise_()
            self.dockwidget.update()

    def restart_consoles(self):
        """Restart consoles when closing, opening and switching projects"""
        if self.main.ipyconsole is not None:
            self.main.ipyconsole.restart()

    def is_valid_project(self, path):
        """Check if a directory is a valid Spyder project"""
        spy_project_dir = osp.join(path, '.spyproject')
        return osp.isdir(path) and osp.isdir(spy_project_dir)

    def add_to_recent(self, project):
        """
        Add an entry to recent projetcs

        We only maintain the list of the 10 most recent projects
        """
        if project not in self.recent_projects:
            self.recent_projects.insert(0, project)
            self.recent_projects = self.recent_projects[:10]

    def register_lsp_server_settings(self, settings):
        """Enable LSP workspace functions."""
        self.completions_available = True
        if self.current_active_project:
            path = self.get_active_project_path()
            self.notify_project_open(path)

    def stop_lsp_services(self):
        """Disable LSP workspace functions."""
        self.completions_available = False

    def emit_request(self, method, params, requires_response):
        """Send request/notification/response to all LSP servers."""
        params['requires_response'] = requires_response
        params['response_instance'] = self
        self.main.completions.broadcast_notification(method, params)

    @Slot(str, dict)
    def handle_response(self, method, params):
        """Method dispatcher for LSP requests."""
        if method in self.handler_registry:
            handler_name = self.handler_registry[method]
            handler = getattr(self, handler_name)
            handler(params)

    @Slot(str, str, bool)
    @request(method=LSPRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE,
             requires_response=False)
    def file_moved(self, src_file, dest_file, is_dir):
        """Notify LSP server about a file that is moved."""
        # LSP specification only considers file updates
        if is_dir:
            return

        deletion_entry = {'file': src_file, 'kind': FileChangeType.DELETED}

        addition_entry = {'file': dest_file, 'kind': FileChangeType.CREATED}

        entries = [addition_entry, deletion_entry]
        params = {'params': entries}
        return params

    @request(method=LSPRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE,
             requires_response=False)
    @Slot(str, bool)
    def file_created(self, src_file, is_dir):
        """Notify LSP server about file creation."""
        if is_dir:
            return

        params = {
            'params': [{
                'file': src_file,
                'kind': FileChangeType.CREATED
            }]
        }
        return params

    @request(method=LSPRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE,
             requires_response=False)
    @Slot(str, bool)
    def file_deleted(self, src_file, is_dir):
        """Notify LSP server about file deletion."""
        if is_dir:
            return

        params = {
            'params': [{
                'file': src_file,
                'kind': FileChangeType.DELETED
            }]
        }
        return params

    @request(method=LSPRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE,
             requires_response=False)
    @Slot(str, bool)
    def file_modified(self, src_file, is_dir):
        """Notify LSP server about file modification."""
        if is_dir:
            return

        params = {
            'params': [{
                'file': src_file,
                'kind': FileChangeType.CHANGED
            }]
        }
        return params

    @request(method=LSPRequestTypes.WORKSPACE_FOLDERS_CHANGE,
             requires_response=False)
    def notify_project_open(self, path):
        """Notify LSP server about project path availability."""
        params = {'folder': path, 'instance': self, 'kind': 'addition'}
        return params

    @request(method=LSPRequestTypes.WORKSPACE_FOLDERS_CHANGE,
             requires_response=False)
    def notify_project_close(self, path):
        """Notify LSP server to unregister project path."""
        params = {'folder': path, 'instance': self, 'kind': 'deletion'}
        return params

    @handles(LSPRequestTypes.WORKSPACE_APPLY_EDIT)
    @request(method=LSPRequestTypes.WORKSPACE_APPLY_EDIT,
             requires_response=False)
    def handle_workspace_edit(self, params):
        """Apply edits to multiple files and notify server about success."""
        edits = params['params']
        response = {
            'applied': False,
            'error': 'Not implemented',
            'language': edits['language']
        }
        return response
예제 #4
0
class Projects(SpyderPluginWidget):
    """Projects plugin."""

    CONF_SECTION = 'project_explorer'
    sig_pythonpath_changed = Signal()
    sig_project_created = Signal(object, object, object)
    sig_project_loaded = Signal(object)
    sig_project_closed = Signal(object)

    def __init__(self, parent=None):
        """Initialization."""
        SpyderPluginWidget.__init__(self, parent)

        self.explorer = ProjectExplorerWidget(
            self,
            name_filters=self.get_option('name_filters'),
            show_all=self.get_option('show_all'),
            show_hscrollbar=self.get_option('show_hscrollbar'),
            options_button=self.options_button,
            single_click_to_open=CONF.get('explorer', 'single_click_to_open'),
        )

        layout = QVBoxLayout()
        layout.addWidget(self.explorer)
        self.setLayout(layout)

        self.recent_projects = self.get_option('recent_projects', default=[])
        self.current_active_project = None
        self.latest_project = None
        self.watcher = WorkspaceWatcher(self)

        # Initialize plugin
        self.initialize_plugin()
        self.explorer.setup_project(self.get_active_project_path())
        self.watcher.connect_signals(self)

    #------ SpyderPluginWidget API ---------------------------------------------
    def get_plugin_title(self):
        """Return widget title"""
        return _("Project")

    def get_focus_widget(self):
        """
        Return the widget to give focus to when
        this plugin's dockwidget is raised on top-level
        """
        return self.explorer.treewidget

    def get_plugin_actions(self):
        """Return a list of actions related to plugin"""
        self.new_project_action = create_action(
            self, _("New Project..."), triggered=self.create_new_project)
        self.open_project_action = create_action(
            self,
            _("Open Project..."),
            triggered=lambda v: self.open_project())
        self.close_project_action = create_action(self,
                                                  _("Close Project"),
                                                  triggered=self.close_project)
        self.delete_project_action = create_action(
            self, _("Delete Project"), triggered=self.delete_project)
        self.clear_recent_projects_action =\
            create_action(self, _("Clear this list"),
                          triggered=self.clear_recent_projects)
        self.edit_project_preferences_action =\
            create_action(self, _("Project Preferences"),
                          triggered=self.edit_project_preferences)
        self.recent_project_menu = QMenu(_("Recent Projects"), self)

        if self.main is not None:
            self.main.projects_menu_actions += [
                self.new_project_action, MENU_SEPARATOR,
                self.open_project_action, self.close_project_action,
                self.delete_project_action, MENU_SEPARATOR,
                self.recent_project_menu, self.toggle_view_action
            ]

        self.setup_menu_actions()
        return []

    def register_plugin(self):
        """Register plugin in Spyder's main window"""
        ipyconsole = self.main.ipyconsole
        treewidget = self.explorer.treewidget
        lspmgr = self.main.lspmanager

        self.main.add_dockwidget(self)
        self.explorer.sig_open_file.connect(self.main.open_file)
        self.register_widget_shortcuts(treewidget)

        treewidget.sig_delete_project.connect(self.delete_project)
        treewidget.sig_edit.connect(self.main.editor.load)
        treewidget.sig_removed.connect(self.main.editor.removed)
        treewidget.sig_removed_tree.connect(self.main.editor.removed_tree)
        treewidget.sig_renamed.connect(self.main.editor.renamed)
        treewidget.sig_renamed_tree.connect(self.main.editor.renamed_tree)
        treewidget.sig_create_module.connect(self.main.editor.new)
        treewidget.sig_new_file.connect(lambda t: self.main.editor.new(text=t))
        treewidget.sig_open_interpreter.connect(
            ipyconsole.create_client_from_path)
        treewidget.redirect_stdio.connect(
            self.main.redirect_internalshell_stdio)
        treewidget.sig_run.connect(lambda fname: ipyconsole.run_script(
            fname, osp.dirname(fname), '', False, False, False, True))

        # New project connections. Order matters!
        self.sig_project_loaded.connect(
            lambda v: self.main.workingdirectory.chdir(v))
        self.sig_project_loaded.connect(lambda v: self.main.set_window_title())
        self.sig_project_loaded.connect(lspmgr.reinitialize_all_clients)
        self.sig_project_loaded.connect(
            lambda v: self.main.editor.setup_open_files())
        self.sig_project_loaded.connect(self.update_explorer)
        self.sig_project_closed[object].connect(
            lambda v: self.main.workingdirectory.chdir(self.
                                                       get_last_working_dir()))
        self.sig_project_closed.connect(lambda v: self.main.set_window_title())
        self.sig_project_closed.connect(lspmgr.reinitialize_all_clients)
        self.sig_project_closed.connect(
            lambda v: self.main.editor.setup_open_files())
        self.recent_project_menu.aboutToShow.connect(self.setup_menu_actions)

        self.main.pythonpath_changed()
        self.main.restore_scrollbar_position.connect(
            self.restore_scrollbar_position)
        self.sig_pythonpath_changed.connect(self.main.pythonpath_changed)
        self.main.editor.set_projects(self)

        # Connect to file explorer to keep single click to open files in sync
        self.main.explorer.fileexplorer.sig_option_changed.connect(
            self.set_single_click_to_open)

    def set_single_click_to_open(self, option, value):
        """Set single click to open files and directories."""
        if option == 'single_click_to_open':
            self.explorer.treewidget.set_single_click_to_open(value)

    def refresh_plugin(self):
        """Refresh project explorer widget"""
        pass

    def closing_plugin(self, cancelable=False):
        """Perform actions before parent main window is closed"""
        self.save_config()
        self.explorer.closing_widget()
        return True

    def switch_to_plugin(self):
        """Switch to plugin."""
        # Unmaxizime currently maximized plugin
        if (self.main.last_plugin is not None
                and self.main.last_plugin.ismaximized
                and self.main.last_plugin is not self):
            self.main.maximize_dockwidget()

        # Show plugin only if it was already visible
        if self.get_option('visible_if_project_open'):
            if not self.toggle_view_action.isChecked():
                self.toggle_view_action.setChecked(True)
            self.visibility_changed(True)

    # ------ Public API -------------------------------------------------------
    def setup_menu_actions(self):
        """Setup and update the menu actions."""
        self.recent_project_menu.clear()
        self.recent_projects_actions = []
        if self.recent_projects:
            for project in self.recent_projects:
                if self.is_valid_project(project):
                    name = project.replace(get_home_dir(), '~')
                    action = create_action(
                        self,
                        name,
                        icon=ima.icon('project'),
                        triggered=(
                            lambda _, p=project: self.open_project(path=p)))
                    self.recent_projects_actions.append(action)
                else:
                    self.recent_projects.remove(project)
            self.recent_projects_actions += [
                None, self.clear_recent_projects_action
            ]
        else:
            self.recent_projects_actions = [self.clear_recent_projects_action]
        add_actions(self.recent_project_menu, self.recent_projects_actions)
        self.update_project_actions()

    def update_project_actions(self):
        """Update actions of the Projects menu"""
        if self.recent_projects:
            self.clear_recent_projects_action.setEnabled(True)
        else:
            self.clear_recent_projects_action.setEnabled(False)

        active = bool(self.get_active_project_path())
        self.close_project_action.setEnabled(active)
        self.delete_project_action.setEnabled(active)
        self.edit_project_preferences_action.setEnabled(active)

    def edit_project_preferences(self):
        """Edit Spyder active project preferences"""
        from spyder.plugins.projects.confpage import ProjectPreferences
        if self.project_active:
            active_project = self.project_list[0]
            dlg = ProjectPreferences(self, active_project)
            #            dlg.size_change.connect(self.set_project_prefs_size)
            #            if self.projects_prefs_dialog_size is not None:
            #                dlg.resize(self.projects_prefs_dialog_size)
            dlg.show()
            #        dlg.check_all_settings()
            #        dlg.pages_widget.currentChanged.connect(self.__preference_page_changed)
            dlg.exec_()

    @Slot()
    def create_new_project(self):
        """Create new project"""
        self.switch_to_plugin()
        active_project = self.current_active_project
        dlg = ProjectDialog(self)
        dlg.sig_project_creation_requested.connect(self._create_project)
        dlg.sig_project_creation_requested.connect(self.sig_project_created)
        if dlg.exec_():
            if (active_project is None
                    and self.get_option('visible_if_project_open')):
                self.show_explorer()
            self.sig_pythonpath_changed.emit()
            self.restart_consoles()

    def _create_project(self, path):
        """Create a new project."""
        self.open_project(path=path)
        self.setup_menu_actions()
        self.add_to_recent(path)

    def open_project(self,
                     path=None,
                     restart_consoles=True,
                     save_previous_files=True):
        """Open the project located in `path`"""
        self.switch_to_plugin()
        if path is None:
            basedir = get_home_dir()
            path = getexistingdirectory(parent=self,
                                        caption=_("Open project"),
                                        basedir=basedir)
            path = encoding.to_unicode_from_fs(path)
            if not self.is_valid_project(path):
                if path:
                    QMessageBox.critical(
                        self, _('Error'),
                        _("<b>%s</b> is not a Spyder project!") % path)
                return
        else:
            path = encoding.to_unicode_from_fs(path)

        self.add_to_recent(path)

        # A project was not open before
        if self.current_active_project is None:
            if save_previous_files and self.main.editor is not None:
                self.main.editor.save_open_files()
            if self.main.editor is not None:
                self.main.editor.set_option('last_working_dir',
                                            getcwd_or_home())
            if self.get_option('visible_if_project_open'):
                self.show_explorer()
        else:
            # We are switching projects
            if self.main.editor is not None:
                self.set_project_filenames(
                    self.main.editor.get_open_filenames())

        self.current_active_project = EmptyProject(path)
        self.latest_project = EmptyProject(path)
        self.set_option('current_project_path', self.get_active_project_path())

        self.setup_menu_actions()
        self.sig_project_loaded.emit(path)
        self.sig_pythonpath_changed.emit()
        self.watcher.start(path)

        if restart_consoles:
            self.restart_consoles()

    def close_project(self):
        """
        Close current project and return to a window without an active
        project
        """
        if self.current_active_project:
            self.switch_to_plugin()
            if self.main.editor is not None:
                self.set_project_filenames(
                    self.main.editor.get_open_filenames())
            path = self.current_active_project.root_path
            self.current_active_project = None
            self.set_option('current_project_path', None)
            self.setup_menu_actions()

            self.sig_project_closed.emit(path)
            self.sig_pythonpath_changed.emit()

            if self.dockwidget is not None:
                self.set_option('visible_if_project_open',
                                self.dockwidget.isVisible())
                self.dockwidget.close()

            self.explorer.clear()
            self.restart_consoles()
            self.watcher.stop()

    def delete_project(self):
        """
        Delete the current project without deleting the files in the directory.
        """
        if self.current_active_project:
            self.switch_to_plugin()
            path = self.current_active_project.root_path
            buttons = QMessageBox.Yes | QMessageBox.No
            answer = QMessageBox.warning(
                self, _("Delete"),
                _("Do you really want to delete <b>{filename}</b>?<br><br>"
                  "<b>Note:</b> This action will only delete the project. "
                  "Its files are going to be preserved on disk.").format(
                      filename=osp.basename(path)), buttons)
            if answer == QMessageBox.Yes:
                try:
                    self.close_project()
                    shutil.rmtree(osp.join(path, '.spyproject'))
                except EnvironmentError as error:
                    QMessageBox.critical(
                        self, _("Project Explorer"),
                        _("<b>Unable to delete <i>{varpath}</i></b>"
                          "<br><br>The error message was:<br>{error}").format(
                              varpath=path, error=to_text_string(error)))

    def clear_recent_projects(self):
        """Clear the list of recent projects"""
        self.recent_projects = []
        self.setup_menu_actions()

    def get_active_project(self):
        """Get the active project"""
        return self.current_active_project

    def reopen_last_project(self):
        """
        Reopen the active project when Spyder was closed last time, if any
        """
        current_project_path = self.get_option('current_project_path',
                                               default=None)

        # Needs a safer test of project existence!
        if current_project_path and \
          self.is_valid_project(current_project_path):
            self.open_project(path=current_project_path,
                              restart_consoles=False,
                              save_previous_files=False)
            self.load_config()

    def get_project_filenames(self):
        """Get the list of recent filenames of a project"""
        recent_files = []
        if self.current_active_project:
            recent_files = self.current_active_project.get_recent_files()
        elif self.latest_project:
            recent_files = self.latest_project.get_recent_files()
        return recent_files

    def set_project_filenames(self, recent_files):
        """Set the list of open file names in a project"""
        if (self.current_active_project and self.is_valid_project(
                self.current_active_project.root_path)):
            self.current_active_project.set_recent_files(recent_files)

    def get_active_project_path(self):
        """Get path of the active project"""
        active_project_path = None
        if self.current_active_project:
            active_project_path = self.current_active_project.root_path
        return active_project_path

    def get_pythonpath(self, at_start=False):
        """Get project path as a list to be added to PYTHONPATH"""
        if at_start:
            current_path = self.get_option('current_project_path',
                                           default=None)
        else:
            current_path = self.get_active_project_path()
        if current_path is None:
            return []
        else:
            return [current_path]

    def get_last_working_dir(self):
        """Get the path of the last working directory"""
        return self.main.editor.get_option('last_working_dir',
                                           default=getcwd_or_home())

    def save_config(self):
        """
        Save configuration: opened projects & tree widget state.

        Also save whether dock widget is visible if a project is open.
        """
        self.set_option('recent_projects', self.recent_projects)
        self.set_option('expanded_state',
                        self.explorer.treewidget.get_expanded_state())
        self.set_option('scrollbar_position',
                        self.explorer.treewidget.get_scrollbar_position())
        if self.current_active_project and self.dockwidget:
            self.set_option('visible_if_project_open',
                            self.dockwidget.isVisible())

    def load_config(self):
        """Load configuration: opened projects & tree widget state"""
        expanded_state = self.get_option('expanded_state', None)
        # Sometimes the expanded state option may be truncated in .ini file
        # (for an unknown reason), in this case it would be converted to a
        # string by 'userconfig':
        if is_text_string(expanded_state):
            expanded_state = None
        if expanded_state is not None:
            self.explorer.treewidget.set_expanded_state(expanded_state)

    def restore_scrollbar_position(self):
        """Restoring scrollbar position after main window is visible"""
        scrollbar_pos = self.get_option('scrollbar_position', None)
        if scrollbar_pos is not None:
            self.explorer.treewidget.set_scrollbar_position(scrollbar_pos)

    def update_explorer(self):
        """Update explorer tree"""
        self.explorer.setup_project(self.get_active_project_path())

    def show_explorer(self):
        """Show the explorer"""
        if self.dockwidget is not None:
            if self.dockwidget.isHidden():
                self.dockwidget.show()
            self.dockwidget.raise_()
            self.dockwidget.update()

    def restart_consoles(self):
        """Restart consoles when closing, opening and switching projects"""
        if self.main.ipyconsole is not None:
            self.main.ipyconsole.restart()

    def is_valid_project(self, path):
        """Check if a directory is a valid Spyder project"""
        spy_project_dir = osp.join(path, '.spyproject')
        if osp.isdir(path) and osp.isdir(spy_project_dir):
            return True
        else:
            return False

    def add_to_recent(self, project):
        """
        Add an entry to recent projetcs

        We only maintain the list of the 10 most recent projects
        """
        if project not in self.recent_projects:
            self.recent_projects.insert(0, project)
            self.recent_projects = self.recent_projects[:10]

    @Slot(str, str, bool)
    def file_moved(self, src_file, dest_file, is_dir):
        # TODO: Handle calls to LSP workspace
        pass

    @Slot(str, bool)
    def file_created(self, src_file, is_dir):
        # TODO: Handle calls to LSP workspace
        pass

    @Slot(str, bool)
    def file_deleted(self, src_file, is_dir):
        # TODO: Handle calls to LSP workspace
        pass

    @Slot(str, bool)
    def file_modified(self, src_file, is_dir):
        # TODO: Handle calls to LSP workspace
        pass