diff --git a/st3/sublime_lib/view_utils.py b/st3/sublime_lib/view_utils.py index 042591c..2cee052 100644 --- a/st3/sublime_lib/view_utils.py +++ b/st3/sublime_lib/view_utils.py @@ -1,16 +1,19 @@ import sublime import inspect +from contextlib import contextmanager from .vendor.python.enum import Enum from ._util.enum import ExtensibleConstructorMeta, construct_with_alternatives from .syntax import get_syntax_for_scope from .encodings import to_sublime -from ._compat.typing import Any, Optional, Mapping +from ._compat.typing import Any, Optional, Mapping, Iterable, Generator -__all__ = ['LineEnding', 'new_view', 'close_view'] +__all__ = [ + 'LineEnding', 'new_view', 'close_view', +] def case_insensitive_value(cls: ExtensibleConstructorMeta, value: str) -> Optional[Enum]: @@ -90,6 +93,41 @@ def new_view(window: sublime.Window, **kwargs: Any) -> sublime.View: return view +@contextmanager +def _temporarily_scratch_unsaved_views( + unsaved_views: Iterable[sublime.View] +) -> Generator[None, None, None]: + buffer_ids = {view.buffer_id() for view in unsaved_views} + for view in unsaved_views: + view.set_scratch(True) + + try: + yield + finally: + clones = { + view.buffer_id(): view + for window in sublime.windows() + for view in window.views() + if view.buffer_id() in buffer_ids + } + for view in clones.values(): + view.set_scratch(False) + + +def _clone_view(view: sublime.View) -> sublime.View: + window = view.window() + if window is None: # pragma: no cover + raise ValueError("View has no window.") + + window.focus_view(view) + window.run_command('clone_file') + clone = window.active_view() + if clone is None: # pragma: no cover + raise RuntimeError("Clone was not created.") + + return clone + + def close_view(view: sublime.View, *, force: bool = False) -> None: """Close the given view, discarding unsaved changes if `force` is ``True``. @@ -98,13 +136,18 @@ def close_view(view: sublime.View, *, force: bool = False) -> None: :raise ValueError: if the view has unsaved changes and `force` is not ``True``. :raise ValueError: if the view is not closed for any other reason. """ - if view.is_dirty() and not view.is_scratch(): - if force: - view.set_scratch(True) - else: + unsaved = view.is_dirty() and not view.is_scratch() + + if unsaved: + if not force: raise ValueError('The view has unsaved changes.') - if not view.close(): + with _temporarily_scratch_unsaved_views([view]): + closed = view.close() + else: + closed = view.close() + + if not closed: raise ValueError('The view could not be closed.') diff --git a/st3/sublime_lib/window_utils.py b/st3/sublime_lib/window_utils.py index e6a0ced..35a1579 100644 --- a/st3/sublime_lib/window_utils.py +++ b/st3/sublime_lib/window_utils.py @@ -2,6 +2,8 @@ from ._compat.typing import Optional +from .view_utils import _temporarily_scratch_unsaved_views + __all__ = ['new_window', 'close_window'] @@ -88,11 +90,16 @@ def close_window(window: sublime.Window, *, force: bool = False) -> None: .. versionadded:: 1.2 """ - for view in window.views(): - if view.is_dirty() and not view.is_scratch(): - if force: - view.set_scratch(True) - else: - raise ValueError('A view has unsaved changes.') - - window.run_command('close_window') + unsaved = [ + view for view in window.views() + if view.is_dirty() and not view.is_scratch() + ] + + if unsaved: + if not force: + raise ValueError('A view has unsaved changes.') + + with _temporarily_scratch_unsaved_views(unsaved): + window.run_command('close_window') + else: + window.run_command('close_window') diff --git a/tests/test_view_utils.py b/tests/test_view_utils.py index 7ee1cae..20c61d9 100644 --- a/tests/test_view_utils.py +++ b/tests/test_view_utils.py @@ -1,5 +1,6 @@ import sublime from sublime_lib import new_view, close_view, LineEnding +from sublime_lib.view_utils import _clone_view from unittest import TestCase @@ -144,6 +145,16 @@ def test_close_unsaved(self): close_view(self.view, force=True) self.assertFalse(self.view.is_valid()) + def test_close_unsaved_clone(self): + self.view = new_view(self.window, content="Hello, World!") + + clone = _clone_view(self.view) + close_view(clone, force=True) + + self.assertFalse(clone.is_valid()) + self.assertTrue(self.view.is_valid()) + self.assertFalse(self.view.is_scratch()) + def test_close_closed_error(self): self.view = new_view(self.window) diff --git a/tests/test_window_utils.py b/tests/test_window_utils.py index 0cdfe19..6242ae8 100644 --- a/tests/test_window_utils.py +++ b/tests/test_window_utils.py @@ -8,6 +8,9 @@ class TestNewWindow(DeferrableTestCase): def tearDown(self): + # We have to wait between opening and closing a window to avoid a crash. + # See https://github.com/sublimehq/sublime_text/issues/3960 + yield 100 if getattr(self, '_window', None): close_window(self._window, force=True) @@ -21,6 +24,7 @@ def test_has_view(self): def test_close_window(self): self._window = new_window() + yield 100 close_window(self._window) yield 500 self.assertFalse(self._window.is_valid()) @@ -30,6 +34,7 @@ def test_close_unsaved(self): self._window.active_view().run_command('insert', {'characters': 'Hello, World!'}) + yield 100 with self.assertRaises(ValueError): close_window(self._window)