Repository: sublimelsp/LSP

Issue:
LSP dialog won't close when opening a single file and LSP failed to start
**Describe the bug**

When LSP fail to start a specific language server you're greeted with a dialog that informs you it will be disabled for the current window. As an example, this is the dialog I get when `gopls` fail to start.

```
Failed to start gopls - disabling for this window. Re-enable by running "LSP: Enable Language Server In Project" from the Command Palette.

--- Error: ---
[Errno 2] No such file or directory: 'gopls'
```

If you started Sublime Text using `subl <file>` and you get this dialog, it will go into loop. Pressing `OK` immediately show the same dialog again. However, if you started Sublime Text using `subl .` or already had an instance of Sublime Running (in a project), the behaves correctly.

**To Reproduce**
Steps to reproduce the behavior:
1. Quit Sublime Text
2. Open a file (that will enable LSP but make sure the language server will fail to start) using `subl <file>`
3. Wait until the dialog shows and press `OK`
4. The dialog should now be presented once again

**Expected behavior**

I would expect the dialog to only be presented once.

**Screenshots**

**Environment (please complete the following information):**
- OS: macOS  11.2.1
- Sublime Text version: 4102
- LSP version: 1.2.13
- Language servers used: gopls (probably related to any language server)

**Additional context**

Except from the Discord conversation:

<img width="941" alt="Screenshot 2021-04-21 at 16 58 05" src="https://user-images.githubusercontent.com/503025/115575324-bf31d000-a2c2-11eb-8f7d-8ebcc40227ff.png">



Discussion:

Resolving patch:
diff --git a/plugin/core/configurations.py b/plugin/core/configurations.py
--- a/plugin/core/configurations.py
+++ b/plugin/core/configurations.py
@@ -1,6 +1,6 @@
 from .logging import debug
 from .types import ClientConfig
-from .typing import Any, Generator, List, Dict
+from .typing import Any, Generator, List, Set, Dict
 from .workspace import enable_in_project, disable_in_project
 import sublime
 
@@ -27,6 +27,7 @@ class WindowConfigManager(object):
     def __init__(self, window: sublime.Window, global_configs: Dict[str, ClientConfig]) -> None:
         self._window = window
         self._global_configs = global_configs
+        self._disabled_for_session = set()  # type: Set[str]
         self.all = {}  # type: Dict[str, ClientConfig]
         self.update()
 
@@ -61,6 +62,8 @@ def update(self) -> None:
                 debug("applying .sublime-project override for", name)
             else:
                 overrides = {}
+            if name in self._disabled_for_session:
+                overrides["enabled"] = False
             self.all[name] = ClientConfig.from_config(config, overrides)
         for name, c in project_settings.items():
             debug("loading project-only configuration", name)
@@ -68,9 +71,23 @@ def update(self) -> None:
         self._window.run_command("lsp_recheck_sessions")
 
     def enable_config(self, config_name: str) -> None:
-        enable_in_project(self._window, config_name)
+        if not self._reenable_disabled_for_session(config_name):
+            enable_in_project(self._window, config_name)
         self.update()
 
-    def disable_config(self, config_name: str) -> None:
-        disable_in_project(self._window, config_name)
+    def disable_config(self, config_name: str, only_for_session: bool = False) -> None:
+        if only_for_session:
+            self._disable_for_session(config_name)
+        else:
+            disable_in_project(self._window, config_name, only_for_session)
         self.update()
+
+    def _disable_for_session(self, config_name: str) -> None:
+        self._disabled_for_session.add(config_name)
+
+    def _reenable_disabled_for_session(self, config_name: str) -> bool:
+        try:
+            self._disabled_for_session.remove(config_name)
+            return True
+        except KeyError:
+            return False
diff --git a/plugin/core/windows.py b/plugin/core/windows.py
--- a/plugin/core/windows.py
+++ b/plugin/core/windows.py
@@ -20,8 +20,6 @@
 from .typing import Optional, Any, Dict, Deque, List, Generator, Tuple, Iterable, Sequence, Union
 from .views import extract_variables
 from .views import make_link
-from .workspace import disable_in_project
-from .workspace import enable_in_project
 from .workspace import ProjectFolders
 from .workspace import sorted_workspace_folders
 from abc import ABCMeta
@@ -186,14 +184,10 @@ def update_workspace_folders_async(self) -> None:
                 session.update_folders(workspace_folders)
 
     def enable_config_async(self, config_name: str) -> None:
-        enable_in_project(self._window, config_name)
-        # TODO: Why doesn't enable_in_project cause on_load_project_async to be called?
-        self._configs.update()
+        self._configs.enable_config(config_name)
 
     def disable_config_async(self, config_name: str) -> None:
-        disable_in_project(self._window, config_name)
-        # TODO: Why doesn't disable_in_project cause on_load_project_async to be called?
-        self._configs.update()
+        self._configs.disable_config(config_name)
 
     def _register_listener(self, listener: AbstractViewListener) -> None:
         sublime.set_timeout_async(lambda: self.register_listener_async(listener))
