Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 50 additions & 7 deletions st3/sublime_lib/view_utils.py
Original file line number Diff line number Diff line change
@@ -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]:
Expand Down Expand Up @@ -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``.

Expand All @@ -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.')


Expand Down
23 changes: 15 additions & 8 deletions st3/sublime_lib/window_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from ._compat.typing import Optional

from .view_utils import _temporarily_scratch_unsaved_views

__all__ = ['new_window', 'close_window']


Expand Down Expand Up @@ -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')
11 changes: 11 additions & 0 deletions tests/test_view_utils.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)

Expand Down
5 changes: 5 additions & 0 deletions tests/test_window_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a comment why the wait is necessary here and reference the upstream issue? We won't be able to remove this for a while until we drop compatibility with affected builds.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

if getattr(self, '_window', None):
close_window(self._window, force=True)

Expand All @@ -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())
Expand All @@ -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)

Expand Down