From c9c98a045a33d24f408e23c313755b3e13644999 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 4 Jan 2026 17:52:11 +0000 Subject: [PATCH 1/3] 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 bfaa7d58b4cbd0288dd9c2d5f7164f5b5aeaa05a Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 4 Jan 2026 21:29:28 +0000 Subject: [PATCH 2/3] 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 3b527512b463e1a72fcc06cfe69c45511f9d7af6 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 23 Mar 2026 19:05:39 +0000 Subject: [PATCH 3/3] 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)