@@ -424,7 +418,8 @@ def on_post_exit_async(self, session: Session, exit_code: int, exception: Option
             config = session.config
             msg = "".join((
                 "{0} exited with status code {1}. ",
-                "Do you want to restart it? If you choose Cancel, it will be disabled for this window. ",
+                "Do you want to restart it? If you choose Cancel, it will be disabled for this window for the ",
+                "duration of the current session. ",
                 "Re-enable by running \"LSP: Enable Language Server In Project\" from the Command Palette."
             )).format(config.name, exit_code)
             if exception:
@@ -433,7 +428,7 @@ def on_post_exit_async(self, session: Session, exit_code: int, exception: Option
                 for listener in self._listeners:
                     self.register_listener_async(listener)
             else:
-                self._configs.disable_config(config.name)
+                self._configs.disable_config(config.name, only_for_session=True)
 
     def plugin_unloaded(self) -> None:
         """
diff --git a/plugin/core/workspace.py b/plugin/core/workspace.py
--- a/plugin/core/workspace.py
+++ b/plugin/core/workspace.py
@@ -1,7 +1,6 @@
-from .logging import debug
 from .protocol import WorkspaceFolder
 from .types import diff
-from .typing import List, Any, Union
+from .typing import List, Union
 import sublime
 import os
 
@@ -60,7 +59,7 @@ def sorted_workspace_folders(folders: List[str], file_path: str) -> List[Workspa
     return [WorkspaceFolder.from_path(path) for path in matching_paths + other_paths]
 
 
-def enable_in_project(window: Any, config_name: str) -> None:
+def enable_in_project(window: sublime.Window, config_name: str) -> None:
     project_data = window.project_data()
     if isinstance(project_data, dict):
         project_settings = project_data.setdefault('settings', dict())
@@ -69,10 +68,11 @@ def enable_in_project(window: Any, config_name: str) -> None:
         project_client_settings['enabled'] = True
         window.set_project_data(project_data)
     else:
-        debug('non-dict returned in project_settings: ', project_data)
+        sublime.message_dialog(
+            "Can't enable {} in the current workspace. Ensure that the project is saved first.".format(config_name))
 
 
-def disable_in_project(window: Any, config_name: str) -> None:
+def disable_in_project(window: sublime.Window, config_name: str, only_for_session: bool = False) -> None:
     project_data = window.project_data()
     if isinstance(project_data, dict):
         project_settings = project_data.setdefault('settings', dict())
@@ -81,4 +81,5 @@ def disable_in_project(window: Any, config_name: str) -> None:
         project_client_settings['enabled'] = False
         window.set_project_data(project_data)
     else:
-        debug('non-dict returned in project_settings: ', project_data)
+        sublime.message_dialog(
+            "Can't disable {} in the current workspace. Ensure that the project is saved first.".format(config_name))


Test patch:
diff --git a/tests/test_configurations.py b/tests/test_configurations.py
--- a/tests/test_configurations.py
+++ b/tests/test_configurations.py
@@ -62,7 +62,6 @@ def test_applies_project_settings(self):
             }
         })
         manager = WindowConfigManager(window, {DISABLED_CONFIG.name: DISABLED_CONFIG})
-        manager.update()
         view.syntax = MagicMock(return_value=sublime.Syntax(
             path="Packages/Text/Plain text.tmLanguage",
             name="Plain Text",
@@ -89,11 +88,6 @@ def test_disables_temporarily(self):
         })
 
         manager = WindowConfigManager(window, {DISABLED_CONFIG.name: DISABLED_CONFIG})
-        manager.update()
-
-        # crash handler disables config and shows popup
-        manager.disable_temporarily(DISABLED_CONFIG.name)
-
-        # view is activated after popup, we try to start a session again...
-        manager.update()
+        # disables config in-memory
+        manager.disable_config(DISABLED_CONFIG.name, only_for_session=True)
         self.assertFalse(any(manager.match_view(view)))
diff --git a/tests/test_server_panel_circular.py b/tests/test_server_panel_circular.py
--- a/tests/test_server_panel_circular.py
+++ b/tests/test_server_panel_circular.py
@@ -35,4 +35,6 @@ def test_server_panel_circular_behavior(self):
         self.update_panel("overflow")
         self.update_panel("overflow")
         self.update_panel("one\ntwo\nthree")
-        self.assert_total_lines_equal(n)
+        # The panel only updates when visible but we don't want to test that as
+        # it would hide the unittesting panel.
+        self.assert_total_lines_equal(1)
diff --git a/tests/test_views.py b/tests/test_views.py
--- a/tests/test_views.py
+++ b/tests/test_views.py
@@ -233,7 +233,12 @@ def test_minihtml_ignores_non_allowed_markup_content(self) -> None:
 
     def test_minihtml_magiclinks(self) -> None:
         content = {'value': 'https://github.com/sublimelsp/LSP', 'kind': 'markdown'}
-        expect = '<p><a href="https://github.com/sublimelsp/LSP">github.com/sublimelsp/LSP</a></p>'
+        expect_attributes = [
+            'class="magiclink magiclink-github magiclink-repository"',
+            'href="https://github.com/sublimelsp/LSP"',
+            'title="GitHub Repository: sublimelsp/LSP"'
+        ]
+        expect = '<p><a {}>sublimelsp/LSP</a></p>'.format(' '.join(expect_attributes))
         self.assertEqual(minihtml(self.view, content, allowed_formats=FORMAT_MARKUP_CONTENT), expect)
 
     def _strip_style_attributes(self, content: str) -> str: