Repository: spyder-ide/spyder

Issue:
Save after moving editor tabs with split panes changes wrong files
I couldn't find if this was an open issue, so I apologize if it's a duplicate.

If the editor is open with split panes and the tabs are manually moved in the panes, after editing and saving a tab (file) that is in a different order than the split, the other pane then is updated with the information in the same order.  Following the steps to reproduce will make this more clear than a description.

**What steps will reproduce the problem?**

0. Note: do this on a test file, not a real one!
1. Open three files in the editor.
2. Split vertically or horizontally.
3. Move the order of the tabs on the top pane, so instead of 1-2-3, it's now 3-2-1 (or whatever).  The split pane stays in order 1-2-3.
4.  Edit the file on tab 3 and then click save.
5.  Click on the split pane.
6.  The name of the file on tab 3 changes to match pane 1, but the file doesn't change.  So, the split panel now has 2 tabs open that point to the same file name.
7.  Edit the file in the split panel where the tab name changed and then save.  Notice that all the open files now match that data -- even the ones that originally had different names.

**What is the expected output? What do you see instead?**
I expect the file names on the tabs to not change and the text on each file to sync between tabs based on filename, not based on tab position.

**Please provide any additional information below**


## Versions and main components

* Spyder Version: 3.x and 4.x
* Python Version: 3.6.3
* Qt Version: 5.6.2
* PyQt Version:  PyQt5 5.6
* Operating system: Ubuntu


## Dependencies

Please go to the menu entry `Help > Optional Dependencies` (or
`Help > Dependencies`), press the button `Copy to clipboard`
and paste the contents below:



Discussion:
I can reproduce this issue, This is related to #5271 

I think this is caused for the issues that I mentioned in #5315
Thanks!  This is exactly the same as #5271.  Any of the editor stack methods that use `index` instead of `filename` would be affected the same way.
> This is exactly the same as #5271

But #5271 is already fixed. What should we do here then?
>Thanks! This is exactly the same as #5271

It's similar, #5271 was related to closing files, this is related to saving files

>Any of the editor stack methods that use index instead of filename would be affected the same way.

Yes, probably, but if we are lucky the only one is the save method
@csabella, do you want to work on this one?
@ccordoba12  Sure, I can work on this.
Great, thanks!
If it is of any help, I experienced this myself on Win 10 Ent x64 just yesterday and was going to report, but couldn't figure out exactly how it was triggered to reproduce it. However, this indeed appears to be the exact issue that happened, as moving around a tab reproduces it just now. Also, I note that if Spyder is then closed and reopened (without saving/overwriting the files), the file that was moved is not loaded in the session at all, and needs to be manually re opened.

Resolving patch:
diff --git a/spyder/plugins/editor.py b/spyder/plugins/editor.py
--- a/spyder/plugins/editor.py
+++ b/spyder/plugins/editor.py
@@ -1364,20 +1364,22 @@ def close_file_in_all_editorstacks(self, editorstack_id_str, filename):
                 editorstack.close_file(index, force=True)
                 editorstack.blockSignals(False)
 
-    @Slot(str, int, str)
-    def file_saved_in_editorstack(self, editorstack_id_str, index, filename):
+    @Slot(str, str, str)
+    def file_saved_in_editorstack(self, editorstack_id_str,
+                                  original_filename, filename):
         """A file was saved in editorstack, this notifies others"""
         for editorstack in self.editorstacks:
             if str(id(editorstack)) != editorstack_id_str:
-                editorstack.file_saved_in_other_editorstack(index, filename)
+                editorstack.file_saved_in_other_editorstack(original_filename,
+                                                            filename)
 
-    @Slot(str, int, str)
+    @Slot(str, str, str)
     def file_renamed_in_data_in_editorstack(self, editorstack_id_str,
-                                            index, filename):
+                                            original_filename, filename):
         """A file was renamed in data in editorstack, this notifies others"""
         for editorstack in self.editorstacks:
             if str(id(editorstack)) != editorstack_id_str:
