diff --git a/spyder/api/plugins/new_api.py b/spyder/api/plugins/new_api.py index 47d19a53828..67e7332f13b 100644 --- a/spyder/api/plugins/new_api.py +++ b/spyder/api/plugins/new_api.py @@ -19,7 +19,7 @@ import os import os.path as osp import sys -from typing import List, Union +from typing import List, Optional, Union import warnings # Third party imports @@ -298,6 +298,17 @@ class SpyderPluginV2(QObject, SpyderActionMixin, SpyderConfigurationObserver, The window state. """ + sig_focused_plugin_changed = Signal(object) + """ + This signal is emitted when the plugin with keyboard focus changes. + + Parameters + ---------- + plugin: Optional[SpyderDockablePlugin] + The plugin that currently has keyboard focus, or None if no dockable + plugin has focus. + """ + # ---- Private attributes # ------------------------------------------------------------------------- # Define configuration name map for plugin to split configuration @@ -972,6 +983,18 @@ class SpyderDockablePlugin(SpyderPluginV2): # the action to switch is called a second time. RAISE_AND_FOCUS = False + # List of file extensions which the plugin can open. + # If the user opens a file with one of these extensions, then the file + # will open in this plugin using the `open_file` function. + # Example: ['.ipynb'] for spyder-notebook + FILE_EXTENSIONS = [] + + # Whether the plugin can handle file actions. + # If set to true, then the `create_new_file`, `open_last_closed`, + # `save_file`, `save_file_as`, `save_copy_as`, `save_all` and `revert_file` + # functions will be called to handle the corresponding actions. + CAN_HANDLE_FILE_ACTIONS = False + # ---- API: Available signals # ------------------------------------------------------------------------- sig_focus_changed = Signal() @@ -1053,6 +1076,135 @@ def __init__(self, parent, configuration): widget.sig_update_ancestor_requested.connect( self.sig_update_ancestor_requested) + # ---- API: Optional methods to override + # ------------------------------------------------------------------------- + def create_new_file(self) -> None: + """ + Create a new file inside the plugin. + + This function will be called if the user create a new file using + the `File > New` menu item or the "New file" button in the toolbar, + and `CAN_HANDLE_FILE_ACTIONS` is set to `True`. + """ + raise NotImplementedError + + def open_file(self, filename: str): + """ + Open file inside plugin. + + This method will be called if the user wants to open a file with one + of the file name extensions listed in `FILE_EXTENSIONS`, so you need + to define that variable too. + + Parameters + ---------- + filename: str + The name of the file to be opened. + """ + raise NotImplementedError + + def open_last_closed_file(self) -> None: + """ + Open the last closed file again. + + This function will be called if the `File > Open last closed` menu item + is selected while the plugin has focus and `CAN_HANDLE_FILE_ACTIONS` + is set to `True`. + """ + raise NotImplementedError + + def save_file(self) -> None: + """ + Save the current file. + + This function will be called if the user saves the current file using + the `File > Save` menu item or the "Save file" button in the toolbar, + the plugin has focus, and `CAN_HANDLE_FILE_ACTIONS` is set to `True`. + """ + raise NotImplementedError + + def save_file_as(self) -> None: + """ + Save the current file under a different name. + + This function will be called if the `File > Save as` menu item is + selected while the plugin has focus and `CAN_HANDLE_FILE_ACTIONS` is + set to `True`. + """ + raise NotImplementedError + + def save_copy_as(self) -> None: + """ + Save a copy of the current file under a different name. + + This function will be called if the `File > Save copy as` menu item is + selected while the plugin has focus and `CAN_HANDLE_FILE_ACTIONS` is + set to `True`. + """ + raise NotImplementedError + + def save_all(self) -> None: + """ + Save all files that are opened in the plugin. + + This function will be called if the user saves all files using the + `File > Save all` menu item or the "Save all" button in the toolbar, + the plugin has focus, and `CAN_HANDLE_FILE_ACTIONS` is set to `True`. + """ + raise NotImplementedError + + def revert_file(self) -> None: + """ + Revert the current file to the version stored on disk. + + This function will be called if the `File > Revert` menu item is + selected while the plugin has focus and `CAN_HANDLE_FILE_ACTIONS` is + set to `True`. + """ + raise NotImplementedError + + def close_file(self) -> None: + """ + Close the current file. + + This function will be called if the `File > Close` menu item is + selected while the plugin has focus and `CAN_HANDLE_FILE_ACTIONS` is + set to `True`. + """ + raise NotImplementedError + + def close_all(self) -> None: + """ + Close all opened files. + + This function will be called if the `File > Close all` menu item is + selected while the plugin has focus and `CAN_HANDLE_FILE_ACTIONS` is + set to `True`. + """ + raise NotImplementedError + + def get_current_filename(self) -> Optional[str]: + """ + Return file name of the file that is currently displayed. + + This is meant for plugins like the Editor or Notebook plugin which + editor display files. Return `None` if no file is displayed or if this + does not display files. + + This function is used in the `Open file` action to initialize the + "Open file" dialog. + """ + return None + + def current_file_is_temporary(self) -> bool: + """ + Return whether currently displayed file is a temporary file. + + This function should only be called if a file is displayed, that is, + if `self.get_current_filename()` does not return `None`. + """ + return False + # ---- API: available methods # ------------------------------------------------------------------------- def before_long_process(self, message): diff --git a/spyder/api/widgets/menus.py b/spyder/api/widgets/menus.py index 194eb7a1da4..96237b60bf4 100644 --- a/spyder/api/widgets/menus.py +++ b/spyder/api/widgets/menus.py @@ -370,7 +370,6 @@ def _add_section(self, section, before_section=None): self._sections.remove(after_section) idx = self._sections.index(section) - idx = idx if (idx == 0) else (idx - 1) self._sections.insert(idx, after_section) def _set_icons(self): diff --git a/spyder/app/mainwindow.py b/spyder/app/mainwindow.py index 4b8c302af8a..3b0b276383c 100644 --- a/spyder/app/mainwindow.py +++ b/spyder/app/mainwindow.py @@ -151,6 +151,17 @@ class MainWindow(QMainWindow, SpyderMainWindowMixin, SpyderShortcutsMixin): The window state. """ + sig_focused_plugin_changed = Signal(object) + """ + This signal is emitted when the another plugin received keyboard focus. + + Parameters + ---------- + plugin: Optional[SpyderDockablePlugin] + The plugin that currently has keyboard focus, or None if no dockable + plugin has focus. + """ + def __init__(self, splash=None, options=None): QMainWindow.__init__(self) qapp = QApplication.instance() @@ -275,6 +286,7 @@ def signal_handler(signum, frame=None): # To keep track of the last focused widget self.last_focused_widget = None + self.last_focused_plugin = None self.previous_focused_widget = None # Server to open external files on a single instance @@ -419,6 +431,8 @@ def register_plugin(self, plugin_name, external=False, omit_conf=False): self.sig_resized.connect(plugin.sig_mainwindow_resized) self.sig_window_state_changed.connect( plugin.sig_mainwindow_state_changed) + self.sig_focused_plugin_changed.connect( + plugin.sig_focused_plugin_changed) # Register plugin plugin._register(omit_conf=omit_conf) @@ -1063,14 +1077,43 @@ def set_splash(self, message): QApplication.processEvents() def change_last_focused_widget(self, old, now): - """To keep track of to the last focused widget""" + """ + Keep track of widget and plugin that has keyboard focus. + + This function is connected to the `focusChanged` signal. It keeps + track of the widget and plugin that currently has keyboard focus, so + that we can give focus to the last focused widget when restoring it + after minimization. It also emits `sig_focused_plugin_changed` if the + plugin with focus has changed. + + Parameters + ---------- + old : Optional[QWidget] + Widget that used to have keyboard focus. + now : Optional[QWidget] + Widget that currently has keyboard focus. + """ if (now is None and QApplication.activeWindow() is not None): QApplication.activeWindow().setFocus() self.last_focused_widget = QApplication.focusWidget() elif now is not None: self.last_focused_widget = now - self.previous_focused_widget = old + self.previous_focused_widget = old + + if self.last_focused_widget: + for plugin_name, plugin in self.get_dockable_plugins(): + if self.is_plugin_available(plugin_name): + plugin_widget = plugin.get_widget() + if plugin_widget.isAncestorOf(self.last_focused_widget): + focused_plugin = plugin + break + else: + focused_plugin = None + + if focused_plugin != self.last_focused_plugin: + self.last_focused_plugin = focused_plugin + self.sig_focused_plugin_changed.emit(focused_plugin) def closing(self, cancelable=False, close_immediately=False): """Exit tasks""" @@ -1164,27 +1207,6 @@ def redirect_internalshell_stdio(self, state): else: console.restore_stds() - def open_file(self, fname, external=False): - """ - Open filename with the appropriate application - Redirect to the right widget (txt -> editor, spydata -> workspace, ...) - or open file outside Spyder (if extension is not supported) - """ - fname = to_text_string(fname) - ext = osp.splitext(fname)[1] - editor = self.get_plugin(Plugins.Editor, error=False) - variableexplorer = self.get_plugin( - Plugins.VariableExplorer, error=False) - - if encoding.is_text_file(fname): - if editor: - editor.load(fname) - elif variableexplorer is not None and ext in IMPORT_EXT: - variableexplorer.get_widget().import_data(fname) - elif not external: - fname = file_uri(fname) - start_file(fname) - def get_initial_working_directory(self): """Return the initial working directory.""" return self.INITIAL_CWD @@ -1209,8 +1231,9 @@ def open_external_file(self, fname): if sys.platform == 'darwin' and 'bin/spyder' in fname: return - if osp.isfile(fpath): - self.open_file(fpath, external=True) + application = self.get_plugin(Plugins.Application, error=False) + if osp.isfile(fpath) and application: + application.open_file_in_plugin(fpath) elif osp.isdir(fpath): QMessageBox.warning( self, _("Error"), diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py index f977b90c2a0..37aca605967 100644 --- a/spyder/app/tests/test_mainwindow.py +++ b/spyder/app/tests/test_mainwindow.py @@ -3001,6 +3001,7 @@ def test_pylint_follows_file(qtbot, tmpdir, main_window): timeout=SHELL_TIMEOUT) pylint_plugin = main_window.get_plugin(Plugins.Pylint) + application_plugin = main_window.get_plugin(Plugins.Application) # Show pylint plugin pylint_plugin.dockwidget.show() @@ -3014,7 +3015,7 @@ def test_pylint_follows_file(qtbot, tmpdir, main_window): fh = basedir.join('{}.py'.format(idx)) fname = str(fh) fh.write('print("Hello world!")') - main_window.open_file(fh) + application_plugin.open_file_in_plugin(fname) qtbot.wait(200) assert fname == pylint_plugin.get_filename() @@ -3029,7 +3030,7 @@ def test_pylint_follows_file(qtbot, tmpdir, main_window): fh = basedir.join('{}.py'.format(idx)) fh.write('print("Hello world!")') fname = str(fh) - main_window.open_file(fh) + application_plugin.open_file_in_plugin(fname) qtbot.wait(200) assert fname == pylint_plugin.get_filename() diff --git a/spyder/config/main.py b/spyder/config/main.py index ec6f7fe8df0..79c7d70ded6 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -84,6 +84,7 @@ 'completion/size': (300, 180), 'report_error/remember_token': False, 'show_dpi_message': True, + 'max_recent_files': 20, }), ('update_manager', { @@ -261,7 +262,6 @@ 'always_remove_trailing_newlines': False, 'show_tab_bar': True, 'show_class_func_dropdown': False, - 'max_recent_files': 20, 'onsave_analysis': False, 'autosave_enabled': True, 'autosave_interval': 60, @@ -405,6 +405,17 @@ '_/run': "F5", '_/configure': "Ctrl+F6", '_/re-run last script': "F6", + # -- File menu -- + # (intended context for these is plugins that support them) + 'main/new file': "Ctrl+N", + 'main/open file': "Ctrl+O", + 'main/open last closed': "Ctrl+Shift+T", + 'main/save file': "Ctrl+S", + 'main/save all': "Ctrl+Alt+S", + 'main/save as': 'Ctrl+Shift+S', + 'main/close file 1': "Ctrl+W", + 'main/close file 2': "Ctrl+F4", + 'main/close all': "Ctrl+Shift+W", # -- Switch to plugin -- '_/switch to help': "Ctrl+Shift+H", '_/switch to outline_explorer': "Ctrl+Shift+O", @@ -475,13 +486,6 @@ 'editor/go to next file': CTRL + '+Tab', 'editor/cycle to previous file': 'Ctrl+PgUp', 'editor/cycle to next file': 'Ctrl+PgDown', - 'editor/new file': "Ctrl+N", - 'editor/open last closed':"Ctrl+Shift+T", - 'editor/open file': "Ctrl+O", - 'editor/save file': "Ctrl+S", - 'editor/save all': "Ctrl+Alt+S", - 'editor/save as': 'Ctrl+Shift+S', - 'editor/close all': "Ctrl+Shift+W", 'editor/last edit location': "Ctrl+Alt+Shift+Left", 'editor/previous cursor position': "Alt+Left", 'editor/next cursor position': "Alt+Right", @@ -491,8 +495,6 @@ 'editor/zoom in 2': "Ctrl+=", 'editor/zoom out': "Ctrl+-", 'editor/zoom reset': "Ctrl+0", - 'editor/close file 1': "Ctrl+W", - 'editor/close file 2': "Ctrl+F4", 'editor/run cell': CTRL + '+Return', 'editor/run cell and advance': 'Shift+Return', 'editor/run selection and advance': "F9", @@ -590,6 +592,7 @@ 'crash', 'current_version', 'historylog_filename', + 'recent_files', 'window/position', 'window/size', 'window/state', @@ -600,7 +603,6 @@ 'bookmarks', 'filenames', 'layout_settings', - 'recent_files', 'splitter_state', 'file_uuids' ] diff --git a/spyder/plugins/application/container.py b/spyder/plugins/application/container.py index 7b4bd29370f..8c38abb0a52 100644 --- a/spyder/plugins/application/container.py +++ b/spyder/plugins/application/container.py @@ -11,24 +11,33 @@ """ # Standard library imports +import functools +import glob import os +import os.path as osp import sys -import glob +from typing import Optional # Third party imports -from qtpy.QtCore import Qt, QThread, QTimer, Signal, Slot +from qtpy.compat import getopenfilenames +from qtpy.QtCore import QDir, Qt, QThread, QTimer, Signal, Slot from qtpy.QtGui import QGuiApplication -from qtpy.QtWidgets import QAction, QMessageBox, QPushButton +from qtpy.QtWidgets import ( + QAction, QFileDialog, QInputDialog, QMessageBox, QPushButton) # Local imports from spyder import __docs_url__, __forum_url__, __trouble_url__ from spyder import dependencies from spyder.api.translations import _ from spyder.api.widgets.main_container import PluginMainContainer -from spyder.utils.installers import InstallerMissingDependencies -from spyder.config.base import get_conf_path, get_debug_level +from spyder.config.base import ( + get_conf_path, get_debug_level, running_under_pytest) +from spyder.config.utils import ( + get_edit_filetypes, get_edit_filters, get_filter) from spyder.plugins.application.widgets import AboutDialog, InAppAppealStatus from spyder.plugins.console.api import ConsoleActions +from spyder.utils.icon_manager import ima +from spyder.utils.installers import InstallerMissingDependencies from spyder.utils.environ import UserEnvDialog from spyder.utils.qthelpers import start_file, DialogManager from spyder.widgets.dependencies import DependenciesDialog @@ -37,6 +46,7 @@ class ApplicationPluginMenus: DebugLogsMenu = "debug_logs_menu" + RecentFilesMenu = "recent_files_menu" class LogsMenuSections: @@ -46,10 +56,11 @@ class LogsMenuSections: # Actions class ApplicationActions: + # For actions with shortcuts, the name of the action needs to match the + # name of the shortcut so 'spyder documentation' is used instead of + # something like 'spyder_documentation' + # Help - # The name of the action needs to match the name of the shortcut so - # 'spyder documentation' is used instead of something - # like 'spyder_documentation' SpyderDocumentationAction = "spyder documentation" SpyderDocumentationVideoAction = "spyder_documentation_video_action" SpyderTroubleshootingAction = "spyder_troubleshooting_action" @@ -62,8 +73,18 @@ class ApplicationActions: SpyderUserEnvVariables = "spyder_user_env_variables_action" # File - # The name of the action needs to match the name of the shortcut - # so 'Restart' is used instead of something like 'restart_action' + NewFile = "New file" + OpenFile = "Open file" + OpenLastClosed = "Open last closed" + MaxRecentFiles = "max_recent_files_action" + ClearRecentFiles = "clear_recent_files_action" + SaveFile = "Save file" + SaveAll = "Save all" + SaveAs = "Save as" + SaveCopyAs = "save_copy_as_action" + RevertFile = "Revert file" + CloseFile = "Close file" + CloseAll = "Close all" SpyderRestart = "Restart" SpyderRestartDebug = "Restart in debug mode" @@ -80,6 +101,65 @@ class ApplicationContainer(PluginMainContainer): Signal to load a log file """ + sig_new_file_requested = Signal() + """ + Signal to request that a new file be created in a suitable plugin. + """ + + sig_open_file_in_plugin_requested = Signal(str) + """ + Signal to request that given file is opened in a suitable plugin. + + Arguments + --------- + filename : str + """ + + sig_open_file_using_dialog_requested = Signal() + """ + Signal to request that the Open File dialog is shown to open a file. + """ + + sig_open_last_closed_requested = Signal() + """ + Signal to request that the last closed file be opened again. + """ + + sig_save_file_requested = Signal() + """ + Signal to request that the current file be saved. + """ + + sig_save_all_requested = Signal() + """ + Signal to request that all files in the current plugin be saved. + """ + + sig_save_file_as_requested = Signal() + """ + Signal to request that the current file be saved under a different name. + """ + + sig_save_copy_as_requested = Signal() + """ + Signal to request that copy of current file be saved under a new name. + """ + + sig_revert_file_requested = Signal() + """ + Signal to request that the current file be reverted from disk. + """ + + sig_close_file_requested = Signal() + """ + Signal to request that the current file be closed. + """ + + sig_close_all_requested = Signal() + """ + Signal to request that all open files be closed. + """ + def __init__(self, name, plugin, parent=None): super().__init__(name, plugin, parent) @@ -87,6 +167,9 @@ def __init__(self, name, plugin, parent=None): self.current_dpi = None self.dpi_messagebox = None + # Keep track of list of recent files + self.recent_files = self.get_conf('recent_files', []) + # ---- PluginMainContainer API # ------------------------------------------------------------------------- def setup(self): @@ -187,6 +270,109 @@ def setup(self): shortcut_context="_", register_shortcut=True) + # File actions + self.new_action = self.create_action( + ApplicationActions.NewFile, + text=_("&New file..."), + icon=self.create_icon('filenew'), + tip=_("New file"), + triggered=self.sig_new_file_requested.emit, + shortcut_context="main", + register_shortcut=True + ) + self.open_action = self.create_action( + ApplicationActions.OpenFile, + text=_("&Open..."), + icon=self.create_icon('fileopen'), + tip=_("Open file"), + triggered=self.sig_open_file_using_dialog_requested.emit, + shortcut_context="main", + register_shortcut=True + ) + self.open_last_closed_action = self.create_action( + ApplicationActions.OpenLastClosed, + text=_("O&pen last closed"), + tip=_("Open last closed"), + triggered=self.sig_open_last_closed_requested.emit, + shortcut_context="main", + register_shortcut=True + ) + self.recent_files_menu = self.create_menu( + ApplicationPluginMenus.RecentFilesMenu, + title=_("Open &recent") + ) + self.recent_files_menu.aboutToShow.connect( + self.update_recent_files_menu + ) + self.max_recent_action = self.create_action( + ApplicationActions.MaxRecentFiles, + text=_("Maximum number of recent files..."), + triggered=self.change_max_recent_files + ) + self.clear_recent_action = self.create_action( + ApplicationActions.ClearRecentFiles, + text=_("Clear this list"), + tip=_("Clear recent files list"), + triggered=self.clear_recent_files + ) + self.save_action = self.create_action( + ApplicationActions.SaveFile, + text=_("&Save"), + icon=self.create_icon('filesave'), + tip=_("Save file"), + triggered=self.sig_save_file_requested.emit, + shortcut_context="main", + register_shortcut=True + ) + self.save_all_action = self.create_action( + ApplicationActions.SaveAll, + text=_("Sav&e all"), + icon=self.create_icon('save_all'), + tip=_("Save all files"), + triggered=self.sig_save_all_requested.emit, + shortcut_context="main", + register_shortcut=True + ) + self.save_as_action = self.create_action( + ApplicationActions.SaveAs, + text=_("Save &as"), + icon=self.create_icon('filesaveas'), + tip=_("Save current file as..."), + triggered=self.sig_save_file_as_requested.emit, + shortcut_context="main", + register_shortcut=True + ) + self.save_copy_as_action = self.create_action( + ApplicationActions.SaveCopyAs, + text=_("Save copy as..."), + icon=self.create_icon('filesaveas'), + tip=_("Save copy of current file as..."), + triggered=self.sig_save_copy_as_requested.emit + ) + self.revert_action = self.create_action( + ApplicationActions.RevertFile, + text=_("&Revert"), + icon=self.create_icon('revert'), + tip=_("Revert file from disk"), + triggered=self.sig_revert_file_requested.emit + ) + self.close_file_action = self.create_action( + ApplicationActions.CloseFile, + text=_("&Close"), + icon=self.create_icon('fileclose'), + tip=_("Close current file"), + triggered=self.sig_close_file_requested.emit + ) + self.close_all_action = self.create_action( + ApplicationActions.CloseAll, + text=_("C&lose all"), + icon=ima.icon('filecloseall'), + tip=_("Close all opened files"), + triggered=self.sig_close_all_requested.emit, + shortcut_context="main", + register_shortcut=True + ) + # Debug logs if get_debug_level() >= 2: self.menu_debug_logs = self.create_menu( @@ -199,6 +385,10 @@ def setup(self): self.menu_debug_logs.aboutToShow.connect( self.create_debug_log_actions) + # File types and filters used by the Open dialog + self.edit_filetypes = None + self.edit_filters = None + def update_actions(self): pass @@ -207,6 +397,7 @@ def update_actions(self): def on_close(self): """To call from Spyder when the plugin is closed.""" self.dialog_manager.close_all() + self.set_conf('recent_files', self.recent_files) if self.dependencies_thread is not None: self.dependencies_thread.quit() self.dependencies_thread.wait() @@ -331,6 +522,146 @@ def restart_debug(self): self.sig_restart_requested.emit() + # ---- File actions + # ------------------------------------------------------------------------- + def open_file_using_dialog(self, filename: Optional[str], basedir: str): + """ + Show Open File dialog and open the selected file. + + Parameters + ---------- + filename : Optional[str] + Name of currently active file. This is used to set the selected + name filter for the Open File dialog. + basedir : str + Directory initially displayed in the Open File dialog. + """ + if self.edit_filetypes is None: + self.edit_filetypes = get_edit_filetypes() + if self.edit_filters is None: + self.edit_filters = get_edit_filters() + + self.sig_redirect_stdio_requested.emit(False) + if filename is not None: + selectedfilter = get_filter(self.edit_filetypes, + osp.splitext(filename)[1]) + else: + selectedfilter = '' + + if not running_under_pytest(): + # See: spyder-ide/spyder#3291 + if sys.platform == 'darwin': + dialog = QFileDialog( + parent=self, + caption=_("Open file"), + directory=basedir, + ) + dialog.setNameFilters(self.edit_filters.split(';;')) + dialog.setOption(QFileDialog.HideNameFilterDetails, True) + dialog.setFilter(QDir.AllDirs | QDir.Files | QDir.Drives + | QDir.Hidden) + dialog.setFileMode(QFileDialog.ExistingFiles) + + if dialog.exec_(): + filenames = dialog.selectedFiles() + else: + filenames, _sf = getopenfilenames( + self, + _("Open file"), + basedir, + self.edit_filters, + selectedfilter=selectedfilter, + options=QFileDialog.HideNameFilterDetails, + ) + else: + # Use a Qt (i.e. scriptable) dialog for pytest + dialog = QFileDialog(self, _("Open file"), + options=QFileDialog.DontUseNativeDialog) + if dialog.exec_(): + filenames = dialog.selectedFiles() + + self.sig_redirect_stdio_requested.emit(True) + + for filename in filenames: + filename = osp.normpath(filename) + self.sig_open_file_in_plugin_requested.emit(filename) + + def update_recent_files_menu(self): + """ + Update recent files menu + + Add menu items for all the recent files to the menu. Also add items + for setting the maximum number and for clearing the list. + + This function is called before the menu is about to be shown. + """ + self.recent_files_menu.clear_actions() + recent_files = [fname for fname in self.recent_files + if osp.isfile(fname)] + for fname in recent_files: + icon = ima.get_icon_by_extension_or_type(fname, scale_factor=1.0) + action = self.create_action( + name=f'Recent file {fname}', + text=fname, + icon=icon, + triggered=functools.partial( + self.sig_open_file_in_plugin_requested.emit, + fname + ) + ) + self.recent_files_menu.add_action( + action, + section='recent_files_section', + omit_id=True, + before_section='recent_files_actions_section' + ) + + self.clear_recent_action.setEnabled(len(recent_files) > 0) + for menu_action in (self.max_recent_action, self.clear_recent_action): + self.recent_files_menu.add_action( + menu_action, + section='recent_files_actions_section' + ) + + self.recent_files_menu.render() + + def add_recent_file(self, fname: str) -> None: + """ + Add file to list of recent files. + + This function adds the given file name to the list of recent files, + which is used in the `File > Open recent` menu. The function ensures + that the list has no duplicates and it is no longer than the maximum + length. + """ + if fname in self.recent_files: + self.recent_files.remove(fname) + self.recent_files.insert(0, fname) + if len(self.recent_files) > self.get_conf('max_recent_files'): + self.recent_files.pop(-1) + + def clear_recent_files(self) -> None: + """ + Clear list of recent files. + """ + self.recent_files = [] + + def change_max_recent_files(self) -> None: + """ + Change the maximum length of the list of recent files. + """ + mrf, valid = QInputDialog.getInt( + self, + _('Editor'), + _('Maximum number of recent files'), + self.get_conf('max_recent_files'), + 1, + 35 + ) + + if valid: + self.set_conf('max_recent_files', mrf) + # ---- Log files # ------------------------------------------------------------------------- def create_debug_log_actions(self): diff --git a/spyder/plugins/application/plugin.py b/spyder/plugins/application/plugin.py index e49e04598a8..a5b61c93bc2 100644 --- a/spyder/plugins/application/plugin.py +++ b/spyder/plugins/application/plugin.py @@ -13,15 +13,17 @@ import os.path as osp import subprocess import sys +from typing import Dict, Optional, Tuple # Third party imports from qtpy.QtCore import Slot # Local imports -from spyder.api.plugins import Plugins, SpyderPluginV2 +from spyder.api.plugins import Plugins, SpyderDockablePlugin, SpyderPluginV2 from spyder.api.translations import _ from spyder.api.plugin_registration.decorators import ( on_plugin_available, on_plugin_teardown) +from spyder.api.plugin_registration.registry import PLUGIN_REGISTRY from spyder.api.widgets.menus import SpyderMenu, MENU_SEPARATOR from spyder.config.base import (get_module_path, get_debug_level, running_under_pytest) @@ -31,6 +33,8 @@ from spyder.plugins.console.api import ConsoleActions from spyder.plugins.mainmenu.api import ( ApplicationMenus, FileMenuSections, HelpMenuSections, ToolsMenuSections) +from spyder.plugins.toolbar.api import ApplicationToolbars +from spyder.utils.misc import getcwd_or_home from spyder.utils.qthelpers import add_actions @@ -38,13 +42,21 @@ class Application(SpyderPluginV2): NAME = 'application' REQUIRES = [Plugins.Console, Plugins.Preferences] OPTIONAL = [Plugins.Help, Plugins.MainMenu, Plugins.Shortcuts, - Plugins.Editor, Plugins.StatusBar, Plugins.UpdateManager] + Plugins.Editor, Plugins.StatusBar, Plugins.UpdateManager, + Plugins.Toolbar] CONTAINER_CLASS = ApplicationContainer CONF_SECTION = 'main' CONF_FILE = False CONF_WIDGET_CLASS = ApplicationConfigPage CAN_BE_DISABLED = False + def __init__(self, parent, configuration=None): + super().__init__(parent, configuration) + self.focused_plugin: Optional[SpyderDockablePlugin] = None + + FileActionEnabledKey = Tuple[SpyderDockablePlugin, str] + self.file_action_enabled: Dict[FileActionEnabledKey, bool] = {} + @staticmethod def get_name(): return _('Application') @@ -60,7 +72,25 @@ def get_description(): def on_initialize(self): container = self.get_container() container.sig_report_issue_requested.connect(self.report_issue) + container.sig_new_file_requested.connect(self.create_new_file) + container.sig_open_file_in_plugin_requested.connect( + self.open_file_in_plugin + ) + container.sig_open_file_using_dialog_requested.connect( + self.open_file_using_dialog + ) + container.sig_open_last_closed_requested.connect( + self.open_last_closed_file + ) + container.sig_save_file_requested.connect(self.save_file) + container.sig_save_all_requested.connect(self.save_all) + container.sig_save_file_as_requested.connect(self.save_file_as) + container.sig_save_copy_as_requested.connect(self.save_copy_as) + container.sig_revert_file_requested.connect(self.revert_file) + container.sig_close_file_requested.connect(self.close_file) + container.sig_close_all_requested.connect(self.close_all) container.set_window(self._window) + self.sig_focused_plugin_changed.connect(self.update_focused_plugin) # --------------------- PLUGIN INITIALIZATION ----------------------------- @on_plugin_available(plugin=Plugins.Shortcuts) @@ -104,6 +134,21 @@ def on_statusbar_available(self): inapp_appeal_status = self.get_container().inapp_appeal_status statusbar.add_status_widget(inapp_appeal_status) + @on_plugin_available(plugin=Plugins.Toolbar) + def on_toolbar_available(self): + container = self.get_container() + toolbar = self.get_plugin(Plugins.Toolbar) + for action in [ + container.new_action, + container.open_action, + container.save_action, + container.save_all_action + ]: + toolbar.add_item_to_application_toolbar( + action, + toolbar_id=ApplicationToolbars.File + ) + # -------------------------- PLUGIN TEARDOWN ------------------------------ @on_plugin_teardown(plugin=Plugins.Preferences) def on_preferences_teardown(self): @@ -133,6 +178,20 @@ def on_statusbar_teardown(self): inapp_appeal_status = self.get_container().inapp_appeal_status statusbar.remove_status_widget(inapp_appeal_status.ID) + @on_plugin_teardown(plugin=Plugins.Toolbar) + def on_toolbar_teardown(self): + toolbar = self.get_plugin(Plugins.Toolbar) + for action in [ + ApplicationActions.NewFile, + ApplicationActions.OpenFile, + ApplicationActions.SaveFile, + ApplicationActions.SaveAll + ]: + toolbar.remove_item_from_application_toolbar( + action, + toolbar_id=ApplicationToolbars.File + ) + def on_close(self, _unused=True): self.get_container().on_close() @@ -171,15 +230,71 @@ def on_mainwindow_visible(self): # ---- Private API # ------------------------------------------------------------------------ def _populate_file_menu(self): + container = self.get_container() mainmenu = self.get_plugin(Plugins.MainMenu) + + # New section + mainmenu.add_item_to_application_menu( + container.new_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.New, + before_section=FileMenuSections.Open + ) + + # Open section + open_actions = [ + container.open_action, + container.open_last_closed_action, + container.recent_files_menu, + ] + for open_action in open_actions: + mainmenu.add_item_to_application_menu( + open_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Open, + before_section=FileMenuSections.Save + ) + + # Save section + save_actions = [ + container.save_action, + container.save_all_action, + container.save_as_action, + container.save_copy_as_action, + container.revert_action + ] + for save_action in save_actions: + mainmenu.add_item_to_application_menu( + save_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Save, + before_section=FileMenuSections.Print + ) + + # Close section + close_actions = [ + container.close_file_action, + container.close_all_action + ] + for close_action in close_actions: + mainmenu.add_item_to_application_menu( + close_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Close, + before_section=FileMenuSections.Restart + ) + + # Restart section mainmenu.add_item_to_application_menu( self.restart_action, menu_id=ApplicationMenus.File, - section=FileMenuSections.Restart) + section=FileMenuSections.Restart + ) mainmenu.add_item_to_application_menu( self.restart_debug_action, menu_id=ApplicationMenus.File, - section=FileMenuSections.Restart) + section=FileMenuSections.Restart + ) def _populate_tools_menu(self): """Add base actions and menus to the Tools menu.""" @@ -281,9 +396,23 @@ def _depopulate_help_menu_about_section(self): menu_id=ApplicationMenus.Help) def _depopulate_file_menu(self): + container = self.get_container() mainmenu = self.get_plugin(Plugins.MainMenu) - for action_id in [ApplicationActions.SpyderRestart, - ApplicationActions.SpyderRestartDebug]: + for action_id in [ + ApplicationActions.NewFile, + ApplicationActions.OpenFile, + ApplicationActions.OpenLastClosed, + container.recent_file_menu, + ApplicationActions.SaveFile, + ApplicationActions.SaveAll, + ApplicationActions.SaveAs, + ApplicationActions.SaveCopyAs, + ApplicationActions.RevertFile, + ApplicationActions.CloseFile, + ApplicationActions.CloseAll, + ApplicationActions.SpyderRestart, + ApplicationActions.SpyderRestartDebug + ]: mainmenu.remove_item_from_application_menu( action_id, menu_id=ApplicationMenus.File) @@ -401,6 +530,264 @@ def restart(self, reset=False, close_immediately=False): print(error) # spyder: test-skip print(command) # spyder: test-skip + def update_focused_plugin( + self, plugin: Optional[SpyderDockablePlugin] + ) -> None: + """ + Update which plugin has currently focus. + + This function is called if another plugin gets keyboard focus. + """ + self.focused_plugin = plugin + self.update_file_actions() + + def create_new_file(self) -> None: + """ + Create new file in a suitable plugin. + + If the plugin that currently has focus, has its + `CAN_HANDLE_FILE_ACTIONS` attribute set to `True`, then create a new + file in that plugin. Otherwise, create a new file in the Editor plugin. + """ + plugin = self.focused_plugin + if plugin and getattr(plugin, 'CAN_HANDLE_FILE_ACTIONS', False): + plugin.create_new_file() + elif self.is_plugin_available(Plugins.Editor): + editor = self.get_plugin(Plugins.Editor) + editor.new() + + def open_file_using_dialog(self) -> None: + """ + Show Open File dialog and open the selected file. + + Try asking the plugin that currently has focus for the name of the + displayed file and whether it is a temporary file. If that does not + work, ask the Editor plugin. Finally, call the function with the same + name in the container widget to do the actual work. + """ + plugin = self.focused_plugin + if plugin: + filename = plugin.get_current_filename() + else: + filename = None + + if filename is None and self.is_plugin_available(Plugins.Editor): + plugin = self.get_plugin(Plugins.Editor) + filename = plugin.get_current_filename() + + if filename is not None and not plugin.current_file_is_temporary(): + basedir = osp.dirname(filename) + else: + basedir = getcwd_or_home() + + self.get_container().open_file_using_dialog(filename, basedir) + + def open_file_in_plugin(self, filename: str) -> None: + """ + Open given file in a suitable plugin. + + Go through all plugins and open the file in the first plugin that + registered the extension of the given file name. If none is found, + then open the file in the Editor plugin. + """ + ext = osp.splitext(filename)[1] + for plugin_name in PLUGIN_REGISTRY: + if PLUGIN_REGISTRY.is_plugin_available(plugin_name): + plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) + if ( + isinstance(plugin, SpyderDockablePlugin) + and ext in plugin.FILE_EXTENSIONS + ): + plugin.switch_to_plugin() + plugin.open_file(filename) + return + + if self.is_plugin_available(Plugins.Editor): + editor = self.get_plugin(Plugins.Editor) + editor.load(filename) + + def open_last_closed_file(self) -> None: + """ + Open the last closed file again. + + If the plugin that currently has focus, has its + `CAN_HANDLE_FILE_ACTIONS` attribute set to `True`, then open the + last closed file in that plugin. Otherwise, open the last closed file + in the Editor plugin. + """ + plugin = self.focused_plugin + if plugin and getattr(plugin, 'CAN_HANDLE_FILE_ACTIONS', False): + plugin.open_last_closed_file() + elif self.is_plugin_available(Plugins.Editor): + editor = self.get_plugin(Plugins.Editor) + editor.open_last_closed() + + def add_recent_file(self, fname: str) -> None: + """ + Add file to list of recent files. + + This function adds the given file name to the list of recent files, + which is used in the `File > Open recent` menu. The function ensures + that the list has no duplicates and it is no longer than the maximum + length. + """ + self.get_container().add_recent_file(fname) + + def save_file(self) -> None: + """ + Save current file. + + If the plugin that currently has focus, has its + `CAN_HANDLE_FILE_ACTIONS` attribute set to `True`, then save the + current file in that plugin. Otherwise, save the current file in the + Editor plugin. + """ + plugin = self.focused_plugin + if plugin and getattr(plugin, 'CAN_HANDLE_FILE_ACTIONS', False): + plugin.save_file() + elif self.is_plugin_available(Plugins.Editor): + editor = self.get_plugin(Plugins.Editor) + editor.save() + + def save_file_as(self) -> None: + """ + Save current file under a different name. + + If the plugin that currently has focus, has its + `CAN_HANDLE_FILE_ACTIONS` attribute set to `True`, then save the + current file in that plugin under a different name. Otherwise, save + the current file in the Editor plugin under a different name. + """ + plugin = self.focused_plugin + if plugin and getattr(plugin, 'CAN_HANDLE_FILE_ACTIONS', False): + plugin.save_file_as() + elif self.is_plugin_available(Plugins.Editor): + editor = self.get_plugin(Plugins.Editor) + editor.save_as() + + def save_copy_as(self) -> None: + """ + Save copy of current file under a different name. + + If the plugin that currently has focus, has its + `CAN_HANDLE_FILE_ACTIONS` attribute set to `True`, then save a copy of + the current file in that plugin under a different name. Otherwise, save + a copy of the current file in the Editor plugin under a different name. + """ + plugin = self.focused_plugin + if plugin and getattr(plugin, 'CAN_HANDLE_FILE_ACTIONS', False): + plugin.save_copy_as() + elif self.is_plugin_available(Plugins.Editor): + editor = self.get_plugin(Plugins.Editor) + editor.save_copy_as() + + def save_all(self) -> None: + """ + Save all files. + + If the plugin that currently has focus, has its + `CAN_HANDLE_FILE_ACTIONS` attribute set to `True`, then save all files + in that plugin. Otherwise, save all files in the Editor plugin. + """ + plugin = self.focused_plugin + if plugin and getattr(plugin, 'CAN_HANDLE_FILE_ACTIONS', False): + plugin.save_all() + elif self.is_plugin_available(Plugins.Editor): + editor = self.get_plugin(Plugins.Editor) + editor.save_all() + + def revert_file(self) -> None: + """ + Revert current file to version on disk. + + If the plugin that currently has focus, has its + `CAN_HANDLE_FILE_ACTIONS` attribute set to `True`, then revert the + current file in that plugin to the version stored on disk. Otherwise, + revert the current file in the Editor plugin. + """ + plugin = self.focused_plugin + if plugin and getattr(plugin, 'CAN_HANDLE_FILE_ACTIONS', False): + plugin.revert_file() + elif self.is_plugin_available(Plugins.Editor): + editor = self.get_plugin(Plugins.Editor) + editor.revert_file() + + def close_file(self) -> None: + """ + Close the current file. + + If the plugin that currently has focus, has its + `CAN_HANDLE_FILE_ACTIONS` attribute set to `True`, then revert the + current file in that plugin to the version stored on disk. Otherwise, + revert the current file in the Editor plugin. + """ + plugin = self.focused_plugin + if plugin and getattr(plugin, 'CAN_HANDLE_FILE_ACTIONS', False): + plugin.close_file() + elif self.is_plugin_available(Plugins.Editor): + editor = self.get_plugin(Plugins.Editor) + editor.close_file() + + def close_all(self) -> None: + """ + Close all opened files in the current plugin. + + If the plugin that currently has focus, has its + `CAN_HANDLE_FILE_ACTIONS` attribute set to `True`, then revert the + current file in that plugin to the version stored on disk. Otherwise, + revert the current file in the Editor plugin. + """ + plugin = self.focused_plugin + if plugin and getattr(plugin, 'CAN_HANDLE_FILE_ACTIONS', False): + plugin.close_all() + elif self.is_plugin_available(Plugins.Editor): + editor = self.get_plugin(Plugins.Editor) + editor.close_all_files() + + def enable_file_action( + self, + action_name: str, + enabled: bool, + plugin: SpyderDockablePlugin + ) -> None: + """ + Enable or disable file actions for given plugin. + action_name : str + The name of the action to be enabled or disabled. These names + are listed in ApplicationActions, for instance "New file" + enabled : bool + True to enable the action, False to disable it. + plugin : SpyderDockablePlugin + The plugin for which the save actions are enabled or disabled. + """ + self.file_action_enabled[(plugin, action_name)] = enabled + self.update_file_actions() + + def update_file_actions(self) -> None: + """ + Update which file actions are enabled. + + File actions are enabled depending on whether the plugin that would + currently process the file action has enabled it or not. + """ + plugin = self.focused_plugin + if not plugin or not getattr(plugin, 'CAN_HANDLE_FILE_ACTIONS', False): + plugin = self.get_plugin(Plugins.Editor, error=False) + if plugin: + for action_name in [ + ApplicationActions.NewFile, + ApplicationActions.OpenLastClosed, + ApplicationActions.SaveFile, + ApplicationActions.SaveAs, + ApplicationActions.SaveAll, + ApplicationActions.SaveCopyAs, + ApplicationActions.RevertFile + ]: + action = self.get_action(action_name) + key = (plugin, action_name) + state = self.file_action_enabled.get(key, True) + action.setEnabled(state) + @property def documentation_action(self): """Open Spyder's Documentation in the browser.""" diff --git a/spyder/plugins/editor/plugin.py b/spyder/plugins/editor/plugin.py index 40f86ad9d66..40ac6a627a5 100644 --- a/spyder/plugins/editor/plugin.py +++ b/spyder/plugins/editor/plugin.py @@ -47,7 +47,7 @@ class Editor(SpyderDockablePlugin): """ NAME = 'editor' - REQUIRES = [Plugins.Console, Plugins.Preferences] + REQUIRES = [Plugins.Application, Plugins.Console, Plugins.Preferences] OPTIONAL = [ Plugins.Completions, Plugins.Debugger, @@ -366,19 +366,6 @@ def on_mainmenu_available(self): before_section=FileMenuSections.Close ) - # Close - close_actions = [ - widget.close_action, - widget.close_all_action - ] - for close_action in close_actions: - mainmenu.add_item_to_application_menu( - close_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Close, - before_section=FileMenuSections.Restart - ) - # Navigation if sys.platform == 'darwin': tab_navigation_actions = [ @@ -393,44 +380,6 @@ def on_mainmenu_available(self): before_section=FileMenuSections.Restart ) - # Open section - open_actions = [ - widget.open_action, - widget.open_last_closed_action, - widget.recent_file_menu, - ] - for open_action in open_actions: - mainmenu.add_item_to_application_menu( - open_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Open, - before_section=FileMenuSections.Save - ) - - # Save section - save_actions = [ - widget.save_action, - widget.save_all_action, - widget.save_as_action, - widget.save_copy_as_action, - widget.revert_action, - ] - for save_action in save_actions: - mainmenu.add_item_to_application_menu( - save_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Save, - before_section=FileMenuSections.Print - ) - - # New Section - mainmenu.add_item_to_application_menu( - widget.new_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.New, - before_section=FileMenuSections.Open - ) - # ---- Edit menu ---- edit_menu = mainmenu.get_application_menu(ApplicationMenus.Edit) edit_menu.aboutToShow.connect(widget.update_edit_menu) @@ -551,17 +500,6 @@ def on_mainmenu_teardown(self): menu_id=ApplicationMenus.File ) - # Close - close_actions = [ - widget.close_action, - widget.close_all_action - ] - for close_action in close_actions: - mainmenu.remove_item_from_application_menu( - close_action, - menu_id=ApplicationMenus.File - ) - # Navigation if sys.platform == 'darwin': tab_navigation_actions = [ @@ -574,38 +512,6 @@ def on_mainmenu_teardown(self): menu_id=ApplicationMenus.File ) - # Open section - open_actions = [ - widget.open_action, - widget.open_last_closed_action, - widget.recent_file_menu, - ] - for open_action in open_actions: - mainmenu.remove_item_from_application_menu( - open_action, - menu_id=ApplicationMenus.File - ) - - # Save section - save_actions = [ - widget.save_action, - widget.save_all_action, - widget.save_as_action, - widget.save_copy_as_action, - widget.revert_action, - ] - for save_action in save_actions: - mainmenu.remove_item_from_application_menu( - save_action, - menu_id=ApplicationMenus.File - ) - - # New Section - mainmenu.remove_item_from_application_menu( - widget.new_action, - menu_id=ApplicationMenus.File - ) - # ---- Edit menu ---- edit_menu = mainmenu.get_application_menu(ApplicationMenus.Edit) edit_menu.aboutToShow.disconnect(widget.update_edit_menu) @@ -699,34 +605,18 @@ def on_mainmenu_teardown(self): def on_toolbar_available(self): widget = self.get_widget() toolbar = self.get_plugin(Plugins.Toolbar) - file_toolbar_actions = [ - widget.new_action, - widget.open_action, - widget.save_action, - widget.save_all_action, - widget.create_new_cell - ] - for file_toolbar_action in file_toolbar_actions: - toolbar.add_item_to_application_toolbar( - file_toolbar_action, - toolbar_id=ApplicationToolbars.File, - ) + toolbar.add_item_to_application_toolbar( + widget.create_new_cell, + toolbar_id=ApplicationToolbars.File, + ) @on_plugin_teardown(plugin=Plugins.Toolbar) def on_toolbar_teardown(self): toolbar = self.get_plugin(Plugins.Toolbar) - file_toolbar_actions = [ - EditorWidgetActions.NewFile, - EditorWidgetActions.OpenFile, - EditorWidgetActions.SaveFile, - EditorWidgetActions.SaveAll, - EditorWidgetActions.NewCell - ] - for file_toolbar_action_id in file_toolbar_actions: - toolbar.remove_item_from_application_toolbar( - file_toolbar_action_id, - toolbar_id=ApplicationToolbars.File, - ) + toolbar.remove_item_from_application_toolbar( + EditorWidgetActions.NewCell, + toolbar_id=ApplicationToolbars.File, + ) @on_plugin_available(plugin=Plugins.Completions) def on_completions_available(self): @@ -824,6 +714,20 @@ def on_projects_teardown(self): projects.sig_project_loaded.disconnect(self._on_project_loaded) projects.sig_project_closed.disconnect(self._on_project_closed) + @on_plugin_available(plugin=Plugins.Application) + def on_application_available(self): + application = self.get_plugin(Plugins.Application) + widget = self.get_widget() + widget.sig_new_recent_file.connect(application.add_recent_file) + widget.sig_file_action_enabled.connect(self._enable_file_action) + + @on_plugin_teardown(plugin=Plugins.Application) + def on_application_teardown(self): + application = self.get_plugin(Plugins.Application) + widget = self.get_widget() + widget.sig_new_recent_file.disconnect(application.add_recent_file) + widget.sig_file_action_enabled.disconnect(self._enable_file_action) + def update_font(self): """Update font from Preferences""" font = self.get_font(SpyderFontType.Monospace) @@ -940,6 +844,12 @@ def load_edit_goto(self, filename, goto, word): filenames=filename, goto=goto, word=word, editorwindow=widget ) + def open_last_closed(self) -> None: + """ + Open the last closed tab again. + """ + return self.get_widget().open_last_closed() + def new(self, *args, **kwargs): """ Create a new file. @@ -1136,6 +1046,30 @@ def save(self, index=None, force=False): """ return self.get_widget().save(index=None, force=False) + def save_all(self) -> None: + """ + Save all files. + """ + return self.get_widget().save_all() + + def save_as(self) -> None: + """ + Save all files. + """ + self.get_widget().save_as() + + def save_copy_as(self) -> None: + """ + Save copy of file under a different name. + """ + self.get_widget().save_copy_as() + + def revert_file(self) -> None: + """ + Revert the currently edited file from disk. + """ + self.get_widget().revert() + def save_bookmark(self, slot_num): """ Save current line and position as bookmark. @@ -1164,6 +1098,10 @@ def get_current_filename(self): """Get current editor 'filename'.""" return self.get_widget().get_current_filename() + def current_file_is_temporary(self) -> bool: + """Return whether file in current editor is a temporary file.""" + return self.get_current_editor() == self.get_widget().TEMPFILE_PATH + def get_filenames(self): """ Get list with all open files. @@ -1304,3 +1242,12 @@ def _debugger_close_file(self, filename): if debugger is None: return True return debugger.can_close_file(filename) + + # ---- Methods related to Application plugin + def _enable_file_action(self, action_name: str, enabled: bool) -> None: + """ + Enable or disable file action for this plugin. + """ + application = self.get_plugin(Plugins.Application, error=False) + if application: + application.enable_file_action(action_name, enabled, self) diff --git a/spyder/plugins/editor/tests/conftest.py b/spyder/plugins/editor/tests/conftest.py index c7084585edb..c5d5eaf3503 100644 --- a/spyder/plugins/editor/tests/conftest.py +++ b/spyder/plugins/editor/tests/conftest.py @@ -13,6 +13,9 @@ from qtpy.QtCore import QCoreApplication, Qt +# ImportError: QtWebEngineWidgets must be imported before a QCoreApplication instance is created +from qtpy import QtWebEngineWidgets # Jitse + from spyder.api.plugins import Plugins from spyder.utils.qthelpers import qapplication diff --git a/spyder/plugins/editor/widgets/completion.py b/spyder/plugins/editor/widgets/completion.py index 2969467f093..09362e9a25b 100644 --- a/spyder/plugins/editor/widgets/completion.py +++ b/spyder/plugins/editor/widgets/completion.py @@ -401,7 +401,7 @@ def keyPressEvent(self, event): # Ask to save file if the user pressed the sequence for that. # Fixes spyder-ide/spyder#14806 save_shortcut = self.get_conf( - 'editor/save file', section='shortcuts') + 'main/save file', section='shortcuts') if key_sequence == save_shortcut: self.textedit.sig_save_requested.emit() diff --git a/spyder/plugins/editor/widgets/editorstack/editorstack.py b/spyder/plugins/editor/widgets/editorstack/editorstack.py index e6bdf1e9e45..9d3f3ffa68e 100644 --- a/spyder/plugins/editor/widgets/editorstack/editorstack.py +++ b/spyder/plugins/editor/widgets/editorstack/editorstack.py @@ -39,6 +39,7 @@ from spyder.config.utils import ( get_edit_filetypes, get_edit_filters, get_filter, is_kde_desktop ) +from spyder.plugins.application.api import ApplicationActions from spyder.plugins.editor.api.panel import Panel from spyder.plugins.editor.utils.autosave import AutosaveForStack from spyder.plugins.editor.utils.editor import get_file_language @@ -123,7 +124,7 @@ class EditorStack(QWidget, SpyderWidgetMixin): todo_results_changed = Signal() sig_update_code_analysis_actions = Signal() refresh_file_dependent_actions = Signal() - refresh_save_all_action = Signal() + refresh_save_actions = Signal() text_changed_at = Signal(str, int) current_file_changed = Signal(str, int, int, int) plugin_load = Signal((str,), ()) @@ -131,7 +132,6 @@ class EditorStack(QWidget, SpyderWidgetMixin): sig_split_vertically = Signal() sig_split_horizontally = Signal() sig_new_file = Signal((str,), ()) - sig_save_as = Signal() sig_prev_edit_pos = Signal() sig_prev_cursor = Signal() sig_next_cursor = Signal() @@ -142,8 +142,17 @@ class EditorStack(QWidget, SpyderWidgetMixin): sig_save_bookmark = Signal(int) sig_load_bookmark = Signal(int) sig_save_bookmarks = Signal(str, str) - sig_trigger_run_action = Signal(str) - sig_trigger_debugger_action = Signal(str) + sig_trigger_action = Signal(str, str) + """ + This signal is emitted to request that an action be triggered. + + Parameters + ---------- + id: str + The id of the action. + plugin: str + The plugin in which the action is registered. + """ sig_open_last_closed = Signal() """ @@ -305,10 +314,6 @@ def __init__(self, parent, actions, use_switcher=True): ) self._given_actions = actions self.outlineexplorer = None - self.new_action = None - self.open_action = None - self.save_action = None - self.revert_action = None self.tempfile_path = None self.title = _("Editor") self.todolist_enabled = True @@ -453,13 +458,6 @@ def register_shortcuts(self): ('Go to next file', self.tab_navigation_mru), ('Cycle to previous file', lambda: self.tabs.tab_navigate(-1)), ('Cycle to next file', lambda: self.tabs.tab_navigate(1)), - ('New file', self.sig_new_file[()]), - ('Open file', self.plugin_load[()]), - ('Open last closed', self.sig_open_last_closed), - ('Save file', self.save), - ('Save all', self.save_all), - ('Save As', self.sig_save_as), - ('Close all', self.close_all_files), ("Last edit location", self.sig_prev_edit_pos), ("Previous cursor position", self.sig_prev_cursor), ("Next cursor position", self.sig_next_cursor), @@ -467,8 +465,6 @@ def register_shortcuts(self): ("zoom in 2", self.zoom_in), ("zoom out", self.zoom_out), ("zoom reset", self.zoom_reset), - ("close file 1", self.close_file), - ("close file 2", self.close_file), ("go to next cell", self.advance_cell), ("go to previous cell", lambda: self.advance_cell(reverse=True)), ("Previous warning", self.sig_prev_warning), @@ -499,8 +495,9 @@ def register_shortcuts(self): self.register_shortcut_for_widget( name=action_id, triggered=functools.partial( - self.sig_trigger_run_action.emit, + self.sig_trigger_action.emit, action_id, + Plugins.Run ), ) @@ -516,12 +513,42 @@ def register_shortcuts(self): self.register_shortcut_for_widget( name=action_id, triggered=functools.partial( - self.sig_trigger_debugger_action.emit, + self.sig_trigger_action.emit, action_id, + Plugins.Debugger ), context=Plugins.Debugger, ) + # Register shortcuts for file actions defined in Applications plugin + for shortcut_name in [ + "New file", + "Open file", + "Open last closed", + "Save file", + "Save all", + "Save as", + "Close file 1", + "Close file 2", + "Close all" + ]: + # The shortcut has the same name as the action, except for + # "Close file" which has two shortcuts associated to it + if shortcut_name.startswith('Close file'): + action_id = 'Close file' + else: + action_id = shortcut_name + + self.register_shortcut_for_widget( + name=shortcut_name, + triggered=functools.partial( + self.sig_trigger_action.emit, + action_id, + Plugins.Application + ), + context='main', + ) + def update_switcher_actions(self, switcher_available): if self.use_switcher and switcher_available: self.switcher_action = self.get_action( @@ -755,13 +782,6 @@ def set_closable(self, state): """Parent widget must handle the closable state""" self.is_closable = state - def set_io_actions(self, new_action, open_action, - save_action, revert_action): - self.new_action = new_action - self.open_action = open_action - self.save_action = save_action - self.revert_action = revert_action - def set_find_widget(self, find_widget): self.find_widget = find_widget @@ -1293,9 +1313,16 @@ def __setup_menu(self): section=EditorStackMenuSections.CloseOrderSection ) else: - actions = (self.new_action, self.open_action) self.setFocus() # --> Editor.__get_focus_editortabwidget - for menu_action in actions: + new_action = self.get_action( + ApplicationActions.NewFile, + plugin=Plugins.Application + ) + open_action = self.get_action( + ApplicationActions.OpenFile, + plugin=Plugins.Application + ) + for menu_action in (new_action, open_action): self.menu.add_action(menu_action) for split_actions in self.__get_split_actions(): @@ -2448,8 +2475,7 @@ def modification_changed(self, state=None, index=None, editor_id=None): self.set_stack_title(index, state) # Toggle save/save all actions state - self.save_action.setEnabled(state) - self.refresh_save_all_action.emit() + self.refresh_save_actions.emit() # Refreshing eol mode eol_chars = finfo.editor.get_line_separator() diff --git a/spyder/plugins/editor/widgets/editorstack/tests/conftest.py b/spyder/plugins/editor/widgets/editorstack/tests/conftest.py index 3f8f3c29175..e9fb180acaf 100644 --- a/spyder/plugins/editor/widgets/editorstack/tests/conftest.py +++ b/spyder/plugins/editor/widgets/editorstack/tests/conftest.py @@ -36,7 +36,6 @@ def editor_factory(new_file=True, text=None): editorstack = EditorStack(None, [], False) editorstack.set_find_widget(FindReplace(editorstack)) - editorstack.set_io_actions(Mock(), Mock(), Mock(), Mock()) if new_file: if not text: text = ( diff --git a/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack.py b/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack.py index d142b006936..fd61d73e29c 100644 --- a/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack.py +++ b/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack.py @@ -38,7 +38,6 @@ def base_editor_bot(qtbot): editor_stack = EditorStack(None, [], False) editor_stack.set_find_widget(Mock()) - editor_stack.set_io_actions(Mock(), Mock(), Mock(), Mock()) return editor_stack diff --git a/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack_and_outline.py b/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack_and_outline.py index 273f4f6a118..4ea9056e2c0 100644 --- a/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack_and_outline.py +++ b/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack_and_outline.py @@ -70,7 +70,6 @@ def editorstack(qtbot, outlineexplorer): def _create_editorstack(files): editorstack = EditorStack(None, [], False) editorstack.set_find_widget(Mock()) - editorstack.set_io_actions(Mock(), Mock(), Mock(), Mock()) editorstack.analysis_timer = Mock() editorstack.save_dialog_on_tests = True editorstack.set_outlineexplorer(outlineexplorer) diff --git a/spyder/plugins/editor/widgets/editorstack/tests/test_save.py b/spyder/plugins/editor/widgets/editorstack/tests/test_save.py index 8315ee6a8e8..8e9ae2eb58c 100644 --- a/spyder/plugins/editor/widgets/editorstack/tests/test_save.py +++ b/spyder/plugins/editor/widgets/editorstack/tests/test_save.py @@ -20,6 +20,7 @@ from qtpy.QtCore import Qt # Local imports +from spyder.api.plugins import Plugins from spyder.config.base import running_in_ci from spyder.plugins.debugger.panels.debuggerpanel import DebuggerPanel from spyder.plugins.editor.widgets.editorstack import editorstack as editor @@ -37,7 +38,6 @@ def add_files(editorstack): editorstack.close_split_action.setEnabled(False) editorstack.set_find_widget(Mock()) - editorstack.set_io_actions(Mock(), Mock(), Mock(), Mock()) editorstack.new('foo.py', 'utf-8', 'a = 1\n' 'print(a)\n' '\n' @@ -53,7 +53,6 @@ def add_files(editorstack): def base_editor_bot(qtbot): editor_stack = EditorStack(None, [], False) editor_stack.set_find_widget(Mock()) - editor_stack.set_io_actions(Mock(), Mock(), Mock(), Mock()) return editor_stack, qtbot @@ -493,7 +492,8 @@ def test_save_as_change_file_type(editor_bot, mocker, tmpdir): def test_save_when_completions_are_visible(completions_editor, qtbot): """ Test that save works when the completion widget is visible and the user - press the save shortcut (Ctrl+S). + press the save shortcut (Ctrl+S). This only checks that the correct signal + is emitted; the Application plugin is needed to actually save the file. Regression test for issue spyder-ide/spyder#14806. """ @@ -513,14 +513,16 @@ def test_save_when_completions_are_visible(completions_editor, qtbot): assert "some" in [x['label'] for x in sig.args[0]] assert "something" in [x['label'] for x in sig.args[0]] - # Press keyboard shortcut corresponding to save - qtbot.keyPress( - completion, Qt.Key_S, modifier=Qt.ControlModifier, delay=300) - - # Assert file was saved - with open(file_path, 'r') as f: - saved_text = f.read() - assert saved_text == 'some = 0\nsomething = 1\nsome' + # Check that pressing Ctrl-S emits the signal for saving the file + with qtbot.waitSignal( + editorstack.sig_trigger_action, timeout=5_000 + ) as blocker: + # Press keyboard shortcut corresponding to save + qtbot.keyPress( + completion, Qt.Key_S, modifier=Qt.ControlModifier, delay=300) + + # Assert the signal had the correct arguments + assert blocker.args == ['Save file', Plugins.Application] code_editor.toggle_code_snippets(True) diff --git a/spyder/plugins/editor/widgets/editorstack/tests/test_shortcuts.py b/spyder/plugins/editor/widgets/editorstack/tests/test_shortcuts.py index 8a5df73b4bc..1ff2869900d 100644 --- a/spyder/plugins/editor/widgets/editorstack/tests/test_shortcuts.py +++ b/spyder/plugins/editor/widgets/editorstack/tests/test_shortcuts.py @@ -34,7 +34,6 @@ def editorstack(qtbot): """ editorstack = EditorStack(None, [], False) editorstack.set_find_widget(Mock()) - editorstack.set_io_actions(Mock(), Mock(), Mock(), Mock()) editorstack.close_split_action.setEnabled(False) editorstack.new('foo.py', 'utf-8', 'Line1\nLine2\nLine3\nLine4') diff --git a/spyder/plugins/editor/widgets/main_widget.py b/spyder/plugins/editor/widgets/main_widget.py index ca70aa67c25..8954d9286d3 100644 --- a/spyder/plugins/editor/widgets/main_widget.py +++ b/spyder/plugins/editor/widgets/main_widget.py @@ -24,21 +24,17 @@ import uuid # Third party imports -from qtpy.compat import from_qvariant, getopenfilenames, to_qvariant -from qtpy.QtCore import QByteArray, Qt, Signal, Slot, QDir +from qtpy.compat import from_qvariant +from qtpy.QtCore import QByteArray, Qt, Signal, Slot from qtpy.QtGui import QTextCursor from qtpy.QtPrintSupport import QAbstractPrintDialog, QPrintDialog, QPrinter from qtpy.QtWidgets import (QAction, QActionGroup, QApplication, QDialog, - QFileDialog, QInputDialog, QSplitter, - QVBoxLayout, QWidget) + QSplitter, QVBoxLayout, QWidget) # Local imports from spyder.api.config.decorators import on_conf_change -from spyder.api.plugins import Plugins from spyder.api.widgets.main_widget import PluginMainWidget -from spyder.config.base import _, get_conf_path, running_under_pytest -from spyder.config.utils import (get_edit_filetypes, get_edit_filters, - get_filter) +from spyder.config.base import _, get_conf_path from spyder.plugins.editor.api.panel import Panel from spyder.py3compat import qbytearray_to_str, to_text_string from spyder.utils import encoding, programs, sourcecode @@ -46,6 +42,7 @@ from spyder.utils.qthelpers import create_action from spyder.utils.misc import getcwd_or_home from spyder.widgets.findreplace import FindReplace +from spyder.plugins.application.api import ApplicationActions from spyder.plugins.editor.api.run import ( EditorRunConfiguration, FileRun, SelectionRun, CellRun, SelectionContextModificator, ExtraAction) @@ -73,20 +70,8 @@ class EditorWidgetActions: # File operations - NewFile = "New file" - OpenLastClosed = "Open last closed" - OpenFile = "Open file" - RevertFileFromDisk = "Revert file from disk" - SaveFile = "Save file" - SaveAll = "Save all" - SaveAs = "Save As" - SaveCopyAs = "save_copy_as_action" PrintPreview = "print_preview_action" Print = "print_action" - CloseFile = "Close current file" - CloseAll = "Close all" - MaxRecentFiles = "max_recent_files_action" - ClearRecentFiles = "clear_recent_files_action" # Navigation GoToNextFile = "Go to next file" @@ -151,7 +136,6 @@ class EditorWidgetMenus: TodoList = "todo_list_menu" WarningErrorList = "warning_error_list_menu" EOL = "eol_menu" - RecentFiles = "recent_files_menu" class EditorMainWidget(PluginMainWidget): @@ -271,6 +255,29 @@ class EditorMainWidget(PluginMainWidget): This signal will request to change the focus to the plugin. """ + sig_new_recent_file = Signal(str) + """ + This signal is emitted when a file is opened or got a new name. + + Parameters + ---------- + filename: str + The name of the opened file. If the file is renamed, then this should + be the new name. + """ + + sig_file_action_enabled = Signal(str, bool) + """ + This signal is emitted to enable or disable an file action. + + Parameters + ---------- + action_name: str + Name of the file action to be enabled or disabled. + enabled: bool + True if the action should be enabled, False if it should disabled. + """ + def __init__(self, name, plugin, parent, ignore_last_opened_files=False): super().__init__(name, plugin, parent) @@ -370,72 +377,6 @@ def get_focus_widget(self): def setup(self): # ---- File operations ---- - self.new_action = self.create_action( - EditorWidgetActions.NewFile, - text=_("&New file..."), - icon=self.create_icon('filenew'), - tip=_("New file"), - triggered=self.new, - context=Qt.WidgetShortcut, - register_shortcut=True - ) - self.open_last_closed_action = self.create_action( - EditorWidgetActions.OpenLastClosed, - text=_("O&pen last closed"), - tip=_("Open last closed"), - triggered=self.open_last_closed, - register_shortcut=True - ) - self.open_action = self.create_action( - EditorWidgetActions.OpenFile, - text=_("&Open..."), - icon=self.create_icon('fileopen'), - tip=_("Open file"), - triggered=self.load, - context=Qt.WidgetShortcut, - register_shortcut=True - ) - self.revert_action = self.create_action( - EditorWidgetActions.RevertFileFromDisk, - text=_("&Revert"), - icon=self.create_icon('revert'), - tip=_("Revert file from disk"), - triggered=self.revert - ) - self.save_action = self.create_action( - EditorWidgetActions.SaveFile, - text=_("&Save"), - icon=self.create_icon('filesave'), - tip=_("Save file"), - triggered=self.save, - context=Qt.WidgetShortcut, - register_shortcut=True - ) - self.save_all_action = self.create_action( - EditorWidgetActions.SaveAll, - text=_("Sav&e all"), - icon=self.create_icon('save_all'), - tip=_("Save all files"), - triggered=self.save_all, - context=Qt.WidgetShortcut, - register_shortcut=True - ) - self.save_as_action = self.create_action( - EditorWidgetActions.SaveAs, - text=_("Save &as..."), - icon=self.create_icon('filesaveas'), - tip=_("Save current file as..."), - triggered=self.save_as, - context=Qt.WidgetShortcut, - register_shortcut=True - ) - self.save_copy_as_action = self.create_action( - EditorWidgetActions.SaveCopyAs, - text=_("Save copy as..."), - icon=self.create_icon('filesaveas'), - tip=_("Save copy of current file as..."), - triggered=self.save_copy_as - ) self.print_preview_action = self.create_action( EditorWidgetActions.PrintPreview, text=_("Print preview..."), @@ -449,37 +390,6 @@ def setup(self): tip=_("Print current file..."), triggered=self.print_file ) - self.close_file_action = self.create_action( - EditorWidgetActions.CloseFile, - text=_("&Close"), - icon=self.create_icon('fileclose'), - tip=_("Close current file"), - triggered=self.close_file - ) - self.close_all_action = self.create_action( - EditorWidgetActions.CloseAll, - text=_("C&lose all"), - icon=ima.icon('filecloseall'), - tip=_("Close all opened files"), - triggered=self.close_all_files, - context=Qt.WidgetShortcut, - register_shortcut=True - ) - self.recent_file_menu = self.create_menu( - EditorWidgetMenus.RecentFiles, - title=_("Open &recent") - ) - self.recent_file_menu.aboutToShow.connect(self.update_recent_file_menu) - self.max_recent_action = self.create_action( - EditorWidgetActions.MaxRecentFiles, - text=_("Maximum number of recent files..."), - triggered=self.change_max_recent_files - ) - self.clear_recent_action = self.create_action( - EditorWidgetActions.ClearRecentFiles, - text=_("Clear this list"), tip=_("Clear recent files list"), - triggered=self.clear_recent_files - ) self.workdir_action = self.create_action( EditorWidgetActions.SetWorkingDirectory, text=_("Set console working directory"), @@ -885,18 +795,11 @@ def setup(self): self.file_dependent_actions = ( self.pythonfile_dependent_actions + [ - self.save_action, - self.save_as_action, - self.save_copy_as_action, self.print_preview_action, self.print_action, - self.save_all_action, self.gotoline_action, self.workdir_action, - self.close_file_action, - self.close_all_action, self.toggle_comment_action, - self.revert_action, self.indent_action, self.unindent_action ] @@ -939,17 +842,12 @@ def setup(self): QByteArray().fromHex(str(state).encode('utf-8')) ) - self.recent_files = self.get_conf('recent_files', []) self.untitled_num = 0 # Parameters of last file execution: self.__last_ic_exec = None # internal console self.__last_ec_exec = None # external console - # File types and filters used by the Open dialog - self.edit_filetypes = None - self.edit_filters = None - self.__ignore_cursor_history = False current_editor = self.get_current_editor() if current_editor is not None: @@ -1014,7 +912,6 @@ def on_close(self): ) for window in self.editorwindows: window.close() - self.set_conf('recent_files', self.recent_files) self.autosave.stop_autosave_timer() # ---- Private API @@ -1206,7 +1103,7 @@ def refresh(self): """Refresh editor widgets""" editorstack = self.get_current_editorstack() editorstack.refresh() - self.refresh_save_all_action() + self.refresh_save_actions() # ---- Update menus # ------------------------------------------------------------------------- @@ -1492,8 +1389,7 @@ def register_editorstack(self, editorstack): self.vcs_status.update_vcs_state) editorstack.update_switcher_actions(self.switcher_manager is not None) - editorstack.set_io_actions(self.new_action, self.open_action, - self.save_action, self.revert_action) + editorstack.set_tempfile_path(self.TEMPFILE_PATH) # ********************************************************************* @@ -1610,8 +1506,8 @@ def register_editorstack(self, editorstack): self.update_todo_actions) editorstack.refresh_file_dependent_actions.connect( self.refresh_file_dependent_actions) - editorstack.refresh_save_all_action.connect( - self.refresh_save_all_action + editorstack.refresh_save_actions.connect( + self.refresh_save_actions ) editorstack.sig_refresh_eol_chars.connect(self.refresh_eol_chars) editorstack.sig_refresh_formatting.connect(self.refresh_formatting) @@ -1620,7 +1516,6 @@ def register_editorstack(self, editorstack): editorstack.plugin_load.connect(self.load) editorstack.plugin_load[()].connect(self.load) editorstack.edit_goto.connect(self.load) - editorstack.sig_save_as.connect(self.save_as) editorstack.sig_prev_edit_pos.connect(self.go_to_last_edit_location) editorstack.sig_prev_cursor.connect( self.go_to_previous_cursor_position @@ -1635,10 +1530,7 @@ def register_editorstack(self, editorstack): editorstack.sig_codeeditor_created.connect(self.sig_codeeditor_created) editorstack.sig_codeeditor_changed.connect(self.sig_codeeditor_changed) editorstack.sig_codeeditor_deleted.connect(self.sig_codeeditor_deleted) - editorstack.sig_trigger_run_action.connect(self.trigger_run_action) - editorstack.sig_trigger_debugger_action.connect( - self.trigger_debugger_action - ) + editorstack.sig_trigger_action.connect(self.trigger_action) # Register editorstack's autosave component with plugin's autosave # component @@ -1799,15 +1691,32 @@ def refresh_file_dependent_actions(self): for action in self.file_dependent_actions: action.setEnabled(enable) - def refresh_save_all_action(self): - """Enable 'Save All' if there are files to be saved""" + def refresh_save_actions(self): + """ + Enable or disable 'Save' and 'Save All' actions. + + The 'Save' action is enabled if the current document is either modified + or newly created. The 'Save all' action is enabled if any document is + either modified or newly created. + """ editorstack = self.get_current_editorstack() - if editorstack: - state = any( + if not editorstack: + return + + finfo = editorstack.get_current_finfo() + if finfo: + enabled = ( finfo.editor.document().isModified() or finfo.newly_created - for finfo in editorstack.data ) - self.save_all_action.setEnabled(state) + else: + enabled = False + self.sig_file_action_enabled.emit(ApplicationActions.SaveFile, enabled) + + enabled = any( + finfo.editor.document().isModified() or finfo.newly_created + for finfo in editorstack.data + ) + self.sig_file_action_enabled.emit(ApplicationActions.SaveAll, enabled) def update_warning_menu(self): """Update warning list menu""" @@ -1966,16 +1875,6 @@ def __set_workdir(self): directory = osp.dirname(osp.abspath(fname)) self.sig_dir_opened.emit(directory) - def __add_recent_file(self, fname): - """Add to recent file list""" - if fname is None: - return - if fname in self.recent_files: - self.recent_files.remove(fname) - self.recent_files.insert(0, fname) - if len(self.recent_files) > self.get_conf('max_recent_files'): - self.recent_files.pop(-1) - def _clone_file_everywhere(self, finfo): """ Clone file (*src_editor* widget) in all editorstacks. @@ -2099,59 +1998,6 @@ def edit_template(self): """Edit new file template""" self.load(self.TEMPLATE_PATH) - def update_recent_file_menu(self): - """Update recent file menu""" - recent_files = [] - for fname in self.recent_files: - if osp.isfile(fname): - recent_files.append(fname) - - self.recent_file_menu.clear_actions() - if recent_files: - for fname in recent_files: - action = create_action( - self, fname, - icon=ima.get_icon_by_extension_or_type( - fname, scale_factor=1.0)) - action.triggered[bool].connect(self.load) - action.setData(to_qvariant(fname)) - - self.recent_file_menu.add_action( - action, - section="recent_files_section", - omit_id=True, - before_section="recent_files_actions_section" - ) - - self.clear_recent_action.setEnabled(len(recent_files) > 0) - for menu_action in (self.max_recent_action, self.clear_recent_action): - self.recent_file_menu.add_action( - menu_action, section="recent_files_actions_section" - ) - - self.recent_file_menu.render() - - @Slot() - def clear_recent_files(self): - """Clear recent files list""" - self.recent_files = [] - - @Slot() - def change_max_recent_files(self): - """Change max recent files entries""" - editorstack = self.get_current_editorstack() - mrf, valid = QInputDialog.getInt( - editorstack, - _('Editor'), - _('Maximum number of recent files'), - self.get_conf('max_recent_files'), - 1, - 35 - ) - - if valid: - self.set_conf('max_recent_files', mrf) - @Slot() @Slot(str) @Slot(str, int, str) @@ -2183,77 +2029,12 @@ def load(self, filenames=None, goto=None, word='', except (AttributeError, RuntimeError): pass - editor0 = self.get_current_editor() - if editor0 is not None: - filename0 = self.get_current_filename() - else: - filename0 = None - if not filenames: # Recent files action action = self.sender() if isinstance(action, QAction): filenames = from_qvariant(action.data(), to_text_string) - if not filenames: - basedir = getcwd_or_home() - if self.edit_filetypes is None: - self.edit_filetypes = get_edit_filetypes() - if self.edit_filters is None: - self.edit_filters = get_edit_filters() - - c_fname = self.get_current_filename() - if c_fname is not None and c_fname != self.TEMPFILE_PATH: - basedir = osp.dirname(c_fname) - - self.sig_redirect_stdio_requested.emit(False) - parent_widget = self.get_current_editorstack() - if filename0 is not None: - selectedfilter = get_filter(self.edit_filetypes, - osp.splitext(filename0)[1]) - else: - selectedfilter = '' - - if not running_under_pytest(): - # See: spyder-ide/spyder#3291 - if sys.platform == 'darwin': - dialog = QFileDialog( - parent=parent_widget, - caption=_("Open file"), - directory=basedir, - ) - dialog.setNameFilters(self.edit_filters.split(';;')) - dialog.setOption(QFileDialog.HideNameFilterDetails, True) - dialog.setFilter(QDir.AllDirs | QDir.Files | QDir.Drives - | QDir.Hidden) - dialog.setFileMode(QFileDialog.ExistingFiles) - - if dialog.exec_(): - filenames = dialog.selectedFiles() - else: - filenames, _sf = getopenfilenames( - parent_widget, - _("Open file"), - basedir, - self.edit_filters, - selectedfilter=selectedfilter, - options=QFileDialog.HideNameFilterDetails, - ) - else: - # Use a Qt (i.e. scriptable) dialog for pytest - dialog = QFileDialog(parent_widget, _("Open file"), - options=QFileDialog.DontUseNativeDialog) - if dialog.exec_(): - filenames = dialog.selectedFiles() - - self.sig_redirect_stdio_requested.emit(True) - - if filenames: - filenames = [osp.normpath(fname) for fname in filenames] - else: - self.__ignore_cursor_history = cursor_history_state - return - focus_widget = QApplication.focusWidget() if self.editorwindows and not self.dockwidget.isVisible(): # We override the editorwindow variable to force a focus on @@ -2331,7 +2112,7 @@ def _convert(fname): slots = self.get_conf('bookmarks', default={}) current_editor.set_bookmarks(load_bookmarks(filename, slots)) current_es.analyze_script() - self.__add_recent_file(filename) + self.sig_new_recent_file.emit(filename) if goto is not None: # 'word' is assumed to be None as well current_editor.go_to_line(goto[index], word=word, @@ -2455,7 +2236,7 @@ def save_as(self): editorstack = self.get_current_editorstack() if editorstack.save_as(): fname = editorstack.get_current_filename() - self.__add_recent_file(fname) + self.sig_new_recent_file.emit(fname) @Slot() def save_copy_as(self): @@ -3163,14 +2944,9 @@ def focus_run_configuration(self, uuid: str): if current_fname != fname: editorstack.set_current_filename(fname) - def trigger_run_action(self, action_id): - """Trigger a run action according to its id.""" - action = self.get_action(action_id, plugin=Plugins.Run) - action.trigger() - - def trigger_debugger_action(self, action_id): - """Trigger a run action according to its id.""" - action = self.get_action(action_id, plugin=Plugins.Debugger) + def trigger_action(self, action_id, plugin): + """Trigger an action according to its id and plugin.""" + action = self.get_action(action_id, plugin=plugin) action.trigger() # ---- Code bookmarks diff --git a/spyder/plugins/editor/widgets/tests/test_editorsplitter.py b/spyder/plugins/editor/widgets/tests/test_editorsplitter.py index 82e33d35a96..a6a24c66879 100644 --- a/spyder/plugins/editor/widgets/tests/test_editorsplitter.py +++ b/spyder/plugins/editor/widgets/tests/test_editorsplitter.py @@ -31,7 +31,6 @@ def editor_stack(): editor_stack = EditorStack(None, [], False) editor_stack.set_find_widget(Mock()) - editor_stack.set_io_actions(Mock(), Mock(), Mock(), Mock()) return editor_stack @@ -76,7 +75,6 @@ def unregister_editorstack(editorstack): def clone(editorstack, template=None): editorstack.set_find_widget(Mock()) - editorstack.set_io_actions(Mock(), Mock(), Mock(), Mock()) # Emulate "cloning" editorstack.new('test.py', 'utf-8', text) @@ -90,7 +88,6 @@ def clone(editorstack, template=None): ) editorsplitter.editorstack.set_find_widget(Mock()) - editorsplitter.editorstack.set_io_actions(Mock(), Mock(), Mock(), Mock()) editorsplitter.editorstack.new('test.py', 'utf-8', text) mock_main_widget.clone_editorstack.side_effect = partial( @@ -117,7 +114,6 @@ def editor_splitter_layout_bot(editor_splitter_bot): def clone(editorstack): editorstack.close_split_action.setEnabled(False) editorstack.set_find_widget(Mock()) - editorstack.set_io_actions(Mock(), Mock(), Mock(), Mock()) editorstack.new('foo.py', 'utf-8', 'a = 1\nprint(a)\n\nx = 2') editorstack.new('layout_test.py', 'utf-8', 'print(spam)') with open(__file__) as f: diff --git a/spyder/plugins/editor/widgets/window.py b/spyder/plugins/editor/widgets/window.py index 15a970797ad..bf915c5fcc1 100644 --- a/spyder/plugins/editor/widgets/window.py +++ b/spyder/plugins/editor/widgets/window.py @@ -618,8 +618,6 @@ def register_editorstack(self, editorstack): oe_btn = create_toolbutton(self) editorstack.add_corner_widgets_to_tabbar([5, oe_btn]) - action = QAction(self) - editorstack.set_io_actions(action, action, action, action) font = QFont("Courier New") font.setPointSize(10) editorstack.set_default_font(font, color_scheme='Spyder') diff --git a/spyder/plugins/explorer/plugin.py b/spyder/plugins/explorer/plugin.py index 6dfe9de5db1..0bee625a0dc 100644 --- a/spyder/plugins/explorer/plugin.py +++ b/spyder/plugins/explorer/plugin.py @@ -31,7 +31,7 @@ class Explorer(SpyderDockablePlugin): NAME = 'explorer' REQUIRES = [Plugins.Preferences] - OPTIONAL = [Plugins.IPythonConsole, Plugins.Editor] + OPTIONAL = [Plugins.IPythonConsole, Plugins.Editor, Plugins.Application] TABIFY = Plugins.VariableExplorer WIDGET_CLASS = ExplorerWidget CONF_SECTION = NAME @@ -194,7 +194,6 @@ def on_editor_available(self): self.sig_folder_removed.connect(editor.removed_tree) self.sig_folder_renamed.connect(editor.renamed_tree) self.sig_module_created.connect(editor.new) - self.sig_open_file_requested.connect(editor.load) @on_plugin_available(plugin=Plugins.Preferences) def on_preferences_available(self): @@ -217,6 +216,11 @@ def on_ipython_console_available(self): ) ) + @on_plugin_available(plugin=Plugins.Application) + def on_application_available(self): + application = self.get_plugin(Plugins.Application) + self.sig_open_file_requested.connect(application.open_file_in_plugin) + @on_plugin_teardown(plugin=Plugins.Editor) def on_editor_teardown(self): editor = self.get_plugin(Plugins.Editor) @@ -228,7 +232,6 @@ def on_editor_teardown(self): self.sig_folder_removed.disconnect(editor.removed_tree) self.sig_folder_renamed.disconnect(editor.renamed_tree) self.sig_module_created.disconnect(editor.new) - self.sig_open_file_requested.disconnect(editor.load) @on_plugin_teardown(plugin=Plugins.Preferences) def on_preferences_teardown(self): @@ -242,6 +245,13 @@ def on_ipython_console_teardown(self): ipyconsole.create_client_from_path) self.sig_run_requested.disconnect() + @on_plugin_teardown(plugin=Plugins.Application) + def on_application_teardown(self): + application = self.get_plugin(Plugins.Application) + self.sig_open_file_requested.disconnect( + application.open_file_in_plugin + ) + # ---- Public API # ------------------------------------------------------------------------ def chdir(self, directory, emit=True): diff --git a/spyder/plugins/projects/plugin.py b/spyder/plugins/projects/plugin.py index 7748d6adbe0..869f2e23754 100644 --- a/spyder/plugins/projects/plugin.py +++ b/spyder/plugins/projects/plugin.py @@ -43,7 +43,7 @@ class Projects(SpyderDockablePlugin): CONF_FILE = False REQUIRES = [] OPTIONAL = [Plugins.Completions, Plugins.IPythonConsole, Plugins.Editor, - Plugins.MainMenu, Plugins.Switcher] + Plugins.MainMenu, Plugins.Switcher, Plugins.Application] WIDGET_CLASS = ProjectExplorerWidget # Signals @@ -116,7 +116,6 @@ def on_initialize(self): lambda plugin, check: self._show_main_widget()) if self.main: - widget.sig_open_file_requested.connect(self.main.open_file) widget.sig_project_loaded.connect( lambda v: self.main.set_window_title()) widget.sig_project_closed.connect( @@ -132,7 +131,6 @@ def on_editor_available(self): widget = self.get_widget() treewidget = widget.treewidget - treewidget.sig_open_file_requested.connect(editor.load) treewidget.sig_removed.connect(editor.removed) treewidget.sig_tree_removed.connect(editor.removed_tree) treewidget.sig_renamed.connect(editor.renamed) @@ -145,8 +143,6 @@ def on_editor_available(self): widget.sig_project_closed[bool].connect(self._setup_editor_files) widget.sig_project_loaded.connect(self._set_path_in_editor) widget.sig_project_closed.connect(self._unset_path_in_editor) - # To handle switcher open request - widget.sig_open_file_requested.connect(editor.load) @on_plugin_available(plugin=Plugins.Completions) def on_completions_available(self): @@ -215,13 +211,18 @@ def on_switcher_available(self): self._switcher.sig_search_text_available.connect( self._handle_switcher_search) + @on_plugin_available(plugin=Plugins.Application) + def on_application_available(self): + application = self.get_plugin(Plugins.Application) + widget = self.get_widget() + widget.sig_open_file_requested.connect(application.open_file_in_plugin) + @on_plugin_teardown(plugin=Plugins.Editor) def on_editor_teardown(self): editor = self.get_plugin(Plugins.Editor) widget = self.get_widget() treewidget = widget.treewidget - treewidget.sig_open_file_requested.disconnect(editor.load) treewidget.sig_removed.disconnect(editor.removed) treewidget.sig_tree_removed.disconnect(editor.removed_tree) treewidget.sig_renamed.disconnect(editor.renamed) @@ -234,8 +235,6 @@ def on_editor_teardown(self): widget.sig_project_closed[bool].disconnect(self._setup_editor_files) widget.sig_project_loaded.disconnect(self._set_path_in_editor) widget.sig_project_closed.disconnect(self._unset_path_in_editor) - # To handle switcher open request - widget.sig_open_file_requested.disconnect(editor.load) @on_plugin_teardown(plugin=Plugins.Completions) def on_completions_teardown(self): @@ -280,6 +279,14 @@ def on_switcher_teardown(self): self._handle_switcher_search) self._switcher = None + @on_plugin_teardown(plugin=Plugins.Application) + def on_application_teardown(self): + application = self.get_plugin(Plugins.Application) + widget = self.get_widget() + widget.sig_open_file_requested.disconnect( + application.open_file_in_plugin + ) + def on_close(self, cancelable=False): """Perform actions before parent main window is closed""" self.get_widget().save_config() diff --git a/spyder/plugins/variableexplorer/plugin.py b/spyder/plugins/variableexplorer/plugin.py index da87da3a5f5..777e9a93445 100644 --- a/spyder/plugins/variableexplorer/plugin.py +++ b/spyder/plugins/variableexplorer/plugin.py @@ -8,6 +8,9 @@ Variable Explorer Plugin. """ +# Third-party imports +from spyder_kernels.utils.iofuncs import iofunctions + # Local imports from spyder.api.plugins import Plugins, SpyderDockablePlugin from spyder.api.plugin_registration.decorators import ( @@ -34,6 +37,10 @@ class VariableExplorer(SpyderDockablePlugin, ShellConnectPluginMixin): CONF_WIDGET_CLASS = VariableExplorerConfigPage DISABLE_ACTIONS_WHEN_HIDDEN = False + # Open binary data files in this plugin + FILE_EXTENSIONS = [ext for ext in iofunctions.load_funcs + if ext not in ['.csv', '.txt', '.json']] + # ---- SpyderDockablePlugin API # ------------------------------------------------------------------------ @staticmethod @@ -72,6 +79,12 @@ def on_preferences_teardown(self): preferences = self.get_plugin(Plugins.Preferences) preferences.deregister_plugin_preferences(self) + def open_file(self, filename: str): + """ + Load data file in variable explorer. + """ + self.get_widget().import_data([filename]) + # ---- Private API # ------------------------------------------------------------------------- def _open_preferences(self): diff --git a/spyder/widgets/tabs.py b/spyder/widgets/tabs.py index a7f32dfb8dc..4f8854ff4e2 100644 --- a/spyder/widgets/tabs.py +++ b/spyder/widgets/tabs.py @@ -686,12 +686,12 @@ def register_shortcuts(self, parent): ( "close file 1", lambda: self.sig_close_tab.emit(self.currentIndex()), - "editor", + "main", ), ( "close file 2", lambda: self.sig_close_tab.emit(self.currentIndex()), - "editor", + "main", ), )