Repository: spyder-ide/spyder

Issue:
Outline explorer do not sync correctly when closing/re-opening a file in master
<!--- **PLEASE READ:** When submitting here, please ensure you've completed the following checklist and checked the boxes to confirm. Issue reports without it may be closed. Thanks! --->

## Problem Description

Files are not removed from the outline explorer when they are closed + duplicated entry when re-opening the file.

### What steps reproduce the problem?

1. Open a file
2. Close the file
3. Re-Open the file or click on the file in the outline explore

![sync_oe_when_closing_file](https://user-images.githubusercontent.com/10170372/44939552-24bca100-ad54-11e8-977a-e8192877489e.gif)

### What is the expected output? What do you see instead?

When closing a file, it should not appear anymore in the explorer.


### Paste Traceback/Error Below (if applicable)
<!--- Copy from error dialog or View > Panes > Internal Console --->

```python-traceback

PASTE TRACEBACK HERE

```

## Versions
<!--- You can get this information from Help > About Spyder...
or (if Spyder won't launch) the "conda list" command
from the Anaconda Prompt/Terminal/command line. --->

* Spyder version: Spyder 4.0.0.dev0 https://github.com/spyder-ide/spyder/commit/3465e77f077a346b71dfdf85d5f7eea98e64162b
* Python version: Python 3.6.5 64bits
* Qt version: Qt 5.9.3
* PyQt version: PyQt5 5.9.2
* Operating System name/version: Windows 10

### Dependencies
<!--- Please go to the menu entry Help > Dependencies,
press the Copy to clipboard button and paste below --->

```
pyflakes >=0.6.0  :  1.6.0 (OK)
pycodestyle >=2.3 :  2.3.1 (OK)
pygments >=2.0    :  2.2.0 (OK)
sphinx >=0.6.6    :  1.7.1 (OK)
rope >=0.9.4      :  0.10.7 (OK)
jedi >=0.11.0     :  0.12.1 (OK)
nbconvert >=4.0   :  5.3.1 (OK)
pandas >=0.13.1   :  0.22.0 (OK)
numpy >=1.7       :  1.13.0 (OK)
sympy >=0.7.3     :  1.1.1 (OK)
cython >=0.21     :  0.27.3 (OK)
qtconsole >=4.2.0 :  4.3.1 (OK)
IPython >=4.0     :  6.2.1 (OK)
matplotlib >=2.0.0:  2.2.0 (OK)
pylint >=0.25     :  1.8.3 (OK)
```



Discussion:

Resolving patch:
diff --git a/spyder/plugins/editor/widgets/editor.py b/spyder/plugins/editor/widgets/editor.py
--- a/spyder/plugins/editor/widgets/editor.py
+++ b/spyder/plugins/editor/widgets/editor.py
@@ -851,7 +851,7 @@ def closeEvent(self, event):
         # Remove editor references from the outline explorer settings
         if self.outlineexplorer is not None:
             for finfo in self.data:
-                self.outlineexplorer.remove_editor(finfo.editor)
+                self.outlineexplorer.remove_editor(finfo.editor.oe_proxy)
 
         QWidget.closeEvent(self, event)
 
@@ -1296,6 +1296,7 @@ def get_tab_tip(self, filename, is_modified=None, is_readonly=None):
             return text % (osp.basename(filename), osp.dirname(filename))
 
     def add_to_data(self, finfo, set_current):
+        finfo.editor.oe_proxy = None
         self.data.append(finfo)
         index = self.data.index(finfo)
         editor = finfo.editor
@@ -2070,7 +2071,9 @@ def _refresh_outlineexplorer(self, index=None, update=True, clear=False):
             if finfo.editor.is_python():
                 enable = True
                 oe.setEnabled(True)
-                finfo.editor.oe_proxy = OutlineExplorerProxyEditor(finfo.editor, finfo.filename)
+                if finfo.editor.oe_proxy is None:
+                    finfo.editor.oe_proxy = OutlineExplorerProxyEditor(
+                        finfo.editor, finfo.filename)
                 oe.set_current_editor(finfo.editor.oe_proxy,
                                       update=update, clear=clear)
         if not enable:
diff --git a/spyder/plugins/outlineexplorer/widgets.py b/spyder/plugins/outlineexplorer/widgets.py
--- a/spyder/plugins/outlineexplorer/widgets.py
+++ b/spyder/plugins/outlineexplorer/widgets.py
@@ -13,7 +13,8 @@
 # Third party imports
 from qtpy.compat import from_qvariant
 from qtpy.QtCore import QSize, Qt, Signal, Slot
-from qtpy.QtWidgets import QHBoxLayout, QTreeWidgetItem, QVBoxLayout, QWidget
+from qtpy.QtWidgets import (QHBoxLayout, QTreeWidgetItem, QVBoxLayout, QWidget,
+                            QTreeWidgetItemIterator)
 
 # Local imports
 from spyder.config.base import _, STDOUT
@@ -489,11 +490,26 @@ def restore(self):
             self.root_item_selected(self.editor_items[editor_id])
 
     def get_root_item(self, item):
+        """Return the root item of the specified item."""
         root_item = item
         while isinstance(root_item.parent(), QTreeWidgetItem):
             root_item = root_item.parent()
         return root_item
-                
+
+    def get_visible_items(self):
+        """Return a list of all visible items in the treewidget."""
+        items = []
+        iterator = QTreeWidgetItemIterator(self)
+        while iterator.value():
+            item = iterator.value()
+            if item.parent():
+                if item.parent().isExpanded():
+                    items.append(item)
+            else:
+                items.append(item)
+            iterator += 1
+        return items
+
     def activated(self, item):
         """Double-click event"""
         line = 0


Test patch:
diff --git a/spyder/plugins/editor/widgets/tests/test_editor_and_outline.py b/spyder/plugins/editor/widgets/tests/test_editor_and_outline.py
new file mode 100644
--- /dev/null
+++ b/spyder/plugins/editor/widgets/tests/test_editor_and_outline.py
@@ -0,0 +1,146 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+#
+
+"""
+Tests syncing between the EditorStack and OutlineExplorerWidget.
+"""
+
+# Standard library imports
+import os.path as osp
+try:
+    from unittest.mock import Mock
+except ImportError:
+    from mock import Mock  # Python 2
+
+# Third party imports
+import pytest
+
+# Local imports
+from spyder.plugins.editor.widgets import editor
+from spyder.plugins.outlineexplorer.widgets import OutlineExplorerWidget
+
+
+# ---- Qt Test Fixtures
+@pytest.fixture(scope="module")
+def python_files(tmpdir_factory):
+    """Create and save some python codes in temporary files."""
+    tmpdir = tmpdir_factory.mktemp("files")
+
+    filename1 = osp.join(tmpdir.strpath, 'foo1.py')
+    with open(filename1, 'w') as f:
+        f.write("# -*- coding: utf-8 -*-\n"
+                "def foo:\n"
+                "    print(Hello World!)\n")
+
+    filename2 = osp.join(tmpdir.strpath, 'foo2.py')
+    with open(filename2, 'w') as f:
+        f.write("# -*- coding: utf-8 -*-\n"
+                "# ---- a comment\n")
+
+    return [filename1, filename2]
+
+
+@pytest.fixture
+def empty_editor_bot(qtbot):
+    """Set up an empty EditorStack with an OutlineExplorerWidget."""
+    outlineexplorer = OutlineExplorerWidget(
+        show_fullpath=False, show_all_files=True, group_cells=False,
+        show_comments=True)
+    # Fix the size of the outline explorer to prevent an
+    # 'Unable to set geometry ' warning if the test fails.
+    outlineexplorer.setFixedSize(400, 350)
+
+    qtbot.addWidget(outlineexplorer)
+    outlineexplorer.show()
+
+    editorstack = editor.EditorStack(None, [])
+    editorstack.set_introspector(Mock())
+    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)
+
+    qtbot.addWidget(editorstack)
+    editorstack.show()
+
+    return editorstack, outlineexplorer, qtbot
+
+
+@pytest.fixture
+def editor_bot(empty_editor_bot, python_files):
+    """
+    Set up an EditorStack with an OutlineExplorerWidget and load some files.
+    """
+    editorstack, outlineexplorer, qtbot = empty_editor_bot
+    for file in python_files:
+        editorstack.load(file)
+
+    return editorstack, outlineexplorer, qtbot
+
+
+# ---- Tests
+def test_load_files(empty_editor_bot, python_files):
+    """
+    Test that the content of the outline explorer is updated correctly
+    after a file is loaded in the editor.
+    """
+    editorstack, outlineexplorer, qtbot = empty_editor_bot
+    treewidget = outlineexplorer.treewidget
+
+    # Load the first file and assert the content of the outline explorer.
+    editorstack.load(python_files[0])
+    assert editorstack.get_current_filename() == python_files[0]
+    editorstack.tabs.currentIndex() == 0
+
+    results = [item.text(0) for item in treewidget.get_visible_items()]
+    assert results == ['foo1.py', 'foo']
+
+    # Load the second file and assert the content of the outline explorer tree.
+    editorstack.load(python_files[1])
+    assert editorstack.get_current_filename() == python_files[1]
+    editorstack.tabs.currentIndex() == 1
+
+    results = [item.text(0) for item in treewidget.get_visible_items()]
+    assert results == ['foo1.py', 'foo2.py', '---- a comment']
+
+
+def test_close_editor(editor_bot):
+    """
+    Test that the content of the outline explorer is empty after the
+    editorstack has been closed.
+
+    Regression test for issue #7798.
+    """
+    editorstack, outlineexplorer, qtbot = editor_bot
+    treewidget = outlineexplorer.treewidget
+    assert treewidget.get_visible_items()
+
+    # Close the editor and assert that the outline explorer tree is empty.
+    editorstack.close()
+    assert not treewidget.get_visible_items()
+
+
+def test_close_a_file(editor_bot):
+    """
+    Test that the content of the outline explorer is updated corrrectly
+    after a file has been closed in the editorstack.
+
+    Regression test for issue #7798.
+    """
+    editorstack, outlineexplorer, qtbot = editor_bot
+    treewidget = outlineexplorer.treewidget
+
+    # Close 'foo2.py' and assert that the content of the outline explorer
+    # tree has been updated.
+    editorstack.close_file(index=1)
+    results = [item.text(0) for item in treewidget.get_visible_items()]
+    assert results == ['foo1.py', 'foo']
+
+
+if __name__ == "__main__":
+    import os
+    pytest.main([os.path.basename(__file__), '-vv', '-rw'])