-                editorstack.rename_in_data(index, filename)
+                editorstack.rename_in_data(original_filename, filename)
 
     def set_editorstack_for_introspection(self):
         """
@@ -1553,9 +1555,10 @@ def analysis_results_changed(self):
         results = editorstack.get_analysis_results()
         index = editorstack.get_stack_index()
         if index != -1:
+            filename = editorstack.data[index].filename
             for other_editorstack in self.editorstacks:
                 if other_editorstack is not editorstack:
-                    other_editorstack.set_analysis_results(index, results)
+                    other_editorstack.set_analysis_results(filename, results)
         self.update_code_analysis_actions()
             
     def update_todo_menu(self):
@@ -1584,9 +1587,10 @@ def todo_results_changed(self):
         results = editorstack.get_todo_results()
         index = editorstack.get_stack_index()
         if index != -1:
+            filename = editorstack.data[index].filename
             for other_editorstack in self.editorstacks:
                 if other_editorstack is not editorstack:
-                    other_editorstack.set_todo_results(index, results)
+                    other_editorstack.set_todo_results(filename, results)
         self.update_todo_actions()
             
     def refresh_eol_chars(self, os_name):
@@ -2072,7 +2076,7 @@ def renamed(self, source, dest):
         index = self.editorstacks[0].has_filename(filename)
         if index is not None:
             for editorstack in self.editorstacks:
-                editorstack.rename_in_data(index,
+                editorstack.rename_in_data(filename,
                                            new_filename=to_text_string(dest))
         
     
diff --git a/spyder/widgets/editor.py b/spyder/widgets/editor.py
--- a/spyder/widgets/editor.py
+++ b/spyder/widgets/editor.py
@@ -440,8 +440,8 @@ class EditorStack(QWidget):
     zoom_out = Signal()
     zoom_reset = Signal()
     sig_close_file = Signal(str, str)
-    file_saved = Signal(str, int, str)
-    file_renamed_in_data = Signal(str, int, str)
+    file_saved = Signal(str, str, str)
+    file_renamed_in_data = Signal(str, str, str)
     create_new_window = Signal()
     opened_files_list_changed = Signal()
     analysis_results_changed = Signal()
@@ -1231,7 +1231,10 @@ def __repopulate_stack(self):
             self.tabs.setTabToolTip(index, tab_tip)
         self.tabs.blockSignals(False)
 
-    def rename_in_data(self, index, new_filename):
+    def rename_in_data(self, original_filename, new_filename):
+        index = self.has_filename(original_filename)
+        if index is None:
+            return
         finfo = self.data[index]
         if osp.splitext(finfo.filename)[1] != osp.splitext(new_filename)[1]:
             # File type has changed!
@@ -1491,7 +1494,21 @@ def set_last_closed_files(self, fnames):
 
     #------ Save
     def save_if_changed(self, cancelable=False, index=None):
-        """Ask user to save file if modified"""
+        """Ask user to save file if modified.
+
+        Args:
+            cancelable: Show Cancel button.
+            index: File to check for modification.
+
+        Returns:
+            False when save() fails or is cancelled.
+            True when save() is successful, there are no modifications,
+                or user selects No or NoToAll.
+
+        This function controls the message box prompt for saving
+        changed files.  The actual save is performed in save() for
+        each index processed.
+        """
         if index is None:
             indexes = list(range(self.get_stack_count()))
         else:
@@ -1513,7 +1530,7 @@ def save_if_changed(self, cancelable=False, index=None):
             self.set_stack_index(index)
             finfo = self.data[index]
             if finfo.filename == self.tempfile_path or yes_all:
-                if not self.save():
+                if not self.save(index):
                     return False
             elif finfo.editor.document().isModified():
 
@@ -1528,10 +1545,10 @@ def save_if_changed(self, cancelable=False, index=None):
 
                 answer = self.msgbox.exec_()
                 if answer == QMessageBox.Yes:
-                    if not self.save():
+                    if not self.save(index):
                         return False
                 elif answer == QMessageBox.YesAll:
-                    if not self.save():
+                    if not self.save(index):
                         return False
                     yes_all = True
                 elif answer == QMessageBox.NoAll:
@@ -1541,7 +1558,22 @@ def save_if_changed(self, cancelable=False, index=None):
         return True
 
     def save(self, index=None, force=False):
-        """Save file"""
+        """Write text of editor to a file.
+
+        Args:
+            index: self.data index to save.  If None, defaults to
+                currentIndex().
+            force: Force save regardless of file state.
+
+        Returns:
+            True upon successful save or when file doesn't need to be saved.
+            False if save failed.
+
+        If the text isn't modified and it's not newly created, then the save
+        is aborted.  If the file hasn't been saved before, then save_as()
+        is invoked.  Otherwise, the file is written using the file name
+        currently in self.data.  This function doesn't change the file name.
+        """
         if index is None:
             # Save the currently edited file
             if not self.get_stack_count():
@@ -1569,7 +1601,10 @@ def save(self, index=None, force=False):
             # depend on the platform: long for 64bit, int for 32bit. Replacing
             # by long all the time is not working on some 32bit platforms
             # (see Issue 1094, Issue 1098)
-            self.file_saved.emit(str(id(self)), index, finfo.filename)
+            # The filename is passed instead of an index in case the tabs
+            # have been rearranged (see issue 5703).
+            self.file_saved.emit(str(id(self)),
+                                 finfo.filename, finfo.filename)
 
             finfo.editor.document().setModified(False)
             self.modification_changed(index=index)
@@ -1597,20 +1632,35 @@ def save(self, index=None, force=False):
             self.msgbox.exec_()
             return False
 
-    def file_saved_in_other_editorstack(self, index, filename):
+    def file_saved_in_other_editorstack(self, original_filename, filename):
         """
         File was just saved in another editorstack, let's synchronize!
-        This avoid file to be automatically reloaded
+        This avoids file being automatically reloaded.
 
-        Filename is passed in case file was just saved as another name
+        The original filename is passed instead of an index in case the tabs
+        on the editor stacks were moved and are now in a different order - see
+        issue 5703.
+        Filename is passed in case file was just saved as another name.
         """
+        index = self.has_filename(original_filename)
+        if index is None:
+            return
         finfo = self.data[index]
         finfo.newly_created = False
         finfo.filename = to_text_string(filename)
         finfo.lastmodified = QFileInfo(finfo.filename).lastModified()
 
     def select_savename(self, original_filename):
-        """Select a name to save a file."""
+        """Select a name to save a file.
+
+        Args:
+            original_filename: Used in the dialog to display the current file
+                    path and name.
+
+        Returns:
+            Normalized path for the selected file name or None if no name was
+            selected.
+        """
         if self.edit_filetypes is None:
             self.edit_filetypes = get_edit_filetypes()
         if self.edit_filters is None:
@@ -1636,9 +1686,28 @@ def select_savename(self, original_filename):
         self.redirect_stdio.emit(True)
         if filename:
             return osp.normpath(filename)
+        return None
 
     def save_as(self, index=None):
-        """Save file as..."""
+        """Save file as...
+
+        Args:
+            index: self.data index for the file to save.
+
+        Returns:
+            False if no file name was selected or if save() was unsuccessful.
+            True is save() was successful.
+
+        Gets the new file name from select_savename().  If no name is chosen,
+        then the save_as() aborts.  Otherwise, the current stack is checked
+        to see if the selected name already exists and, if so, then the tab
+        with that name is closed.
+
+        The current stack (self.data) and current tabs are updated with the
+        new name and other file info.  The text is written with the new
+        name using save() and the name change is propagated to the other stacks
+        via the file_renamed_in_data signal.
+        """
         if index is None:
             # Save the currently edited file
             index = self.get_stack_index()
@@ -1647,7 +1716,8 @@ def save_as(self, index=None):
         # While running __check_file_status
         # See issues 3678 and 3026
         finfo.newly_created = True
-        filename = self.select_savename(finfo.filename)
+        original_filename = finfo.filename
+        filename = self.select_savename(original_filename)
         if filename:
             ao_index = self.has_filename(filename)
             # Note: ao_index == index --> saving an untitled file
@@ -1657,13 +1727,15 @@ def save_as(self, index=None):
                 if ao_index < index:
                     index -= 1
 
-            new_index = self.rename_in_data(index, new_filename=filename)
+            new_index = self.rename_in_data(original_filename,
+                                            new_filename=filename)
 
             # We pass self object ID as a QString, because otherwise it would
             # depend on the platform: long for 64bit, int for 32bit. Replacing
             # by long all the time is not working on some 32bit platforms
             # (see Issue 1094, Issue 1098)
-            self.file_renamed_in_data.emit(str(id(self)), index, filename)
+            self.file_renamed_in_data.emit(str(id(self)),
+                                           original_filename, filename)
 
             ok = self.save(index=new_index, force=True)
             self.refresh(new_index)
@@ -1673,12 +1745,30 @@ def save_as(self, index=None):
             return False
 
     def save_copy_as(self, index=None):
-        """Save copy of file as..."""
+        """Save copy of file as...
+
+        Args:
+            index: self.data index for the file to save.
+
+        Returns:
+            False if no file name was selected or if save() was unsuccessful.
+            True is save() was successful.
+
+        Gets the new file name from select_savename().  If no name is chosen,
+        then the save_copy_as() aborts.  Otherwise, the current stack is
+        checked to see if the selected name already exists and, if so, then the
+        tab with that name is closed.
+
+        Unlike save_as(), this calls write() directly instead of using save().
+        The current file and tab aren't changed at all.  The copied file is
+        opened in a new tab.
+        """
         if index is None:
             # Save the currently edited file
             index = self.get_stack_index()
         finfo = self.data[index]
-        filename = self.select_savename(finfo.filename)
+        original_filename = finfo.filename
+        filename = self.select_savename(original_filename)
         if filename:
             ao_index = self.has_filename(filename)
             # Note: ao_index == index --> saving an untitled file
@@ -1690,8 +1780,6 @@ def save_copy_as(self, index=None):
             txt = to_text_string(finfo.editor.get_text_with_eol())
             try:
                 finfo.encoding = encoding.write(txt, filename, finfo.encoding)
-                self.file_saved.emit(str(id(self)), index, filename)
-
                 # open created copy file
                 self.plugin_load.emit(filename)
                 return True
@@ -1709,11 +1797,12 @@ def save_copy_as(self, index=None):
             return False
 
     def save_all(self):
-        """Save all opened files"""
-        folders = set()
+        """Save all opened files.
+
+        Iterate through self.data and call save() on any modified files.
+        """
         for index in range(self.get_stack_count()):
             if self.data[index].editor.document().isModified():
-                folders.add(osp.dirname(self.data[index].filename))
                 self.save(index)
 
     #------ Update UI
@@ -1738,16 +1827,22 @@ def analyze_script(self, index=None):
                 finfo.run_todo_finder()
         self.is_analysis_done = True
 
-    def set_analysis_results(self, index, analysis_results):
+    def set_analysis_results(self, filename, analysis_results):
         """Synchronize analysis results between editorstacks"""
+        index = self.has_filename(filename)
+        if index is None:
+            return
         self.data[index].set_analysis_results(analysis_results)
 
     def get_analysis_results(self):
         if self.data:
             return self.data[self.get_stack_index()].analysis_results
 
-    def set_todo_results(self, index, todo_results):
+    def set_todo_results(self, filename, todo_results):
         """Synchronize todo results between editorstacks"""
+        index = self.has_filename(filename)
+        if index is None:
+            return
         self.data[index].set_todo_results(todo_results)
 
     def get_todo_results(self):
@@ -2800,22 +2895,24 @@ def close_file_in_all_editorstacks(self, editorstack_id_str, filename):
 
     # This method is never called in this plugin example. It's here only
     # to show how to use the file_saved signal (see above).
-    @Slot(str, int, str)
-    def file_saved_in_editorstack(self, editorstack_id_str, index, filename):
+    @Slot(str, str, str)
+    def file_saved_in_editorstack(self, editorstack_id_str,
+                                  original_filename, filename):
         """A file was saved in editorstack, this notifies others"""
         for editorstack in self.editorstacks:
             if str(id(editorstack)) != editorstack_id_str:
-                editorstack.file_saved_in_other_editorstack(index, filename)
+                editorstack.file_saved_in_other_editorstack(original_filename,
+                                                            filename)
 
     # This method is never called in this plugin example. It's here only
     # to show how to use the file_saved signal (see above).
-    @Slot(str, int, str)
+    @Slot(str, str, str)
     def file_renamed_in_data_in_editorstack(self, editorstack_id_str,
-                                            index, filename):
+                                            original_filename, filename):
         """A file was renamed in data in editorstack, this notifies others"""
         for editorstack in self.editorstacks:
             if str(id(editorstack)) != editorstack_id_str:
-                editorstack.rename_in_data(index, filename)
+                editorstack.rename_in_data(original_filename, filename)
 
     def register_widget_shortcuts(self, widget):
         """Fake!"""


Test patch:
diff --git a/spyder/widgets/tests/test_save.py b/spyder/widgets/tests/test_save.py
new file mode 100644
--- /dev/null
+++ b/spyder/widgets/tests/test_save.py
@@ -0,0 +1,331 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+#
+
+"""
+Tests for EditorStack save methods.
+"""
+
+# Standard library imports
+try:
+    from unittest.mock import Mock
+except ImportError:
+    from mock import Mock  # Python 2
+
+# Third party imports
+import pytest
+
+# Local imports
+from spyder.widgets import editor
+
+
+# Helpers
+# --------------------------------
+def add_files(editorstack):
+    editorstack.close_action.setEnabled(False)
+    editorstack.set_introspector(Mock())
+    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'
+                                       'x = 2')
+    editorstack.new('secondtab.py', 'utf-8', 'print(spam)')
+    with open(__file__) as f:
+        text = f.read()
+    editorstack.new(__file__, 'utf-8', text)
+
+
+# Qt Test Fixtures
+# --------------------------------
+@pytest.fixture
+def base_editor_bot(qtbot):
+    editor_stack = editor.EditorStack(None, [])
+    editor_stack.set_introspector(Mock())
+    editor_stack.set_find_widget(Mock())
+    editor_stack.set_io_actions(Mock(), Mock(), Mock(), Mock())
+    return editor_stack, qtbot
+
+
+@pytest.fixture
+def editor_bot(base_editor_bot):
+    """
+    Set up EditorStack with CodeEditors containing some Python code.
+    The cursor is at the empty line below the code.
+    """
+    editor_stack, qtbot = base_editor_bot
+    qtbot.addWidget(editor_stack)
+    add_files(editor_stack)
+    return editor_stack, qtbot
+
+
+@pytest.fixture
+def editor_splitter_bot(qtbot):
+    """Create editor splitter."""
+    es = editor_splitter = editor.EditorSplitter(None, Mock(), [], first=True)
+    qtbot.addWidget(es)
+    es.show()
+    yield es
+    es.destroy()
+
+
+@pytest.fixture
+def editor_splitter_layout_bot(editor_splitter_bot):
+    """Create editor splitter for testing layouts."""
+    es = editor_splitter_bot
+    es.plugin.clone_editorstack.side_effect = add_files
+
+    # Setup editor info for this EditorStack.
+    add_files(es.editorstack)
+    return es
+
+
+# Tests
+# -------------------------------
+def test_save_if_changed(editor_bot, mocker):
+    """Test EditorStack.save_if_changed()."""
+    editor_stack, qtbot = editor_bot
+    save_if_changed = editor_stack.save_if_changed
+    mocker.patch.object(editor.QMessageBox, 'exec_')
+    mocker.patch.object(editor_stack, 'save')
+    editor_stack.save.return_value = True
+
+    # No file changed - returns True.
+    editor_stack.data[0].editor.document().setModified(False)
+    editor_stack.data[1].editor.document().setModified(False)
+    editor_stack.data[2].editor.document().setModified(False)
+    assert save_if_changed() is True
+    assert not editor_stack.save.called
+    editor_stack.data[0].editor.document().setModified(True)
+    editor_stack.data[1].editor.document().setModified(True)
+    editor_stack.data[2].editor.document().setModified(True)
+
+    # Cancel button - returns False.
+    editor.QMessageBox.exec_.return_value = editor.QMessageBox.Cancel
+    assert save_if_changed(index=0, cancelable=True) is False
+    assert not editor_stack.save.called
+    assert editor_stack.tabs.currentIndex() == 0
+
+    # Yes button - return value from save().
+    editor.QMessageBox.exec_.return_value = editor.QMessageBox.Yes
+    assert save_if_changed(index=0, cancelable=True) is True
+    assert editor_stack.save.called
+
+    # YesToAll button - if any save() fails, then return False.
+    editor_stack.save.reset_mock()
+    editor.QMessageBox.exec_.return_value = editor.QMessageBox.YesToAll
+    assert save_if_changed() is True
+    assert editor_stack.save.call_count == 3
+
+    # No button - returns True.
+    editor_stack.save.reset_mock()
+    editor.QMessageBox.exec_.return_value = editor.QMessageBox.No
+    assert save_if_changed(index=0, cancelable=True) is True
+    assert not editor_stack.save.called
+
+    # NoToAll button - returns True.
+    editor_stack.save.reset_mock()
+    editor.QMessageBox.exec_.return_value = editor.QMessageBox.NoToAll
+    assert save_if_changed() is True
+    assert not editor_stack.save.called
+
+    # Tempfile doesn't show message box - always calls save().
+    editor.QMessageBox.exec_.reset_mock()
+    editor_stack.set_tempfile_path(__file__)
+    editor_stack.save.return_value = False
+    assert save_if_changed(index=2, cancelable=True) is False
+    assert editor_stack.save.called
+    editor.QMessageBox.exec_.assert_not_called()
+
+
+def test_save(editor_bot, mocker):
+    """Test EditorStack.save()."""
+    editor_stack, qtbot = editor_bot
+    save = editor_stack.save
+    mocker.patch.object(editor.QMessageBox, 'exec_')
+    mocker.patch.object(editor.os.path, 'isfile')
+    mocker.patch.object(editor.encoding, 'write')
+    mocker.patch.object(editor_stack, 'save_as')
+    save_file_saved = editor_stack.file_saved
+    editor_stack.file_saved = Mock()
+    editor.encoding.write.return_value = 'utf-8'
+
+    # Not modified and not newly created - don't write.
+    editor_stack.data[0].editor.document().setModified(False)
+    editor_stack.data[0].newly_created = False
+    assert save(index=0) is True
+    assert not editor.encoding.write.called
+
+    # File modified.
+    editor_stack.data[0].editor.document().setModified(True)
+
+    # File not saved yet - call save_as().
+    editor.os.path.isfile.return_value = False
+    editor_stack.save_as.return_value = 'save_as_called'
+    assert save(index=0) == 'save_as_called'
+    editor_stack.save_as.assert_called_with(index=0)
+    assert not editor.encoding.write.called
+
+    # Force save.
+    editor.os.path.isfile.return_value = True
+    assert save(index=0, force=True)
+    assert editor.encoding.write.called == 1
+    editor_stack.file_saved.emit.assert_called_with(str(id(editor_stack)),
+                                                    'foo.py', 'foo.py')
+
+    editor_stack.file_saved = save_file_saved
+
+
+def test_file_saved_in_other_editorstack(editor_splitter_layout_bot):
+    """Test EditorStack.file_saved_in_other_editorstack()."""
+    es = editor_splitter_layout_bot
+    es.split()
+    # Represents changed editor stack.
+    panel1 = es.editorstack
+    # Represents split editor stack.
+    panel2 = es.widget(1).editorstack
+
+    # Tabs match.
+    for i in range(3):
+        assert panel1.data[i].filename == panel2.data[i].filename
+
+    # Rearrange tabs on first panel so that tabs aren't the same anymore.
+    panel1.tabs.tabBar().moveTab(0, 1)
+    assert panel1.data[0].filename == panel2.data[1].filename
+    assert panel1.data[1].filename == panel2.data[0].filename
+    assert panel1.data[2].filename == panel2.data[2].filename
+
+    # Call file_saved_in_other_editorstack to align stacks.
+    panel2.file_saved_in_other_editorstack(panel1.data[0].filename,
+                                           panel1.data[0].filename)
+    panel2.file_saved_in_other_editorstack(panel1.data[1].filename,
+                                           panel1.data[1].filename)
+    # Originally this test showed that using index as an arg instead
+    # of the original_filename would incorrectly update the names on panel2.
+    # See issue 5703.
+    assert panel1.data[0].filename == panel2.data[1].filename
+    assert panel1.data[1].filename == panel2.data[0].filename
+    assert panel1.data[2].filename == panel2.data[2].filename
+
+
+def test_select_savename(editor_bot, mocker):
+    """Test EditorStack.select_savename()."""
+    editor_stack, qtbot = editor_bot
+    select_savename = editor_stack.select_savename
+    mocker.patch.object(editor, 'getsavefilename')
+    save_redirect_stdio = editor_stack.redirect_stdio
+    editor_stack.redirect_stdio = Mock()
+
+    # Cancel selection.
+    editor.getsavefilename.return_value = ('', '')
+    assert select_savename(__file__) is None
+
+    # Select same name.
+    editor.getsavefilename.return_value = (__file__, '')
+    assert select_savename(__file__) == __file__
+
+    # Select different name.
+    editor.getsavefilename.return_value = ('mytest.py', '')
+    assert select_savename(__file__) == 'mytest.py'
+
+    # Restore.
+    editor_stack.redirect_stdio = save_redirect_stdio
+
+
+def test_save_as(editor_bot, mocker):
+    """Test EditorStack.save_as()."""
+    editor_stack, qtbot = editor_bot
+    save_as = editor_stack.save_as
+    mocker.patch.object(editor.encoding, 'write')
+    mocker.patch.object(editor_stack, 'save')
+    mocker.patch.object(editor_stack, 'close_file')
+    mocker.patch.object(editor_stack, 'select_savename')
+    mocker.patch.object(editor_stack, 'rename_in_data')
+    mocker.patch.object(editor_stack, 'refresh')
+    save_file_renamed_in_data = editor_stack.file_renamed_in_data
+    editor_stack.file_renamed_in_data = Mock()
+    editor.encoding.write.return_value = 'utf-8'
+    editor_stack.save.return_value = True
+
+    # No save name.
+    editor_stack.select_savename.return_value = None
+    assert save_as() is False
+    assert not editor_stack.save.called
+
+    # Save name is in the stack, but not the current index.
+    editor_stack.select_savename.return_value = 'foo.py'
+    editor_stack.close_file.return_value = False
+    assert save_as(index=2) is None
+    assert not editor_stack.save.called
+
+    # Save name is in the stack, but not the current index.
+    editor_stack.close_file.return_value = True
+    assert save_as(index=2) is True
+    editor_stack.close_file.assert_called_with(0)
+    assert editor_stack.save.called
+    # This index is one less because the tab with the saved name was closed.
+    editor_stack.rename_in_data.assert_called_with(__file__,
+                                                   new_filename='foo.py')
+    assert editor_stack.file_renamed_in_data.emit.called == 1
+    assert editor_stack.save.called == 1
+    assert editor_stack.refresh.called == 1
+
+    # Restore.
+    editor_stack.file_renamed_in_data = save_file_renamed_in_data
+
+
+def test_save_copy_as(editor_bot, mocker):
+    """Test EditorStack.save_copy as()."""
+    editor_stack, qtbot = editor_bot
+    save_copy_as = editor_stack.save_copy_as
+    mocker.patch.object(editor.QMessageBox, 'exec_')
+    mocker.patch.object(editor.encoding, 'write')
+    mocker.patch.object(editor_stack, 'close_file')
+    mocker.patch.object(editor_stack, 'select_savename')
+    save_plugin_load = editor_stack.plugin_load
+    editor_stack.plugin_load = Mock()
+    editor.encoding.write.return_value = 'utf-8'
+
+    # No save name.
+    editor_stack.select_savename.return_value = None
+    assert save_copy_as() is False
+    assert not editor.encoding.write.called
+
+    # Save name is in the stack, but not the current index.
+    editor_stack.select_savename.return_value = 'foo.py'
+    editor_stack.close_file.return_value = False
+    assert save_copy_as(index=2) is None
+    assert not editor.encoding.write.called
+
+    # Save name is in the stack, but not the current index.
+    editor_stack.close_file.return_value = True
+    assert save_copy_as(index=2) is True
+    editor_stack.close_file.assert_called_with(0)
+    assert editor.encoding.write.called
+    editor_stack.plugin_load.emit.assert_called_with('foo.py')
+
+    # Restore mocked objects.
+    editor_stack.plugin_load = save_plugin_load
+
+
+def test_save_all(editor_bot, mocker):
+    """Test EditorStack.save_all()."""
+    editor_stack, qtbot = editor_bot
+    save_all = editor_stack.save_all
+    mocker.patch.object(editor_stack, 'save')
+    # Save return value isn't used in save_all.
+    editor_stack.save.return_value = False
+
+    save_all()
+    assert editor_stack.save.call_count == 3
+    editor_stack.save.assert_any_call(0)
+    editor_stack.save.assert_any_call(1)
+    editor_stack.save.assert_any_call(2)
+    with pytest.raises(AssertionError):
+        editor_stack.save.assert_any_call(3)
+
+
+if __name__ == "__main__":
+    pytest.main()