From f7f5a8e7e6905ba89e2bb9e9c83c3f0f088d4194 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 2 Mar 2026 21:12:23 +0100 Subject: [PATCH 01/26] Introduce SerenaDashboardViewer based on pywebview Shows the dashboard in a separate application window we can control, replacing the use of the webbrowser module to open the dashboard in a browser tab --- pyproject.toml | 2 + src/serena/agent.py | 20 ++-- src/serena/cli.py | 16 ++++ src/serena/dashboard.py | 92 ++++++++++++++++++ uv.lock | 202 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 326 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 587bbf2c..55265316 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,8 @@ dependencies = [ "tiktoken>=0.9.0", "anthropic>=0.54.0", "beautifulsoup4>=4.14.2", + "pywebview>=6.1", + "pystray>=0.19.5", ] [[tool.uv.index]] diff --git a/src/serena/agent.py b/src/serena/agent.py index 83f9694b..d19ff241 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -2,6 +2,7 @@ The Serena Model Context Protocol (MCP) Server """ +import multiprocessing import os import platform import subprocess @@ -27,7 +28,7 @@ from serena.config.serena_config import ( SerenaPaths, ToolInclusionDefinition, ) -from serena.dashboard import SerenaDashboardAPI +from serena.dashboard import SerenaDashboardAPI, SerenaDashboardViewer from serena.ls_manager import LanguageServerManager, LanguageServerManagerInitialisationError from serena.project import Project from serena.prompt_factory import SerenaPromptFactory @@ -375,8 +376,7 @@ class SerenaAgent: dashboard_url = f"http://{dashboard_host}:{port}/dashboard/index.html" self._dashboard_url = dashboard_url log.info("Serena web dashboard started at %s", dashboard_url) - if self.serena_config.web_dashboard_open_on_launch: - self.open_dashboard() + self._start_dashboard_viewer(minimized=not self.serena_config.web_dashboard_open_on_launch) # inform the GUI window (if any) if self._gui_log_viewer is not None: self._gui_log_viewer.set_dashboard_url(dashboard_url) @@ -528,11 +528,19 @@ class SerenaAgent: """ return self._dashboard_url + @staticmethod + def _start_dashboard_viewer_process_function(url: str, minimized: bool) -> None: + SerenaDashboardViewer(url, start_minimized=minimized).run() + + def _start_dashboard_viewer(self, minimized: bool) -> None: + url = self.get_dashboard_url() + assert url is not None + process = multiprocessing.Process(target=self._start_dashboard_viewer_process_function, args=(url, minimized), daemon=True) + process.start() + def open_dashboard(self) -> bool: """ - Opens the Serena web dashboard in the default web browser. - - :return: a message indicating success or failure + Opens the viewer for the Serena dashboard """ if self._dashboard_url is None: raise Exception("Dashboard is not running.") diff --git a/src/serena/cli.py b/src/serena/cli.py index 711e8e9a..61546b1d 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -350,6 +350,22 @@ class TopLevelCommands(AutoRegisteringGroup): else: print(f"{prefix}\n{instr}\n{postfix}") + @staticmethod + @click.command( + "dashboard-viewer", + help="Open the Serena dashboard viewer for a given URL.", + context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, + ) + @click.argument("url", type=str) + @click.option("--width", type=int, default=1400, show_default=True, help="Window width.") + @click.option("--height", type=int, default=900, show_default=True, help="Window height.") + @click.option("--minimized", is_flag=True, default=False, help="Whether to start minimized/in tray.") + def dashboard_viewer(url: str, width: int, height: int, minimized: bool) -> None: + from serena.dashboard import SerenaDashboardViewer + + viewer = SerenaDashboardViewer(url, start_minimized=minimized, width=width, height=height) + viewer.run() + class ModeCommands(AutoRegisteringGroup): """Group for 'mode' subcommands.""" diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index a5c91a04..b2b23df7 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -1,12 +1,17 @@ import os import socket +import sys import threading from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, Any, Self +import pystray +import webview from flask import Flask, Response, request, send_from_directory +from PIL import Image from pydantic import BaseModel +from pystray import MenuItem as Item from sensai.util import logging from serena.analytics import ToolUsageStats @@ -643,3 +648,90 @@ class SerenaDashboardAPI: thread = threading.Thread(target=lambda: self.run(host=host, port=port), daemon=True) thread.start() return thread, port + + +class SerenaDashboardViewer: + """ + Minimal pywebview wrapper with optional system tray. + """ + + def __init__( + self, + url: str, + *, + start_minimized: bool = False, + width: int = 1400, + height: int = 900, + ): + self.url = url + self.tray = True + self.title = "Serena Dashboard" + self.width = width + self.height = height + self.start_minimized = start_minimized + + self.window: webview.Window | None = None + self._tray_icon = None + + def run(self) -> None: + # Set app id (avoid app being lumped together with other Python-based apps in Windows taskbar) + if sys.platform == "win32": + import ctypes + + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("oraios.serena") + + dashboard_path = Path(SERENA_DASHBOARD_DIR) + icon_path = str(dashboard_path / "serena-icon-48.png") + + # Create hidden to avoid flash; show/restore/minimize in start callback. + self.window = webview.create_window( + self.title, + self.url, + width=self.width, + height=self.height, + hidden=self.start_minimized, + confirm_close=False, + ) + + def _start_callback(): + if self.start_minimized: + self.window.minimize() + else: + self.window.show() + self.window.restore() + + if self.tray: + threading.Thread(target=self._run_tray, daemon=True).start() + + webview.start(_start_callback, icon=icon_path) + + def _run_tray(self) -> None: + dashboard_path = Path(SERENA_DASHBOARD_DIR) + + icon_path = dashboard_path / "serena-icon-48.png" + icon_img = Image.open(icon_path) + + def show(_icon, _item): + if self.window: + self.window.show() + self.window.restore() + + def hide(_icon, _item): + if self.window: + self.window.hide() + + def quit_app(_icon, _item): + try: + _icon.stop() + finally: + if self.window: + self.window.destroy() + + menu = pystray.Menu( + Item("Open", show, default=True), + Item("Hide", hide), + Item("Quit", quit_app), + ) + + self._tray_icon = pystray.Icon("dashboard_viewer", icon_img, self.title, menu) + self._tray_icon.run() diff --git a/uv.lock b/uv.lock index 1b96f636..2e111743 100644 --- a/uv.lock +++ b/uv.lock @@ -229,6 +229,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] +[[package]] +name = "bottle" +version = "0.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" }, +] + [[package]] name = "cachecontrol" version = "0.14.3" @@ -323,6 +332,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, ] +[[package]] +name = "clr-loader" +version = "0.2.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/24/c12faf3f61614b3131b5c98d3bf0d376b49c7feaa73edca559aeb2aee080/clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446", size = 83605, upload-time = "2026-01-03T23:13:06.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/61/cf819f8e8bb4d4c74661acf2498ba8d4a296714be3478d21eaabf64f5b9b/clr_loader-0.2.10-py3-none-any.whl", hash = "sha256:ebbbf9d511a7fe95fa28a95a4e04cd195b097881dfe66158dc2c281d3536f282", size = 56483, upload-time = "2026-01-03T23:13:05.439Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -1350,6 +1371,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +] + [[package]] name = "platformdirs" version = "4.3.8" @@ -1393,6 +1440,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, ] +[[package]] +name = "proxy-tools" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/cf/77d3e19b7fabd03895caca7857ef51e4c409e0ca6b37ee6e9f7daa50b642/proxy_tools-0.1.0.tar.gz", hash = "sha256:ccb3751f529c047e2d8a58440d86b205303cf0fe8146f784d1cbcd94f0a28010", size = 2978, upload-time = "2014-05-05T21:02:24.606Z" } + [[package]] name = "psutil" version = "7.0.0" @@ -1603,6 +1656,79 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pyobjc-core" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263, upload-time = "2025-11-14T09:31:35.231Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812, upload-time = "2025-11-14T09:40:53.169Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ef/dcd22b743e38b3c430fce4788176c2c5afa8bfb01085b8143b02d1e75201/pyobjc_framework_quartz-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19f99ac49a0b15dd892e155644fe80242d741411a9ed9c119b18b7466048625a", size = 217795, upload-time = "2025-11-14T09:59:46.922Z" }, +] + +[[package]] +name = "pyobjc-framework-security" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/aa/796e09a3e3d5cee32ebeebb7dcf421b48ea86e28c387924608a05e3f668b/pyobjc_framework_security-12.1.tar.gz", hash = "sha256:7fecb982bd2f7c4354513faf90ba4c53c190b7e88167984c2d0da99741de6da9", size = 168044, upload-time = "2025-11-14T10:22:06.334Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/3d/8d3a39cd292d7c76ab76233498189bc7170fc80f573b415308464f68c7ee/pyobjc_framework_security-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b2d8819f0fb7b619ec7627a0d8c1cac1a57c5143579ce8ac21548165680684b", size = 41287, upload-time = "2025-11-14T10:02:54.491Z" }, +] + +[[package]] +name = "pyobjc-framework-uniformtypeidentifiers" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/b8/dd9d2a94509a6c16d965a7b0155e78edf520056313a80f0cd352413f0d0b/pyobjc_framework_uniformtypeidentifiers-12.1.tar.gz", hash = "sha256:64510a6df78336579e9c39b873cfcd03371c4b4be2cec8af75a8a3d07dff607d", size = 17030, upload-time = "2025-11-14T10:23:02.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/5f/1f10f5275b06d213c9897850f1fca9c881c741c1f9190cea6db982b71824/pyobjc_framework_uniformtypeidentifiers-12.1-py2.py3-none-any.whl", hash = "sha256:ec5411e39152304d2a7e0e426c3058fa37a00860af64e164794e0bcffee813f2", size = 4901, upload-time = "2025-11-14T10:05:51.532Z" }, +] + +[[package]] +name = "pyobjc-framework-webkit" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/10/110a50e8e6670765d25190ca7f7bfeecc47ec4a8c018cb928f4f82c56e04/pyobjc_framework_webkit-12.1.tar.gz", hash = "sha256:97a54dd05ab5266bd4f614e41add517ae62cdd5a30328eabb06792474b37d82a", size = 284531, upload-time = "2025-11-14T10:23:40.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/37/5082a0bbe12e48d4ffa53b0c0f09c77a4a6ffcfa119e26fa8dd77c08dc1c/pyobjc_framework_webkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3db734877025614eaef4504fadc0fbbe1279f68686a6f106f2e614e89e0d1a9d", size = 49970, upload-time = "2025-11-14T10:07:01.413Z" }, +] + [[package]] name = "pyright" version = "1.1.403" @@ -1616,6 +1742,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504, upload-time = "2025-07-09T07:15:50.958Z" }, ] +[[package]] +name = "pystray" +version = "0.19.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "sys_platform == 'linux'" }, + { name = "six" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/64/927a4b9024196a4799eba0180e0ca31568426f258a4a5c90f87a97f51d28/pystray-0.19.5-py2.py3-none-any.whl", hash = "sha256:a0c2229d02cf87207297c22d86ffc57c86c227517b038c0d3c59df79295ac617", size = 49068, upload-time = "2023-09-17T13:44:26.872Z" }, +] + [[package]] name = "pytest" version = "8.4.1" @@ -1687,6 +1827,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, ] +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + +[[package]] +name = "pythonnet" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "clr-loader" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/f1/bfb6811df4745f92f14c47a29e50e89a36b1533130fcc56452d4660bd2d6/pythonnet-3.0.5-py3-none-any.whl", hash = "sha256:f6702d694d5d5b163c9f3f5cc34e0bed8d6857150237fae411fefb883a656d20", size = 297506, upload-time = "2024-12-13T08:30:40.661Z" }, +] + +[[package]] +name = "pywebview" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bottle" }, + { name = "proxy-tools" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-security", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-uniformtypeidentifiers", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-webkit", marker = "sys_platform == 'darwin'" }, + { name = "pythonnet", marker = "sys_platform == 'win32'" }, + { name = "qtpy", marker = "sys_platform == 'openbsd6'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/09/75cf92c4db19350ddb084d841e352ce30e89668815d0494d1dd595c85469/pywebview-6.1.tar.gz", hash = "sha256:f0b95047860caf3d921581f9e16b4edb1a125b23e2ce691c4da2968f8b160ff2", size = 496270, upload-time = "2025-10-21T18:02:44.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/12/ce9006cdcc1834378826b5c0d4b554d899cd25ac9c665569d060f220b0e1/pywebview-6.1-py3-none-any.whl", hash = "sha256:2b552a340557e76a740b045a25937f72dae751e0985d5f5d9556f697ba622ec5", size = 511255, upload-time = "2025-10-21T18:02:42.377Z" }, +] + [[package]] name = "pywin32" version = "311" @@ -1750,6 +1936,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, ] +[[package]] +name = "qtpy" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" }, +] + [[package]] name = "referencing" version = "0.36.2" @@ -1945,7 +2143,9 @@ dependencies = [ { name = "psutil" }, { name = "pydantic" }, { name = "pyright" }, + { name = "pystray" }, { name = "python-dotenv" }, + { name = "pywebview" }, { name = "pyyaml" }, { name = "requests" }, { name = "ruamel-yaml" }, @@ -2009,10 +2209,12 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.10.6" }, { name = "pyinstrument", marker = "extra == 'dev'" }, { name = "pyright", specifier = ">=1.1.396,<2" }, + { name = "pystray", specifier = ">=0.19.5" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.2" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.4.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.5.0" }, { name = "python-dotenv", specifier = ">=1.0.0,<2" }, + { name = "pywebview", specifier = ">=6.1" }, { name = "pyyaml", specifier = ">=6.0.2" }, { name = "requests", specifier = ">=2.32.3,<3" }, { name = "ruamel-yaml", specifier = "==0.18.14" }, From 12b63721619b5b1c01a183975c70a140b1bf2dff Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 7 Mar 2026 12:18:18 +0100 Subject: [PATCH 02/26] Depend on dev version of pywebview for improved icon support --- pyproject.toml | 5 ++++- uv.lock | 10 +++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 55265316..f4ad1be8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,10 +36,13 @@ dependencies = [ "tiktoken>=0.9.0", "anthropic>=0.54.0", "beautifulsoup4>=4.14.2", - "pywebview>=6.1", + "pywebview", "pystray>=0.19.5", ] +[tool.uv.sources] +pywebview = { git = "https://github.com/r0x0r/pywebview", rev = "345596c1f4fd59fd26c7aa02d4176c5362bcbabe" } + [[tool.uv.index]] name = "testpypi" url = "https://test.pypi.org/simple/" diff --git a/uv.lock b/uv.lock index 2e111743..7a750069 100644 --- a/uv.lock +++ b/uv.lock @@ -1853,8 +1853,8 @@ wheels = [ [[package]] name = "pywebview" -version = "6.1" -source = { registry = "https://pypi.org/simple" } +version = "6.2.dev50+g345596c1f" +source = { git = "https://github.com/r0x0r/pywebview?rev=345596c1f4fd59fd26c7aa02d4176c5362bcbabe#345596c1f4fd59fd26c7aa02d4176c5362bcbabe" } dependencies = [ { name = "bottle" }, { name = "proxy-tools" }, @@ -1868,10 +1868,6 @@ dependencies = [ { name = "qtpy", marker = "sys_platform == 'openbsd6'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/09/75cf92c4db19350ddb084d841e352ce30e89668815d0494d1dd595c85469/pywebview-6.1.tar.gz", hash = "sha256:f0b95047860caf3d921581f9e16b4edb1a125b23e2ce691c4da2968f8b160ff2", size = 496270, upload-time = "2025-10-21T18:02:44.323Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/12/ce9006cdcc1834378826b5c0d4b554d899cd25ac9c665569d060f220b0e1/pywebview-6.1-py3-none-any.whl", hash = "sha256:2b552a340557e76a740b045a25937f72dae751e0985d5f5d9556f697ba622ec5", size = 511255, upload-time = "2025-10-21T18:02:42.377Z" }, -] [[package]] name = "pywin32" @@ -2214,7 +2210,7 @@ requires-dist = [ { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.4.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.5.0" }, { name = "python-dotenv", specifier = ">=1.0.0,<2" }, - { name = "pywebview", specifier = ">=6.1" }, + { name = "pywebview", git = "https://github.com/r0x0r/pywebview?rev=345596c1f4fd59fd26c7aa02d4176c5362bcbabe" }, { name = "pyyaml", specifier = ">=6.0.2" }, { name = "requests", specifier = ">=2.32.3,<3" }, { name = "ruamel-yaml", specifier = "==0.18.14" }, From 658bf29c8d141b898ec9c58c8b5279c41b6fabc9 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 2 Mar 2026 21:52:40 +0100 Subject: [PATCH 03/26] Update Serena icons, adding .ico file --- src/serena/dashboard.py | 2 +- .../resources/dashboard/serena-icon-48.png | Bin 6262 -> 5203 bytes .../resources/dashboard/serena-icon-64.png | Bin 0 -> 5286 bytes src/serena/resources/dashboard/serena.ico | Bin 0 -> 6102 bytes 4 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 src/serena/resources/dashboard/serena-icon-64.png create mode 100644 src/serena/resources/dashboard/serena.ico diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index b2b23df7..f54ebbdb 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -681,7 +681,7 @@ class SerenaDashboardViewer: ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("oraios.serena") dashboard_path = Path(SERENA_DASHBOARD_DIR) - icon_path = str(dashboard_path / "serena-icon-48.png") + icon_path = str(dashboard_path / "serena.ico") # Create hidden to avoid flash; show/restore/minimize in start callback. self.window = webview.create_window( diff --git a/src/serena/resources/dashboard/serena-icon-48.png b/src/serena/resources/dashboard/serena-icon-48.png index acb6e08c671ecdee9546fccde58bdab00b0e35af..6aadd7b689af2b823d654e0916320a02fd2abf82 100644 GIT binary patch literal 5203 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 literal 6262 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 diff --git a/src/serena/resources/dashboard/serena-icon-64.png b/src/serena/resources/dashboard/serena-icon-64.png new file mode 100644 index 0000000000000000000000000000000000000000..28dc7d042e5957bd9106335a0d4790f1abefe524 GIT binary patch literal 5286 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 H?_~cMTAWj= literal 0 HcmV?d00001 From 3880b75ec6b48ae1c24a13a94d1fc9fc05597c90 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 7 Mar 2026 12:48:53 +0100 Subject: [PATCH 04/26] In tray mode, intercept close button to minimise to tray instead --- src/serena/dashboard.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index f54ebbdb..f5675da3 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -665,6 +665,9 @@ class SerenaDashboardViewer: ): self.url = url self.tray = True + """ + whether we use the system tray to allow minimizing to tray and intercepting the close event to hide to tray instead of quitting. + """ self.title = "Serena Dashboard" self.width = width self.height = height @@ -672,6 +675,7 @@ class SerenaDashboardViewer: self.window: webview.Window | None = None self._tray_icon = None + self._quitting = False def run(self) -> None: # Set app id (avoid app being lumped together with other Python-based apps in Windows taskbar) @@ -684,6 +688,7 @@ class SerenaDashboardViewer: icon_path = str(dashboard_path / "serena.ico") # Create hidden to avoid flash; show/restore/minimize in start callback. + # When tray is enabled, confirm_close allows us to intercept the X button. self.window = webview.create_window( self.title, self.url, @@ -693,6 +698,9 @@ class SerenaDashboardViewer: confirm_close=False, ) + if self.tray: + self.window.events.closing += self._on_closing + def _start_callback(): if self.start_minimized: self.window.minimize() @@ -705,6 +713,14 @@ class SerenaDashboardViewer: webview.start(_start_callback, icon=icon_path) + def _on_closing(self) -> bool: + """Intercept window close: hide to tray instead of quitting.""" + if self._quitting: + return True + if self.window: + self.window.hide() + return False # Prevent the window from actually closing + def _run_tray(self) -> None: dashboard_path = Path(SERENA_DASHBOARD_DIR) @@ -721,6 +737,7 @@ class SerenaDashboardViewer: self.window.hide() def quit_app(_icon, _item): + self._quitting = True try: _icon.stop() finally: From 161c8209094e4ad697530a910317a2e225c2c864 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 18 Mar 2026 14:52:47 +0100 Subject: [PATCH 05/26] _start_dashboard_viewer: Open in browser as a fallback (upon exception from pywebview) --- src/serena/agent.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index d19ff241..73202d35 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -11,6 +11,7 @@ from collections.abc import Callable, Sequence from logging import Logger from typing import TYPE_CHECKING, Optional, TypeVar +import webview from sensai.util import logging from sensai.util.logging import LogTime from sensai.util.string import TextBuilder @@ -530,7 +531,12 @@ class SerenaAgent: @staticmethod def _start_dashboard_viewer_process_function(url: str, minimized: bool) -> None: - SerenaDashboardViewer(url, start_minimized=minimized).run() + try: + SerenaDashboardViewer(url, start_minimized=minimized).run() + except webview.errors.WebViewException as e: + # This can happen on Linux if neither GTK or QT are available. Open in browser as a fallback + log.warning(f"Could not open Serena Dashboard viewer; falling back to opening browser. Cause:\n{e}") + SerenaAgent._open_dashboard_in_browser(url) def _start_dashboard_viewer(self, minimized: bool) -> None: url = self.get_dashboard_url() @@ -549,15 +555,19 @@ class SerenaAgent: log.warning("Not opening the Serena web dashboard because no usable display was detected.") return False + self._open_dashboard_in_browser(self._dashboard_url) + return True + + @staticmethod + def _open_dashboard_in_browser(url) -> None: # Use a subprocess to avoid any output from webbrowser.open being written to stdout subprocess.Popen( - [sys.executable, "-c", f"import webbrowser; webbrowser.open({self._dashboard_url!r})"], + [sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, # Detach from parent process ) - return True def get_project_root(self) -> str: """ From 1a4e57eec711eb71e00af4aa44a1327236556597 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 28 Mar 2026 11:36:02 +0100 Subject: [PATCH 06/26] SerenaDashboardViewer: Attempt to fix tray on macOS (cannot run in background thread) --- src/serena/dashboard.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 9143bcd5..58d17d5a 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -709,7 +709,7 @@ class SerenaDashboardViewer: self._quitting = False def run(self) -> None: - # Set app id (avoid app being lumped together with other Python-based apps in Windows taskbar) + # set app id (avoid app being lumped together with other Python-based apps in Windows taskbar) if sys.platform == "win32": import ctypes @@ -732,6 +732,9 @@ class SerenaDashboardViewer: if self.tray: self.window.events.closing += self._on_closing + if self.tray: + self._start_tray() + def _start_callback(): if self.start_minimized: self.window.minimize() @@ -739,9 +742,6 @@ class SerenaDashboardViewer: self.window.show() self.window.restore() - if self.tray: - threading.Thread(target=self._run_tray, daemon=True).start() - webview.start(_start_callback, icon=icon_path) def _on_closing(self) -> bool: @@ -752,7 +752,7 @@ class SerenaDashboardViewer: self.window.hide() return False # Prevent the window from actually closing - def _run_tray(self) -> None: + def _start_tray(self) -> None: dashboard_path = Path(SERENA_DASHBOARD_DIR) icon_path = dashboard_path / "serena-icon-48.png" @@ -781,5 +781,19 @@ class SerenaDashboardViewer: Item("Quit", quit_app), ) - self._tray_icon = pystray.Icon("dashboard_viewer", icon_img, self.title, menu) - self._tray_icon.run() + kwargs = {} + if sys.platform == "darwin": + # Passing darwin_nsapplication integrates pystray with the NSApplication + # run loop that webview.start() is about to enter. sharedApplication() + # is idempotent; pywebview will reuse the same singleton. + from AppKit import NSApplication + + kwargs["darwin_nsapplication"] = NSApplication.sharedApplication() + + self._tray_icon = pystray.Icon("dashboard_viewer", icon_img, self.title, menu, **kwargs) + + # On Windows/Linux, run_detached spawns pystray's own internal thread and + # returns immediately. On macOS it hooks into the NSApplication run loop + # that webview.start() is about to enter (run_detached is always called + # before webview.start() on macOS — see run()). + self._tray_icon.run_detached() From 38565950803d46b80193408922cd413b40866ab2 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 28 Mar 2026 11:43:37 +0100 Subject: [PATCH 07/26] SerenaDashboardViewer: Use 64px png icon on non-Windows platforms --- src/serena/dashboard.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 58d17d5a..844b80e3 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -716,7 +716,9 @@ class SerenaDashboardViewer: ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("oraios.serena") dashboard_path = Path(SERENA_DASHBOARD_DIR) - icon_path = str(dashboard_path / "serena.ico") + # .ico is Windows-only; macOS expects a PNG for the window/dock icon. + icon_filename = "serena.ico" if sys.platform == "win32" else "serena-icon-64.png" + icon_path = str(dashboard_path / icon_filename) # Create hidden to avoid flash; show/restore/minimize in start callback. # When tray is enabled, confirm_close allows us to intercept the X button. @@ -755,8 +757,10 @@ class SerenaDashboardViewer: def _start_tray(self) -> None: dashboard_path = Path(SERENA_DASHBOARD_DIR) - icon_path = dashboard_path / "serena-icon-48.png" - icon_img = Image.open(icon_path) + # macOS menu bar icons are displayed at 16pt; 32px covers Retina (@2x). + # Windows/Linux tray icons are larger, so 48px is the better fit there. + icon_filename = "serena-icon-32.png" if sys.platform == "darwin" else "serena-icon-48.png" + icon_img = Image.open(dashboard_path / icon_filename) def show(_icon, _item): if self.window: From 341b00577b422a1594aa7a3c0facf8a638f12438 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 28 Mar 2026 12:33:38 +0100 Subject: [PATCH 08/26] SerenaDashboardViewer: macOS: Try to hide the main window icon from the dock when minimised to tray --- src/serena/dashboard.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 844b80e3..b52aa809 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -736,6 +736,7 @@ class SerenaDashboardViewer: if self.tray: self._start_tray() + self._set_dock_icon_visible(not self.start_minimized) def _start_callback(): if self.start_minimized: @@ -752,7 +753,36 @@ class SerenaDashboardViewer: return True if self.window: self.window.hide() - return False # Prevent the window from actually closing + if sys.platform == "darwin": + # setActivationPolicy_ must run on the main thread; _on_closing + # may be called from a pywebview background thread. + from PyObjCTools.AppHelper import callAfter + + callAfter(self._set_dock_icon_visible, False) + return False # prevent the window from actually closing + + def _set_dock_icon_visible(self, visible: bool) -> None: + """macOS-only; toggles the dock icon. + + Sets the NSApplication activation policy to Regular (shows in dock) or + Accessory (hides from dock). No-op on other platforms. + """ + if sys.platform != "darwin": + return + from AppKit import ( + NSApplication, + NSApplicationActivationPolicyAccessory, + NSApplicationActivationPolicyRegular, + ) + + ns_app = NSApplication.sharedApplication() + policy = NSApplicationActivationPolicyRegular if visible else NSApplicationActivationPolicyAccessory + ns_app.setActivationPolicy_(policy) + if visible: + # setActivationPolicy_ alone is not enough at runtime; unhide_ fires + # the full applicationWillUnhide:/applicationDidUnhide: cycle, which + # is what actually restores the dock icon and brings windows forward. + ns_app.unhide_(None) def _start_tray(self) -> None: dashboard_path = Path(SERENA_DASHBOARD_DIR) @@ -764,12 +794,14 @@ class SerenaDashboardViewer: def show(_icon, _item): if self.window: + self._set_dock_icon_visible(True) self.window.show() self.window.restore() def hide(_icon, _item): if self.window: self.window.hide() + self._set_dock_icon_visible(False) def quit_app(_icon, _item): self._quitting = True From fa8cef0fe88d5d5741e1865004094cb7e3d5516c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 28 Mar 2026 12:52:07 +0100 Subject: [PATCH 09/26] SerenaDashboardViewer: Fix typing issues --- src/serena/dashboard.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index b52aa809..357764a9 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -12,6 +12,7 @@ from flask import Flask, Response, redirect, request, send_from_directory from PIL import Image from pydantic import BaseModel from pystray import MenuItem as Item +from pystray._base import Icon as TrayIcon from sensai.util import logging from serena.analytics import ToolUsageStats @@ -704,8 +705,8 @@ class SerenaDashboardViewer: self.height = height self.start_minimized = start_minimized - self.window: webview.Window | None = None - self._tray_icon = None + self.window: webview.Window + self._tray_icon: TrayIcon self._quitting = False def run(self) -> None: @@ -722,7 +723,7 @@ class SerenaDashboardViewer: # Create hidden to avoid flash; show/restore/minimize in start callback. # When tray is enabled, confirm_close allows us to intercept the X button. - self.window = webview.create_window( + window = webview.create_window( self.title, self.url, width=self.width, @@ -730,6 +731,8 @@ class SerenaDashboardViewer: hidden=self.start_minimized, confirm_close=False, ) + assert window is not None + self.window = window if self.tray: self.window.events.closing += self._on_closing @@ -738,7 +741,7 @@ class SerenaDashboardViewer: self._start_tray() self._set_dock_icon_visible(not self.start_minimized) - def _start_callback(): + def _start_callback() -> None: if self.start_minimized: self.window.minimize() else: @@ -792,18 +795,18 @@ class SerenaDashboardViewer: icon_filename = "serena-icon-32.png" if sys.platform == "darwin" else "serena-icon-48.png" icon_img = Image.open(dashboard_path / icon_filename) - def show(_icon, _item): + def show(_icon: TrayIcon, _item: Item) -> None: if self.window: self._set_dock_icon_visible(True) self.window.show() self.window.restore() - def hide(_icon, _item): + def hide(_icon: TrayIcon, _item: Item) -> None: if self.window: self.window.hide() self._set_dock_icon_visible(False) - def quit_app(_icon, _item): + def quit_app(_icon: TrayIcon, _item: Item) -> None: self._quitting = True try: _icon.stop() @@ -817,7 +820,7 @@ class SerenaDashboardViewer: Item("Quit", quit_app), ) - kwargs = {} + kwargs: dict[str, Any] = {} if sys.platform == "darwin": # Passing darwin_nsapplication integrates pystray with the NSApplication # run loop that webview.start() is about to enter. sharedApplication() From 07dbf5ed22724cba2b20ba30d0bf5ce1352d3553 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 28 Mar 2026 13:07:51 +0100 Subject: [PATCH 10/26] Implement proper fallback behaviour for dashboard viewer Open browser if the viewer was supposed to be shown directly --- src/serena/agent.py | 31 ++++++++++++++++++++++++------- src/serena/dashboard.py | 9 +++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index b495fa59..76d42224 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -522,22 +522,39 @@ class SerenaAgent: @staticmethod def _start_dashboard_viewer_process_function(url: str, minimized: bool) -> None: + """ + Main function of the subprocess for starting the dashboard viewer + """ try: SerenaDashboardViewer(url, start_minimized=minimized).run() except webview.errors.WebViewException as e: - # This can happen on Linux if neither GTK or QT are available. Open in browser as a fallback - log.warning(f"Could not open Serena Dashboard viewer; falling back to opening browser. Cause:\n{e}") - SerenaAgent._open_dashboard_in_browser(url) + log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}") + # Fall back to opening the browser window if the window was supposed to be shown directly + if not minimized: + SerenaAgent._open_dashboard_in_browser(url) def _start_dashboard_viewer(self, minimized: bool) -> None: + """ + Starts the dashboard viewer (in a separate process) or, if the current platform does not support it, + opens the dashboard in the default web browser. + + :param minimized: whether the dashboard viewer should be started minimized (if supported on the current platform). + If the viewer is not supported on the current platform, then this controls whether to open the browser window. + """ url = self.get_dashboard_url() assert url is not None - process = multiprocessing.Process(target=self._start_dashboard_viewer_process_function, args=(url, minimized), daemon=True) - process.start() + if SerenaDashboardViewer.is_current_platform_supported(): + process = multiprocessing.Process(target=self._start_dashboard_viewer_process_function, args=(url, minimized), daemon=True) + process.start() + else: + if not minimized: + self._open_dashboard_in_browser(url) def open_dashboard(self) -> bool: """ - Opens the viewer for the Serena dashboard + Opens the Serena dashboard (for on-demand usage as triggered by the user, e.g. via a tool) + + :return: True if the dashboard was opened, False if it could not be opened """ if self._dashboard_url is None: raise Exception("Dashboard is not running.") @@ -550,7 +567,7 @@ class SerenaAgent: return True @staticmethod - def _open_dashboard_in_browser(url) -> None: + def _open_dashboard_in_browser(url: str) -> None: # Use a subprocess to avoid any output from webbrowser.open being written to stdout subprocess.Popen( [sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"], diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 357764a9..65cbb2d3 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -709,6 +709,15 @@ class SerenaDashboardViewer: self._tray_icon: TrayIcon self._quitting = False + @staticmethod + def is_current_platform_supported() -> bool: + """ + :return: whether the current platform supports the dashboard viewer + """ + # We fully support the dashboard viewer (with system tray) only on Windows and macOS. + # Linux support is problematic; see https://github.com/oraios/serena/pull/1117#issuecomment-4128753943 + return sys.platform in ("win32", "darwin") + def run(self) -> None: # set app id (avoid app being lumped together with other Python-based apps in Windows taskbar) if sys.platform == "win32": From af1de59d2b852d98e38cebee84fd17e9c257140d Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 29 Mar 2026 20:35:50 +0200 Subject: [PATCH 11/26] Adapt Dashboard documention in light of pywebview changes --- docs/02-usage/060_dashboard.md | 78 ++++++++++++++----- .../resources/serena_config.template.yml | 26 +++---- 2 files changed, 71 insertions(+), 33 deletions(-) diff --git a/docs/02-usage/060_dashboard.md b/docs/02-usage/060_dashboard.md index 9bb6406e..3e4e3a5a 100644 --- a/docs/02-usage/060_dashboard.md +++ b/docs/02-usage/060_dashboard.md @@ -2,38 +2,80 @@ Serena comes with built-in tools for monitoring and managing the current session: -* the **web-based dashboard** (enabled by default) +* the **Serena Dashboard** (enabled by default) The dashboard provides detailed information on your Serena session, the current configuration and provides access to logs. Some settings (e.g. the current set of active programming languages) can also be directly modified through the dashboard. The dashboard is supported on all platforms. - - By default, it will be accessible at `http://localhost:24282/dashboard/index.html`, - but a higher port may be used if the default port is unavailable/multiple instances are running. - **We recommend always enabling the dashboard**. If you don't want the browser to open automatically, - you can disable it while still keeping the dashboard running in the background (see below). - -* the **GUI tool** (disabled by default) +* the **GUI Log Viewer** (disabled by default) - The GUI tool is a native application window which displays logs. - It furthermore allows you to shut down the agent and to access the dashboard's URL (if it is running). + The GUI tool is a legacy native application which displays Serena's live logs. This is mainly supported on Windows, but it may also work on Linux; macOS is unsupported. Both can be configured in Serena's [configuration](050_configuration) file (`serena_config.yml`). -If enabled, they will automatically be opened as soon as the Serena agent/MCP server is started. -For the dashboard, this can be disabled if desired (see below). -## Disabling Automatic Browser Opening +## The Serena Dashboard -If you prefer not to have the dashboard open automatically (e.g., to avoid focus stealing), you can disable it -by setting `web_dashboard_open_on_launch: False` in your `serena_config.yml` or by passing `--open-web-dashboard False` -to `start-mcp-server` CLI command. +The dashboard is a web application, which is opened via a native application wrapper on Windows and macOS, +where it will appear in the system tray. **Click on the tray icon** to open the application window. -When automatic opening is disabled, you can still access the dashboard by: +On Linux, native app support is difficult to provide, so the dashboard will be opened in your default web browser instead. + +By default, the dashboard can be accessed at `http://localhost:24282/dashboard/index.html`, +but a higher port may be used if the default port is unavailable/multiple instances are running. + +### Features + +The dashboard provides ... + * a detailed overview of + * the current Serena status and configuration (e.g. active tools, active programming languages, enabled modes and contexts, etc.) + * ongoing and past tool calls (and statistics on tool usage) + * access to Serena's live logs + * the ability to modify settings (e.g. the set of active programming languages) on the fly + * the ability to edit + * configuration files (global `serena_config.yml` and project-specific `project.yml`) + * memories of the current project + * the ability to shut down the Serena MCP server. + +### Configuring the Dashboard + +You can configure settings for the dashboard in the [global configuration file](global-config). + +In particular, you can configure + * whether the dashboard is enabled at all. + We highly recommend keeping it enabled, as it provides valuable insights into Serena's operation and allows you to adjust configuration conveniently. + * whether the dashboard window is opened automatically when Serena is started (see below). + * network settings (for cases where you might want to access the Dashboard non-locally). + +#### Dashboard Opening Behaviour + +When Serena is started, the Dashboard window is opened by default in order to make users aware of its existence. + +If you prefer not to have this happen (e.g., to avoid focus stealing), you can disable prevent it from opening +by setting `web_dashboard_open_on_launch: False` in the [global configuration file](global-config) or by passing `--open-web-dashboard False` +to the `start-mcp-server` CLI command. + +On Windows and macOS, you can conveniently open the dashboard at any time by clicking on the tray icon, so automatic +opening is not a requirement to be able to access the dashboard on these platforms. + +On Linux, access to the dashboard is more cumbersome without native app support. +Yet you may still access it by * asking the LLM to "open the Serena dashboard", which will open the dashboard in your default browser (the tool `open_dashboard` is enabled for this purpose, provided that the dashboard is active, not opened by default and the GUI tool, which can provide the URL, is not enabled) -* navigating directly to the URL (see above) +* navigating directly to the URL (see above). + +## The GUI Log Viewer + +The Serena GUI Log Viewer is a legacy application which provides access to Serena's live logs. + +Via its menu, it furthermore allows you to + * shut down the agent + * access the dashboard's URL (if it is running). + +The tool is mainly supported on Windows, but it may work on some Linux systems as well (depending on your desktop environment). + +To enable the tool, set `gui_log_window` to `true` in Serena's [global configuration file](global-config). \ No newline at end of file diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 7ec556db..a823c743 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -29,30 +29,26 @@ line_ending: native # we have to live with this limitation for now. gui_log_window: False -# whether to open the Serena web dashboard (which will be accessible through your web browser) that -# provides access to Serena's configuration and state as well as the current session logs. -# The web dashboard is supported on all platforms. -# We strongly recommend to always enable this option, since the dashboard provides important information -# about the current state of Serena, including the configuration, the logs and tool usage statistics. -# If you don't want the browser window to pop up automatically, set the `web_dashboard_open_on_launch` to False -# (either here or through the corresponding flag in the `start-mcp-server` CLI command). -# You can then open the dashboard by asking your agent to do so (e.g., by saying "open the dashboard"), Serena provides -# a tool for this. +# whether to start the Serena Dashboard, which provides detailed information on your Serena session, +# the current configuration and furthermore allows some settings to be conveniently modified on the fly. +# We strongly recommend to always enable this option! +# If you want to prevent the Dashboard window from being opened on launch, +# set `web_dashboard_open_on_launch` to false (see below). # Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html web_dashboard: True -# the address the web dashboard will listen on (bind address). -web_dashboard_listen_address: 127.0.0.1 - -# whether to open a browser window with the web dashboard when Serena starts (provided that web_dashboard -# is enabled). See also the web_dashboard option. -# If set to false, you can still open the dashboard manually by +# whether to open the Dashboard window/browser tab when Serena starts (provided that web_dashboard is enabled). +# If set to false, you can still open the dashboard manually by clicking on the Serena icon in your system +# tray on Windows and macOS. On Linux, there is no system tray support, so you can only open the dashboard by # a) telling the LLM to "open the dashboard" (provided that the open_dashboard tool is enabled) or by # b) manually navigating to http://localhost:24282/dashboard/ in your web browser (actual port # may be higher if you have multiple instances running; try ports 24283, 24284, etc.) # See also: https://oraios.github.io/serena/02-usage/060_dashboard.html web_dashboard_open_on_launch: True +# the address the web dashboard will listen on (bind address). +web_dashboard_listen_address: 127.0.0.1 + # address where JetBrains plugin servers are running (only relevant when using the JetBrains language backend) jetbrains_plugin_server_address: 127.0.0.1 From 01e9582cacd26ef82d5dba2695f6f8f4d86fbab7 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Tue, 31 Mar 2026 11:26:01 +0200 Subject: [PATCH 12/26] Nix flake: add proxy-tools (needed to build pywebview) --- flake.nix | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flake.nix b/flake.nix index e8d9ccfa..2454b5fc 100644 --- a/flake.nix +++ b/flake.nix @@ -57,6 +57,11 @@ final.setuptools ]; }); + proxy-tools = prev.proxy-tools.overrideAttrs (old: { + nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ + final.setuptools + ]; + }); }; python = pkgs.python311; From e53e487cae26b296797832201c4d98f216fdf2bc Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Tue, 31 Mar 2026 11:27:11 +0200 Subject: [PATCH 13/26] Pinned pystray exactly --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ca115350..fa434238 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ "anthropic==0.59.0", "beautifulsoup4==4.14.2", "pywebview", - "pystray>=0.19.5", + "pystray==0.19.5", # Transitive deps pinned for security (dependabot alerts). # Exact pins because uvx installs from git, ignoring the lock file. "urllib3==2.6.3", diff --git a/uv.lock b/uv.lock index d7684133..e4ec874a 100644 --- a/uv.lock +++ b/uv.lock @@ -3009,7 +3009,7 @@ requires-dist = [ { name = "pyinstrument", marker = "extra == 'dev'", specifier = "==5.1.1" }, { name = "pyjwt", marker = "extra == 'dev'", specifier = "==2.10.1" }, { name = "pyright", specifier = "==1.1.403" }, - { name = "pystray", specifier = ">=0.19.5" }, + { name = "pystray", specifier = "==0.19.5" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==8.4.1" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = "==2.4.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" }, From 568f9a06af2373f86afb6c092f67f0f8ecd66145 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Tue, 31 Mar 2026 14:29:08 +0200 Subject: [PATCH 14/26] Nix flake: added setuptools as build input for pywebview --- flake.nix | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/flake.nix b/flake.nix index 2454b5fc..e172a186 100644 --- a/flake.nix +++ b/flake.nix @@ -62,6 +62,12 @@ final.setuptools ]; }); + pywebview = prev.pywebview.overrideAttrs (old: { + nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ + final.setuptools + final.setuptools-scm + ]; + }); }; python = pkgs.python311; From 92301c058becb9ed214e25bc828a688f23576937 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 31 Mar 2026 15:27:42 +0200 Subject: [PATCH 15/26] Import pystray locally, because import crashes if there is no display --- src/serena/dashboard.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index dfb5a165..640bee51 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -6,13 +6,10 @@ from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, Any, Self -import pystray import webview from flask import Flask, Response, redirect, request, send_from_directory from PIL import Image from pydantic import BaseModel -from pystray import MenuItem as Item -from pystray._base import Icon as TrayIcon from sensai.util import logging from serena.analytics import ToolUsageStats @@ -708,7 +705,7 @@ class SerenaDashboardViewer: self.start_minimized = start_minimized self.window: webview.Window - self._tray_icon: TrayIcon + self._tray_icon: Any self._quitting = False @staticmethod @@ -799,6 +796,11 @@ class SerenaDashboardViewer: ns_app.unhide_(None) def _start_tray(self) -> None: + # import pystray locally, because the import fails when there is no display! + import pystray + from pystray import MenuItem as Item + from pystray._base import Icon as TrayIcon + dashboard_path = Path(SERENA_DASHBOARD_DIR) # macOS menu bar icons are displayed at 16pt; 32px covers Retina (@2x). From 0bab0125d72f167c4ae853f166800504bfe36896 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 31 Mar 2026 15:30:11 +0200 Subject: [PATCH 16/26] _start_dashboard_viewer: Do nothing if no display detected, improve logging --- src/serena/agent.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/serena/agent.py b/src/serena/agent.py index 76d42224..91431192 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -541,12 +541,17 @@ class SerenaAgent: :param minimized: whether the dashboard viewer should be started minimized (if supported on the current platform). If the viewer is not supported on the current platform, then this controls whether to open the browser window. """ + if not system_has_usable_display(): + log.info("Not starting the Serena dashboard viewer because no usable display was detected.") + return + url = self.get_dashboard_url() assert url is not None if SerenaDashboardViewer.is_current_platform_supported(): process = multiprocessing.Process(target=self._start_dashboard_viewer_process_function, args=(url, minimized), daemon=True) process.start() else: + log.info("Not starting Serena dashboard viewer because the current platform does not support it; using browser-based fallback") if not minimized: self._open_dashboard_in_browser(url) From 0cf16d1eefac3c6b1b434111b00e372e352438f5 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 31 Mar 2026 15:36:04 +0200 Subject: [PATCH 17/26] Circumvent mypy unreachable code error --- src/serena/dashboard.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 640bee51..34927596 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -778,22 +778,21 @@ class SerenaDashboardViewer: Sets the NSApplication activation policy to Regular (shows in dock) or Accessory (hides from dock). No-op on other platforms. """ - if sys.platform != "darwin": - return - from AppKit import ( - NSApplication, - NSApplicationActivationPolicyAccessory, - NSApplicationActivationPolicyRegular, - ) + if sys.platform == "darwin": + from AppKit import ( + NSApplication, + NSApplicationActivationPolicyAccessory, + NSApplicationActivationPolicyRegular, + ) - ns_app = NSApplication.sharedApplication() - policy = NSApplicationActivationPolicyRegular if visible else NSApplicationActivationPolicyAccessory - ns_app.setActivationPolicy_(policy) - if visible: - # setActivationPolicy_ alone is not enough at runtime; unhide_ fires - # the full applicationWillUnhide:/applicationDidUnhide: cycle, which - # is what actually restores the dock icon and brings windows forward. - ns_app.unhide_(None) + ns_app = NSApplication.sharedApplication() + policy = NSApplicationActivationPolicyRegular if visible else NSApplicationActivationPolicyAccessory + ns_app.setActivationPolicy_(policy) + if visible: + # setActivationPolicy_ alone is not enough at runtime; unhide_ fires + # the full applicationWillUnhide:/applicationDidUnhide: cycle, which + # is what actually restores the dock icon and brings windows forward. + ns_app.unhide_(None) def _start_tray(self) -> None: # import pystray locally, because the import fails when there is no display! From 823d5bbe309af494ada1480a2578f664abe27333 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Wed, 1 Apr 2026 09:03:00 +0200 Subject: [PATCH 18/26] Bump pyjwt (dependabot alert) --- pyproject.toml | 2 +- uv.lock | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eb1317ba..a7cdc372 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,7 +93,7 @@ dev = [ "nbconvert==7.17.0", "wheel==0.46.3", "pyasn1==0.6.3", - "PyJWT==2.10.1", + "PyJWT==2.12.0", ] agno = ["agno==2.5.10", "sqlalchemy==2.0.41"] # agno bumped for session state overwrite CVE google = ["google-genai==1.27.0"] diff --git a/uv.lock b/uv.lock index 232870eb..92be1d5d 100644 --- a/uv.lock +++ b/uv.lock @@ -762,6 +762,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -770,6 +771,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -778,6 +780,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -786,6 +789,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -794,6 +798,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -2179,11 +2184,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, + { url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" }, ] [package.optional-dependencies] @@ -3002,7 +3007,7 @@ requires-dist = [ { name = "pyasn1", marker = "extra == 'dev'", specifier = "==0.6.3" }, { name = "pydantic", specifier = "==2.12.5" }, { name = "pyinstrument", marker = "extra == 'dev'", specifier = "==5.1.1" }, - { name = "pyjwt", marker = "extra == 'dev'", specifier = "==2.10.1" }, + { name = "pyjwt", marker = "extra == 'dev'", specifier = "==2.12.0" }, { name = "pyright", specifier = "==1.1.403" }, { name = "pystray", specifier = "==0.19.5" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==8.4.1" }, From 4b0f408e3e458d7535431dbd89103a45fc7bcd4d Mon Sep 17 00:00:00 2001 From: zoajimen Date: Wed, 1 Apr 2026 11:40:34 +0200 Subject: [PATCH 19/26] Implement macOS dock handling and window state management in SerenaDashboardViewer --- src/serena/dashboard.py | 119 ++++++++++++++++++++++------------ test/serena/test_dashboard.py | 107 +++++++++++++++++++++++++++++- 2 files changed, 184 insertions(+), 42 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 34927596..f0cb7c82 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -706,7 +706,10 @@ class SerenaDashboardViewer: self.window: webview.Window self._tray_icon: Any + self._app_delegate: Any = None self._quitting = False + self._window_hidden_to_tray = start_minimized + self._suppress_next_activate_reopen = start_minimized @staticmethod def is_current_platform_supported() -> bool: @@ -747,53 +750,92 @@ class SerenaDashboardViewer: if self.tray: self._start_tray() - self._set_dock_icon_visible(not self.start_minimized) def _start_callback() -> None: + # pywebview installs its own NSApplication delegate during startup, + # so on macOS we need to wrap that final delegate here instead of + # earlier in run(), or the dock reopen handler gets overwritten. + if sys.platform == "darwin" and self.tray: + from PyObjCTools.AppHelper import callAfter + + callAfter(self._setup_macos_dock_handler) if self.start_minimized: self.window.minimize() else: - self.window.show() - self.window.restore() + self._show_window() webview.start(_start_callback, icon=icon_path) - def _on_closing(self) -> bool: - """Intercept window close: hide to tray instead of quitting.""" - if self._quitting: - return True + def _show_window(self) -> None: + if self.window: + self.window.show() + self.window.restore() + self._window_hidden_to_tray = False + self._suppress_next_activate_reopen = False + + def _hide_window(self) -> None: if self.window: self.window.hide() - if sys.platform == "darwin": - # setActivationPolicy_ must run on the main thread; _on_closing - # may be called from a pywebview background thread. - from PyObjCTools.AppHelper import callAfter + self._window_hidden_to_tray = True - callAfter(self._set_dock_icon_visible, False) + def _setup_macos_dock_handler(self) -> None: + """Set up handler for dock icon clicks on macOS.""" + from AppKit import NSApplication, NSObject + from objc import python_method # type: ignore[import-untyped] + + viewer = self # capture reference for the delegate + + # Use a unique class name to avoid conflict with pywebview's AppDelegate + class SerenaDockDelegate(NSObject): + @python_method + def initWithOriginalDelegate_(self, original: Any) -> Any: + obj = self.init() + if obj is not None: + obj._original_delegate = original + return obj + + def applicationShouldHandleReopen_hasVisibleWindows_(self, sender: Any, has_visible_windows: bool) -> bool: + """Called when dock icon is clicked.""" + if not has_visible_windows and viewer.window: + viewer._show_window() + # Forward to original delegate if it exists and has this method + if hasattr(self, "_original_delegate") and self._original_delegate is not None: + if hasattr(self._original_delegate, "applicationShouldHandleReopen_hasVisibleWindows_"): + return self._original_delegate.applicationShouldHandleReopen_hasVisibleWindows_(sender, has_visible_windows) + return True + + def applicationDidBecomeActive_(self, notification: Any) -> None: + """Fallback for dock activation paths that do not invoke reopen.""" + if viewer._suppress_next_activate_reopen: + viewer._suppress_next_activate_reopen = False + elif viewer._window_hidden_to_tray: + viewer._show_window() + # Forward to original delegate if it exists and has this method + if hasattr(self, "_original_delegate") and self._original_delegate is not None: + if hasattr(self._original_delegate, "applicationDidBecomeActive_"): + self._original_delegate.applicationDidBecomeActive_(notification) + + def forwardingTargetForSelector_(self, selector: Any) -> Any: + """Forward unknown selectors to original delegate.""" + if hasattr(self, "_original_delegate") and self._original_delegate is not None: + if self._original_delegate.respondsToSelector_(selector): + return self._original_delegate + return None + + ns_app = NSApplication.sharedApplication() + original_delegate = ns_app.delegate() + if original_delegate is getattr(self, "_app_delegate", None): + return + self._app_delegate = SerenaDockDelegate.alloc().initWithOriginalDelegate_(original_delegate) + ns_app.setDelegate_(self._app_delegate) + + def _on_closing(self) -> bool: + """Intercept window close: hide window instead of quitting (macOS standard behavior).""" + if self._quitting: + return True + self._hide_window() return False # prevent the window from actually closing - def _set_dock_icon_visible(self, visible: bool) -> None: - """macOS-only; toggles the dock icon. - - Sets the NSApplication activation policy to Regular (shows in dock) or - Accessory (hides from dock). No-op on other platforms. - """ - if sys.platform == "darwin": - from AppKit import ( - NSApplication, - NSApplicationActivationPolicyAccessory, - NSApplicationActivationPolicyRegular, - ) - - ns_app = NSApplication.sharedApplication() - policy = NSApplicationActivationPolicyRegular if visible else NSApplicationActivationPolicyAccessory - ns_app.setActivationPolicy_(policy) - if visible: - # setActivationPolicy_ alone is not enough at runtime; unhide_ fires - # the full applicationWillUnhide:/applicationDidUnhide: cycle, which - # is what actually restores the dock icon and brings windows forward. - ns_app.unhide_(None) - def _start_tray(self) -> None: # import pystray locally, because the import fails when there is no display! import pystray @@ -808,15 +850,10 @@ class SerenaDashboardViewer: icon_img = Image.open(dashboard_path / icon_filename) def show(_icon: TrayIcon, _item: Item) -> None: - if self.window: - self._set_dock_icon_visible(True) - self.window.show() - self.window.restore() + self._show_window() def hide(_icon: TrayIcon, _item: Item) -> None: - if self.window: - self.window.hide() - self._set_dock_icon_visible(False) + self._hide_window() def quit_app(_icon: TrayIcon, _item: Item) -> None: self._quitting = True diff --git a/test/serena/test_dashboard.py b/test/serena/test_dashboard.py index 8de6e8eb..578c06cd 100644 --- a/test/serena/test_dashboard.py +++ b/test/serena/test_dashboard.py @@ -1,6 +1,8 @@ +import sys from types import SimpleNamespace -from serena.dashboard import SerenaDashboardAPI +import serena.dashboard as dashboard_module +from serena.dashboard import SerenaDashboardAPI, SerenaDashboardViewer from solidlsp.ls_config import Language @@ -53,3 +55,106 @@ def test_available_languages_exclude_project_languages(): assert Language.MARKDOWN.value not in available # ensure experimental languages remain available for selection assert Language.ANSIBLE.value in available + + +def test_dashboard_viewer_initializes_macos_delegate_slot(): + viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html") + assert viewer._app_delegate is None + assert viewer._window_hidden_to_tray is False + assert viewer._suppress_next_activate_reopen is False + + +def test_dashboard_viewer_initializes_hidden_state_for_minimized_start(): + viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html", start_minimized=True) + assert viewer._window_hidden_to_tray is True + assert viewer._suppress_next_activate_reopen is True + + +def test_dashboard_viewer_show_and_hide_helpers_update_state(): + viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html", start_minimized=True) + + class _FakeWindow: + def __init__(self) -> None: + self.calls: list[str] = [] + + def show(self) -> None: + self.calls.append("show") + + def restore(self) -> None: + self.calls.append("restore") + + def hide(self) -> None: + self.calls.append("hide") + + viewer.window = _FakeWindow() + + viewer._show_window() + assert viewer.window.calls == ["show", "restore"] + assert viewer._window_hidden_to_tray is False + assert viewer._suppress_next_activate_reopen is False + + viewer._hide_window() + assert viewer.window.calls == ["show", "restore", "hide"] + assert viewer._window_hidden_to_tray is True + + +def test_dashboard_viewer_installs_macos_dock_handler_after_pywebview_start(monkeypatch): + order: list[str] = [] + + class _ClosingEvent: + def __iadd__(self, _handler): + order.append("bind-closing") + return self + + class _FakeWindow: + def __init__(self) -> None: + self.events = SimpleNamespace(closing=_ClosingEvent()) + + def minimize(self) -> None: + order.append("minimize") + + def show(self) -> None: + order.append("show") + + def restore(self) -> None: + order.append("restore") + + fake_window = _FakeWindow() + + def fake_create_window(*args, **kwargs): + order.append("create-window") + return fake_window + + def fake_start(callback, **kwargs): + del kwargs + order.append("start") + callback() + + class _FakeAppHelperModule: + @staticmethod + def callAfter(func, *args, **kwargs): + del args, kwargs + order.append("schedule-dock-handler") + func() + + monkeypatch.setattr(dashboard_module.sys, "platform", "darwin") + monkeypatch.setattr(dashboard_module.webview, "create_window", fake_create_window) + monkeypatch.setattr(dashboard_module.webview, "start", fake_start) + monkeypatch.setitem(sys.modules, "PyObjCTools.AppHelper", _FakeAppHelperModule) + + viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html") + monkeypatch.setattr(viewer, "_start_tray", lambda: order.append("start-tray")) + monkeypatch.setattr(viewer, "_setup_macos_dock_handler", lambda: order.append("setup-dock-handler")) + + viewer.run() + + assert order == [ + "create-window", + "bind-closing", + "start-tray", + "start", + "schedule-dock-handler", + "setup-dock-handler", + "show", + "restore", + ] From 7c202de49366c4c75314a70dec7ecf99681535bc Mon Sep 17 00:00:00 2001 From: zoajimen Date: Wed, 1 Apr 2026 12:15:03 +0200 Subject: [PATCH 20/26] Refactor SerenaDashboardViewer to improve macOS dock handling and streamline event delegation --- src/serena/dashboard.py | 45 +++++++------- test/serena/test_dashboard.py | 107 +--------------------------------- 2 files changed, 21 insertions(+), 131 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index f0cb7c82..0e2f7129 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -695,10 +695,8 @@ class SerenaDashboardViewer: height: int = 900, ): self.url = url + # Use system tray to allow hiding to tray and intercepting close to hide instead of quit self.tray = True - """ - whether we use the system tray to allow minimizing to tray and intercepting the close event to hide to tray instead of quitting. - """ self.title = "Serena Dashboard" self.width = width self.height = height @@ -733,22 +731,18 @@ class SerenaDashboardViewer: icon_path = str(dashboard_path / icon_filename) # Create hidden to avoid flash; show/restore/minimize in start callback. - # When tray is enabled, confirm_close allows us to intercept the X button. window = webview.create_window( self.title, self.url, width=self.width, height=self.height, hidden=self.start_minimized, - confirm_close=False, ) assert window is not None self.window = window if self.tray: self.window.events.closing += self._on_closing - - if self.tray: self._start_tray() def _start_callback() -> None: @@ -783,26 +777,32 @@ class SerenaDashboardViewer: from AppKit import NSApplication, NSObject from objc import python_method # type: ignore[import-untyped] - viewer = self # capture reference for the delegate + viewer = self - # Use a unique class name to avoid conflict with pywebview's AppDelegate class SerenaDockDelegate(NSObject): + """Wraps pywebview's delegate to handle dock icon clicks.""" + @python_method def initWithOriginalDelegate_(self, original: Any) -> Any: obj = self.init() if obj is not None: - obj._original_delegate = original + obj._original = original return obj + @python_method + def _forward(self, method: str, *args: Any) -> Any: + """Forward method call to original delegate if it has the method.""" + orig = getattr(self, "_original", None) + if orig and hasattr(orig, method): + return getattr(orig, method)(*args) + return None + def applicationShouldHandleReopen_hasVisibleWindows_(self, sender: Any, has_visible_windows: bool) -> bool: """Called when dock icon is clicked.""" - if not has_visible_windows and viewer.window: + if not has_visible_windows: viewer._show_window() - # Forward to original delegate if it exists and has this method - if hasattr(self, "_original_delegate") and self._original_delegate is not None: - if hasattr(self._original_delegate, "applicationShouldHandleReopen_hasVisibleWindows_"): - return self._original_delegate.applicationShouldHandleReopen_hasVisibleWindows_(sender, has_visible_windows) - return True + result = self._forward("applicationShouldHandleReopen_hasVisibleWindows_", sender, has_visible_windows) + return result if result is not None else True def applicationDidBecomeActive_(self, notification: Any) -> None: """Fallback for dock activation paths that do not invoke reopen.""" @@ -810,21 +810,16 @@ class SerenaDashboardViewer: viewer._suppress_next_activate_reopen = False elif viewer._window_hidden_to_tray: viewer._show_window() - # Forward to original delegate if it exists and has this method - if hasattr(self, "_original_delegate") and self._original_delegate is not None: - if hasattr(self._original_delegate, "applicationDidBecomeActive_"): - self._original_delegate.applicationDidBecomeActive_(notification) + self._forward("applicationDidBecomeActive_", notification) def forwardingTargetForSelector_(self, selector: Any) -> Any: """Forward unknown selectors to original delegate.""" - if hasattr(self, "_original_delegate") and self._original_delegate is not None: - if self._original_delegate.respondsToSelector_(selector): - return self._original_delegate - return None + orig = getattr(self, "_original", None) + return orig if orig and orig.respondsToSelector_(selector) else None ns_app = NSApplication.sharedApplication() original_delegate = ns_app.delegate() - if original_delegate is getattr(self, "_app_delegate", None): + if original_delegate is self._app_delegate: return self._app_delegate = SerenaDockDelegate.alloc().initWithOriginalDelegate_(original_delegate) ns_app.setDelegate_(self._app_delegate) diff --git a/test/serena/test_dashboard.py b/test/serena/test_dashboard.py index 578c06cd..8de6e8eb 100644 --- a/test/serena/test_dashboard.py +++ b/test/serena/test_dashboard.py @@ -1,8 +1,6 @@ -import sys from types import SimpleNamespace -import serena.dashboard as dashboard_module -from serena.dashboard import SerenaDashboardAPI, SerenaDashboardViewer +from serena.dashboard import SerenaDashboardAPI from solidlsp.ls_config import Language @@ -55,106 +53,3 @@ def test_available_languages_exclude_project_languages(): assert Language.MARKDOWN.value not in available # ensure experimental languages remain available for selection assert Language.ANSIBLE.value in available - - -def test_dashboard_viewer_initializes_macos_delegate_slot(): - viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html") - assert viewer._app_delegate is None - assert viewer._window_hidden_to_tray is False - assert viewer._suppress_next_activate_reopen is False - - -def test_dashboard_viewer_initializes_hidden_state_for_minimized_start(): - viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html", start_minimized=True) - assert viewer._window_hidden_to_tray is True - assert viewer._suppress_next_activate_reopen is True - - -def test_dashboard_viewer_show_and_hide_helpers_update_state(): - viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html", start_minimized=True) - - class _FakeWindow: - def __init__(self) -> None: - self.calls: list[str] = [] - - def show(self) -> None: - self.calls.append("show") - - def restore(self) -> None: - self.calls.append("restore") - - def hide(self) -> None: - self.calls.append("hide") - - viewer.window = _FakeWindow() - - viewer._show_window() - assert viewer.window.calls == ["show", "restore"] - assert viewer._window_hidden_to_tray is False - assert viewer._suppress_next_activate_reopen is False - - viewer._hide_window() - assert viewer.window.calls == ["show", "restore", "hide"] - assert viewer._window_hidden_to_tray is True - - -def test_dashboard_viewer_installs_macos_dock_handler_after_pywebview_start(monkeypatch): - order: list[str] = [] - - class _ClosingEvent: - def __iadd__(self, _handler): - order.append("bind-closing") - return self - - class _FakeWindow: - def __init__(self) -> None: - self.events = SimpleNamespace(closing=_ClosingEvent()) - - def minimize(self) -> None: - order.append("minimize") - - def show(self) -> None: - order.append("show") - - def restore(self) -> None: - order.append("restore") - - fake_window = _FakeWindow() - - def fake_create_window(*args, **kwargs): - order.append("create-window") - return fake_window - - def fake_start(callback, **kwargs): - del kwargs - order.append("start") - callback() - - class _FakeAppHelperModule: - @staticmethod - def callAfter(func, *args, **kwargs): - del args, kwargs - order.append("schedule-dock-handler") - func() - - monkeypatch.setattr(dashboard_module.sys, "platform", "darwin") - monkeypatch.setattr(dashboard_module.webview, "create_window", fake_create_window) - monkeypatch.setattr(dashboard_module.webview, "start", fake_start) - monkeypatch.setitem(sys.modules, "PyObjCTools.AppHelper", _FakeAppHelperModule) - - viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html") - monkeypatch.setattr(viewer, "_start_tray", lambda: order.append("start-tray")) - monkeypatch.setattr(viewer, "_setup_macos_dock_handler", lambda: order.append("setup-dock-handler")) - - viewer.run() - - assert order == [ - "create-window", - "bind-closing", - "start-tray", - "start", - "schedule-dock-handler", - "setup-dock-handler", - "show", - "restore", - ] From d9b278c357ec1ca3a5c4eb9bd61c4d9ffb519661 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 1 Apr 2026 15:05:48 +0200 Subject: [PATCH 21/26] Add pythonnet as an explicit dependency (actually transitive via pywebview) Using pre-release version, because otherwise Python 3.14 support is broken --- pyproject.toml | 1 + uv.lock | 7 ++----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a7cdc372..3d693eb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ "filelock==3.25.2", "cryptography==46.0.6", "regex==2026.2.28", + "pythonnet==3.1.0-rc0 ; sys_platform == 'win32'", ] [tool.uv.sources] diff --git a/uv.lock b/uv.lock index 92be1d5d..8fc13c86 100644 --- a/uv.lock +++ b/uv.lock @@ -762,7 +762,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -771,7 +770,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -780,7 +778,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, - { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -789,7 +786,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -798,7 +794,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -2933,6 +2928,7 @@ dependencies = [ { name = "pystray" }, { name = "python-dotenv" }, { name = "python-multipart" }, + { name = "pythonnet", marker = "sys_platform == 'win32'" }, { name = "pywebview" }, { name = "pyyaml" }, { name = "regex" }, @@ -3015,6 +3011,7 @@ requires-dist = [ { name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" }, { name = "python-dotenv", specifier = "==1.2.1" }, { name = "python-multipart", specifier = "==0.0.22" }, + { name = "pythonnet", marker = "sys_platform == 'win32'", specifier = ">=3.0.5" }, { name = "pywebview", git = "https://github.com/r0x0r/pywebview?rev=345596c1f4fd59fd26c7aa02d4176c5362bcbabe" }, { name = "pyyaml", specifier = "==6.0.2" }, { name = "regex", specifier = "==2026.2.28" }, From 7377a84337a76530039194584862d2830a3ce52c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 1 Apr 2026 15:42:47 +0200 Subject: [PATCH 22/26] Update lock file --- uv.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index 8fc13c86..4151f3ae 100644 --- a/uv.lock +++ b/uv.lock @@ -2401,14 +2401,14 @@ wheels = [ [[package]] name = "pythonnet" -version = "3.0.5" +version = "3.1.0rc0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "clr-loader" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/59/4295652a9a93a2bd6755ecce2dc32e3d3c704e7b8ef301946ee65c818a59/pythonnet-3.1.0rc0.tar.gz", hash = "sha256:5f0f505e100233d70864b00dc5f5dd760603cfd94ac64da5c8b0e15ed95af939", size = 241025, upload-time = "2026-02-25T10:46:53.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/f1/bfb6811df4745f92f14c47a29e50e89a36b1533130fcc56452d4660bd2d6/pythonnet-3.0.5-py3-none-any.whl", hash = "sha256:f6702d694d5d5b163c9f3f5cc34e0bed8d6857150237fae411fefb883a656d20", size = 297506, upload-time = "2024-12-13T08:30:40.661Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3d/a378aa74085fc6f72923d5e39fc30efbc97541cfd49b3fe0a35f20d09ff7/pythonnet-3.1.0rc0-py3-none-any.whl", hash = "sha256:286ccf23d08e1cc2b5200bb91d0ee649436d2e8386fec36a85193c66d0ff8b5b", size = 293150, upload-time = "2026-02-25T10:46:48.535Z" }, ] [[package]] @@ -3011,7 +3011,7 @@ requires-dist = [ { name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" }, { name = "python-dotenv", specifier = "==1.2.1" }, { name = "python-multipart", specifier = "==0.0.22" }, - { name = "pythonnet", marker = "sys_platform == 'win32'", specifier = ">=3.0.5" }, + { name = "pythonnet", marker = "sys_platform == 'win32'", specifier = "==3.1.0rc0" }, { name = "pywebview", git = "https://github.com/r0x0r/pywebview?rev=345596c1f4fd59fd26c7aa02d4176c5362bcbabe" }, { name = "pyyaml", specifier = "==6.0.2" }, { name = "regex", specifier = "==2026.2.28" }, From d30cdf847456dcdfa6050a4c217e57881db8d783 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <35432522+MischaPanch@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:29:00 +0200 Subject: [PATCH 23/26] Fix symbol name resolution in selected LS (#1247) Adds tests for name resolution for each language and fixes the name resolution where tests have failed. Also: Extends python tests to cover ty Extends skipping conditions for several LS (but only when not in CI) Better test output in CI Removed a wrong python test (started failing for ty and should have failed for pyright too) --- .github/workflows/pytest.yml | 2 +- .../language_servers/al_language_server.py | 18 ++++- .../language_servers/dart_language_server.py | 14 +++- .../elixir_tools/elixir_tools.py | 12 ++- src/solidlsp/language_servers/gopls.py | 24 ++++-- src/solidlsp/language_servers/lua_ls.py | 24 +++++- .../powershell_language_server.py | 28 ++++++- .../language_servers/sourcekit_lsp.py | 21 ++++- test/conftest.py | 1 + test/resources/repos/go/test_repo/main.go | 4 + .../repos/powershell/test_repo/main.ps1 | 6 ++ test/serena/test_serena_agent.py | 4 +- test/serena/test_symbol.py | 14 ++-- test/solidlsp/al/test_al_basic.py | 37 +++++++++ test/solidlsp/ansible/test_ansible_basic.py | 15 ++++ test/solidlsp/bash/test_bash_basic.py | 14 ++++ test/solidlsp/clojure/test_clojure_basic.py | 22 +++++- test/solidlsp/conftest.py | 56 ++++++++++++++ test/solidlsp/cpp/test_cpp_basic.py | 14 ++++ test/solidlsp/csharp/test_csharp_basic.py | 15 ++++ test/solidlsp/dart/test_dart_basic.py | 14 ++++ test/solidlsp/elixir/test_elixir_basic.py | 28 +++++++ test/solidlsp/elm/test_elm_basic.py | 17 ++++ test/solidlsp/erlang/test_erlang_basic.py | 14 ++++ test/solidlsp/fortran/test_fortran_basic.py | 14 ++++ test/solidlsp/fsharp/test_fsharp_basic.py | 14 ++++ test/solidlsp/go/test_go_basic.py | 29 +++++++ test/solidlsp/groovy/test_groovy_basic.py | 14 ++++ test/solidlsp/haskell/test_haskell_basic.py | 23 ++++++ test/solidlsp/hlsl/test_hlsl_basic.py | 15 ++++ test/solidlsp/java/test_java_basic.py | 14 ++++ test/solidlsp/julia/test_julia_basic.py | 18 +++++ test/solidlsp/kotlin/test_kotlin_basic.py | 15 ++++ test/solidlsp/lua/test_lua_basic.py | 19 +++++ test/solidlsp/luau/test_luau_basic.py | 14 ++++ test/solidlsp/markdown/test_markdown_basic.py | 14 ++++ test/solidlsp/matlab/test_matlab_basic.py | 14 ++++ test/solidlsp/nix/test_nix_basic.py | 32 ++++++++ test/solidlsp/ocaml/test_ocaml_basic.py | 22 +++++- test/solidlsp/pascal/test_pascal_basic.py | 21 ++++- test/solidlsp/perl/test_perl_basic.py | 14 ++++ test/solidlsp/php/test_php_basic.py | 24 +++++- .../powershell/test_powershell_basic.py | 23 ++++++ test/solidlsp/python/test_python_basic.py | 27 +++++-- .../test_retrieval_with_ignored_dirs.py | 7 +- test/solidlsp/python/test_symbol_retrieval.py | 77 ++++++------------- test/solidlsp/r/test_r_basic.py | 19 ++++- test/solidlsp/rego/test_rego_basic.py | 20 +++++ test/solidlsp/ruby/test_ruby_basic.py | 33 +++++++- test/solidlsp/rust/test_rust_basic.py | 14 ++++ test/solidlsp/solidity/test_solidity_basic.py | 14 ++++ test/solidlsp/swift/test_swift_basic.py | 14 ++++ .../systemverilog/test_systemverilog_basic.py | 16 ++++ .../terraform/test_terraform_basic.py | 21 +++++ test/solidlsp/toml/test_toml_basic.py | 14 ++++ .../typescript/test_typescript_basic.py | 14 ++++ test/solidlsp/vue/test_vue_basic.py | 23 ++++++ test/solidlsp/yaml_ls/test_yaml_basic.py | 14 ++++ test/solidlsp/zig/test_zig_basic.py | 14 ++++ 59 files changed, 1015 insertions(+), 97 deletions(-) create mode 100644 test/solidlsp/conftest.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index e757f030..1f20f585 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -566,7 +566,7 @@ jobs: df -h - name: Test with pytest shell: bash - run: uv run poe test + run: uv run poe test -q --tb=short - name: Type-checking with mypy shell: bash run: uv run poe type-check diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py index 43cf1ab3..ed3cf9c7 100644 --- a/src/solidlsp/language_servers/al_language_server.py +++ b/src/solidlsp/language_servers/al_language_server.py @@ -1013,9 +1013,18 @@ class ALLanguageServer(SolidLanguageServer): original_name = symbol["name"] normalized_name = self._extract_al_display_name(original_name) - # Store original name if it was normalized (for hover injection) + if symbol.get("kind") in (SymbolKind.Function, SymbolKind.Method) and "(" in normalized_name: + normalized_name = normalized_name.split("(", 1)[0].strip() + + if symbol.get("kind") == SymbolKind.Method and normalized_name.lower().startswith("action "): + normalized_name = normalized_name.split(None, 1)[-1].strip() + + if symbol.get("kind") == SymbolKind.Field and ":" in normalized_name: + normalized_name = normalized_name.split(":", 1)[0].strip() + + # Store original name if it was normalized for an AL object declaration # Only store if we have valid position data to avoid false matches at (0, 0) - if original_name != normalized_name: + if original_name != normalized_name and self._AL_OBJECT_NAME_PATTERN.match(original_name): sel_range = symbol.get("selectionRange") if sel_range: start = sel_range.get("start") # type: ignore @@ -1026,6 +1035,11 @@ class ALLanguageServer(SolidLanguageServer): return normalized_name + @override + def _document_symbols_cache_fingerprint(self) -> int: + normalize_symbol_name_version = 1 + return normalize_symbol_name_version + @override def request_hover( self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None diff --git a/src/solidlsp/language_servers/dart_language_server.py b/src/solidlsp/language_servers/dart_language_server.py index 406c06e0..11f78106 100644 --- a/src/solidlsp/language_servers/dart_language_server.py +++ b/src/solidlsp/language_servers/dart_language_server.py @@ -1,9 +1,12 @@ import logging import os import pathlib +from collections.abc import Hashable from typing import cast -from solidlsp.ls import SolidLanguageServer +from overrides import override + +from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -34,6 +37,15 @@ class DartLanguageServer(SolidLanguageServer): config, repository_root_path, ProcessLaunchInfo(cmd=executable_path, cwd=repository_root_path), "dart", solidlsp_settings ) + @override + def _document_symbols_cache_fingerprint(self) -> Hashable: + normalize_symbol_name_version = 1 + return normalize_symbol_name_version + + @override + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + return symbol["name"].rsplit(".", 1)[-1] + @classmethod def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: dart_settings = solidlsp_settings.get_ls_specific_settings(Language.DART) diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py index d200c899..688da5e8 100644 --- a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -4,11 +4,12 @@ import pathlib import stat import subprocess import threading +from collections.abc import Hashable from typing import Any, cast from overrides import override -from solidlsp.ls import SolidLanguageServer +from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams @@ -215,6 +216,15 @@ class ElixirTools(SolidLanguageServer): # Set generous timeout for Expert which can be slow to initialize and respond self.set_request_timeout(180.0) + @override + def _document_symbols_cache_fingerprint(self) -> Hashable: + normalize_symbol_name_version = 1 + return normalize_symbol_name_version + + @override + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + return symbol["name"].removeprefix("defp ").removeprefix("def ").split("(", 1)[0].strip() + @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py index 40768842..c855b836 100644 --- a/src/solidlsp/language_servers/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -1,12 +1,15 @@ +import hashlib +import json import logging import os import pathlib import subprocess +from collections.abc import Hashable from typing import Any, cast from overrides import override -from solidlsp.ls import SolidLanguageServer +from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo @@ -165,15 +168,13 @@ class Gopls(SolidLanguageServer): _CACHE_CONTEXT_ENV_KEYS = ("GOFLAGS", "GOOS", "GOARCH", "CGO_ENABLED") @override - def _document_symbols_cache_fingerprint(self) -> str | None: + def _document_symbols_cache_fingerprint(self) -> Hashable: """ Compute a deterministic fingerprint of the Go build context. The fingerprint includes gopls_settings and selected env vars that affect symbol discovery. """ - import hashlib - import json - + normalize_symbol_name_version = 1 gopls_settings_raw = self._custom_settings.settings.get("gopls_settings") gopls_settings: dict | None @@ -190,11 +191,14 @@ class Gopls(SolidLanguageServer): if value: env_subset[key] = value - # Return None only when BOTH settings and env subset are effectively empty. + # Version processed symbols even when the build context itself is empty. if gopls_settings is None and not env_subset: - return None + return normalize_symbol_name_version - fingerprint_data: dict[str, object] = {"env": env_subset} + fingerprint_data: dict[str, object] = { + "env": env_subset, + "normalize_symbol_name_version": normalize_symbol_name_version, + } if gopls_settings is not None: fingerprint_data["gopls_settings"] = gopls_settings @@ -202,6 +206,10 @@ class Gopls(SolidLanguageServer): return hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()[:16] + @override + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + return symbol["name"].rsplit(".", 1)[-1] + def _start_server(self) -> None: """Start gopls server process""" diff --git a/src/solidlsp/language_servers/lua_ls.py b/src/solidlsp/language_servers/lua_ls.py index f8a37b3f..ef5e8674 100644 --- a/src/solidlsp/language_servers/lua_ls.py +++ b/src/solidlsp/language_servers/lua_ls.py @@ -11,12 +11,14 @@ import os import pathlib import platform import shutil +from collections.abc import Hashable from pathlib import Path from overrides import override -from solidlsp.ls import SolidLanguageServer +from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import FileUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo @@ -169,6 +171,26 @@ class LuaLanguageServer(SolidLanguageServer): ) self.request_id = 0 + @override + def _document_symbols_cache_fingerprint(self) -> Hashable: + normalize_symbol_name_version = 1 + return normalize_symbol_name_version + + @override + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + original_name = symbol["name"] + + if symbol.get("kind") not in (SymbolKind.Function, SymbolKind.Method): + return original_name + + if "." in original_name: + return original_name.rsplit(".", 1)[-1] + + if ":" in original_name: + return original_name.rsplit(":", 1)[-1] + + return original_name + @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ diff --git a/src/solidlsp/language_servers/powershell_language_server.py b/src/solidlsp/language_servers/powershell_language_server.py index 828babd9..72f9ac27 100644 --- a/src/solidlsp/language_servers/powershell_language_server.py +++ b/src/solidlsp/language_servers/powershell_language_server.py @@ -14,12 +14,14 @@ import platform import shutil import tempfile import threading +from collections.abc import Hashable from pathlib import Path from overrides import override -from solidlsp.ls import SolidLanguageServer +from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import FileUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo @@ -199,6 +201,30 @@ class PowerShellLanguageServer(SolidLanguageServer): ) self.server_ready = threading.Event() + @override + def _document_symbols_cache_fingerprint(self) -> Hashable: + normalize_symbol_name_version = 1 + return normalize_symbol_name_version + + @override + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + original_name = symbol["name"] + symbol_kind = symbol.get("kind") + + # normalize class declarations + if symbol_kind == SymbolKind.Class: + return original_name.removeprefix("class ").split("{", 1)[0].strip() + + # normalize method signatures + if symbol_kind == SymbolKind.Method: + return original_name.split("(", 1)[0].rsplit(None, 1)[-1] + + # normalize function declarations + if symbol_kind == SymbolKind.Function: + return original_name.removeprefix("function ").split("(", 1)[0].strip() + + return original_name + @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ diff --git a/src/solidlsp/language_servers/sourcekit_lsp.py b/src/solidlsp/language_servers/sourcekit_lsp.py index ba55e35b..8d30daeb 100644 --- a/src/solidlsp/language_servers/sourcekit_lsp.py +++ b/src/solidlsp/language_servers/sourcekit_lsp.py @@ -3,12 +3,14 @@ import os import pathlib import subprocess import time +from collections.abc import Hashable from overrides import override from solidlsp import ls_types -from solidlsp.ls import SolidLanguageServer +from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_types import SymbolKind from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -56,6 +58,23 @@ class SourceKitLSP(SolidLanguageServer): self._did_sleep_before_requesting_references = False self._initialization_timestamp: float | None = None + @override + def _document_symbols_cache_fingerprint(self) -> Hashable: + normalize_symbol_name_version = 1 + return normalize_symbol_name_version + + @override + def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: + original_name = symbol["name"] + + if symbol.get("kind") not in (SymbolKind.Function, SymbolKind.Method, SymbolKind.Constructor): + return original_name + + if "(" not in original_name: + return original_name + + return original_name.split("(", 1)[0].strip() + @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ diff --git a/test/conftest.py b/test/conftest.py index f8981251..ee476321 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -41,6 +41,7 @@ _LANGUAGE_REPO_ALIASES: dict[Language, Language] = { Language.PHP_PHPACTOR: Language.PHP, Language.PYTHON_JEDI: Language.PYTHON, Language.RUBY_SOLARGRAPH: Language.RUBY, + Language.PYTHON_TY: Language.PYTHON, } diff --git a/test/resources/repos/go/test_repo/main.go b/test/resources/repos/go/test_repo/main.go index b38c31ff..a5716358 100644 --- a/test/resources/repos/go/test_repo/main.go +++ b/test/resources/repos/go/test_repo/main.go @@ -15,6 +15,10 @@ type DemoStruct struct { Field int } +func (d *DemoStruct) Value() int { + return d.Field +} + func UsingHelper() { Helper() } diff --git a/test/resources/repos/powershell/test_repo/main.ps1 b/test/resources/repos/powershell/test_repo/main.ps1 index 6829fda4..f6d331f7 100644 --- a/test/resources/repos/powershell/test_repo/main.ps1 +++ b/test/resources/repos/powershell/test_repo/main.ps1 @@ -105,5 +105,11 @@ function Main { Write-Output "Script completed successfully" } +class PersonFormatter { + [string] FormatName([string]$Name) { + return "Formatted: $Name" + } +} + # Run main function Main @args diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 0b40ab28..e2ca7362 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -187,7 +187,7 @@ class TestSerenaAgent: pytest.param(Language.PHP, "helperFunction", "Function", "helper.php", marks=pytest.mark.php), pytest.param(Language.CLOJURE, "greet", "Function", clj.CORE_PATH, marks=pytest.mark.clojure), pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp), - pytest.param(Language.POWERSHELL, "function Greet-User ()", "Function", "main.ps1", marks=pytest.mark.powershell), + pytest.param(Language.POWERSHELL, "Greet-User", "Function", "main.ps1", marks=pytest.mark.powershell), pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp), pytest.param(Language.LEAN4, "add", "Method", "Helper.lean", marks=pytest.mark.lean4), ], @@ -288,7 +288,7 @@ class TestSerenaAgent: marks=pytest.mark.clojure, ), pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp), - pytest.param(Language.POWERSHELL, "function Greet-User ()", "main.ps1", "main.ps1", marks=pytest.mark.powershell), + pytest.param(Language.POWERSHELL, "Greet-User", "main.ps1", "main.ps1", marks=pytest.mark.powershell), pytest.param(Language.CPP_CCLS, "add", "b.cpp", "a.cpp", marks=pytest.mark.cpp), pytest.param(Language.LEAN4, "add", "Helper.lean", "Main.lean", marks=pytest.mark.lean4), ], diff --git a/test/serena/test_symbol.py b/test/serena/test_symbol.py index 60b936da..08188f49 100644 --- a/test/serena/test_symbol.py +++ b/test/serena/test_symbol.py @@ -5,7 +5,7 @@ import pytest from serena.jetbrains.jetbrains_types import SymbolDTO, SymbolDTOKey from serena.project import Project from serena.symbol import LanguageServerSymbol, LanguageServerSymbolRetriever, NamePathComponent, NamePathMatcher -from solidlsp.ls_config import Language +from test.solidlsp.conftest import PYTHON_BACKEND_LANGUAGES class TestSymbolNameMatching: @@ -235,7 +235,7 @@ class TestSymbolNameMatching: @pytest.mark.python class TestLanguageServerSymbolRetriever: - @pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("project_with_ls", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_info(self, project_with_ls: Project): symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) create_user_method_symbol = symbol_retriever.find("UserService/create_user", within_relative_path="test_repo/services.py")[0] @@ -280,7 +280,7 @@ def _make_mock_symbols(count: int, *, relative_path: str = "test_repo/services.p class TestHoverBudget: """Tests for symbol_info_budget time budget behavior.""" - @pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("project_with_ls", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_budget_not_exceeded_all_lookups_performed(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch): """With a large budget, all hover lookups are performed.""" # Create symbol retriever with a mock agent that has large budget @@ -309,7 +309,7 @@ class TestHoverBudget: assert all(info is not None for info in result.values()) assert len(result) == 3 - @pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("project_with_ls", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_budget_exceeded_partial_info(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch): """With a small budget, hover lookups stop and remaining symbols get None info.""" project_with_ls.serena_config.symbol_info_budget = 0.1 @@ -353,7 +353,7 @@ class TestHoverBudget: assert result_list[3] is None assert result_list[4] is None - @pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("project_with_ls", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_budget_zero_means_unlimited(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch): """With budget=0, all hover lookups proceed (no early stopping).""" project_with_ls.serena_config.symbol_info_budget = 0.0 @@ -380,7 +380,7 @@ class TestHoverBudget: assert call_count == 5 assert all(info is not None for info in result.values()) - @pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("project_with_ls", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_project_budget_overrides_global(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch): """Project-level budget overrides global budget.""" # Create symbol retriever with global budget 10.0 but project budget 0.05 @@ -418,7 +418,7 @@ class TestHoverBudget: # So 2 calls succeed (proving project budget 0.05 overrode global 10.0) assert call_count == 2 - @pytest.mark.parametrize("project_with_ls", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("project_with_ls", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_project_null_inherits_global(self, project_with_ls: Project, monkeypatch: pytest.MonkeyPatch): """When project budget is None, global budget is used.""" # Create symbol retriever with project budget=None (inherit global) diff --git a/test/solidlsp/al/test_al_basic.py b/test/solidlsp/al/test_al_basic.py index c226421f..a52e2f14 100644 --- a/test/solidlsp/al/test_al_basic.py +++ b/test/solidlsp/al/test_al_basic.py @@ -6,8 +6,10 @@ from serena.symbol import LanguageServerSymbol from solidlsp import SolidLanguageServer from solidlsp.language_servers.al_language_server import ALLanguageServer from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols pytestmark = [pytest.mark.al, pytest.mark.skipif(not language_tests_enabled(Language.AL), reason="AL tests are disabled")] @@ -556,3 +558,38 @@ class TestALPathNormalization: f"Hover for {symbol_name} should have injection with mixed paths. Got: {value[:200]}" ) break + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + is_al_display_symbol = ( + s["name"].endswith((".Codeunit", ".Page", ".Table", ".TableExt", ".Enum", ".Interface")) + or (s["name"].startswith('"') and s["name"].endswith('"')) + or s["name"].startswith( + ( + "Enum Name ", + "Area ", + "Group ", + "Field ", + "Part ", + "SystemPart ", + "Repeater ", + "ActionRef ", + "Key ", + "FieldGroup ", + ) + ) + or ":" in s["name"] + ) + if not is_al_display_symbol and has_malformed_name( + s, + whitespace_allowed=s["kind"] in {SymbolKind.Class, SymbolKind.Struct, SymbolKind.Interface, SymbolKind.Enum}, + ): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/ansible/test_ansible_basic.py b/test/solidlsp/ansible/test_ansible_basic.py index 154b2231..bf6dc6ca 100644 --- a/test/solidlsp/ansible/test_ansible_basic.py +++ b/test/solidlsp/ansible/test_ansible_basic.py @@ -13,6 +13,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.skipif(platform.system() == "Windows", reason="ansible-language-server does not support native Windows operation") @@ -51,3 +52,17 @@ class TestAnsibleLanguageServerBasics: assert len(result) > 0, "Expected non-empty completion list" labels = [item["completionText"] for item in result if "completionText" in item] assert labels, f"Expected completions with completionText, got: {result[:3]}" + + @pytest.mark.xfail(reason="Seems like ansible LS lacks basic functionality at the moment, textDocument/documentSymbol doesn't work") + @pytest.mark.parametrize("language_server", [Language.ANSIBLE], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/bash/test_bash_basic.py b/test/solidlsp/bash/test_bash_basic.py index a3ead08f..c7715a29 100644 --- a/test/solidlsp/bash/test_bash_basic.py +++ b/test/solidlsp/bash/test_bash_basic.py @@ -9,6 +9,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.bash @@ -119,3 +120,16 @@ class TestBashLanguageServerBasics: # Verify total counts match expectations assert len(main_functions) >= 3, f"Should find at least 3 functions in main.sh, found {len(main_functions)}" assert len(utils_functions) >= 8, f"Should find at least 8 functions in utils.sh, found {len(utils_functions)}" + + @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/clojure/test_clojure_basic.py b/test/solidlsp/clojure/test_clojure_basic.py index 1a199673..c42067ac 100644 --- a/test/solidlsp/clojure/test_clojure_basic.py +++ b/test/solidlsp/clojure/test_clojure_basic.py @@ -3,8 +3,9 @@ import pytest from serena.project import Project from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language -from solidlsp.ls_types import UnifiedSymbolInformation +from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols from . import CORE_PATH, UTILS_PATH @@ -187,6 +188,8 @@ class TestLanguageServerBasics: assert found_relevant_references, f"Should have found calculate-area referencing multiply, but got: {result}" +@pytest.mark.skipif(not language_tests_enabled(Language.CLOJURE), reason="Clojure tests are disabled") +@pytest.mark.clojure class TestProjectBasics: @pytest.mark.parametrize("project", [Language.CLOJURE], indirect=True) def test_retrieve_content_around_line(self, project: Project): @@ -221,3 +224,20 @@ class TestProjectBasics: assert result is not None, "Should find require statements" utils_matches = [match for match in result if match.source_file_path and "utils.clj" in match.source_file_path] assert len(utils_matches) > 0, "Should find require statement in utils.clj" + + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + # clojure-lsp exposes namespace and dependency/container entries in addition to real vars/functions. + # Those qualified names are not the target of this regression check. + if s["kind"] in {SymbolKind.Namespace, SymbolKind.Struct}: + continue + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/conftest.py b/test/solidlsp/conftest.py new file mode 100644 index 00000000..08bb6a01 --- /dev/null +++ b/test/solidlsp/conftest.py @@ -0,0 +1,56 @@ +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation + +PYTHON_BACKEND_LANGUAGES = [Language.PYTHON, Language.PYTHON_TY] + + +def has_malformed_name( + symbol: UnifiedSymbolInformation, + whitespace_allowed: bool = False, + period_allowed: bool = False, + colon_allowed: bool = False, + brace_allowed: bool = False, + parenthesis_allowed: bool = False, + comma_allowed: bool = False, +) -> bool: + forbidden_chars: list[str] = [] + + if not whitespace_allowed: + forbidden_chars.append(" ") + if not period_allowed: + forbidden_chars.append(".") + if not colon_allowed: + forbidden_chars.append(":") + if not brace_allowed: + forbidden_chars.append("{") + if not parenthesis_allowed: + forbidden_chars.append("(") + if not comma_allowed: + forbidden_chars.append(",") + + return any(separator in symbol["name"] for separator in forbidden_chars) + + +def request_all_symbols(language_server: SolidLanguageServer) -> list[UnifiedSymbolInformation]: + result: list[UnifiedSymbolInformation] = [] + + def visit(symbol: UnifiedSymbolInformation) -> None: + result.append(symbol) + for child in symbol.get("children", []): + visit(child) + + symbols = language_server.request_full_symbol_tree() + for symbol in symbols: + visit(symbol) + + return result + + +def format_symbol_for_assert(symbol: UnifiedSymbolInformation) -> str: + relative_path = symbol.get("location", {}).get("relativePath", "") + try: + kind = SymbolKind(symbol["kind"]).name + except ValueError: + kind = str(symbol["kind"]) + return f"{symbol['name']} [{kind}] ({relative_path})" diff --git a/test/solidlsp/cpp/test_cpp_basic.py b/test/solidlsp/cpp/test_cpp_basic.py index a8767949..3ca52693 100644 --- a/test/solidlsp/cpp/test_cpp_basic.py +++ b/test/solidlsp/cpp/test_cpp_basic.py @@ -15,6 +15,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols def _ccls_available() -> bool: @@ -142,3 +143,16 @@ int use_add() { # Clean up the new file if os.path.exists(new_file_abs_path): os.remove(new_file_abs_path) + + @pytest.mark.parametrize("language_server", _cpp_servers, indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py index 3d538061..91f67781 100644 --- a/test/solidlsp/csharp/test_csharp_basic.py +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -15,8 +15,10 @@ from solidlsp.language_servers.csharp_language_server import ( find_solution_or_project_file, ) from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils from solidlsp.settings import SolidLSPSettings +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.csharp @@ -359,3 +361,16 @@ class TestCSharpSolutionProjectOpening: # Verify the file actually exists assert os.path.exists(result) + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s, period_allowed=s["kind"] == SymbolKind.Namespace): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/dart/test_dart_basic.py b/test/solidlsp/dart/test_dart_basic.py index cdd98a97..0a5b69cf 100644 --- a/test/solidlsp/dart/test_dart_basic.py +++ b/test/solidlsp/dart/test_dart_basic.py @@ -8,6 +8,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.dart @@ -361,3 +362,16 @@ class TestDartLanguageServer: if main_dart_refs: for ref in main_dart_refs: assert "range" in ref or "location" in ref + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/elixir/test_elixir_basic.py b/test/solidlsp/elixir/test_elixir_basic.py index fe18cd34..11b983ca 100644 --- a/test/solidlsp/elixir/test_elixir_basic.py +++ b/test/solidlsp/elixir/test_elixir_basic.py @@ -11,6 +11,8 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON @@ -110,3 +112,29 @@ class TestElixirBasic: for _ in range(3): symbols = language_server.request_document_symbols("lib/services.ex").get_all_symbols_and_roots() assert symbols is not None + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if s["kind"] in {SymbolKind.Module, SymbolKind.Namespace, SymbolKind.Struct}: + continue + + allow_test_style_name = s["name"].startswith(('test "', 'describe "')) + allow_struct_name = s["name"].startswith("%") + allow_type_name = s["name"].startswith("@type ") + if has_malformed_name( + s, + whitespace_allowed=allow_test_style_name or allow_type_name, + period_allowed=allow_struct_name, + colon_allowed=allow_test_style_name, + brace_allowed=allow_test_style_name or allow_struct_name, + comma_allowed=allow_test_style_name, + ): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/elm/test_elm_basic.py b/test/solidlsp/elm/test_elm_basic.py index 6283d914..5ff6fe22 100644 --- a/test/solidlsp/elm/test_elm_basic.py +++ b/test/solidlsp/elm/test_elm_basic.py @@ -1,12 +1,16 @@ import os +import shutil import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.conftest import is_ci +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +@pytest.mark.skipif(shutil.which("elm") is None and not is_ci, reason="Elm compiler is not available") @pytest.mark.elm class TestElmLanguageServer: @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) @@ -52,3 +56,16 @@ class TestElmLanguageServer: # Verify that at least one reference is in Main.elm (where formatMessage is used) assert any("Main.elm" in ref.get("relativePath", "") for ref in refs), "Expected to find usage of formatMessage in Main.elm" + + @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/erlang/test_erlang_basic.py b/test/solidlsp/erlang/test_erlang_basic.py index 50ab3197..a1dba06a 100644 --- a/test/solidlsp/erlang/test_erlang_basic.py +++ b/test/solidlsp/erlang/test_erlang_basic.py @@ -9,6 +9,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols from . import ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON @@ -41,3 +42,16 @@ class TestErlangLanguageServerBasics: pytest.skip("Erlang language server not fully initialized") else: raise + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/fortran/test_fortran_basic.py b/test/solidlsp/fortran/test_fortran_basic.py index 1554e959..e7a45235 100644 --- a/test/solidlsp/fortran/test_fortran_basic.py +++ b/test/solidlsp/fortran/test_fortran_basic.py @@ -11,6 +11,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols # Mark all tests in this module as fortran tests pytestmark = pytest.mark.fortran @@ -272,3 +273,16 @@ class TestFortranLanguageServer: _refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) # refs might be empty if Point3D isn't used elsewhere, but the call should not fail # The important thing is that it doesn't error due to wrong character position + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py index 8e6ff221..d0f52443 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -8,6 +8,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils from test.conftest import is_ci +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols # Currently, most F# tests fail, there seems to be a regression or instability. @@ -183,3 +184,16 @@ class TestFSharpLanguageServer: # This is a successful test - FsAutoComplete is working with F# files assert True, "F# language server can handle files successfully" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/go/test_go_basic.py b/test/solidlsp/go/test_go_basic.py index 8d83298c..93fcd98e 100644 --- a/test/solidlsp/go/test_go_basic.py +++ b/test/solidlsp/go/test_go_basic.py @@ -3,9 +3,11 @@ from pathlib import Path import pytest +from serena.symbol import LanguageServerSymbol from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.go @@ -17,6 +19,20 @@ class TestGoLanguageServer: assert SymbolUtils.symbol_tree_contains_name(symbols, "Helper"), "Helper function not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "DemoStruct"), "DemoStruct not found in symbol tree" + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_find_symbol_matches_go_method_by_bare_name(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree(within_relative_path="main.go") + + assert SymbolUtils.symbol_tree_contains_name(symbols, "Value"), "Expected Go method name to be normalized to bare name" + assert not SymbolUtils.symbol_tree_contains_name(symbols, "(*DemoStruct).Value"), ( + "Expected receiver-qualified Go method name to be normalized away" + ) + + bare_name_matches = [match for root in symbols for match in LanguageServerSymbol(root).find("Value")] + + assert bare_name_matches, "Expected a Go method to match by bare name" + assert all(match.name == "Value" for match in bare_name_matches) + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: file_path = os.path.join("main.go") @@ -192,3 +208,16 @@ class TestGoBuildTags: # A cache miss should repopulate and mark caches modified. _assert_caches_modified(ls_foo) + + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/groovy/test_groovy_basic.py b/test/solidlsp/groovy/test_groovy_basic.py index 57e0baf3..47f22ed8 100644 --- a/test/solidlsp/groovy/test_groovy_basic.py +++ b/test/solidlsp/groovy/test_groovy_basic.py @@ -8,6 +8,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import SymbolUtils from solidlsp.settings import SolidLSPSettings +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.groovy @@ -103,3 +104,16 @@ class TestGroovyLanguageServer: assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "ModelUser"), "ModelUser missing from overview" + + def test_bare_symbol_names(self) -> None: + assert self.language_server is not None + all_symbols = request_all_symbols(self.language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/haskell/test_haskell_basic.py b/test/solidlsp/haskell/test_haskell_basic.py index b080a306..67452151 100644 --- a/test/solidlsp/haskell/test_haskell_basic.py +++ b/test/solidlsp/haskell/test_haskell_basic.py @@ -19,6 +19,8 @@ import pytest from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.haskell @@ -217,3 +219,24 @@ class TestHaskellLanguageServer: assert any("Main.hs" in path or "Calculator.hs" in path for path in calc_ref_paths), ( f"Expected Calculator to be referenced in Main.hs or Calculator.hs, got: {calc_ref_paths}" ) + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if s["kind"] == SymbolKind.Module: + continue + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + diagnostics = [ + { + "formatted": format_symbol_for_assert(sym), + "name": sym["name"], + "kind": SymbolKind(sym["kind"]).name, + "detail": sym.get("detail"), + } + for sym in malformed_symbols + ] + pytest.fail(f"Found malformed symbols: {diagnostics}", pytrace=False) diff --git a/test/solidlsp/hlsl/test_hlsl_basic.py b/test/solidlsp/hlsl/test_hlsl_basic.py index 7034579d..90d1c864 100644 --- a/test/solidlsp/hlsl/test_hlsl_basic.py +++ b/test/solidlsp/hlsl/test_hlsl_basic.py @@ -12,7 +12,9 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols def _find_symbol_by_name(language_server: SolidLanguageServer, file_path: str, name: str) -> dict[str, Any] | None: @@ -158,3 +160,16 @@ class TestHlslHover: hover_info = language_server.request_hover("common.hlsl", 3, 7) assert hover_info is not None, "Hover should return information for VertexInput" assert "contents" in hover_info, "Hover should have contents" + + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s, period_allowed=s["kind"] == SymbolKind.File): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/java/test_java_basic.py b/test/solidlsp/java/test_java_basic.py index 0810dac2..56b3bbf4 100644 --- a/test/solidlsp/java/test_java_basic.py +++ b/test/solidlsp/java/test_java_basic.py @@ -6,6 +6,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils from test.conftest import language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols pytestmark = [pytest.mark.java, pytest.mark.skipif(not language_tests_enabled(Language.JAVA), reason="Java tests disabled")] @@ -50,3 +51,16 @@ class TestJavaLanguageServer: assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview" + + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/julia/test_julia_basic.py b/test/solidlsp/julia/test_julia_basic.py index 99bf720c..44adb43d 100644 --- a/test/solidlsp/julia/test_julia_basic.py +++ b/test/solidlsp/julia/test_julia_basic.py @@ -1,9 +1,14 @@ +import shutil + import pytest from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import is_ci +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +@pytest.mark.skipif(shutil.which("julia") is None and not is_ci, reason="Julia is not available") @pytest.mark.julia class TestJuliaLanguageServer: @pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True) @@ -48,3 +53,16 @@ class TestJuliaLanguageServer: reference_paths = [ref["relativePath"] for ref in references] # The reference might be in either file (definition or usage) assert "main.jl" in reference_paths or "lib/helper.jl" in reference_paths + + @pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/kotlin/test_kotlin_basic.py b/test/solidlsp/kotlin/test_kotlin_basic.py index 762c391c..9a990458 100644 --- a/test/solidlsp/kotlin/test_kotlin_basic.py +++ b/test/solidlsp/kotlin/test_kotlin_basic.py @@ -4,8 +4,10 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils from test.conftest import is_ci +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols # Kotlin LSP (IntelliJ-based, pre-alpha v261) crashes on JVM restart under CI resource constraints @@ -55,3 +57,16 @@ class TestKotlinLanguageServer: assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview" + + @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s, period_allowed=s["kind"] == SymbolKind.File): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/lua/test_lua_basic.py b/test/solidlsp/lua/test_lua_basic.py index ab58ec5e..f140881b 100644 --- a/test/solidlsp/lua/test_lua_basic.py +++ b/test/solidlsp/lua/test_lua_basic.py @@ -10,6 +10,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.lua @@ -253,3 +254,21 @@ class TestLuaLanguageServer: # The test file should have some content that references calculator symbol_list = test_symbols[0] if isinstance(test_symbols, tuple) else test_symbols assert len(symbol_list) > 0, "test_calculator.lua should have symbols" + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name( + s, + whitespace_allowed=s["name"] == " ", + period_allowed="." in s["name"], + colon_allowed=":" in s["name"], + ): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/luau/test_luau_basic.py b/test/solidlsp/luau/test_luau_basic.py index 264ace92..4564e257 100644 --- a/test/solidlsp/luau/test_luau_basic.py +++ b/test/solidlsp/luau/test_luau_basic.py @@ -9,6 +9,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.luau @@ -151,3 +152,16 @@ class TestLuauLanguageServer: assert definition["uri"].endswith("init.luau"), f"Definition should be in init.luau, got: {definition['uri']}" # createConfig is defined at line 8 (0-indexed): `local function createConfig(...)` assert definition["range"]["start"]["line"] == 8, f"Definition should be at line 8, got line {definition['range']['start']['line']}" + + @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/markdown/test_markdown_basic.py b/test/solidlsp/markdown/test_markdown_basic.py index 5a3c65f8..34f57533 100644 --- a/test/solidlsp/markdown/test_markdown_basic.py +++ b/test/solidlsp/markdown/test_markdown_basic.py @@ -11,6 +11,7 @@ from serena.symbol import LanguageServerSymbol from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.markdown @@ -93,3 +94,16 @@ class TestMarkdownLanguageServerBasics: for symbol in all_symbols: assert symbol["kind"] == SymbolKind.Namespace, f"Nested heading '{symbol['name']}' should be remapped to Namespace" + + @pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s, whitespace_allowed=True, parenthesis_allowed=True): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/matlab/test_matlab_basic.py b/test/solidlsp/matlab/test_matlab_basic.py index a6c423c8..71724ff7 100644 --- a/test/solidlsp/matlab/test_matlab_basic.py +++ b/test/solidlsp/matlab/test_matlab_basic.py @@ -16,6 +16,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols # Skip all tests if MATLAB is not available pytestmark = pytest.mark.matlab @@ -115,3 +116,16 @@ class TestMatlabLanguageServerReferences: # Should find references in both main.m and Calculator.m assert references is not None + + @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/nix/test_nix_basic.py b/test/solidlsp/nix/test_nix_basic.py index 42d4d414..23f4311a 100644 --- a/test/solidlsp/nix/test_nix_basic.py +++ b/test/solidlsp/nix/test_nix_basic.py @@ -10,7 +10,9 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind from test.conftest import is_ci +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols # Skip all Nix tests on Windows as Nix doesn't support Windows pytestmark = pytest.mark.skipif(platform.system() == "Windows", reason="Nix and nil are not available on Windows") @@ -218,3 +220,33 @@ class TestNixLanguageServer: root = symbols[0] assert isinstance(root, dict), "Root should be a dict" assert "name" in root, "Root should have a name" + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + # nixd can surface anonymous expression nodes using synthetic display labels + # like "(anonymous lambda)", "(dynamic string)", "{anonymous}", and + # "(dynamic attribute name)". It also emits literal value nodes such as + # strings and arrays. These do not correspond to named declarations, so + # there is no bare identifier we could or should normalize them to in the + # Nix wrapper. This test is only meant to enforce bare names for symbols + # that actually have declaration names. + if s["kind"] in {SymbolKind.String, SymbolKind.Array}: + continue + if s["name"] in {"(anonymous lambda)", "(dynamic string)", "{anonymous}", "(dynamic attribute name)"}: + continue + if s["kind"] in {SymbolKind.Field, SymbolKind.Property} and "." in s["name"]: + continue + if has_malformed_name( + s, + whitespace_allowed=s["name"] == "(anonymous lambda)", + parenthesis_allowed=s["name"] == "(anonymous lambda)", + ): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/ocaml/test_ocaml_basic.py b/test/solidlsp/ocaml/test_ocaml_basic.py index 82af5fa9..f3dca2eb 100644 --- a/test/solidlsp/ocaml/test_ocaml_basic.py +++ b/test/solidlsp/ocaml/test_ocaml_basic.py @@ -1,12 +1,16 @@ import os +import shutil import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.conftest import is_ci +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +@pytest.mark.skipif(shutil.which("opam") is None and not is_ci, reason="OPAM is not available") @pytest.mark.ocaml class TestOCamlLanguageServer: @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) @@ -49,8 +53,6 @@ class TestOCamlLanguageServer: def test_reason_file_patterns(self) -> None: """Test that OCaml language configuration recognizes Reason file extensions""" - from solidlsp.ls_config import Language - ocaml_lang = Language.OCAML file_matcher = ocaml_lang.get_source_fn_matcher() @@ -150,3 +152,19 @@ class TestOCamlLanguageServer: file_path = os.path.join("bin", "main.ml") symbols, _roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() assert len(symbols) > 0, "Should find symbols in main.ml that use opened modules" + + @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + # happens for lambdas a la let () = ... + if s["name"] == "()": + continue + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/pascal/test_pascal_basic.py b/test/solidlsp/pascal/test_pascal_basic.py index d926207a..e9636e94 100644 --- a/test/solidlsp/pascal/test_pascal_basic.py +++ b/test/solidlsp/pascal/test_pascal_basic.py @@ -10,12 +10,15 @@ Uses genericptr/pascal-language-server which returns SymbolInformation[] format: - Method names don't include parent class prefix; uses containerName instead """ +import shutil + import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind -from test.conftest import language_tests_enabled +from test.conftest import is_ci, language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols pytestmark = [ pytest.mark.pascal, @@ -24,6 +27,7 @@ pytestmark = [ @pytest.mark.pascal +@pytest.mark.skipif(shutil.which("fpc") is None and not is_ci, reason="Free Pascal compiler is not available") class TestPascalLanguageServerBasics: """Test basic functionality of the Pascal language server.""" @@ -193,3 +197,18 @@ class TestPascalLanguageServerBasics: # Should contain the doc comment assert "Calculates the sum" in value, f"Hover should include doc comment. Got: {value[:500]}" + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if s["kind"] == SymbolKind.Package: + continue + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/perl/test_perl_basic.py b/test/solidlsp/perl/test_perl_basic.py index 59dbcc5f..41682e3f 100644 --- a/test/solidlsp/perl/test_perl_basic.py +++ b/test/solidlsp/perl/test_perl_basic.py @@ -5,6 +5,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.perl @@ -74,3 +75,16 @@ class TestPerlLanguageServer: main_pl_lines = sorted([ref["range"]["start"]["line"] for ref in main_pl_refs]) assert 17 in main_pl_lines, f"Expected reference at line 18 (0-indexed 17), found: {main_pl_lines}" assert 20 in main_pl_lines, f"Expected reference at line 21 (0-indexed 20), found: {main_pl_lines}" + + @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/php/test_php_basic.py b/test/solidlsp/php/test_php_basic.py index bc59d471..aab824f3 100644 --- a/test/solidlsp/php/test_php_basic.py +++ b/test/solidlsp/php/test_php_basic.py @@ -1,3 +1,4 @@ +import subprocess from pathlib import Path import pytest @@ -5,10 +6,18 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.conftest import is_ci, is_windows, language_tests_enabled +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + + +def _php_supports_phpactor() -> bool: + completed_process = subprocess.run(["php", "-m"], capture_output=True, text=True, check=False) + installed_extensions = {line.strip().lower() for line in completed_process.stdout.splitlines()} + return "openssl" in installed_extensions + _php_servers: list[Language] = [Language.PHP] if language_tests_enabled(Language.PHP_PHPACTOR): - if not (is_windows and is_ci): # TODO: Phpactor tests are flaky in Windows CI and can even cause hangs #1040 + if not (is_windows and is_ci) and _php_supports_phpactor(): _php_servers.append(Language.PHP_PHPACTOR) @@ -256,3 +265,16 @@ class TestPhpLanguageServers: dog_root = next((s for s in symbols if s.get("name") == "Dog"), None) if dog_root is not None: assert SymbolUtils.symbol_tree_contains_name([dog_root], "greet"), "greet should be nested under Dog in symbol tree" + + @pytest.mark.parametrize("language_server", _php_servers, indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/powershell/test_powershell_basic.py b/test/solidlsp/powershell/test_powershell_basic.py index 3d6ef7a1..bf1b9e46 100644 --- a/test/solidlsp/powershell/test_powershell_basic.py +++ b/test/solidlsp/powershell/test_powershell_basic.py @@ -9,6 +9,8 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.powershell @@ -130,6 +132,14 @@ class TestPowerShellLanguageServerBasics: assert len(main_functions) >= 3, f"Should find at least 3 functions in main.ps1, found {len(main_functions)}" assert len(utils_functions) >= 8, f"Should find at least 8 functions in utils.ps1, found {len(utils_functions)}" + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_powershell_class_method_symbols_use_bare_method_name(self, language_server: SolidLanguageServer) -> None: + """Test whether PSES already reports class methods with bare names.""" + symbols = language_server.request_full_symbol_tree(within_relative_path="main.ps1") + + assert SymbolUtils.symbol_tree_contains_name(symbols, "PersonFormatter"), "Should find PersonFormatter class in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "FormatName"), "Expected PowerShell method to be exposed with bare name" + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) def test_powershell_find_references_within_file(self, language_server: SolidLanguageServer) -> None: """Test finding references to a function within the same file.""" @@ -172,3 +182,16 @@ class TestPowerShellLanguageServerBasics: assert any("utils.ps1" in loc.get("uri", "") for loc in definition_locations), ( f"Should find definition in utils.ps1, got {definition_locations}" ) + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s, colon_allowed=s["name"].startswith("$")): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/python/test_python_basic.py b/test/solidlsp/python/test_python_basic.py index faeca67a..f57b37bd 100644 --- a/test/solidlsp/python/test_python_basic.py +++ b/test/solidlsp/python/test_python_basic.py @@ -12,14 +12,14 @@ import pytest from serena.project import Project from serena.util.text_utils import LineType from solidlsp import SolidLanguageServer -from solidlsp.ls_config import Language +from test.solidlsp.conftest import PYTHON_BACKEND_LANGUAGES, format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.python class TestPythonLanguageServerBasics: """Test basic functionality of the language server.""" - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_references_user_class(self, language_server: SolidLanguageServer) -> None: """Test request_references on the User class.""" # Get references to the User class in models.py @@ -34,7 +34,7 @@ class TestPythonLanguageServerBasics: references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert len(references) > 1, "User class should be referenced in multiple files (using selectionRange if present)" - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_references_item_class(self, language_server: SolidLanguageServer) -> None: """Test request_references on the Item class.""" # Get references to the Item class in models.py @@ -50,7 +50,7 @@ class TestPythonLanguageServerBasics: services_references = [ref for ref in references if "services.py" in ref["uri"]] assert len(services_references) > 0, "At least one reference should be in services.py (using selectionRange if present)" - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_references_function_parameter(self, language_server: SolidLanguageServer) -> None: """Test request_references on a function parameter.""" # Get references to the id parameter in get_user method @@ -65,7 +65,7 @@ class TestPythonLanguageServerBasics: references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert len(references) > 0, "id parameter should be referenced within the method (using selectionRange if present)" - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_references_create_user_method(self, language_server: SolidLanguageServer) -> None: # Get references to the create_user method in UserService file_path = os.path.join("test_repo", "services.py") @@ -81,7 +81,7 @@ class TestPythonLanguageServerBasics: class TestProjectBasics: - @pytest.mark.parametrize("project", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("project", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_retrieve_content_around_line(self, project: Project) -> None: """Test retrieve_content_around_line functionality with various scenarios.""" file_path = os.path.join("test_repo", "models.py") @@ -188,7 +188,7 @@ class TestProjectBasics: else: assert line.match_type == LineType.AFTER_MATCH - @pytest.mark.parametrize("project", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("project", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_search_files_for_pattern(self, project: Project) -> None: """Test search_files_for_pattern with various patterns and glob filters.""" # Test 1: Search for class definitions across all files @@ -233,3 +233,16 @@ class TestProjectBasics: no_match_pattern = r"def\s+this_method_does_not_exist\s*\([^)]*\):" matches = project.search_source_files_for_pattern(no_match_pattern) assert len(matches) == 0 + + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/python/test_retrieval_with_ignored_dirs.py b/test/solidlsp/python/test_retrieval_with_ignored_dirs.py index 031f185c..9c12af8d 100644 --- a/test/solidlsp/python/test_retrieval_with_ignored_dirs.py +++ b/test/solidlsp/python/test_retrieval_with_ignored_dirs.py @@ -6,6 +6,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.conftest import start_ls_context +from test.solidlsp.conftest import PYTHON_BACKEND_LANGUAGES # This mark will be applied to all tests in this module pytestmark = pytest.mark.python @@ -19,7 +20,7 @@ def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]: yield ls -@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.PYTHON], indirect=True) +@pytest.mark.parametrize("ls_with_ignored_dirs", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): """Tests that request_full_symbol_tree ignores the configured directory.""" root = ls_with_ignored_dirs.request_full_symbol_tree()[0] @@ -28,7 +29,7 @@ def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): assert children_names == {"test_repo", "examples"} -@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.PYTHON], indirect=True) +@pytest.mark.parametrize("ls_with_ignored_dirs", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): """Tests that find_references ignores the configured directory.""" # Location of Item, which is referenced in scripts @@ -42,7 +43,7 @@ def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): assert not any("scripts" in ref["relativePath"] for ref in references) -@pytest.mark.parametrize("repo_path", [Language.PYTHON], indirect=True) +@pytest.mark.parametrize("repo_path", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None: """Tests that refs and symbols with glob patterns are ignored.""" ignored_paths = ["*ipts", "custom_t*"] diff --git a/test/solidlsp/python/test_symbol_retrieval.py b/test/solidlsp/python/test_symbol_retrieval.py index ff98bd70..3baff0a0 100644 --- a/test/solidlsp/python/test_symbol_retrieval.py +++ b/test/solidlsp/python/test_symbol_retrieval.py @@ -12,8 +12,8 @@ import pytest from serena.symbol import LanguageServerSymbol from solidlsp import SolidLanguageServer -from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind +from test.solidlsp.conftest import PYTHON_BACKEND_LANGUAGES pytestmark = pytest.mark.python @@ -21,7 +21,7 @@ pytestmark = pytest.mark.python class TestLanguageServerSymbols: """Test the language server's symbol-related functionality.""" - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a function.""" # Test for a position inside the create_user method @@ -36,7 +36,7 @@ class TestLanguageServerSymbols: if "body" in containing_symbol: assert containing_symbol["body"].get_text().strip().startswith("def create_user(self") - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_references_to_variables(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a variable.""" file_path = os.path.join("test_repo", "variables.py") @@ -51,7 +51,7 @@ class TestLanguageServerSymbols: assert "dataclass_instance" in ref_names assert "second_dataclass" in ref_names - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_containing_symbol_class(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a class.""" # Test for a position inside the UserService class but outside any method @@ -64,7 +64,7 @@ class TestLanguageServerSymbols: assert containing_symbol["name"] == "UserService" assert containing_symbol["kind"] == SymbolKind.Class - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol with nested scopes.""" # Test for a position inside a method which is inside a class @@ -90,7 +90,7 @@ class TestLanguageServerSymbols: assert parent_symbol["name"] == "UserService" assert parent_symbol["kind"] == SymbolKind.Class - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_containing_symbol_none(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol for a position with no containing symbol.""" # Test for a position outside any function/class (e.g., in imports) @@ -101,7 +101,7 @@ class TestLanguageServerSymbols: # Should return None or an empty dictionary assert containing_symbol is None or containing_symbol == {} - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_referencing_symbols_function(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a function.""" # Test referencing symbols for create_user function @@ -125,7 +125,7 @@ class TestLanguageServerSymbols: assert "start" in symbol["location"]["range"] assert "end" in symbol["location"]["range"] - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_referencing_symbols_class(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a class.""" # Test referencing symbols for User class @@ -146,7 +146,7 @@ class TestLanguageServerSymbols: ] assert len(services_references) > 0, "No referencing symbols from services.py for User (selectionRange)" - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_referencing_symbols_parameter(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a function parameter.""" # Test referencing symbols for id parameter in get_user @@ -167,7 +167,7 @@ class TestLanguageServerSymbols: ] assert len(method_refs) > 0, "No referencing symbols within method body for get_user (selectionRange)" - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_referencing_symbols_none(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a position with no symbol.""" # For positions with no symbol, the method might throw an error or return None/empty list @@ -185,7 +185,7 @@ class TestLanguageServerSymbols: pass # Tests for request_defining_symbol - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_defining_symbol_variable(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a variable usage.""" # Test finding the definition of a symbol in the create_user method @@ -204,7 +204,7 @@ class TestLanguageServerSymbols: if "location" in defining_symbol and "uri" in defining_symbol["location"]: assert "services.py" in defining_symbol["location"]["uri"] - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_defining_symbol_imported_class(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for an imported class.""" # Test finding the definition of the 'User' class used in the UserService.create_user method @@ -216,7 +216,7 @@ class TestLanguageServerSymbols: assert defining_symbol is not None assert defining_symbol.get("name") == "User" - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_defining_symbol_method_call(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a method call.""" # Create an example file path for a file that calls UserService.create_user @@ -241,7 +241,7 @@ class TestLanguageServerSymbols: warnings.warn("Could not verify method call definition - file structure may differ from expected") - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a position with no symbol.""" # Test for a position with no symbol (e.g., whitespace or comment) @@ -252,7 +252,7 @@ class TestLanguageServerSymbols: # Should return None for positions with no symbol assert defining_symbol is None - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_containing_symbol_variable(self, language_server: SolidLanguageServer) -> None: """Test request_containing_symbol where the symbol is a variable.""" # Test for a position inside a variable definition @@ -265,7 +265,7 @@ class TestLanguageServerSymbols: assert containing_symbol["name"] == "user_var_str" assert containing_symbol["kind"] == SymbolKind.Variable - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_defining_symbol_nested_function(self, language_server: SolidLanguageServer) -> None: """Test request_defining_symbol for a nested function or closure.""" # Use the existing nested.py file which contains nested classes and methods @@ -290,15 +290,8 @@ class TestLanguageServerSymbols: # Test 3: Find definition of a method-local function defining_symbol = language_server.request_defining_symbol(file_path, 9, 15) # Position inside func_within_func - # This is challenging for many language servers and may fail - try: - assert defining_symbol is not None - assert defining_symbol.get("name") == "func_within_func" - except (AssertionError, TypeError, KeyError): - # This is expected to potentially fail in many implementations - import warnings - - warnings.warn("Could not resolve nested class method definition - implementation limitation") + assert defining_symbol is not None + assert defining_symbol.get("name") == "func_within_func" # Test 2: Find definition of the nested class defining_symbol = language_server.request_defining_symbol(file_path, 15, 18) # Position of NestedClass @@ -314,9 +307,9 @@ class TestLanguageServerSymbols: # This is challenging for many language servers and may fail assert defining_symbol is not None assert defining_symbol.get("name") == "func_within_func" - assert defining_symbol.get("kind") == SymbolKind.Function.value + assert defining_symbol.get("kind") in [SymbolKind.Function.value, SymbolKind.Method.value] - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None: """Test the integration between different symbol-related methods.""" # This test demonstrates using the various symbol methods together @@ -365,7 +358,7 @@ class TestLanguageServerSymbols: warnings.warn("Could not verify container hierarchy - implementation detail") - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None: """Test that the symbol tree structure is correctly built.""" # Get all symbols in the test file @@ -395,7 +388,7 @@ class TestLanguageServerSymbols: ).get_all_symbols_and_roots() assert user_management_roots == user_management_node["children"] - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_symbol_tree_structure_subdir(self, language_server: SolidLanguageServer) -> None: """Test that the symbol tree structure is correctly built.""" # Get all symbols in the test file @@ -418,7 +411,7 @@ class TestLanguageServerSymbols: ).get_all_symbols_and_roots() assert user_management_roots == user_management_node["children"] - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None: """Test that request_dir_overview returns correct symbol information for files in a directory.""" # Get overview of the examples directory @@ -442,7 +435,7 @@ class TestLanguageServerSymbols: retrieved_symbols = {symbol["name"] for symbol in services_symbols if "name" in symbol} assert expected_symbols.issubset(retrieved_symbols) - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) def test_request_document_overview(self, language_server: SolidLanguageServer) -> None: """Test that request_document_overview returns correct symbol information for a file.""" # Get overview of the user_management.py file @@ -451,25 +444,3 @@ class TestLanguageServerSymbols: # Verify that we have entries for both files symbol_names = {LanguageServerSymbol(s_info).name for s_info in overview} assert {"UserStats", "UserManager", "process_user_data", "main"}.issubset(symbol_names) - - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) - def test_containing_symbol_of_var_is_file(self, language_server: SolidLanguageServer) -> None: - """Test that the containing symbol of a variable is the file itself.""" - # Get the containing symbol of a variable in a file - file_path = os.path.join("test_repo", "services.py") - # import of typing - references_to_typing = [ - ref.symbol - for ref in language_server.request_referencing_symbols(file_path, 4, 6, include_imports=False, include_file_symbols=True) - ] - assert {ref["kind"] for ref in references_to_typing} == {SymbolKind.File} - - # now include bodies - references_to_typing = [ - ref.symbol - for ref in language_server.request_referencing_symbols( - file_path, 4, 6, include_imports=False, include_file_symbols=True, include_body=True - ) - ] - assert {ref["kind"] for ref in references_to_typing} == {SymbolKind.File} - assert references_to_typing[0]["body"] diff --git a/test/solidlsp/r/test_r_basic.py b/test/solidlsp/r/test_r_basic.py index 647ff6c1..76f82602 100644 --- a/test/solidlsp/r/test_r_basic.py +++ b/test/solidlsp/r/test_r_basic.py @@ -3,14 +3,18 @@ Basic tests for R Language Server integration """ import os +import shutil from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import is_ci +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +@pytest.mark.skipif(shutil.which("R") is None and not is_ci, reason="R is not available") @pytest.mark.r class TestRLanguageServer: """Test basic functionality of the R language server.""" @@ -82,8 +86,6 @@ class TestRLanguageServer: def test_file_matching(self): """Test that R files are properly matched.""" - from solidlsp.ls_config import Language - matcher = Language.R.get_source_fn_matcher() assert matcher.is_relevant_filename("script.R") @@ -95,3 +97,16 @@ class TestRLanguageServer: """Test R language enum value.""" assert Language.R == "r" assert str(Language.R) == "r" + + @pytest.mark.parametrize("language_server", [Language.R], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/rego/test_rego_basic.py b/test/solidlsp/rego/test_rego_basic.py index 79155a0c..ce8028c2 100644 --- a/test/solidlsp/rego/test_rego_basic.py +++ b/test/solidlsp/rego/test_rego_basic.py @@ -1,14 +1,19 @@ """Tests for Rego language server (Regal) functionality.""" import os +import shutil import pytest from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils +from test.conftest import is_ci +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +@pytest.mark.skipif(shutil.which("regal") is None and not is_ci, reason="Regal is not available") @pytest.mark.rego class TestRegoLanguageServer: """Test Regal language server functionality for Rego.""" @@ -132,3 +137,18 @@ class TestRegoLanguageServer: assert "validate_user_input" in symbol_names, "validate_user_input rule not found" assert "has_valid_credentials" in symbol_names, "has_valid_credentials function not found" assert "validate_request" in symbol_names, "validate_request rule not found" + + @pytest.mark.parametrize("language_server", [Language.REGO], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if s["kind"] == SymbolKind.Package: + continue + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/ruby/test_ruby_basic.py b/test/solidlsp/ruby/test_ruby_basic.py index b7a4cf75..2a76b4f8 100644 --- a/test/solidlsp/ruby/test_ruby_basic.py +++ b/test/solidlsp/ruby/test_ruby_basic.py @@ -6,6 +6,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.ruby @@ -37,8 +38,36 @@ class TestRubyLanguageServer: str(repo_path / "main.rb"), 16, 17 ) # add method at line 17 (0-indexed 16), position 17 - assert len(definition_location_list) == 1 - definition_location = definition_location_list[0] + unique_locations = { + ( + location["relativePath"], + location["range"]["start"]["line"], + location["range"]["start"]["character"], + location["range"]["end"]["line"], + location["range"]["end"]["character"], + ): location + for location in definition_location_list + } + + assert len(unique_locations) == 1 + definition_location = next(iter(unique_locations.values())) print(f"Found definition: {definition_location}") assert definition_location["uri"].endswith("lib.rb") assert definition_location["range"]["start"]["line"] == 1 # add method on line 2 (0-indexed 1) + + @pytest.mark.parametrize("language_server", [Language.RUBY], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name( + s, + whitespace_allowed=s["name"] == "<< self", + period_allowed=s["name"].startswith("self."), + ): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/rust/test_rust_basic.py b/test/solidlsp/rust/test_rust_basic.py index 8d84d736..814892d0 100644 --- a/test/solidlsp/rust/test_rust_basic.py +++ b/test/solidlsp/rust/test_rust_basic.py @@ -5,6 +5,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.rust @@ -55,3 +56,16 @@ class TestRustLanguageServer: symbols = language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add missing from overview" + + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/solidity/test_solidity_basic.py b/test/solidlsp/solidity/test_solidity_basic.py index 348c460b..e095871f 100644 --- a/test/solidlsp/solidity/test_solidity_basic.py +++ b/test/solidlsp/solidity/test_solidity_basic.py @@ -13,6 +13,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols def _find_identifier_position(file_path: Path, symbol_name: str) -> Optional[tuple[int, int]]: @@ -163,3 +164,16 @@ class TestSolidityLanguageServerBasics: ref_files = {ref.get("uri", "") for ref in references} assert any("Token.sol" in uri for uri in ref_files), "IERC20.transfer references should include Token.sol" + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/swift/test_swift_basic.py b/test/solidlsp/swift/test_swift_basic.py index 3f713f87..5a0d8bd0 100644 --- a/test/solidlsp/swift/test_swift_basic.py +++ b/test/solidlsp/swift/test_swift_basic.py @@ -15,6 +15,7 @@ from serena.util.text_utils import LineType from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.conftest import is_ci +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols # Skip Swift tests on Windows due to complex GitHub Actions configuration WINDOWS_SKIP = platform.system() == "Windows" @@ -224,3 +225,16 @@ class TestSwiftProjectBasics: # Should find Status enum status_matches = [m for m in matches if "Status" in str(m)] assert len(status_matches) > 0, "Should find Status enum" + + @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/systemverilog/test_systemverilog_basic.py b/test/solidlsp/systemverilog/test_systemverilog_basic.py index b3a8a4c5..4653a84c 100644 --- a/test/solidlsp/systemverilog/test_systemverilog_basic.py +++ b/test/solidlsp/systemverilog/test_systemverilog_basic.py @@ -5,6 +5,7 @@ This module tests Language.SYSTEMVERILOG using verible-verilog-ls. Tests are skipped if the language server is not available. """ +import shutil from typing import Any import pytest @@ -12,6 +13,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols def _find_symbol_by_name(language_server: SolidLanguageServer, file_path: str, name: str) -> dict[str, Any] | None: @@ -262,3 +264,17 @@ class TestSystemVerilogRename: for uri, file_edits in changes.items(): for edit in file_edits: assert edit["newText"] == "my_counter", f"Expected 'my_counter', got {edit['newText']}" + + @pytest.mark.skipif(shutil.which("verible-verilog-ls") is None, reason="verible-verilog-ls is not available") + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/terraform/test_terraform_basic.py b/test/solidlsp/terraform/test_terraform_basic.py index d608e4e5..612e3f9a 100644 --- a/test/solidlsp/terraform/test_terraform_basic.py +++ b/test/solidlsp/terraform/test_terraform_basic.py @@ -5,12 +5,18 @@ These tests validate the functionality of the language server APIs like request_references using the test repository. """ +import shutil + import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from test.conftest import is_ci +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +@pytest.mark.skipif(shutil.which("terraform") is None and not is_ci, reason="Terraform CLI is not available") @pytest.mark.terraform class TestLanguageServerBasics: """Test basic functionality of the Terraform language server.""" @@ -51,3 +57,18 @@ class TestLanguageServerBasics: sel_start = var_symbol["selectionRange"]["start"] references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert len(references) >= 1, "variable should be referenced at least once" + + @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if s["kind"] == SymbolKind.Class: + continue + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/toml/test_toml_basic.py b/test/solidlsp/toml/test_toml_basic.py index b217aef8..522f8407 100644 --- a/test/solidlsp/toml/test_toml_basic.py +++ b/test/solidlsp/toml/test_toml_basic.py @@ -11,6 +11,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.toml @@ -233,3 +234,16 @@ class TestTomlLanguageServerBasics: strict_symbol = next((s for s in pyproject_symbols if s.get("name") == "strict"), None) if strict_symbol: assert strict_symbol.get("kind") == 17, "'strict' should have kind 17 (boolean)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/typescript/test_typescript_basic.py b/test/solidlsp/typescript/test_typescript_basic.py index 58c560f0..ba86ba04 100644 --- a/test/solidlsp/typescript/test_typescript_basic.py +++ b/test/solidlsp/typescript/test_typescript_basic.py @@ -5,6 +5,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.typescript @@ -31,3 +32,16 @@ class TestTypescriptLanguageServer: assert any("index.ts" in ref.get("relativePath", "") for ref in refs), ( "index.ts should reference helperFunction (tried all positions in selectionRange)" ) + + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/vue/test_vue_basic.py b/test/solidlsp/vue/test_vue_basic.py index ca6a5e19..d3e05356 100644 --- a/test/solidlsp/vue/test_vue_basic.py +++ b/test/solidlsp/vue/test_vue_basic.py @@ -5,6 +5,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.vue @@ -360,3 +361,25 @@ class TestVueEdgeCases: f"Composable '{expected_composable}' should appear exactly once in directory overview. " f"Found {len(matching_files)} matches. All files: {list(composables_overview.keys())}" ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + is_callback = s["name"].endswith(" callback") + is_vue_selector = "." in s["name"] or ":" in s["name"] + is_vue_block = s["name"] in {"script setup", "style scoped"} + if has_malformed_name( + s, + whitespace_allowed=is_callback or is_vue_selector or is_vue_block, + period_allowed=is_vue_selector, + colon_allowed=is_vue_selector, + parenthesis_allowed=is_callback or is_vue_selector, + ): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/yaml_ls/test_yaml_basic.py b/test/solidlsp/yaml_ls/test_yaml_basic.py index ded7f01a..da60e487 100644 --- a/test/solidlsp/yaml_ls/test_yaml_basic.py +++ b/test/solidlsp/yaml_ls/test_yaml_basic.py @@ -11,6 +11,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.yaml @@ -175,3 +176,16 @@ class TestYAMLLanguageServerBasics: app_port = next((s for s in port_symbols if s["range"]["start"]["line"] == 4), None) assert app_port is not None, "Should find 'port' under 'app'" assert app_port["range"]["start"]["character"] == 2, "'port' should be indented 2 spaces" + + @pytest.mark.parametrize("language_server", [Language.YAML], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) diff --git a/test/solidlsp/zig/test_zig_basic.py b/test/solidlsp/zig/test_zig_basic.py index 12e23f8c..51b23650 100644 --- a/test/solidlsp/zig/test_zig_basic.py +++ b/test/solidlsp/zig/test_zig_basic.py @@ -13,6 +13,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @pytest.mark.zig @@ -341,3 +342,16 @@ class TestZigLanguageServer: root = symbols[0] assert isinstance(root, dict), "Root should be a dict" assert "name" in root, "Root should have a name" + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_bare_symbol_names(self, language_server) -> None: + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + if has_malformed_name(s, whitespace_allowed=True): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) From fecf4102666f327abfa7a0afc3b9b1844a3a913c Mon Sep 17 00:00:00 2001 From: zoajimen Date: Wed, 1 Apr 2026 16:47:59 +0200 Subject: [PATCH 24/26] Enhance macOS dock handling in SerenaDashboardViewer and add high-resolution app icon --- resources/serena-mac-dock-icon.cdr | Bin 0 -> 26002 bytes src/serena/dashboard.py | 119 ++++++++---------- .../dashboard/serena-icon-1024-mac.png | Bin 0 -> 38227 bytes 3 files changed, 54 insertions(+), 65 deletions(-) create mode 100644 resources/serena-mac-dock-icon.cdr create mode 100644 src/serena/resources/dashboard/serena-icon-1024-mac.png diff --git a/resources/serena-mac-dock-icon.cdr b/resources/serena-mac-dock-icon.cdr new file mode 100644 index 0000000000000000000000000000000000000000..3f3f164cd7fe096eb86d2af4223f1b2e661b55e9 GIT binary patch literal 26002 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 literal 0 HcmV?d00001 diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 0e2f7129..538fd250 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -704,10 +704,8 @@ class SerenaDashboardViewer: self.window: webview.Window self._tray_icon: Any - self._app_delegate: Any = None self._quitting = False - self._window_hidden_to_tray = start_minimized - self._suppress_next_activate_reopen = start_minimized + self._app_icon_path: str | None = None @staticmethod def is_current_platform_supported() -> bool: @@ -727,8 +725,9 @@ class SerenaDashboardViewer: dashboard_path = Path(SERENA_DASHBOARD_DIR) # .ico is Windows-only; macOS expects a PNG for the window/dock icon. - icon_filename = "serena.ico" if sys.platform == "win32" else "serena-icon-64.png" + icon_filename = "serena.ico" if sys.platform == "win32" else "serena-icon-1024-mac.png" icon_path = str(dashboard_path / icon_filename) + self._app_icon_path = icon_path # Create hidden to avoid flash; show/restore/minimize in start callback. window = webview.create_window( @@ -746,83 +745,73 @@ class SerenaDashboardViewer: self._start_tray() def _start_callback() -> None: - # pywebview installs its own NSApplication delegate during startup, - # so on macOS we need to wrap that final delegate here instead of - # earlier in run(), or the dock reopen handler gets overwritten. - if sys.platform == "darwin" and self.tray: - from PyObjCTools.AppHelper import callAfter - - callAfter(self._setup_macos_dock_handler) if self.start_minimized: - self.window.minimize() + self._hide_window() else: self._show_window() webview.start(_start_callback, icon=icon_path) def _show_window(self) -> None: - if self.window: + if not self.window: + return + + if sys.platform == "darwin": + from PyObjCTools.AppHelper import callAfter + + callAfter(self._show_window_on_macos) + else: self.window.show() self.window.restore() - self._window_hidden_to_tray = False - self._suppress_next_activate_reopen = False def _hide_window(self) -> None: - if self.window: + if not self.window: + return + + if sys.platform == "darwin": + from PyObjCTools.AppHelper import callAfter + + callAfter(self._hide_window_on_macos) + else: self.window.hide() - self._window_hidden_to_tray = True - def _setup_macos_dock_handler(self) -> None: - """Set up handler for dock icon clicks on macOS.""" - from AppKit import NSApplication, NSObject - from objc import python_method # type: ignore[import-untyped] - - viewer = self - - class SerenaDockDelegate(NSObject): - """Wraps pywebview's delegate to handle dock icon clicks.""" - - @python_method - def initWithOriginalDelegate_(self, original: Any) -> Any: - obj = self.init() - if obj is not None: - obj._original = original - return obj - - @python_method - def _forward(self, method: str, *args: Any) -> Any: - """Forward method call to original delegate if it has the method.""" - orig = getattr(self, "_original", None) - if orig and hasattr(orig, method): - return getattr(orig, method)(*args) - return None - - def applicationShouldHandleReopen_hasVisibleWindows_(self, sender: Any, has_visible_windows: bool) -> bool: - """Called when dock icon is clicked.""" - if not has_visible_windows: - viewer._show_window() - result = self._forward("applicationShouldHandleReopen_hasVisibleWindows_", sender, has_visible_windows) - return result if result is not None else True - - def applicationDidBecomeActive_(self, notification: Any) -> None: - """Fallback for dock activation paths that do not invoke reopen.""" - if viewer._suppress_next_activate_reopen: - viewer._suppress_next_activate_reopen = False - elif viewer._window_hidden_to_tray: - viewer._show_window() - self._forward("applicationDidBecomeActive_", notification) - - def forwardingTargetForSelector_(self, selector: Any) -> Any: - """Forward unknown selectors to original delegate.""" - orig = getattr(self, "_original", None) - return orig if orig and orig.respondsToSelector_(selector) else None + def _show_window_on_macos(self) -> None: + from AppKit import ( + NSApplication, + NSApplicationActivationPolicyRegular, + ) + from PyObjCTools.AppHelper import callLater ns_app = NSApplication.sharedApplication() - original_delegate = ns_app.delegate() - if original_delegate is self._app_delegate: + ns_app.setActivationPolicy_(NSApplicationActivationPolicyRegular) + self._set_macos_app_icon(ns_app) + ns_app.unhide_(None) + ns_app.activateIgnoringOtherApps_(True) + # Give the status item menu a beat to close before restoring the window. + callLater(0.1, self._restore_window_on_macos) + + def _restore_window_on_macos(self) -> None: + self.window.show() + self.window.restore() + + def _hide_window_on_macos(self) -> None: + from AppKit import ( + NSApplication, + NSApplicationActivationPolicyAccessory, + ) + + self.window.hide() + NSApplication.sharedApplication().setActivationPolicy_(NSApplicationActivationPolicyAccessory) + + def _set_macos_app_icon(self, ns_app: Any) -> None: + if not self._app_icon_path: return - self._app_delegate = SerenaDockDelegate.alloc().initWithOriginalDelegate_(original_delegate) - ns_app.setDelegate_(self._app_delegate) + + from AppKit import NSImage + + ns_image = NSImage.alloc().initByReferencingFile_(self._app_icon_path) + if ns_image is not None: + ns_app.setApplicationIconImage_(ns_image) def _on_closing(self) -> bool: """Intercept window close: hide window instead of quitting (macOS standard behavior).""" diff --git a/src/serena/resources/dashboard/serena-icon-1024-mac.png b/src/serena/resources/dashboard/serena-icon-1024-mac.png new file mode 100644 index 0000000000000000000000000000000000000000..2ad41dae7efa8844187336b802b9f006e3311795 GIT binary patch literal 38227 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 literal 0 HcmV?d00001 From 9bbdd30219e5bc3e9e721fb73a04c4e0cb5a9834 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 1 Apr 2026 17:16:32 +0200 Subject: [PATCH 25/26] Use white tray icon for macOS --- resources/serena-mac-tray-icon.cdr | Bin 0 -> 22777 bytes src/serena/dashboard.py | 2 +- .../dashboard/serena-icon-tray-mac.png | Bin 0 -> 2429 bytes 3 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 resources/serena-mac-tray-icon.cdr create mode 100644 src/serena/resources/dashboard/serena-icon-tray-mac.png diff --git a/resources/serena-mac-tray-icon.cdr b/resources/serena-mac-tray-icon.cdr new file mode 100644 index 0000000000000000000000000000000000000000..98a26a13fc9d523ce083acb6c4c0cd88107a4147 GIT binary patch literal 22777 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 Q+IN#y)V)G017~3PAN+|vjQ{`u literal 0 HcmV?d00001 diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 538fd250..6a1ef09c 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -830,7 +830,7 @@ class SerenaDashboardViewer: # macOS menu bar icons are displayed at 16pt; 32px covers Retina (@2x). # Windows/Linux tray icons are larger, so 48px is the better fit there. - icon_filename = "serena-icon-32.png" if sys.platform == "darwin" else "serena-icon-48.png" + icon_filename = "serena-icon-tray-mac.png" if sys.platform == "darwin" else "serena-icon-48.png" icon_img = Image.open(dashboard_path / icon_filename) def show(_icon: TrayIcon, _item: Item) -> None: diff --git a/src/serena/resources/dashboard/serena-icon-tray-mac.png b/src/serena/resources/dashboard/serena-icon-tray-mac.png new file mode 100644 index 0000000000000000000000000000000000000000..8e807057d5672cd245f1efce106427f927e8c347 GIT binary patch literal 2429 zcmb_ec~leU7N4M~EFJ}^q#zgrNS~TzKp+H?6i7s{i8Qi9kt{wAq9Vj6MkGNGFQ8aTiYo%5eT{-!?VF(W`L@6M&Y79-`|kYa-ut`vch8xE zC|3AfTNhgZ0Om%lrn8ZCh2@?z3wgTlNJJqEO2G~f1?sOa8buB>1tH830BGE6_l9GQ zoNc756BGb|_psb3RTV7@0A>i`=y+v3^9wRhBEoa{5-x;SiKGY`0D@^MDTkKGM39n$QHg|N z1zAPGPCF(edyAQXr3TCSX=FBi)q4bTqF~dNN-3E@$j;8jXZzwMasdGh3=AX?eF#22 zI0S)HY!oXwDx6s1@!kO)Qt;%kR0&JO7>h#=SCXluU^6peJ~@q(=Evpp{cv24FCPbj zd=8Go_XTl$UtfNJA4u{^1G(7u;rYA|(WRMk;dE?1j{pfF5hPYB2p}G`8>Dx zMtb)_{+3zzFhYVD`x{%>oqGZG?r(;{xYpEk(sUMlRl1v0Kzt>L@kXj4? z)}jb{NOXMoL;_P79pgmJu3ol}{CTK--OfXE_Q&U9XiHBM(>=ir5O2=vZ)6X;k1fRy zbm>yJYI6%+7wp@4;Jkw;dQToYiYr`Q>FKn@x}XJ46jUD`Rhl-91mPc=oA0=o zJ8Rkv`l%tslZ_ISCi{t-2Uba2QnhoF+dSL$L1MOg-rl`?2||(R>wdJ^(E;aGp0x&B zzC_4F@t`lPjP@`r7l_9eLb;W06H($Cy#yHaM@)6_cvlQ$P zkFITT?6q+?=ZOsi^78Z9HPIKq>LIj|L2p>lD<@R1r%zV1YbuV!E`DWyV8Cenc9v9o zA<+wXt$*-~QGN1;ui$z!(fk@<0bRP}=QAi23hY+Wxok<%ZDAI5>$)r}fRwzf_|Idq zLsjgW1=`!CSnI9%`9aj;p0tti7o=F5L+6sksI57g)-FTR^HNeCrxm5`NZICHx>116 zBLJ=GEl*KS6S6J%lB8qi^jI6}glM?_aTGc)13?!R7D7OBlkA?MZDvt*d&~|P9H6i|a(;caDD5qv4ZK0Szd6m6c66T2WogCr@pvzidY;~4qQ5*lGdz6N zszk<)tf!{KR#L+l^@QD-#_*Flu_Ku$9V>rf4{&F!DSYEXVzF3RtqiTjb z@8Q{l8^h6g4f_w$nq?0P3JMt5&?{V7<8mu) zoDN-aZ?n~a;Odc^M>;<#yD{Q?zBU?P5|`ew@Zzq~ptXbd+UvND1J%5hNMCG&s9hsw zyO#BzXbnL^Ei}=8x3jYoJ4FP|L9E%tbo1B8c?aeUpYCZ2+Y~cV1wMo}sge`?K#8f1 zS^pKTM1PS^=b-Dc<=*-Ez~&0xhGEiBihkwywoA*sqDMhIT(R&QQ1keX_Q!LI-xd-M3u9ex%blK!^n^z1v+KMbEf)1%QcRi!-e_Lg}TkFx7 zdJF5HTN~Fv4W%!y@wxr8PR~%(C1yoaQ`4S&_v&uGbAG-~r*rozzgi=A zbj~z<;Sl$;(p*>QS!o>YpCr1tyFc;9cJqDo1zqzUKy&e+)tFXuVJwNYVgPNdF7!n6 zZVp`5ySce}eP7lYS^M`#j~;33dY?~+38-=Gt15IENTt0l8$R6mc~a6~rO|a;z!VdT zabAJFIC&~5p|MqVPqzr0$SfW(A9-NgJ`5L@Ys3u?7CzHd-i}i^F*INC7R zn6jJa#OTc+rxgOTdmg}Y4y|z!KpSS!2MKD Date: Wed, 1 Apr 2026 18:54:19 +0200 Subject: [PATCH 26/26] SerenaDashboardViewer: Disabling macOS support for now because the tray behaviour is suboptimal (too many icons are displayed when subagents are spawned, and macOS does not appropriately hide them) --- src/serena/dashboard.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 6a1ef09c..9a8f3f50 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -712,9 +712,15 @@ class SerenaDashboardViewer: """ :return: whether the current platform supports the dashboard viewer """ - # We fully support the dashboard viewer (with system tray) only on Windows and macOS. + # The dashboard viewer (with system tray) is technically supported only on Windows and macOS. # Linux support is problematic; see https://github.com/oraios/serena/pull/1117#issuecomment-4128753943 - return sys.platform in ("win32", "darwin") + supported_platforms = [ + "win32", + # NOTE: Disabling macOS support for now, because the tray behaviour is suboptimal (too many icons when + # subagents are spawned, etc.) + # "darwin" + ] + return sys.platform in supported_platforms def run(self) -> None: # set app id (avoid app being lumped together with other Python-based apps in Windows taskbar)