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]
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 )
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
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