From 37fef1a62446b50645a93e4699c7ac4e03e1657f Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 2 Mar 2026 23:38:00 +0000 Subject: [PATCH 1/9] lsp-devtools: don't use a thread --- lib/lsp-devtools/changes/229.fix.md | 1 + lib/lsp-devtools/lsp_devtools/cli/inspector.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 lib/lsp-devtools/changes/229.fix.md diff --git a/lib/lsp-devtools/changes/229.fix.md b/lib/lsp-devtools/changes/229.fix.md new file mode 100644 index 0000000..d1b16d7 --- /dev/null +++ b/lib/lsp-devtools/changes/229.fix.md @@ -0,0 +1 @@ +Fix issue where `lsp-devtools inspect` would not exit cleanly diff --git a/lib/lsp-devtools/lsp_devtools/cli/inspector.py b/lib/lsp-devtools/lsp_devtools/cli/inspector.py index 7c09216..81cceda 100644 --- a/lib/lsp-devtools/lsp_devtools/cli/inspector.py +++ b/lib/lsp-devtools/lsp_devtools/cli/inspector.py @@ -68,7 +68,7 @@ async def on_ready(self, event: Ready): table.focus() if self.server is not None: - self.run_worker(self.server.start_tcp(), name="lsp-connection", thread=True) + self.run_worker(self.server.start_tcp(), name="lsp-connection") async def action_quit(self): if self.server is not None: From dc9ddc11301360934deff2356ae779c2a236dea6 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Wed, 18 Mar 2026 18:10:48 +0000 Subject: [PATCH 2/9] pytest-lsp: test on pytest v9 --- lib/pytest-lsp/hatch.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pytest-lsp/hatch.toml b/lib/pytest-lsp/hatch.toml index db2171b..0e06a7c 100644 --- a/lib/pytest-lsp/hatch.toml +++ b/lib/pytest-lsp/hatch.toml @@ -16,9 +16,10 @@ UV_PRERELEASE="allow" [[envs.hatch-test.matrix]] python = ["3.10", "3.11", "3.12", "3.13", "3.14"] -pytest = ["8"] +pytest = ["8", "9"] [envs.hatch-test.overrides] matrix.pytest.dependencies = [ { value = "pytest>=8,<9", if = ["8"] }, + { value = "pytest>=9,<10", if = ["9"] }, ] From e0daa23a7e35374eef022a3d645ff581dd008872 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:22:03 +0000 Subject: [PATCH 3/9] build(deps): bump actions/upload-artifact from 5 to 7 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v5...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/lsp-devtools-release.yml | 2 +- .github/workflows/pytest-lsp-release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lsp-devtools-release.yml b/.github/workflows/lsp-devtools-release.yml index e56eb83..4485945 100644 --- a/.github/workflows/lsp-devtools-release.yml +++ b/.github/workflows/lsp-devtools-release.yml @@ -44,7 +44,7 @@ jobs: hatch build - name: 'Upload Artifact' - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: 'dist' path: lib/lsp-devtools/dist diff --git a/.github/workflows/pytest-lsp-release.yml b/.github/workflows/pytest-lsp-release.yml index 992f69e..47c61b4 100644 --- a/.github/workflows/pytest-lsp-release.yml +++ b/.github/workflows/pytest-lsp-release.yml @@ -44,7 +44,7 @@ jobs: hatch build - name: 'Upload Artifact' - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v7 with: name: 'dist' path: lib/pytest-lsp/dist From b42fca902ba042b288691f296e89d08c494d20b8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 17:51:38 +0000 Subject: [PATCH 4/9] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.14.7 → v0.14.10](https://github.com/astral-sh/ruff-pre-commit/compare/v0.14.7...v0.14.10) - [github.com/pre-commit/mirrors-mypy: v1.19.0 → v1.19.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.19.0...v1.19.1) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eddb966..ec30a1e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,7 +12,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.7 + rev: v0.14.10 hooks: - id: ruff name: ruff (lsp-devtools) @@ -33,7 +33,7 @@ repos: files: 'lib/pytest-lsp/.*\.py' - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.19.0' + rev: 'v1.19.1' hooks: - id: mypy name: mypy (pytest-lsp) From acae70b21e5a9c382c8e39c9e07b927015581016 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sat, 21 Mar 2026 18:48:53 +0000 Subject: [PATCH 5/9] lsp-devtools: don't dismiss dialog twice --- lib/lsp-devtools/changes/231.fix.md | 1 + lib/lsp-devtools/lsp_devtools/inspector/message_filters.py | 1 + 2 files changed, 2 insertions(+) create mode 100644 lib/lsp-devtools/changes/231.fix.md diff --git a/lib/lsp-devtools/changes/231.fix.md b/lib/lsp-devtools/changes/231.fix.md new file mode 100644 index 0000000..4b4b27f --- /dev/null +++ b/lib/lsp-devtools/changes/231.fix.md @@ -0,0 +1 @@ +Fix crash caused by trying to dismiss the message filter dialog twice. diff --git a/lib/lsp-devtools/lsp_devtools/inspector/message_filters.py b/lib/lsp-devtools/lsp_devtools/inspector/message_filters.py index d805706..7fa637d 100644 --- a/lib/lsp-devtools/lsp_devtools/inspector/message_filters.py +++ b/lib/lsp-devtools/lsp_devtools/inspector/message_filters.py @@ -136,5 +136,6 @@ def on_selection_list_selected_changed( def on_button_pressed(self, event: Button.Pressed): if event.button.id == "save": self.dismiss(self.msg_filter) + return self.dismiss(None) From 834b4cd22c8b543ad87c14a330ec52290003c26e Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sat, 21 Mar 2026 18:51:54 +0000 Subject: [PATCH 6/9] lsp-devtools: bump textual version --- lib/lsp-devtools/changes/233.misc.md | 1 + lib/lsp-devtools/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 lib/lsp-devtools/changes/233.misc.md diff --git a/lib/lsp-devtools/changes/233.misc.md b/lib/lsp-devtools/changes/233.misc.md new file mode 100644 index 0000000..57bbcdf --- /dev/null +++ b/lib/lsp-devtools/changes/233.misc.md @@ -0,0 +1 @@ +Bump textual to `>=8.1.0` diff --git a/lib/lsp-devtools/pyproject.toml b/lib/lsp-devtools/pyproject.toml index e33cbca..ea216ef 100644 --- a/lib/lsp-devtools/pyproject.toml +++ b/lib/lsp-devtools/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "platformdirs", "pygls>=2.0", "stamina", - "textual>=6.5.0", + "textual>=8.1.0", ] [project.urls] From 0234bc4162fc12f9854ee8fc572966c680ec5f29 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 4 Jan 2026 17:52:11 +0000 Subject: [PATCH 7/9] lsp-devtools: restructure code, start thinking about config --- .../cli/{client.py => client/__init__.py} | 17 ++++++++++++---- .../lsp_client.py => cli/client/client.py} | 0 .../lsp_devtools/cli/client/config.py | 20 +++++++++++++++++++ .../lsp_devtools/client/__init__.py | 3 --- .../lsp_devtools/inspector/__init__.py | 3 +++ 5 files changed, 36 insertions(+), 7 deletions(-) rename lib/lsp-devtools/lsp_devtools/cli/{client.py => client/__init__.py} (90%) rename lib/lsp-devtools/lsp_devtools/{client/lsp_client.py => cli/client/client.py} (100%) create mode 100644 lib/lsp-devtools/lsp_devtools/cli/client/config.py delete mode 100644 lib/lsp-devtools/lsp_devtools/client/__init__.py diff --git a/lib/lsp-devtools/lsp_devtools/cli/client.py b/lib/lsp-devtools/lsp_devtools/cli/client/__init__.py similarity index 90% rename from lib/lsp-devtools/lsp_devtools/cli/client.py rename to lib/lsp-devtools/lsp_devtools/cli/client/__init__.py index ee8c1ac..814c44d 100644 --- a/lib/lsp-devtools/lsp_devtools/cli/client.py +++ b/lib/lsp-devtools/lsp_devtools/cli/client/__init__.py @@ -14,16 +14,19 @@ from textual.widgets import Footer from lsp_devtools.cli.utils import LiveSqlHandler -from lsp_devtools.client import LanguageClient from lsp_devtools.editor import Explorer from lsp_devtools.editor import OutputWindow from lsp_devtools.editor import Panel from lsp_devtools.editor import TextEditorView -from lsp_devtools.inspector.message_browser import MessageBrowser +from lsp_devtools.inspector import MessageBrowser + +from .client import LanguageClient +from .config import ConfigurationScreen if typing.TYPE_CHECKING: from textual.app import ComposeResult from textual.widgets import DirectoryTree + from textual.worker import Worker class StderrReceived(Message): @@ -64,6 +67,7 @@ class LSPClient(App[None]): BINDINGS = [ ("ctrl+c", "quit"), ("f2", "toggle_explorer", "Explorer"), + ("f5", "run_server", "Run Server"), ("f8", "toggle_panel", "Panel"), ("f12", "toggle_devtools", "Devtools"), ] @@ -73,6 +77,7 @@ def __init__(self, *args, server_command: list[str], **kwargs): self.server_command = server_command self.client: LanguageClient | None = None + self.server: Worker[None] | None = None self.db = LiveSqlHandler() self.db.app = self @@ -89,6 +94,9 @@ def compose(self) -> ComposeResult: # Footer yield Footer() + def action_open_settings(self): + _ = self.push_screen(ConfigurationScreen()) + def action_toggle_explorer(self) -> None: explorer = self.query_one(Explorer) is_visible = not explorer.has_class("hidden") @@ -122,8 +130,9 @@ def action_toggle_panel(self) -> None: panel.remove_class("hidden") self.screen.set_focus(panel) - def on_ready(self, event: events.Ready): - self.run_worker(self.start_server(), name="lsp-connection") + def action_run_server(self): + if self.server is None: + self.server = self.run_worker(self.start_server(), name="server-connection") @on(LiveSqlHandler.MessageReceived) def on_message_received(self, event: LiveSqlHandler.MessageReceived): diff --git a/lib/lsp-devtools/lsp_devtools/client/lsp_client.py b/lib/lsp-devtools/lsp_devtools/cli/client/client.py similarity index 100% rename from lib/lsp-devtools/lsp_devtools/client/lsp_client.py rename to lib/lsp-devtools/lsp_devtools/cli/client/client.py diff --git a/lib/lsp-devtools/lsp_devtools/cli/client/config.py b/lib/lsp-devtools/lsp_devtools/cli/client/config.py new file mode 100644 index 0000000..fc64e3d --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/cli/client/config.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from textual.screen import ModalScreen +from textual.widgets import TabbedContent +from textual.widgets import TabPane + + +class ConfigurationScreen(ModalScreen[None]): + """A a screen used for configuration settings.""" + + def compose(self): + with TabbedContent(): + yield ServerConfig(title="Server Settings") + yield ClientConfig(title="Client Settings") + + +class ServerConfig(TabPane): ... + + +class ClientConfig(TabPane): ... diff --git a/lib/lsp-devtools/lsp_devtools/client/__init__.py b/lib/lsp-devtools/lsp_devtools/client/__init__.py deleted file mode 100644 index 4571234..0000000 --- a/lib/lsp-devtools/lsp_devtools/client/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .lsp_client import LanguageClient - -__all__ = ("LanguageClient",) diff --git a/lib/lsp-devtools/lsp_devtools/inspector/__init__.py b/lib/lsp-devtools/lsp_devtools/inspector/__init__.py index e69de29..fb67d1b 100644 --- a/lib/lsp-devtools/lsp_devtools/inspector/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/inspector/__init__.py @@ -0,0 +1,3 @@ +from .message_browser import MessageBrowser + +__all__ = ("MessageBrowser",) From be525c023fe72b9a7ccf2baf8c5db560ff5f0dfb Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 4 Jan 2026 21:29:28 +0000 Subject: [PATCH 8/9] lsp-devtools: Make server command configurable --- .../lsp_devtools/cli/client/__init__.py | 66 ++++++++-- .../lsp_devtools/cli/client/config.py | 121 +++++++++++++++++- lib/lsp-devtools/pyproject.toml | 3 + 3 files changed, 171 insertions(+), 19 deletions(-) diff --git a/lib/lsp-devtools/lsp_devtools/cli/client/__init__.py b/lib/lsp-devtools/lsp_devtools/cli/client/__init__.py index 814c44d..a254497 100644 --- a/lib/lsp-devtools/lsp_devtools/cli/client/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/cli/client/__init__.py @@ -10,7 +10,9 @@ from textual import events from textual import on from textual.app import App +from textual.containers import Vertical from textual.message import Message +from textual.widgets import Button from textual.widgets import Footer from lsp_devtools.cli.utils import LiveSqlHandler @@ -21,6 +23,7 @@ from lsp_devtools.inspector import MessageBrowser from .client import LanguageClient +from .config import AppConfig from .config import ConfigurationScreen if typing.TYPE_CHECKING: @@ -44,11 +47,16 @@ class LSPClient(App[None]): display: none; } - Explorer { + #left-sidebar { dock: left; width: 15%; } + #open-settings-btn { + margin: 1; + width: 100%; + } + MessageBrowser { dock: right; width: 30%; @@ -72,10 +80,10 @@ class LSPClient(App[None]): ("f12", "toggle_devtools", "Devtools"), ] - def __init__(self, *args, server_command: list[str], **kwargs): + def __init__(self, *args, config: AppConfig, **kwargs): super().__init__(*args, **kwargs) - self.server_command = server_command + self.config: AppConfig = config self.client: LanguageClient | None = None self.server: Worker[None] | None = None @@ -89,16 +97,26 @@ def compose(self) -> ComposeResult: # Sidebars yield MessageBrowser() - yield Explorer() + + with Vertical(id="left-sidebar"): + yield Explorer() + yield Button("Settings", id="open-settings-btn") # Footer yield Footer() def action_open_settings(self): - _ = self.push_screen(ConfigurationScreen()) + def maybe_update_config(new_config: AppConfig | None): + if new_config is not None: + # TODO: Restart server as required. + self.config = new_config + + _ = self.push_screen( + ConfigurationScreen(config=self.config), maybe_update_config + ) def action_toggle_explorer(self) -> None: - explorer = self.query_one(Explorer) + explorer = self.query_one("#left-sidebar") is_visible = not explorer.has_class("hidden") if is_visible: @@ -133,6 +151,19 @@ def action_toggle_panel(self) -> None: def action_run_server(self): if self.server is None: self.server = self.run_worker(self.start_server(), name="server-connection") + return + + # Did the process exit? + if self.server.is_finished: + self.server = self.run_worker(self.start_server(), name="server-connection") + return + + # TODO: Add logic for restarting the server process. + + def on_ready(self, event: events.Ready): + # Auto start server if possible. + if len(self.config.server.command) > 0: + self.action_run_server() @on(LiveSqlHandler.MessageReceived) def on_message_received(self, event: LiveSqlHandler.MessageReceived): @@ -145,6 +176,10 @@ def on_stderr_received(self, event: StderrReceived): log = panel.query_one("#stderr-window", OutputWindow) log.write(event.data) + def on_button_pressed(self, event: Button.Pressed): + if event.button.id == "open-settings-btn": + self.action_open_settings() + def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected): """Handle file-open.""" editor = self.query_one(TextEditorView) @@ -153,6 +188,11 @@ def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected): async def start_server(self): """Start the server and connect to it.""" + server_config = self.config.server + if len(server_config.command) == 0: + # TODO: Prompt user to set a command. + return + def stderr_handler(data: bytes): self.app.post_message(StderrReceived(data)) @@ -162,7 +202,7 @@ def stderr_handler(data: bytes): name="lsp-devtools", version=importlib.metadata.version("lsp-devtools"), ) - await self.client.start_io(*self.server_command) + await self.client.start_io(*server_config.command) result = await self.client.initialize_async( types.InitializeParams( @@ -180,12 +220,14 @@ def stderr_handler(data: bytes): def client(args, extra: list[str]): - if len(extra) == 0: - raise ValueError( - "Missing server command. (e.g. lsp-devtools client -- server-cmd --stdio)" - ) + # TODO: Read configs from file. + config = AppConfig() + + # Allow for a server command to be passed on the cli. + if len(extra) > 0: + config.server.command = extra - app = LSPClient(server_command=extra) + app = LSPClient(config=config) app.run() diff --git a/lib/lsp-devtools/lsp_devtools/cli/client/config.py b/lib/lsp-devtools/lsp_devtools/cli/client/config.py index fc64e3d..44febf4 100644 --- a/lib/lsp-devtools/lsp_devtools/cli/client/config.py +++ b/lib/lsp-devtools/lsp_devtools/cli/client/config.py @@ -1,20 +1,127 @@ from __future__ import annotations +import shlex +import typing + +import attrs +from textual.containers import Grid +from textual.containers import Horizontal from textual.screen import ModalScreen +from textual.widgets import Button +from textual.widgets import Input +from textual.widgets import Label from textual.widgets import TabbedContent from textual.widgets import TabPane +if typing.TYPE_CHECKING: + from textual.app import ComposeResult + + +@attrs.define +class ServerConfig: + """Configuration for the server process.""" + + command: list[str] = attrs.field(factory=list) + + +@attrs.define +class AppConfig: + """Represents the configuration for the whole application.""" + + server: ServerConfig = attrs.field(factory=ServerConfig) -class ConfigurationScreen(ModalScreen[None]): + +class ConfigurationScreen(ModalScreen[AppConfig | None]): """A a screen used for configuration settings.""" - def compose(self): - with TabbedContent(): - yield ServerConfig(title="Server Settings") - yield ClientConfig(title="Client Settings") + DEFAULT_CSS = """ + ConfigurationScreen { + align: center middle; + } + + #config-content { + width: 80%; + height: auto; + max-height: 80%; + + background: $panel; + border: round $primary; + + padding: 2; + grid-size: 1; + grid-gutter: 1; + grid-rows: auto 1fr; + } + + #config-actions { + align: right bottom; + column-span: 2; + } + """ + + def __init__(self, *args, config: AppConfig, **kwargs): + super().__init__(*args, **kwargs) + self.config = config + + def compose(self) -> ComposeResult: + with Grid(id="config-content") as content: + content.border_title = "Configuration Settings" + + with TabbedContent(): + yield ServerConfigForm(title="Server", config=self.config.server) + yield ClientConfigForm(title="Client") + + with Horizontal(id="config-actions"): + yield Button("Cancel", flat=True, id="cancel") + yield Button("Save", id="save", flat=True, variant="success") + + def on_button_pressed(self, event: Button.Pressed): + if event.button.id == "save": + server_form = self.query_one(ServerConfigForm) + config = AppConfig(server=server_form.get_config()) + _ = self.dismiss(config) + + _ = self.dismiss(None) + + +class ServerConfigForm(TabPane): + DEFAULT_CSS = """ + #server-settings { + width: 100%; + height: auto; + + padding: 2; + grid-size: 2; + grid-gutter: 1; + grid-columns: auto 1fr; + } + """ + + def __init__(self, *args, config: ServerConfig, **kwargs): + super().__init__(*args, **kwargs) + self.config = config + + def get_config(self) -> ServerConfig: + """Return the configuration as defined by the form""" + command_field = self.query_one("#server-command", Input) + command = shlex.split(command_field.value) + + return ServerConfig(command=command) + + def set_config(self, config: ServerConfig): + """Set the form according to the given config.""" + command_field = self.query_one("#server-command#", Input) + command_field.value = " ".join(config.command) + + def compose(self) -> ComposeResult: + with Grid(id="server-settings"): + yield Label("Command") + command = None + if len(self.config.command) > 0: + command = " ".join(self.config.command) -class ServerConfig(TabPane): ... + yield Input(id="server-command", value=command) -class ClientConfig(TabPane): ... +class ClientConfigForm(TabPane): ... diff --git a/lib/lsp-devtools/pyproject.toml b/lib/lsp-devtools/pyproject.toml index ea216ef..86b582a 100644 --- a/lib/lsp-devtools/pyproject.toml +++ b/lib/lsp-devtools/pyproject.toml @@ -30,6 +30,9 @@ dependencies = [ "textual>=8.1.0", ] +[dependency-groups] +dev = ["textual-dev"] + [project.urls] "Bug Tracker" = "https://github.com/swyddfa/lsp-devtools/issues" "Documentation" = "https://lsp-devtools.readthedocs.io/en/latest/" From a1d7e52aafade5c31bad6474d3a4a436aa112189 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 23 Mar 2026 19:05:39 +0000 Subject: [PATCH 9/9] lsp-devtools: Stop previous server before starting the next This adds a `ClientState` enum used to track the state of the lsp client. The previous `action_run_server` implementation couldn't detect that the server was already running as the `self.server` worker only tracked the state of the `start_server()` coroutine rather than the server itself! By tracking the state of the client directly this implementation now ensures that the previous server is shutdown before starting the next server. This commit also replaces the `stderr_handler` with a regular Python logger allowing for errors and debug info to also be reported. The `StderrReceived` has also been removed in favour of the `OutputWindowHandler` which formats log messages according to their severity and sends it to the associated `OutputWindow`. --- lib/lsp-devtools/changes/234.feature.md | 3 + .../lsp_devtools/cli/client/__init__.py | 91 ++++++++++--------- .../lsp_devtools/cli/client/client.py | 81 +++++++++++++---- .../lsp_devtools/cli/client/config.py | 2 + lib/lsp-devtools/lsp_devtools/editor/panel.py | 45 +++++++-- 5 files changed, 156 insertions(+), 66 deletions(-) create mode 100644 lib/lsp-devtools/changes/234.feature.md diff --git a/lib/lsp-devtools/changes/234.feature.md b/lib/lsp-devtools/changes/234.feature.md new file mode 100644 index 0000000..e18f97a --- /dev/null +++ b/lib/lsp-devtools/changes/234.feature.md @@ -0,0 +1,3 @@ +The server command used with the `lsp-devtools client` command can now be configured at runtime. + +Server process lifecycle information and errors are now also logged to the `Server` panel. diff --git a/lib/lsp-devtools/lsp_devtools/cli/client/__init__.py b/lib/lsp-devtools/lsp_devtools/cli/client/__init__.py index a254497..44a4e60 100644 --- a/lib/lsp-devtools/lsp_devtools/cli/client/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/cli/client/__init__.py @@ -2,6 +2,7 @@ import argparse import importlib.metadata +import logging import os import typing @@ -22,6 +23,7 @@ from lsp_devtools.editor import TextEditorView from lsp_devtools.inspector import MessageBrowser +from .client import ClientState from .client import LanguageClient from .config import AppConfig from .config import ConfigurationScreen @@ -29,13 +31,6 @@ if typing.TYPE_CHECKING: from textual.app import ComposeResult from textual.widgets import DirectoryTree - from textual.worker import Worker - - -class StderrReceived(Message): - def __init__(self, data: bytes): - super().__init__() - self.data = data @typing.final @@ -85,7 +80,6 @@ def __init__(self, *args, config: AppConfig, **kwargs): self.config: AppConfig = config self.client: LanguageClient | None = None - self.server: Worker[None] | None = None self.db = LiveSqlHandler() self.db.app = self @@ -120,10 +114,10 @@ def action_toggle_explorer(self) -> None: is_visible = not explorer.has_class("hidden") if is_visible: - explorer.add_class("hidden") + _ = explorer.add_class("hidden") else: - explorer.remove_class("hidden") + _ = explorer.remove_class("hidden") self.screen.set_focus(explorer) def action_toggle_devtools(self) -> None: @@ -131,10 +125,10 @@ def action_toggle_devtools(self) -> None: is_visible = not devtools.has_class("hidden") if is_visible: - devtools.add_class("hidden") + _ = devtools.add_class("hidden") else: - devtools.remove_class("hidden") + _ = devtools.remove_class("hidden") self.screen.set_focus(devtools) def action_toggle_panel(self) -> None: @@ -142,40 +136,25 @@ def action_toggle_panel(self) -> None: is_visible = not panel.has_class("hidden") if is_visible: - panel.add_class("hidden") + _ = panel.add_class("hidden") else: - panel.remove_class("hidden") + _ = panel.remove_class("hidden") self.screen.set_focus(panel) - def action_run_server(self): - if self.server is None: - self.server = self.run_worker(self.start_server(), name="server-connection") - return - - # Did the process exit? - if self.server.is_finished: - self.server = self.run_worker(self.start_server(), name="server-connection") - return - - # TODO: Add logic for restarting the server process. + async def action_run_server(self): + _ = self.run_worker(self.run_server()) - def on_ready(self, event: events.Ready): + async def on_ready(self, event: events.Ready): # Auto start server if possible. if len(self.config.server.command) > 0: - self.action_run_server() + await self.action_run_server() @on(LiveSqlHandler.MessageReceived) def on_message_received(self, event: LiveSqlHandler.MessageReceived): browser = self.query_one(MessageBrowser) browser.reload(follow=True) - @on(StderrReceived) - def on_stderr_received(self, event: StderrReceived): - panel = self.query_one(Panel) - log = panel.query_one("#stderr-window", OutputWindow) - log.write(event.data) - def on_button_pressed(self, event: Button.Pressed): if event.button.id == "open-settings-btn": self.action_open_settings() @@ -185,7 +164,22 @@ def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected): editor = self.query_one(TextEditorView) editor.open_text_document(event.path) - async def start_server(self): + async def run_server(self): + """Start, or restart the server.""" + if self.client is None: + self.client = await self.start_server() + return + + # Don't interfere with a client that is starting up. + if self.client.state in {ClientState.Starting}: + return + + if self.client.state in {ClientState.Running}: + await self.stop_server() + + self.client = await self.start_server() + + async def start_server(self) -> LanguageClient | None: """Start the server and connect to it.""" server_config = self.config.server @@ -193,18 +187,23 @@ async def start_server(self): # TODO: Prompt user to set a command. return - def stderr_handler(data: bytes): - self.app.post_message(StderrReceived(data)) + output_window = self.query_one("#stderr-window", OutputWindow) + output_window.clear() + + server_logger = logging.getLogger("server") + server_logger.setLevel(logging.DEBUG) + server_logger.addHandler(output_window.log_handler) - self.client = LanguageClient( + client = LanguageClient( self.db, - stderr_handler=stderr_handler, + logger=server_logger, name="lsp-devtools", version=importlib.metadata.version("lsp-devtools"), ) - await self.client.start_io(*server_config.command) - result = await self.client.initialize_async( + await client.start_io(*server_config.command) + + result = await client.initialize_async( types.InitializeParams( capabilities=types.ClientCapabilities(), process_id=os.getpid(), @@ -216,7 +215,17 @@ def stderr_handler(data: bytes): ], ) ) - self.client.initialized(types.InitializedParams()) + client.initialized(types.InitializedParams()) + return client + + async def stop_server(self): + if self.client is None or self.client.state not in {ClientState.Running}: + return + + await self.client.shutdown_async(None) + self.client.exit(None) + + await self.client.stop() def client(args, extra: list[str]): diff --git a/lib/lsp-devtools/lsp_devtools/cli/client/client.py b/lib/lsp-devtools/lsp_devtools/cli/client/client.py index 56ee55c..a74c3c4 100644 --- a/lib/lsp-devtools/lsp_devtools/cli/client/client.py +++ b/lib/lsp-devtools/lsp_devtools/cli/client/client.py @@ -1,7 +1,8 @@ from __future__ import annotations import asyncio -import inspect +import enum +import logging import typing from pygls.lsp.client import LanguageClient as BaseLanguageClient @@ -20,6 +21,25 @@ StderrHandler = Callable[[bytes], None | Awaitable[None]] +class ClientState(enum.Enum): + """The set of possible states the server may be in.""" + + Starting = enum.auto() + """The server is starting.""" + + Restarting = enum.auto() + """The server is restarting.""" + + Running = enum.auto() + """The server is running normally.""" + + Errored = enum.auto() + """The server has enountered some unrecoverable error and should not be used.""" + + Exited = enum.auto() + """The server is no longer running.""" + + class MessageWriter: """A writer compatible with pygls' AsyncWriter interface.""" @@ -59,13 +79,14 @@ class LanguageClient(BaseLanguageClient): def __init__( self, message_handler: JsonRPCHandler, - stderr_handler: StderrHandler | None = None, + logger: logging.Logger | None = None, *args, **kwargs, ): super().__init__(*args, **kwargs) + self.logger = logger or logging.getLogger(__name__) + self.state: ClientState | None = None self.message_handler: JsonRPCHandler = message_handler - self.stderr_handler: StderrHandler | None = stderr_handler async def start_io(self, cmd: str, *args, **kwargs): """Start the given server and communicate with it over stdio. @@ -74,6 +95,16 @@ async def start_io(self, cmd: str, *args, **kwargs): through the given JsonRPCHandler. """ + try: + await self._start_io(cmd, *args, **kwargs) + self.state = ClientState.Running + except Exception as exc: + self.logger.error("Unable to start server: %s", exc) + self.state = ClientState.Errored + + async def _start_io(self, cmd: str, *args, **kwargs): + self.state = ClientState.Starting + server = await asyncio.create_subprocess_exec( cmd, *args, @@ -95,23 +126,29 @@ async def start_io(self, cmd: str, *args, **kwargs): if server.stderr is None: raise RuntimeError("Server process is missing a stderr stream") + self.logger.debug("Started server [%s]: %s %s", server.pid, cmd, " ".join(args)) self.protocol.set_writer(MessageWriter(self.message_handler, server.stdin)) - connection = asyncio.create_task( - self.connect_streams( - source=server.stdout, - dest=MessageHandler(self.message_handler, self.protocol), - origin=MessageSource.SERVER, + + self._async_tasks.append( + asyncio.create_task( + self.connect_streams( + source=server.stdout, + dest=MessageHandler(self.message_handler, self.protocol), + origin=MessageSource.SERVER, + ), + name="server-connection", + ) + ) + self._async_tasks.append( + asyncio.create_task(self._server_exit(), name="exit-notifier"), + ) + self._async_tasks.append( + asyncio.create_task( + forward_stderr(server.stderr, self.logger), name="stderr-stream" ) ) - notify_exit = asyncio.create_task(self._server_exit()) self._server = server - self._async_tasks.extend([connection, notify_exit]) - - if self.stderr_handler is not None: - self._async_tasks.append( - asyncio.create_task(forward_stderr(server.stderr, self.stderr_handler)) - ) async def connect_streams( self, source: asyncio.StreamReader, dest: MessageHandler, origin: MessageSource @@ -122,10 +159,18 @@ async def connect_streams( while (data := await source.read(1024)) != b"": dest.feed(data, origin) + async def server_exit(self, server: asyncio.subprocess.Process): + """Called when the server process exits.""" + if server.returncode != 0: + self.logger.error("Server process exited with code: %s", server.returncode) + self.state = ClientState.Errored + else: + self.logger.debug("Server process exited with code: %s", server.returncode) + self.state = ClientState.Exited + -async def forward_stderr(stderr: asyncio.StreamReader, handler: StderrHandler): +async def forward_stderr(stderr: asyncio.StreamReader, logger: logging.Logger): """Forward stderr output onto the given handler.""" while (data := await stderr.readline()) != b"": - if inspect.isawaitable(res := handler(data)): - await res + logger.info(data.decode("utf-8")) diff --git a/lib/lsp-devtools/lsp_devtools/cli/client/config.py b/lib/lsp-devtools/lsp_devtools/cli/client/config.py index 44febf4..b54a524 100644 --- a/lib/lsp-devtools/lsp_devtools/cli/client/config.py +++ b/lib/lsp-devtools/lsp_devtools/cli/client/config.py @@ -81,6 +81,8 @@ def on_button_pressed(self, event: Button.Pressed): config = AppConfig(server=server_form.get_config()) _ = self.dismiss(config) + return + _ = self.dismiss(None) diff --git a/lib/lsp-devtools/lsp_devtools/editor/panel.py b/lib/lsp-devtools/lsp_devtools/editor/panel.py index e1e648c..520c022 100644 --- a/lib/lsp-devtools/lsp_devtools/editor/panel.py +++ b/lib/lsp-devtools/lsp_devtools/editor/panel.py @@ -1,7 +1,10 @@ from __future__ import annotations +import logging +import typing + from textual.containers import Container -from textual.widgets import Log +from textual.widgets import RichLog from textual.widgets import TabbedContent from textual.widgets import TabPane @@ -11,14 +14,42 @@ class Panel(Container): def compose(self): with TabbedContent(): - yield OutputWindow(id="stderr-window", title="Stderr") - yield OutputWindow(id="log-window", title="Log") + yield OutputWindow(id="stderr-window", title="Server") + yield OutputWindow(id="log-window", title="window/logMessage") + + +@typing.final +class OutputWindowHandler(logging.Handler): + """A logging handler that writes into an output window.""" + + def __init__(self, output: OutputWindow, *args, **kwargs): + super().__init__(*args, **kwargs) + self.output = output + + def emit(self, record: logging.LogRecord) -> None: + message = self.format(record) + if record.levelno == logging.DEBUG: + message = f"[dim]{message}[/dim]" + elif record.levelno == logging.ERROR: + message = f"[bold red]{message}[/bold red]" + return self.output.write(message) + + +@typing.final class OutputWindow(TabPane): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.log_handler = OutputWindowHandler(self) + def compose(self): - yield Log() + yield RichLog(markup=True) + + def clear(self): + log = self.query_one(RichLog) + log.clear() - def write(self, data: bytes): - log = self.query_one(Log) - log.write(data.decode("utf8")) + def write(self, text: str): + log = self.query_one(RichLog) + _ = log.write(text)