From 81e87e80033a9792532494c8b03fd834cdd69473 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 16:13:29 -0400 Subject: [PATCH 01/36] tui --- pyproject.toml | 2 +- src/ruff_sync/cli.py | 5 +- src/ruff_sync/tui/__init__.py | 3 + src/ruff_sync/tui/app.py | 143 ++++++++++++++++++++++++++++++++++ src/ruff_sync/tui/widgets.py | 92 ++++++++++++++++++++++ tests/test_tui.py | 125 +++++++++++++++++++++++++++++ uv.lock | 2 +- 7 files changed, 368 insertions(+), 4 deletions(-) create mode 100644 src/ruff_sync/tui/__init__.py create mode 100644 src/ruff_sync/tui/app.py create mode 100644 src/ruff_sync/tui/widgets.py create mode 100644 tests/test_tui.py diff --git a/pyproject.toml b/pyproject.toml index 7a8ef51..6721aed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ruff-sync" -version = "0.1.5.dev2" +version = "0.1.5.dev3" description = "Synchronize Ruff linter configuration across projects" keywords = ["ruff", "linter", "config", "synchronize", "python", "linting", "automation", "tomlkit", "pre-commit"] authors = [ diff --git a/src/ruff_sync/cli.py b/src/ruff_sync/cli.py index 6523630..c2b728e 100644 --- a/src/ruff_sync/cli.py +++ b/src/ruff_sync/cli.py @@ -607,8 +607,9 @@ def main() -> int: LOGGER.error(f"❌ {e}") # noqa: TRY400 return 1 - LOGGER.error("❌ The Terminal UI (inspect) is not yet implemented.") - return 1 + from ruff_sync.tui.app import RuffSyncApp # noqa: PLC0415 + + return RuffSyncApp(exec_args).run() if exec_args.command == "check": return asyncio.run(check(exec_args)) diff --git a/src/ruff_sync/tui/__init__.py b/src/ruff_sync/tui/__init__.py new file mode 100644 index 0000000..acf5222 --- /dev/null +++ b/src/ruff_sync/tui/__init__.py @@ -0,0 +1,3 @@ +"""TUI package for ruff-sync.""" + +from __future__ import annotations diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py new file mode 100644 index 0000000..7d999d8 --- /dev/null +++ b/src/ruff_sync/tui/app.py @@ -0,0 +1,143 @@ +"""Main application logic for the Ruff-Sync Terminal User Interface.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Any, ClassVar + +from textual import on +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import DataTable, Footer, Header, Tree + +from ruff_sync.config_io import load_local_ruff_config +from ruff_sync.tui.widgets import CategoryTable, ConfigTree, RuleInspector + +if TYPE_CHECKING: + from ruff_sync.cli import Arguments + + +class RuffSyncApp(App[None]): + """Ruff-Sync Terminal User Interface.""" + + CSS = """ + Screen { + background: $surface; + } + + #config-tree { + width: 1fr; + height: 100%; + border-right: solid $primary-darken-2; + } + + #content-pane { + width: 2fr; + height: 100%; + } + + #category-table { + height: 1fr; + border-bottom: solid $primary-darken-2; + } + + #inspector { + height: 1fr; + padding: 1; + background: $surface-darken-1; + } + + .hidden { + display: none; + } + """ + + BINDINGS: ClassVar[list[tuple[str, str, str]]] = [ + ("q", "quit", "Quit"), + ("/", "focus('config-tree')", "Search"), + ] + + def __init__(self, args: Arguments, **kwargs: Any) -> None: + """Initialize the application. + + Args: + args: The CLI arguments. + **kwargs: Additional keyword arguments. + """ + super().__init__(**kwargs) + self.args = args + self.config: dict[str, Any] = {} + + def compose(self) -> ComposeResult: + """Compose the user interface elements.""" + yield Header() + with Horizontal(): + yield ConfigTree("Local Configuration", id="config-tree") + with Vertical(id="content-pane"): + yield CategoryTable(id="category-table") + yield RuleInspector(id="inspector", classes="hidden") + yield Footer() + + async def on_mount(self) -> None: + """Load the configuration and populate the tree.""" + try: + self.config = load_local_ruff_config(self.args.to) + except Exception: + self.notify("Failed to load Ruff configuration.", severity="error") + self.config = {} + + tree = self.query_one(ConfigTree) + tree.populate(self.config) + tree.focus() + + @on(Tree.NodeSelected) + def handle_node_selected(self, event: Tree.NodeSelected[Any]) -> None: + """Handle tree node selection. + + Args: + event: The tree node selected event. + """ + data = event.node.data + label = str(event.node.label.plain) + + table = self.query_one(CategoryTable) + inspector = self.query_one(RuleInspector) + + # Basic rule code detection (e.g., PIE790, RUF012) + if isinstance(label, str) and re.match(r"^[A-Z]+[0-9]+$", label): + inspector.remove_class("hidden") + inspector.fetch_and_display(label) + # Find closest parent that is a dict/list to update table + p_data = event.node.parent.data if event.node.parent else None # type: ignore[union-attr] + table.update_content(p_data) + elif isinstance(data, (dict, list)): + inspector.add_class("hidden") + table.update_content(data) + else: + inspector.add_class("hidden") + table.update_content(data) + + @on(DataTable.RowSelected) + def handle_row_selected(self, event: DataTable.RowSelected) -> None: + """Handle data table row selection. + + Args: + event: The data table row selected event. + """ + table = self.query_one(CategoryTable) + row = table.get_row_at(event.cursor_row) + key, value = row + + # Check if the value or key looks like a rule code + rule_pattern = re.compile(r"^[A-Z]+[0-9]+$") + rule_code = None + + if rule_pattern.match(str(key)): + rule_code = str(key) + elif rule_pattern.match(str(value)): + rule_code = str(value) + + if rule_code: + inspector = self.query_one(RuleInspector) + inspector.remove_class("hidden") + inspector.fetch_and_display(rule_code) diff --git a/src/ruff_sync/tui/widgets.py b/src/ruff_sync/tui/widgets.py new file mode 100644 index 0000000..0c7fd0b --- /dev/null +++ b/src/ruff_sync/tui/widgets.py @@ -0,0 +1,92 @@ +"""Widgets for the Ruff-Sync Terminal User Interface.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from textual import work +from textual.widgets import DataTable, Markdown, Tree + +from ruff_sync.system import get_ruff_rule_markdown + +if TYPE_CHECKING: + from textual.widgets.tree import TreeNode + + +class ConfigTree(Tree[Any]): + """A tree widget for navigating Ruff configuration.""" + + def populate(self, config: dict[str, Any]) -> None: + """Populate the tree with configuration sections. + + Args: + config: The unwrapped dictionary of Ruff configuration. + """ + self.clear() + self.root.expand() + self._populate_node(self.root, config) + + def _populate_node(self, parent: TreeNode[Any], data: Any) -> None: + """Recursively add nodes to the tree. + + Args: + parent: The parent tree node. + data: The data to add to the tree. + """ + if isinstance(data, dict): + for key, value in sorted(data.items()): + node = parent.add(key, data=value) + if isinstance(value, (dict, list)): + self._populate_node(node, value) + elif isinstance(data, list): + for i, item in enumerate(data): + label = str(item) if not isinstance(item, (dict, list)) else f"[{i}]" + node = parent.add(label, data=item) + if isinstance(item, (dict, list)): + self._populate_node(node, item) + + +class CategoryTable(DataTable[Any]): + """A table widget for displaying configuration keys and values.""" + + def on_mount(self) -> None: + """Initialize the table columns.""" + self.cursor_type = "row" + self.add_columns("Key", "Value") + + def update_content(self, data: Any) -> None: + """Update the table rows based on the selected data. + + Args: + data: The data to display in the table. + """ + self.clear() + if isinstance(data, dict): + for key, value in sorted(data.items()): + self.add_row(key, str(value)) + elif isinstance(data, list): + for i, item in enumerate(data): + self.add_row(str(i), str(item)) + else: + self.add_row("Value", str(data)) + + +class RuleInspector(Markdown): + """A markdown widget for inspecting Ruff rules.""" + + @work(thread=True) + async def fetch_and_display(self, rule_code: str) -> None: + """Fetch and display the documentation for a rule. + + Args: + rule_code: The Ruff rule code to fetch documentation for. + """ + # Set a loading message + self.update(f"## Inspecting {rule_code}...\n\nFetching documentation from `ruff rule`...") + + content = await get_ruff_rule_markdown(rule_code) + + if content: + self.update(content) + else: + self.update(f"## Error\n\nCould not fetch documentation for rule `{rule_code}`.") diff --git a/tests/test_tui.py b/tests/test_tui.py new file mode 100644 index 0000000..1454812 --- /dev/null +++ b/tests/test_tui.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest +from textual.widgets import DataTable, Tree + +from ruff_sync.cli import Arguments +from ruff_sync.tui.app import RuffSyncApp + +if TYPE_CHECKING: + import pathlib + + +@pytest.fixture +def mock_args(tmp_path: pathlib.Path) -> Arguments: + return Arguments( + command="inspect", + upstream=(), + to=tmp_path, + exclude=(), + verbose=0, + ) + + +def test_ruff_sync_app_init(mock_args: Arguments) -> None: + app = RuffSyncApp(mock_args) + assert app.args == mock_args + assert app.config == {} + + +@pytest.mark.asyncio +async def test_ruff_sync_app_mount(mock_args: Arguments, tmp_path: pathlib.Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.ruff] +line-length = 88 +[tool.ruff.lint] +select = ["E", "F"] +""", + encoding="utf-8", + ) + + app = RuffSyncApp(mock_args) + async with app.run_test(): + assert app.config == {"line-length": 88, "lint": {"select": ["E", "F"]}} + tree = app.query_one(Tree) + assert tree.root.label.plain == "Local Configuration" + # Check some children + assert any(n.label.plain == "line-length" for n in tree.root.children) + assert any(n.label.plain == "lint" for n in tree.root.children) + + +@pytest.mark.asyncio +async def test_ruff_sync_app_node_selection(mock_args: Arguments, tmp_path: pathlib.Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.ruff] +line-length = 88 +""", + encoding="utf-8", + ) + + app = RuffSyncApp(mock_args) + async with app.run_test() as pilot: + tree = app.query_one(Tree) + # Select "line-length" + node = next(n for n in tree.root.children if n.label.plain == "line-length") + tree.select_node(node) + await pilot.pause() + + table = app.query_one(DataTable) + # For a simple value, CategoryTable.update_content(88) adds row ("Value", "88") + assert table.row_count == 1 + row = table.get_row_at(0) + assert row == ["Value", "88"] + + +@pytest.mark.asyncio +async def test_ruff_sync_app_rule_selection(mock_args: Arguments, tmp_path: pathlib.Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.ruff.lint] +select = ["RUF012"] +""", + encoding="utf-8", + ) + + app = RuffSyncApp(mock_args) + mock_markdown = "## RUF012 Documentation\n\nDetailed info here." + + with patch("ruff_sync.tui.widgets.get_ruff_rule_markdown", return_value=mock_markdown): + async with app.run_test() as pilot: + tree = app.query_one(Tree) + # Find and select RUF012 node + # It's inside tool.ruff -> lint -> select -> RUF012 + lint_node = next(n for n in tree.root.children if n.label.plain == "lint") + lint_node.expand() + await pilot.pause() + + select_node = next(n for n in lint_node.children if n.label.plain == "select") + select_node.expand() + await pilot.pause() + + rule_node = next(n for n in select_node.children if n.label.plain == "RUF012") + tree.select_node(rule_node) + await pilot.pause() + + inspector = app.query_one("#inspector") + assert "hidden" not in inspector.classes + # Wait for background fetch worker + # Since we mocked it to return immediately, it should be fine + # We might need to wait for worker completion if it was truly async + + # Textual's handle_node_selected calls fetch_and_display which is a @work(thread=True) + # In run_test, we might need a small pause + await pilot.pause(0.1) + + # Verify Markdown content (simplified check) + # Textual's Markdown widget has a 'source' property + assert "RUF012 Documentation" in str(inspector.source) diff --git a/uv.lock b/uv.lock index bd8529a..96166d8 100644 --- a/uv.lock +++ b/uv.lock @@ -1594,7 +1594,7 @@ wheels = [ [[package]] name = "ruff-sync" -version = "0.1.5.dev2" +version = "0.1.5.dev3" source = { editable = "." } dependencies = [ { name = "httpx" }, From cf836ed747d10c5bf3f0cc11dcf131c72312e341 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 16:24:06 -0400 Subject: [PATCH 02/36] ruff-inspect entrypoint --- pyproject.toml | 1 + src/ruff_sync/__init__.py | 2 ++ src/ruff_sync/cli.py | 15 +++++++++++++++ 3 files changed, 18 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 6721aed..bbf35cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ Changelog = "https://github.com/Kilo59/ruff-sync/releases" [project.scripts] ruff-sync = "ruff_sync:main" +ruff-inspect = "ruff_sync:inspect" [dependency-groups] dev = [ diff --git a/src/ruff_sync/__init__.py b/src/ruff_sync/__init__.py index 911897e..28f1ca2 100644 --- a/src/ruff_sync/__init__.py +++ b/src/ruff_sync/__init__.py @@ -9,6 +9,7 @@ Arguments, __version__, get_config, + inspect, main, ) from .config_io import ( @@ -46,6 +47,7 @@ "get_formatter", "get_ruff_config", "get_ruff_tool_table", + "inspect", "is_ruff_toml_file", "load_local_ruff_config", "main", diff --git a/src/ruff_sync/cli.py b/src/ruff_sync/cli.py index c2b728e..14f2cb0 100644 --- a/src/ruff_sync/cli.py +++ b/src/ruff_sync/cli.py @@ -620,5 +620,20 @@ def main() -> int: return 4 +def inspect() -> int: + """Entry point for the ruff-inspect console script.""" + # Handle optional subcommands/args if user passed any to ruff-inspect + # but primarily ensure "inspect" is the command. + if len(sys.argv) > 1 and sys.argv[1] not in ("-h", "--help", "--version"): + # If they passed args but no command, insert 'inspect' + if sys.argv[1] not in ("pull", "check", "inspect"): + sys.argv.insert(1, "inspect") + else: + # Default to 'inspect' if no args or just flags + sys.argv.insert(1, "inspect") + + return main() + + if __name__ == "__main__": sys.exit(main()) From f7d2f77bb5ac851060b22de0f809587c7d4392f2 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 16:40:36 -0400 Subject: [PATCH 03/36] some tests --- src/ruff_sync/cli.py | 5 +++- tests/conftest.py | 62 +++++++++++++++++++++++++++++++++++++++++--- tests/test_tui.py | 31 ++++++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/ruff_sync/cli.py b/src/ruff_sync/cli.py index 14f2cb0..0fb2bc7 100644 --- a/src/ruff_sync/cli.py +++ b/src/ruff_sync/cli.py @@ -378,6 +378,9 @@ def _resolve_upstream(args: CLIArguments, config: Config) -> tuple[URL, ...]: f"got {type(config_upstream).__name__}" ) + if args.command == "inspect": + return () + PARSER.error( "❌ the following arguments are required: upstream " f"(or define it in [tool.ruff-sync] in {RuffConfigFileName.PYPROJECT_TOML}) 💥" @@ -609,7 +612,7 @@ def main() -> int: from ruff_sync.tui.app import RuffSyncApp # noqa: PLC0415 - return RuffSyncApp(exec_args).run() + return RuffSyncApp(exec_args).run() or 0 if exec_args.command == "check": return asyncio.run(check(exec_args)) diff --git a/tests/conftest.py b/tests/conftest.py index 8778369..34e30fd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,9 +2,7 @@ import logging import sys - -import pytest -from typing_extensions import override +from typing import Literal, Protocol, runtime_checkable import ruff_sync @@ -62,3 +60,61 @@ def clear_ruff_sync_caches(): """Clear all lru_caches in ruff_sync.""" ruff_sync.get_config.cache_clear() ruff_sync.Arguments.fields.cache_clear() + + +@runtime_checkable +class CLIRunner(Protocol): + """Protocol for the cli_run fixture.""" + + def __call__( + self, + args: list[str], + entry_point: Literal["ruff-sync", "ruff-inspect"] = "ruff-sync", + ) -> tuple[int, str, str]: + """Run a CLI command with the given arguments.""" + ... + + +@pytest.fixture +def cli_run(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> CLIRunner: + """Fixture to run the ruff-sync CLI or entry points and capture output.""" + + def _run( + args: list[str], + entry_point: Literal["ruff-sync", "ruff-inspect"] = "ruff-sync", + ) -> tuple[int, str, str]: + """Run a CLI command with the given arguments. + + Args: + args: The list of CLI arguments (excluding the program name). + entry_point: The name of the entry point to run ('ruff-sync' or 'ruff-inspect'). + + Returns: + A tuple of (exit_code, stdout, stderr). + """ + # Reset sys.argv for each run + monkeypatch.setattr(sys, "argv", [entry_point, *args]) + + exit_code = 0 + try: + if entry_point == "ruff-inspect": + from ruff_sync.cli import inspect + + exit_code = inspect() + else: + from ruff_sync.cli import main + + exit_code = main() + except SystemExit as e: + # Handle sys.exit calls from argparse or main + if isinstance(e.code, int): + exit_code = e.code + elif e.code is None: + exit_code = 0 + else: + exit_code = 1 + + captured = capsys.readouterr() + return exit_code, captured.out, captured.err + + return _run diff --git a/tests/test_tui.py b/tests/test_tui.py index 1454812..515e197 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -12,6 +12,8 @@ if TYPE_CHECKING: import pathlib + from .conftest import CLIRunner + @pytest.fixture def mock_args(tmp_path: pathlib.Path) -> Arguments: @@ -123,3 +125,32 @@ async def test_ruff_sync_app_rule_selection(mock_args: Arguments, tmp_path: path # Verify Markdown content (simplified check) # Textual's Markdown widget has a 'source' property assert "RUF012 Documentation" in str(inspector.source) + + +def test_cli_inspect_subcommand( + cli_run: CLIRunner, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test that 'ruff-sync inspect' attempts to run the app.""" + # Mock load_local_ruff_config where it's used in RuffSyncApp.on_mount + monkeypatch.setattr("ruff_sync.tui.app.load_local_ruff_config", lambda _: {}) + + # Use patch to prevent the App from actually running (which would block/fail in CI) + # and just verify it was instantiated and run() was called. + with patch("ruff_sync.tui.app.RuffSyncApp.run", return_value=0) as mock_run: + exit_code, out, err = cli_run(["inspect", "--to", str(tmp_path)]) + assert exit_code == 0 + mock_run.assert_called_once() + + +def test_cli_ruff_inspect_entry_point( + cli_run: CLIRunner, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test that the 'ruff-inspect' entry point attempts to run the app.""" + # Mock load_local_ruff_config where it's used in RuffSyncApp.on_mount + monkeypatch.setattr("ruff_sync.tui.app.load_local_ruff_config", lambda _: {}) + + with patch("ruff_sync.tui.app.RuffSyncApp.run", return_value=0) as mock_run: + # Program name 'ruff-inspect' should trigger the inspect logic + exit_code, out, err = cli_run(["--to", str(tmp_path)], entry_point="ruff-inspect") + assert exit_code == 0 + mock_run.assert_called_once() From 940cd3943e68350010bbaac39ce06f9935ab38a2 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 16:41:42 -0400 Subject: [PATCH 04/36] refactor(tui): address review feedback and harden configuration loading - Centralize Ruff rule detection regex in `src/ruff_sync/tui/constants.py` to remove duplication. - Fix thread-safety in `RuleInspector` by using non-threaded async workers for UI updates. - Improve diagnosability of configuration failures by adding `LOGGER.exception` to `on_mount`. - Add `test_ruff_sync_app_mount_load_config_failure` to verify robust error handling. - Resolve multiple mypy type errors and ruff linting issues in TUI modules and tests. - Fix missing imports in `tests/conftest.py` that were blocking test execution. --- src/ruff_sync/tui/app.py | 27 +++++++----- src/ruff_sync/tui/constants.py | 9 ++++ src/ruff_sync/tui/widgets.py | 4 +- tests/conftest.py | 3 ++ tests/test_tui.py | 81 +++++++++++++++++++++++++++++----- 5 files changed, 99 insertions(+), 25 deletions(-) create mode 100644 src/ruff_sync/tui/constants.py diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index 7d999d8..ead35a1 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -2,21 +2,26 @@ from __future__ import annotations -import re +import logging from typing import TYPE_CHECKING, Any, ClassVar from textual import on from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical from textual.widgets import DataTable, Footer, Header, Tree +from typing_extensions import override from ruff_sync.config_io import load_local_ruff_config +from ruff_sync.tui.constants import RULE_PATTERN from ruff_sync.tui.widgets import CategoryTable, ConfigTree, RuleInspector if TYPE_CHECKING: from ruff_sync.cli import Arguments +LOGGER = logging.getLogger(__name__) + + class RuffSyncApp(App[None]): """Ruff-Sync Terminal User Interface.""" @@ -52,7 +57,7 @@ class RuffSyncApp(App[None]): } """ - BINDINGS: ClassVar[list[tuple[str, str, str]]] = [ + BINDINGS: ClassVar[list[Any]] = [ ("q", "quit", "Quit"), ("/", "focus('config-tree')", "Search"), ] @@ -68,6 +73,7 @@ def __init__(self, args: Arguments, **kwargs: Any) -> None: self.args = args self.config: dict[str, Any] = {} + @override def compose(self) -> ComposeResult: """Compose the user interface elements.""" yield Header() @@ -83,6 +89,7 @@ async def on_mount(self) -> None: try: self.config = load_local_ruff_config(self.args.to) except Exception: + LOGGER.exception("Failed to load Ruff configuration.") self.notify("Failed to load Ruff configuration.", severity="error") self.config = {} @@ -98,18 +105,16 @@ def handle_node_selected(self, event: Tree.NodeSelected[Any]) -> None: event: The tree node selected event. """ data = event.node.data - label = str(event.node.label.plain) + label = event.node.label + label_text = str(label.plain) if hasattr(label, "plain") else str(label) table = self.query_one(CategoryTable) inspector = self.query_one(RuleInspector) # Basic rule code detection (e.g., PIE790, RUF012) - if isinstance(label, str) and re.match(r"^[A-Z]+[0-9]+$", label): + if isinstance(label_text, str) and RULE_PATTERN.match(label_text): inspector.remove_class("hidden") - inspector.fetch_and_display(label) - # Find closest parent that is a dict/list to update table - p_data = event.node.parent.data if event.node.parent else None # type: ignore[union-attr] - table.update_content(p_data) + inspector.fetch_and_display(label_text) elif isinstance(data, (dict, list)): inspector.add_class("hidden") table.update_content(data) @@ -129,12 +134,10 @@ def handle_row_selected(self, event: DataTable.RowSelected) -> None: key, value = row # Check if the value or key looks like a rule code - rule_pattern = re.compile(r"^[A-Z]+[0-9]+$") rule_code = None - - if rule_pattern.match(str(key)): + if RULE_PATTERN.match(str(key)): rule_code = str(key) - elif rule_pattern.match(str(value)): + elif RULE_PATTERN.match(str(value)): rule_code = str(value) if rule_code: diff --git a/src/ruff_sync/tui/constants.py b/src/ruff_sync/tui/constants.py new file mode 100644 index 0000000..1404db3 --- /dev/null +++ b/src/ruff_sync/tui/constants.py @@ -0,0 +1,9 @@ +"""Constants for the Ruff-Sync Terminal User Interface.""" + +from __future__ import annotations + +import re +from typing import Final + +# Regex pattern for matching Ruff rule codes (e.g., E501, RUF012) +RULE_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[A-Z]+[0-9]+$") diff --git a/src/ruff_sync/tui/widgets.py b/src/ruff_sync/tui/widgets.py index 0c7fd0b..2035075 100644 --- a/src/ruff_sync/tui/widgets.py +++ b/src/ruff_sync/tui/widgets.py @@ -6,6 +6,7 @@ from textual import work from textual.widgets import DataTable, Markdown, Tree +from typing_extensions import override from ruff_sync.system import get_ruff_rule_markdown @@ -49,6 +50,7 @@ def _populate_node(self, parent: TreeNode[Any], data: Any) -> None: class CategoryTable(DataTable[Any]): """A table widget for displaying configuration keys and values.""" + @override def on_mount(self) -> None: """Initialize the table columns.""" self.cursor_type = "row" @@ -74,7 +76,7 @@ def update_content(self, data: Any) -> None: class RuleInspector(Markdown): """A markdown widget for inspecting Ruff rules.""" - @work(thread=True) + @work async def fetch_and_display(self, rule_code: str) -> None: """Fetch and display the documentation for a rule. diff --git a/tests/conftest.py b/tests/conftest.py index 34e30fd..ba006f8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,9 @@ import sys from typing import Literal, Protocol, runtime_checkable +import pytest +from typing_extensions import override + import ruff_sync diff --git a/tests/test_tui.py b/tests/test_tui.py index 515e197..ae2faac 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, cast from unittest.mock import patch import pytest @@ -12,6 +12,8 @@ if TYPE_CHECKING: import pathlib + from ruff_sync.tui.widgets import ConfigTree, RuleInspector + from .conftest import CLIRunner @@ -32,6 +34,36 @@ def test_ruff_sync_app_init(mock_args: Arguments) -> None: assert app.config == {} +@pytest.mark.asyncio +async def test_ruff_sync_app_mount_load_config_failure( + mock_args: Arguments, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """on_mount should handle a failure to load the Ruff config gracefully.""" + + # Force load_local_ruff_config to fail to exercise the error-handling path. + def mock_fail(_: Any) -> Any: + msg = "boom" + raise RuntimeError(msg) + + monkeypatch.setattr( + "ruff_sync.tui.app.load_local_ruff_config", + mock_fail, + ) + + app = RuffSyncApp(mock_args) + + # Run the app so that on_mount is invoked. + async with app.run_test() as pilot: + # Let the app process startup/mount events. + await pilot.pause() + + # When config loading fails, the app should keep the default (empty) config. + assert app.config == {} + tree = cast("ConfigTree", app.query_one("#config-tree")) + assert not list(tree.root.children) + + @pytest.mark.asyncio async def test_ruff_sync_app_mount(mock_args: Arguments, tmp_path: pathlib.Path) -> None: pyproject = tmp_path / "pyproject.toml" @@ -49,10 +81,19 @@ async def test_ruff_sync_app_mount(mock_args: Arguments, tmp_path: pathlib.Path) async with app.run_test(): assert app.config == {"line-length": 88, "lint": {"select": ["E", "F"]}} tree = app.query_one(Tree) - assert tree.root.label.plain == "Local Configuration" + assert tree.root.label is not None + root_label = tree.root.label + root_label_text = root_label.plain if hasattr(root_label, "plain") else root_label + assert str(root_label_text) == "Local Configuration" # Check some children - assert any(n.label.plain == "line-length" for n in tree.root.children) - assert any(n.label.plain == "lint" for n in tree.root.children) + assert any( + str(n.label.plain if hasattr(n.label, "plain") else n.label) == "line-length" + for n in tree.root.children + ) + assert any( + str(n.label.plain if hasattr(n.label, "plain") else n.label) == "lint" + for n in tree.root.children + ) @pytest.mark.asyncio @@ -70,7 +111,11 @@ async def test_ruff_sync_app_node_selection(mock_args: Arguments, tmp_path: path async with app.run_test() as pilot: tree = app.query_one(Tree) # Select "line-length" - node = next(n for n in tree.root.children if n.label.plain == "line-length") + node = next( + n + for n in tree.root.children + if str(n.label.plain if hasattr(n.label, "plain") else n.label) == "line-length" + ) tree.select_node(node) await pilot.pause() @@ -78,7 +123,7 @@ async def test_ruff_sync_app_node_selection(mock_args: Arguments, tmp_path: path # For a simple value, CategoryTable.update_content(88) adds row ("Value", "88") assert table.row_count == 1 row = table.get_row_at(0) - assert row == ["Value", "88"] + assert [str(cell) for cell in row] == ["Value", "88"] @pytest.mark.asyncio @@ -100,19 +145,31 @@ async def test_ruff_sync_app_rule_selection(mock_args: Arguments, tmp_path: path tree = app.query_one(Tree) # Find and select RUF012 node # It's inside tool.ruff -> lint -> select -> RUF012 - lint_node = next(n for n in tree.root.children if n.label.plain == "lint") + lint_node = next( + n + for n in tree.root.children + if str(n.label.plain if hasattr(n.label, "plain") else n.label) == "lint" + ) lint_node.expand() await pilot.pause() - select_node = next(n for n in lint_node.children if n.label.plain == "select") + select_node = next( + n + for n in lint_node.children + if str(n.label.plain if hasattr(n.label, "plain") else n.label) == "select" + ) select_node.expand() await pilot.pause() - rule_node = next(n for n in select_node.children if n.label.plain == "RUF012") + rule_node = next( + n + for n in select_node.children + if str(n.label.plain if hasattr(n.label, "plain") else n.label) == "RUF012" + ) tree.select_node(rule_node) await pilot.pause() - inspector = app.query_one("#inspector") + inspector = cast("RuleInspector", app.query_one("#inspector")) assert "hidden" not in inspector.classes # Wait for background fetch worker # Since we mocked it to return immediately, it should be fine @@ -137,7 +194,7 @@ def test_cli_inspect_subcommand( # Use patch to prevent the App from actually running (which would block/fail in CI) # and just verify it was instantiated and run() was called. with patch("ruff_sync.tui.app.RuffSyncApp.run", return_value=0) as mock_run: - exit_code, out, err = cli_run(["inspect", "--to", str(tmp_path)]) + exit_code, _out, _err = cli_run(["inspect", "--to", str(tmp_path)]) assert exit_code == 0 mock_run.assert_called_once() @@ -151,6 +208,6 @@ def test_cli_ruff_inspect_entry_point( with patch("ruff_sync.tui.app.RuffSyncApp.run", return_value=0) as mock_run: # Program name 'ruff-inspect' should trigger the inspect logic - exit_code, out, err = cli_run(["--to", str(tmp_path)], entry_point="ruff-inspect") + exit_code, _out, _err = cli_run(["--to", str(tmp_path)], entry_point="ruff-inspect") assert exit_code == 0 mock_run.assert_called_once() From 4900e2fda8b0302441e19c92a3f0783a9a08a157 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 16:43:46 -0400 Subject: [PATCH 05/36] textual dev dependencies --- pyproject.toml | 1 + uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index bbf35cb..d873485 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ dev = [ "respx>=0.21.1", "ruamel-yaml>=0.18.6", "ruff>=0.15.0", + "textual>=8.2.2", "wily>=1.25.0", ] docs = [ diff --git a/uv.lock b/uv.lock index 96166d8..eb024de 100644 --- a/uv.lock +++ b/uv.lock @@ -1622,6 +1622,7 @@ dev = [ { name = "respx" }, { name = "ruamel-yaml" }, { name = "ruff" }, + { name = "textual" }, { name = "wily" }, ] docs = [ @@ -1657,6 +1658,7 @@ dev = [ { name = "respx", specifier = ">=0.21.1" }, { name = "ruamel-yaml", specifier = ">=0.18.6" }, { name = "ruff", specifier = ">=0.15.0" }, + { name = "textual", specifier = ">=8.2.2" }, { name = "wily", specifier = ">=1.25.0" }, ] docs = [ From 20c64bac707f330ce051633c85f3cacba3488c9f Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 16:51:33 -0400 Subject: [PATCH 06/36] test optional dependencies are optional --- .github/workflows/ci.yaml | 20 +++++++++++++ tests/test_minimal_imports.sh | 54 +++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100755 tests/test_minimal_imports.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e223741..40d1164 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -84,6 +84,26 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} slug: Kilo59/ruff-sync + test-no-optional-deps: + name: Test without optional dependencies + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + run: uv python install 3.10 + + - name: Install ruff-sync (no extras) + # We install only the base package without 'dev' or 'tui' groups. + run: uv pip install . + + - name: Run optional dependency validation script + run: bash tests/test_minimal_imports.sh + pre-publish: name: Test package installation needs: [static-analysis, tests] diff --git a/tests/test_minimal_imports.sh b/tests/test_minimal_imports.sh new file mode 100755 index 0000000..1674f3b --- /dev/null +++ b/tests/test_minimal_imports.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This script verifies that ruff-sync works correctly without optional dependencies. +# It is intended to be run in a clean environment where only base dependencies are installed. + +echo "🔍 Verifying absence of optional dependencies..." +if pip show textual > /dev/null 2>&1; then + echo "❌ Error: 'textual' is installed, but it should be absent for this test." + exit 1 +fi +echo "✅ Optional dependencies are absent." + +echo "🚀 Testing 'ruff-sync --help'..." +ruff-sync --version +ruff-sync --help > /dev/null +echo "✅ 'ruff-sync --help' passed." + +echo "🚀 Testing 'ruff-sync pull --help'..." +ruff-sync pull --help > /dev/null +echo "✅ 'ruff-sync pull --help' passed." + +echo "🚀 Testing 'ruff-sync inspect --help'..." +ruff-sync inspect --help > /dev/null +echo "✅ 'ruff-sync inspect --help' passed." + +echo "🚀 Testing 'ruff-inspect --help'..." +ruff-inspect --help > /dev/null +echo "✅ 'ruff-inspect --help' passed." + +echo "🚀 Testing 'ruff-sync check' (dogfooding)..." +# Use the current project's repo for a real-world check that should pass. +ruff-sync check https://github.com/Kilo59/ruff-sync +echo "✅ 'ruff-sync check' passed." + +echo "🚀 Testing 'ruff-sync inspect' graceful failure..." +# Capture output and check for the expected error message. +if ruff-sync inspect 2> inspect_error.log; then + echo "❌ Error: 'ruff-sync inspect' should have failed without 'textual'." + exit 1 +fi + +ERROR_MSG=$(cat inspect_error.log) +echo "Captured error: $ERROR_MSG" + +if [[ "$ERROR_MSG" == *"textual"* ]] && [[ "$ERROR_MSG" == *"ruff-sync[tui]"* ]]; then + echo "✅ 'ruff-sync inspect' failed gracefully with correct message." +else + echo "❌ Error: 'ruff-sync inspect' failed with unexpected message." + exit 1 +fi + +echo "✨ All optional dependency validation tests passed!" +rm inspect_error.log From 365adafd2845020fc324a717fd930bf03b89d611 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 16:57:47 -0400 Subject: [PATCH 07/36] add warnings on version docs --- .agents/skills/mike/SKILL.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.agents/skills/mike/SKILL.md b/.agents/skills/mike/SKILL.md index 48f403e..5efc0d2 100644 --- a/.agents/skills/mike/SKILL.md +++ b/.agents/skills/mike/SKILL.md @@ -129,3 +129,22 @@ The deployment logic is automated in [.github/workflows/ci.yaml](.github/workflo ### 404 for `versions.json` - If you see a 404 for `/versions.json` but `https://.github.io//versions.json` exists, the switcher is looking at the domain root instead of the project root. Verify `site_url` includes the repository name and has a trailing slash. + +## Post-Mortem & Known Issues + +> [!CAUTION] +> **Current Status**: Documentation versioning is currently **BROKEN** on the live site (`kilo59.github.io/ruff-sync`). + +### Failed Repair History +The following fixes have been attempted and **FAILED** to resolve the issue: +1. **Lowercasing `site_url`**: Normalizing the repository name in the URL (e.g., `ruff-sync` instead of `Ruff-Sync`) did not fix the 404s for `versions.json`. +2. **Removing `theme.version`**: Removing the redundant Material 9.x config did not restore the switcher. +3. **Adding `canonical_version: stable`**: Adding this to the `mike` plugin in `mkdocs.yml` was intended to fix path resolution but has not fixed the root page 404. +4. **CI Restoration Logic**: Adding `mike alias --push stable stable` to the CI to manually repair `versions.json` hasn't restored the picker on the root page. + +### Root Cause Suspicions +- **GitHub Pages Subfolder Pathing**: The site is served from a subfolder (`/ruff-sync/`). `mike`'s JavaScript for the version switcher frequently struggles with calculating relative paths to `versions.json` when served from a subfolder if `site_url` or base paths are not perfectly aligned with the deployment environment. +- **`versions.json` Drift**: The `versions.json` file on the `gh-pages` branch frequently becomes desynchronized or loses the `stable` entry, which triggers `mkdocs-material` to hide the switcher entirely. + +### Guidance for Future Agents +Before attempting another "fix," you **MUST** verify the current state of `versions.json` on the `gh-pages` branch and check the browser console on the live site for 404 paths. Do not assume standard configurations will work without manual verification of the deployed assets. From 1ecd848024a379f820844e9ae7f85d548b50b7c7 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 17:02:33 -0400 Subject: [PATCH 08/36] don't use uv --- .github/workflows/ci.yaml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 40d1164..65aa5ad 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -91,15 +91,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install uv - uses: astral-sh/setup-uv@v5 - - name: Set up Python - run: uv python install 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" - name: Install ruff-sync (no extras) # We install only the base package without 'dev' or 'tui' groups. - run: uv pip install . + run: pip install . - name: Run optional dependency validation script run: bash tests/test_minimal_imports.sh From 58a0a11446b9286ecdf4e58cb1268ad360678335 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 17:12:03 -0400 Subject: [PATCH 09/36] display better UI hints --- src/ruff_sync/tui/app.py | 32 ++++++++++++++++++++++++++------ src/ruff_sync/tui/widgets.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index ead35a1..a9306a9 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -60,6 +60,7 @@ class RuffSyncApp(App[None]): BINDINGS: ClassVar[list[Any]] = [ ("q", "quit", "Quit"), ("/", "focus('config-tree')", "Search"), + ("enter", "select", "View Details"), ] def __init__(self, args: Arguments, **kwargs: Any) -> None: @@ -81,7 +82,7 @@ def compose(self) -> ComposeResult: yield ConfigTree("Local Configuration", id="config-tree") with Vertical(id="content-pane"): yield CategoryTable(id="category-table") - yield RuleInspector(id="inspector", classes="hidden") + yield RuleInspector(id="inspector") yield Footer() async def on_mount(self) -> None: @@ -111,16 +112,18 @@ def handle_node_selected(self, event: Tree.NodeSelected[Any]) -> None: table = self.query_one(CategoryTable) inspector = self.query_one(RuleInspector) - # Basic rule code detection (e.g., PIE790, RUF012) + # Build full path for context + full_path = self._get_node_path(event.node) + + # Check if the node label or path matches a ruff rule if isinstance(label_text, str) and RULE_PATTERN.match(label_text): - inspector.remove_class("hidden") inspector.fetch_and_display(label_text) elif isinstance(data, (dict, list)): - inspector.add_class("hidden") table.update_content(data) + inspector.show_context(full_path, data) else: - inspector.add_class("hidden") table.update_content(data) + inspector.show_context(full_path, data) @on(DataTable.RowSelected) def handle_row_selected(self, event: DataTable.RowSelected) -> None: @@ -142,5 +145,22 @@ def handle_row_selected(self, event: DataTable.RowSelected) -> None: if rule_code: inspector = self.query_one(RuleInspector) - inspector.remove_class("hidden") inspector.fetch_and_display(rule_code) + + def _get_node_path(self, node: Any) -> str: + """Construct the full configuration path to a tree node. + + Args: + node: The tree node. + + Returns: + The dot-separated configuration path. + """ + path: list[str] = [] + current = node + while current and current != self.query_one(ConfigTree).root: + label = current.label + label_text = str(label.plain) if hasattr(label, "plain") else str(label) + path.append(label_text) + current = current.parent + return "tool.ruff." + ".".join(reversed(path)) if path else "tool.ruff" diff --git a/src/ruff_sync/tui/widgets.py b/src/ruff_sync/tui/widgets.py index 2035075..a6de5e6 100644 --- a/src/ruff_sync/tui/widgets.py +++ b/src/ruff_sync/tui/widgets.py @@ -74,7 +74,36 @@ def update_content(self, data: Any) -> None: class RuleInspector(Markdown): - """A markdown widget for inspecting Ruff rules.""" + """A markdown widget for inspecting Ruff rules and settings.""" + + @override + def on_mount(self) -> None: + """Set initial placeholder content.""" + self.show_placeholder() + + def show_placeholder(self) -> None: + """Display a placeholder message.""" + self.update( + "## Selection Details\n\nSelect a configuration key in the tree or a rule " + "code in the table to view documentation or additional context." + ) + + def show_context(self, path: str, value: Any) -> None: + """Display general context for a configuration setting. + + Args: + path: The full configuration path (e.g., 'tool.ruff.lint.select'). + value: The value of the setting. + """ + # Show a summary for complex types, or the raw value for simple ones + if isinstance(value, dict): + summary = f"Table with {len(value)} keys" + elif isinstance(data := value, list): + summary = f"List with {len(data)} items" + else: + summary = f"`{value}`" + + self.update(f"### Configuration Context\n\n**Path**: `{path}`\n\n**Value**: {summary}") @work async def fetch_and_display(self, rule_code: str) -> None: From aad541abad49b3a99a5c40872e77123a0f9178a0 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 17:21:45 -0400 Subject: [PATCH 10/36] better content panel --- src/ruff_sync/tui/app.py | 5 +++++ src/ruff_sync/tui/widgets.py | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index a9306a9..56553d2 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -50,6 +50,7 @@ class RuffSyncApp(App[None]): height: 1fr; padding: 1; background: $surface-darken-1; + overflow-y: auto; } .hidden { @@ -117,11 +118,14 @@ def handle_node_selected(self, event: Tree.NodeSelected[Any]) -> None: # Check if the node label or path matches a ruff rule if isinstance(label_text, str) and RULE_PATTERN.match(label_text): + table.add_class("hidden") inspector.fetch_and_display(label_text) elif isinstance(data, (dict, list)): + table.remove_class("hidden") table.update_content(data) inspector.show_context(full_path, data) else: + table.remove_class("hidden") table.update_content(data) inspector.show_context(full_path, data) @@ -145,6 +149,7 @@ def handle_row_selected(self, event: DataTable.RowSelected) -> None: if rule_code: inspector = self.query_one(RuleInspector) + table.add_class("hidden") inspector.fetch_and_display(rule_code) def _get_node_path(self, node: Any) -> str: diff --git a/src/ruff_sync/tui/widgets.py b/src/ruff_sync/tui/widgets.py index a6de5e6..884eee5 100644 --- a/src/ruff_sync/tui/widgets.py +++ b/src/ruff_sync/tui/widgets.py @@ -76,7 +76,6 @@ def update_content(self, data: Any) -> None: class RuleInspector(Markdown): """A markdown widget for inspecting Ruff rules and settings.""" - @override def on_mount(self) -> None: """Set initial placeholder content.""" self.show_placeholder() From 59f1b17fd3146fa8cdf5518a14c2b925e21b0eb3 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 17:25:37 -0400 Subject: [PATCH 11/36] update design --- .agents/tui_design.md | 10 +++++----- .agents/tui_requirements.md | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.agents/tui_design.md b/.agents/tui_design.md index d20fddb..7555918 100644 --- a/.agents/tui_design.md +++ b/.agents/tui_design.md @@ -66,9 +66,9 @@ Right now, discovering and extracting the local `pyproject.toml` is slightly cou yield Header() with Horizontal(): yield ConfigTree(id="config-tree") - with Vertical(): + with Vertical(id="content-pane"): yield CategoryTable(id="category-table") - yield RuleInspector(id="inspector", classes="hidden") + yield RuleInspector(id="inspector") yield Footer() ``` - **Lifecycle (`on_mount`)**: @@ -98,10 +98,10 @@ Contains all custom Textual widgets required for this view. ### [MODIFY] src/ruff_sync/tui/app.py (Reactivity) - Tie widgets together using Textual's event routing (`@on`): - `@on(Tree.NodeSelected)`: - - If the node is a configuration section (e.g. `lint.isort`), hide the Inspector and populate the `CategoryTable` with settings. - - If the node represents a list of rules (unwrapped `lint.select`), display the active rule list in the table. + - If the node is a configuration section (e.g. `lint.isort`), ensure the `CategoryTable` is visible (remove "hidden" class) and populate it with settings. + - If the node represents a rule code, add the "hidden" class to `CategoryTable` to maximize vertical space, and display the rule in `RuleInspector`. - `@on(DataTable.RowSelected)`: - - If the focused row represents a Ruff Rule Code (e.g., `RUF012`), reveal the `RuleInspector` widget and call `inspector.fetch_and_display("RUF012")`. + - If the focused row represents a Ruff Rule Code (e.g., `RUF012`), hide the `CategoryTable`, reveal the `RuleInspector` widget, and call `inspector.fetch_and_display("RUF012")`. - Expose related context natively based on selections. --- diff --git a/.agents/tui_requirements.md b/.agents/tui_requirements.md index 079de64..2940fc0 100644 --- a/.agents/tui_requirements.md +++ b/.agents/tui_requirements.md @@ -28,8 +28,8 @@ ### 2.2 UX / UI Layout Concept - **Header:** Application title and current local repository path. - **Left Sidebar (Navigation):** `Tree` widget for configuration categories (`Global`, `Linting`, `Formatting`, `Rule Index`). -- **Center Area (Main Content):** `DataTable` or `ListView` showing the keys and values for the selected category. -- **Right/Bottom Panel (Inspector):** Context-aware inspector. Because `ruff rule ` output contains proper Markdown, this panel MUST robustly render Markdown (e.g., utilizing Textual's `Markdown` widget) while dynamically adjoining related setting/rule cross-references around it. +- **Center Area (Main Content):** `DataTable` or `ListView` showing the keys and values for the selected category. This panel is dynamically hidden when inspecting dense documentation to maximize reading space. +- **Right/Bottom Panel (Inspector):** Context-aware scrollable inspector (`overflow-y: auto`). Because `ruff rule ` output contains proper Markdown, this panel MUST robustly render Markdown (e.g., utilizing Textual's `Markdown` widget) while dynamically adjoining related setting/rule cross-references around it. - **Footer:** Action key bindings (`q` to Quit, `/` to Search, `?` for Help). --- From 2b3a7d14d6e278e5148b3499c288399426d03a4b Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 17:29:36 -0400 Subject: [PATCH 12/36] update design --- .agents/tui_design.md | 18 +++++++++++------- .agents/tui_requirements.md | 8 ++++++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.agents/tui_design.md b/.agents/tui_design.md index 7555918..b1370a1 100644 --- a/.agents/tui_design.md +++ b/.agents/tui_design.md @@ -12,7 +12,10 @@ Before we build the TUI feature itself, some of the existing codebase should be Currently, `ruff-sync` is primarily concerned with TOML serialization. The TUI introduces a requirement to execute the system `ruff` binary (e.g. `ruff rule `). **Refactoring Task:** - Create a generalized, safe abstraction for interacting with the `ruff` executable. -- Provide a typed function `async def get_ruff_rule_markdown(rule_code: str) -> str | None` that uses `asyncio.create_subprocess_exec` (or threads with `subprocess`) to fetch and return the rule text. This prevents raw `subprocess` sprawl across TUI widgets. +- Provide typed async functions: + - `async def get_ruff_rule_markdown(rule_code: str) -> str | None` + - `async def get_ruff_config_markdown(setting_path: str) -> str | None` +- These should use `asyncio.create_subprocess_exec` to fetch and return the text. This prevents raw `subprocess` sprawl across TUI widgets. ### B. Configuration Reader Extraction (`src/ruff_sync/config_io.py` or similar) Right now, discovering and extracting the local `pyproject.toml` is slightly coupled to the lifecycle of pulling from upstreams (`pull()` / `check()` in `core.py`). @@ -87,9 +90,9 @@ Contains all custom Textual widgets required for this view. - Automatically clears and updates columns/rows when a tree node is highlighted. **3. `RuleInspector` (inherits `textual.widgets.Markdown`)**: -- Displays `ruff rule ` output. -- Features an `async def fetch_and_display(self, rule_code: str)` method. -- **Background Worker**: Uses Textual `@work(thread=True)` to execute our refactored `get_ruff_rule_markdown()` function. +- Displays documentation for both rules (`ruff rule `) and settings (`ruff config `). +- Features an `async def fetch_and_display(self, target: str, is_rule: bool = True)` method. +- **Background Worker**: Uses Textual `@work(thread=True)` to execute the appropriate refactored `get_ruff_*_markdown()` function. --- @@ -98,10 +101,11 @@ Contains all custom Textual widgets required for this view. ### [MODIFY] src/ruff_sync/tui/app.py (Reactivity) - Tie widgets together using Textual's event routing (`@on`): - `@on(Tree.NodeSelected)`: - - If the node is a configuration section (e.g. `lint.isort`), ensure the `CategoryTable` is visible (remove "hidden" class) and populate it with settings. - - If the node represents a rule code, add the "hidden" class to `CategoryTable` to maximize vertical space, and display the rule in `RuleInspector`. + - If the node is a configuration section (e.g. `lint.isort`), ensure the `CategoryTable` is visible (remove "hidden" class) and populate it with settings. It also triggers `inspector.fetch_and_display(section_path, is_rule=False)` to show section-level docs if available. + - If the node represents a rule code, add the "hidden" class to `CategoryTable` to maximize vertical space, and display the rule in `RuleInspector` via `fetch_and_display(code, is_rule=True)`. - `@on(DataTable.RowSelected)`: - - If the focused row represents a Ruff Rule Code (e.g., `RUF012`), hide the `CategoryTable`, reveal the `RuleInspector` widget, and call `inspector.fetch_and_display("RUF012")`. + - If the focused row represents a Ruff Rule Code (e.g., `RUF012`), hide the `CategoryTable`, reveal the `RuleInspector` widget, and call `inspector.fetch_and_display("RUF012", is_rule=True)`. + - If the focused row represents a configuration setting, reveal the `RuleInspector` and call `inspector.fetch_and_display(setting_key, is_rule=False)`. - Expose related context natively based on selections. --- diff --git a/.agents/tui_requirements.md b/.agents/tui_requirements.md index 2940fc0..53940c0 100644 --- a/.agents/tui_requirements.md +++ b/.agents/tui_requirements.md @@ -21,8 +21,12 @@ - Provide visual grouping for rule categories (e.g., `E` for pycodestyle, `F` for Pyflakes, `TC` for flake8-type-checking). 3. **Contextual Inspector & Documentation** - When a user highlights a specific rule (e.g., `RUF012`), asynchronously execute `ruff rule ` to fetch and render the official documentation as Markdown. - - Surface related context depending on the selection: highlighting a rule exposes its documentation AND related configuration settings; highlighting a config setting might expose its structural definition or the specific rules it governs. -4. **Fuzzy Search** + - When a user highlights a configuration setting (e.g., `lint.isort.combine-as-imports`), asynchronously execute `ruff config ` to fetch its documentation, default value, and type information. + - Surface related context depending on the selection: highlighting a rule exposes its documentation AND related configuration settings; highlighting a config setting (in the Tree or CategoryTable) exposes its definition and usage examples. +4. **Rich Metadata Rendering** + - The Inspector should distinguish between Rule documentation and Setting documentation. + - Setting documentation should clearly call out **Default Value** and **Type** in a dedicated header or sidebar within the inspector. +5. **Fuzzy Search** - A search bar to quickly locate a specific configuration key or rule code without manual scrolling. ### 2.2 UX / UI Layout Concept From d47e75b8212d6ccde25111114c317f93a2a74f79 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 17:31:26 -0400 Subject: [PATCH 13/36] show config docs --- src/ruff_sync/system.py | 32 ++++++++++++++++++++++++++++++-- src/ruff_sync/tui/app.py | 14 ++++++++++---- src/ruff_sync/tui/widgets.py | 27 +++++++++++++++++---------- 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/src/ruff_sync/system.py b/src/ruff_sync/system.py index a70b4cc..3543c30 100644 --- a/src/ruff_sync/system.py +++ b/src/ruff_sync/system.py @@ -20,6 +20,34 @@ async def get_ruff_rule_markdown(rule_code: str) -> str | None: or the rule is not found. """ cmd: Final[list[str]] = ["ruff", "rule", rule_code] + return await _run_ruff_command(cmd, f"ruff rule {rule_code}") + + +async def get_ruff_config_markdown(setting_path: str) -> str | None: + """Execute `ruff config ` and return the Markdown documentation. + + Args: + setting_path: The Ruff configuration setting path (e.g., 'lint.select'). + + Returns: + The Markdown documentation for the setting, or None if the execution fails. + """ + # Strip 'tool.ruff.' prefix if present as 'ruff config' expects relative paths + clean_path = setting_path.removeprefix("tool.ruff.") + cmd: Final[list[str]] = ["ruff", "config", clean_path] + return await _run_ruff_command(cmd, f"ruff config {clean_path}") + + +async def _run_ruff_command(cmd: list[str], description: str) -> str | None: + """Execute a ruff command and return the decoded output. + + Args: + cmd: The command to execute. + description: A human-readable description for logging. + + Returns: + The decoded stdout, or None if the command fails. + """ LOGGER.debug(f"Executing system command: {' '.join(cmd)}") try: @@ -32,7 +60,7 @@ async def get_ruff_rule_markdown(rule_code: str) -> str | None: if process.returncode != 0: msg = stderr.decode().strip() - LOGGER.warning(f"Ruff command failed with exit code {process.returncode}: {msg}") + LOGGER.warning(f"Command '{description}' failed with code {process.returncode}: {msg}") return None return stdout.decode().strip() @@ -41,5 +69,5 @@ async def get_ruff_rule_markdown(rule_code: str) -> str | None: LOGGER.exception("Ruff executable not found in PATH.") return None except Exception: - LOGGER.exception(f"Unexpected error executing ruff rule {rule_code}") + LOGGER.exception(f"Unexpected error executing '{description}'") return None diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index 56553d2..079c355 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -119,15 +119,16 @@ def handle_node_selected(self, event: Tree.NodeSelected[Any]) -> None: # Check if the node label or path matches a ruff rule if isinstance(label_text, str) and RULE_PATTERN.match(label_text): table.add_class("hidden") - inspector.fetch_and_display(label_text) + inspector.fetch_and_display(label_text, is_rule=True) elif isinstance(data, (dict, list)): table.remove_class("hidden") table.update_content(data) - inspector.show_context(full_path, data) + # Fetch config documentation for the section if possible + inspector.fetch_and_display(full_path, is_rule=False) else: table.remove_class("hidden") table.update_content(data) - inspector.show_context(full_path, data) + inspector.fetch_and_display(full_path, is_rule=False) @on(DataTable.RowSelected) def handle_row_selected(self, event: DataTable.RowSelected) -> None: @@ -150,7 +151,12 @@ def handle_row_selected(self, event: DataTable.RowSelected) -> None: if rule_code: inspector = self.query_one(RuleInspector) table.add_class("hidden") - inspector.fetch_and_display(rule_code) + inspector.fetch_and_display(rule_code, is_rule=True) + else: + # It's a configuration key, show its documentation + inspector = self.query_one(RuleInspector) + full_path = f"{self._get_node_path(self.query_one(ConfigTree).cursor_node)}.{key}" + inspector.fetch_and_display(full_path, is_rule=False) def _get_node_path(self, node: Any) -> str: """Construct the full configuration path to a tree node. diff --git a/src/ruff_sync/tui/widgets.py b/src/ruff_sync/tui/widgets.py index 884eee5..72a342d 100644 --- a/src/ruff_sync/tui/widgets.py +++ b/src/ruff_sync/tui/widgets.py @@ -2,13 +2,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, ClassVar from textual import work from textual.widgets import DataTable, Markdown, Tree from typing_extensions import override -from ruff_sync.system import get_ruff_rule_markdown +from ruff_sync.system import get_ruff_config_markdown, get_ruff_rule_markdown if TYPE_CHECKING: from textual.widgets.tree import TreeNode @@ -76,6 +76,8 @@ def update_content(self, data: Any) -> None: class RuleInspector(Markdown): """A markdown widget for inspecting Ruff rules and settings.""" + _current_meta: ClassVar[dict[str, str]] = {} + def on_mount(self) -> None: """Set initial placeholder content.""" self.show_placeholder() @@ -97,26 +99,31 @@ def show_context(self, path: str, value: Any) -> None: # Show a summary for complex types, or the raw value for simple ones if isinstance(value, dict): summary = f"Table with {len(value)} keys" - elif isinstance(data := value, list): - summary = f"List with {len(data)} items" + elif isinstance(value, list): + summary = f"List with {len(value)} items" else: summary = f"`{value}`" self.update(f"### Configuration Context\n\n**Path**: `{path}`\n\n**Value**: {summary}") @work - async def fetch_and_display(self, rule_code: str) -> None: - """Fetch and display the documentation for a rule. + async def fetch_and_display(self, target: str, is_rule: bool = True) -> None: + """Fetch and display the documentation for a rule or setting. Args: - rule_code: The Ruff rule code to fetch documentation for. + target: The Ruff rule code or configuration path. + is_rule: True if fetching a rule, False if fetching a config setting. """ # Set a loading message - self.update(f"## Inspecting {rule_code}...\n\nFetching documentation from `ruff rule`...") + desc = "rule" if is_rule else "config" + self.update(f"## Inspecting {target}...\n\nFetching documentation from `ruff {desc}`...") - content = await get_ruff_rule_markdown(rule_code) + if is_rule: + content = await get_ruff_rule_markdown(target) + else: + content = await get_ruff_config_markdown(target) if content: self.update(content) else: - self.update(f"## Error\n\nCould not fetch documentation for rule `{rule_code}`.") + self.update(f"## Error\n\nCould not fetch documentation for {desc} `{target}`.") From 04767503cb428e3a29ba7d893d2c6d14b00cafc8 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 17:44:15 -0400 Subject: [PATCH 14/36] strip whitespace and make content panel bigger --- src/ruff_sync/system.py | 3 ++- src/ruff_sync/tui/app.py | 4 ++-- src/ruff_sync/tui/widgets.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ruff_sync/system.py b/src/ruff_sync/system.py index 3543c30..c8c3f35 100644 --- a/src/ruff_sync/system.py +++ b/src/ruff_sync/system.py @@ -63,7 +63,8 @@ async def _run_ruff_command(cmd: list[str], description: str) -> str | None: LOGGER.warning(f"Command '{description}' failed with code {process.returncode}: {msg}") return None - return stdout.decode().strip() + output = stdout.decode().strip() + return output or None except FileNotFoundError: LOGGER.exception("Ruff executable not found in PATH.") diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index 079c355..0c10670 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -42,12 +42,12 @@ class RuffSyncApp(App[None]): } #category-table { - height: 1fr; + height: 40%; border-bottom: solid $primary-darken-2; } #inspector { - height: 1fr; + height: 60%; padding: 1; background: $surface-darken-1; overflow-y: auto; diff --git a/src/ruff_sync/tui/widgets.py b/src/ruff_sync/tui/widgets.py index 72a342d..b1f280e 100644 --- a/src/ruff_sync/tui/widgets.py +++ b/src/ruff_sync/tui/widgets.py @@ -124,6 +124,6 @@ async def fetch_and_display(self, target: str, is_rule: bool = True) -> None: content = await get_ruff_config_markdown(target) if content: - self.update(content) + self.update(content.strip()) else: self.update(f"## Error\n\nCould not fetch documentation for {desc} `{target}`.") From 46b277b9f7dd30ca92d7ae753124a3b178c478a3 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 17:49:05 -0400 Subject: [PATCH 15/36] make rule inspector full height Expand the rule inspector to fill the available space and hide the data table when a rule is selected. --- src/ruff_sync/tui/app.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index 0c10670..7a10da9 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -56,6 +56,10 @@ class RuffSyncApp(App[None]): .hidden { display: none; } + + .full-height { + height: 100% !important; + } """ BINDINGS: ClassVar[list[Any]] = [ @@ -119,14 +123,17 @@ def handle_node_selected(self, event: Tree.NodeSelected[Any]) -> None: # Check if the node label or path matches a ruff rule if isinstance(label_text, str) and RULE_PATTERN.match(label_text): table.add_class("hidden") + inspector.add_class("full-height") inspector.fetch_and_display(label_text, is_rule=True) elif isinstance(data, (dict, list)): table.remove_class("hidden") + inspector.remove_class("full-height") table.update_content(data) # Fetch config documentation for the section if possible inspector.fetch_and_display(full_path, is_rule=False) else: table.remove_class("hidden") + inspector.remove_class("full-height") table.update_content(data) inspector.fetch_and_display(full_path, is_rule=False) @@ -151,6 +158,7 @@ def handle_row_selected(self, event: DataTable.RowSelected) -> None: if rule_code: inspector = self.query_one(RuleInspector) table.add_class("hidden") + inspector.add_class("full-height") inspector.fetch_and_display(rule_code, is_rule=True) else: # It's a configuration key, show its documentation From b0b4b83e383219d32136c1d8beb70a491b8d2e66 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 17:50:10 -0400 Subject: [PATCH 16/36] update design --- .agents/tui_design.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.agents/tui_design.md b/.agents/tui_design.md index b1370a1..2f28c58 100644 --- a/.agents/tui_design.md +++ b/.agents/tui_design.md @@ -62,7 +62,12 @@ Right now, discovering and extracting the local `pyproject.toml` is slightly cou ### [NEW] src/ruff_sync/tui/app.py - Define `RuffSyncApp` subclassing `textual.app.App`. - **CSS**: Define embedded TCSS. - - Layout: `Horizontal` split with `Tree` (left) sized `1fr`, and a `Vertical` container (right) sized `2fr`. Left side for Navigation, right side for Content & Inspector. + - Layout: `Horizontal` split with `Tree` (left) sized `1fr`, and a `Vertical` container (`#content-pane`) (right) sized `2fr`. + - **Dynamic Layout**: + - `#category-table` takes **40% height** by default. + - `#inspector` (Markdown) takes **60% height** by default. + - When the table is hidden, the `#inspector` uses a `.full-height` class (**100% height**) to fill the content pane. + - Both widgets utilize `overflow-y: auto` for vertical scrolling only when needed. - **Compose Method**: ```python def compose(self) -> ComposeResult: @@ -101,11 +106,11 @@ Contains all custom Textual widgets required for this view. ### [MODIFY] src/ruff_sync/tui/app.py (Reactivity) - Tie widgets together using Textual's event routing (`@on`): - `@on(Tree.NodeSelected)`: - - If the node is a configuration section (e.g. `lint.isort`), ensure the `CategoryTable` is visible (remove "hidden" class) and populate it with settings. It also triggers `inspector.fetch_and_display(section_path, is_rule=False)` to show section-level docs if available. - - If the node represents a rule code, add the "hidden" class to `CategoryTable` to maximize vertical space, and display the rule in `RuleInspector` via `fetch_and_display(code, is_rule=True)`. + - If the node is a configuration section (e.g. `lint.isort`), ensure the `CategoryTable` is visible (remove "hidden" class), remove the `.full-height` class from the inspector, and populate it with settings. It also triggers `inspector.fetch_and_display(section_path, is_rule=False)` to show section-level docs if available. + - If the node represents a rule code, add the "hidden" class to `CategoryTable` to maximize vertical space, add the `.full-height` class to the `RuleInspector`, and display the rule via `fetch_and_display(code, is_rule=True)`. - `@on(DataTable.RowSelected)`: - - If the focused row represents a Ruff Rule Code (e.g., `RUF012`), hide the `CategoryTable`, reveal the `RuleInspector` widget, and call `inspector.fetch_and_display("RUF012", is_rule=True)`. - - If the focused row represents a configuration setting, reveal the `RuleInspector` and call `inspector.fetch_and_display(setting_key, is_rule=False)`. + - If the focused row represents a Ruff Rule Code (e.g., `RUF012`), hide the `CategoryTable`, reveal the `RuleInspector` widget, apply `.full-height`, and call `inspector.fetch_and_display("RUF012", is_rule=True)`. + - If the focused row represents a configuration setting, reveal the `RuleInspector` (at default height if table is shown) and call `inspector.fetch_and_display(setting_key, is_rule=False)`. - Expose related context natively based on selections. --- From 4ecbba3274ad78cc6c4a3ed2a04fca96507cdcb0 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 17:58:12 -0400 Subject: [PATCH 17/36] commit better browsing enhancements --- .agents/tui_rule_browsing.md | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .agents/tui_rule_browsing.md diff --git a/.agents/tui_rule_browsing.md b/.agents/tui_rule_browsing.md new file mode 100644 index 0000000..b17f7ea --- /dev/null +++ b/.agents/tui_rule_browsing.md @@ -0,0 +1,43 @@ +# Ruff-Sync TUI: Rule Browsing & Discovery Proposals + +This document outlines additional convenience features and alternative navigation paradigms for browsing and exploring specific Ruff rules and groups outside the standard TOML hierarchy. + +**Related Documents:** +- [TUI Requirements](tui_requirements.md) +- [TUI Technical Design](tui_design.md) + +## Background +While the TOML hierarchy (as established in the core requirements) provides an exact structural representation (e.g., exposing `tool.ruff.lint.select`), it is not always the most intuitive way to discover or understand the net set of rules actively evaluating the project. + +The following proposals are designed to augment the current "Read-Only" TUI mode to support rapid interrogation, global searching, and global discovery of Ruff rules. + +--- + +## 1. The "Effective Rules" Flat Table +Instead of exclusively browsing via the TOML tree (`tool.ruff.lint.select`), we introduce a top-level **"Effective Rules"** dashboard. +- **How it works:** A single, sortable `DataTable` that flattens all configuration vectors (`select`, `extend-select`, `ignore`, `per-file-ignores`) into a definitive list. +- **Columns:** `Code` (e.g., F401), `Name` (e.g., unused-import), `Category` (e.g., Pyflakes), and `Status` (Enabled/Ignored). +- **Benefit:** Gives the user a complete, "at-a-glance" ledger of exactly what the linter is checking without having to manually perform the mental math of `select` minus `ignore`. + +## 2. Category / Linter Prefix Grouping +Ruff is conceptually built around "Linters" (e.g., `Pyflakes`, `pycodestyle`, `flake8-bugbear`). The TOML configuration often just specifies a prefix like `select = ["E", "F", "B"]`. +- **How it works:** Add a new root node in the `Tree` called **"Linters & Categories"**. +- **Interaction:** Navigating to "flake8-bugbear (B)" displays a table of all `B` rules, instantly showing which ones are locally enabled, ignored, or inactive. +- **Benefit:** Maps directly to how developers usually think about adding new rulesets to their projects. + +## 3. Global Fuzzy Command Palette ("Omnibox") +Navigating a tree or a table can be tedious if the user knows exactly what they are looking for. +- **How it works:** A global keybind (e.g., `Ctrl+P` or `/`) that opens a fuzzy search overlay modal. +- **Interaction:** The user types "unused" and the palette immediately surfaces `F401 (unused-import)`, `F841 (unused-variable)`, etc. Hitting Enter drops them directly into the `RuleInspector` for that specific rule, completely bypassing the Tree hierarchy. +- **Benefit:** The fastest possible way to interrogate a specific rule. + +## 4. Quick-Filter State Toggles +When inside any view displaying rules (like the flattened table or the prefix grouping), give the user hotkeys to rapidly shift perspectives. +- **How it works:** Add toggles (e.g., `1: All`, `2: Enabled`, `3: Ignored`). +- **Benefit:** If a user is looking at a massive category like `E` (pycodestyle), they can quickly press `3` to filter the table down to ONLY the rules they explicitly ignored, providing an instant audit trail. + +## 5. "Rule Registry" / Discovery Mode +Currently, the user only sees rules they explicitly mention in their `pyproject.toml` or have inherited. How do they discover new rules to adopt? +- **How it works:** By executing `ruff rule --all` under the hood, the TUI could populate a "Discovery" tab. This lists every single rule Ruff supports. +- **Visuals:** Rules that are currently enabled in the local project are highlighted or checked off. +- **Benefit:** Huge value-add for configuration exploration. Users can browse new rules they might want to adopt directly inside the TUI without referring back to the Ruff website. From 984ab67b0a8678e7b6e070ae3080ebde48e4f91c Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 18:04:42 -0400 Subject: [PATCH 18/36] widget skill --- .agents/skills/textual/references/widgets.md | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.agents/skills/textual/references/widgets.md b/.agents/skills/textual/references/widgets.md index d56c334..2585d84 100644 --- a/.agents/skills/textual/references/widgets.md +++ b/.agents/skills/textual/references/widgets.md @@ -57,3 +57,29 @@ def on_key(self, event: events.Key) -> None: > [!TIP] > Use `BINDINGS` in your `App` or `Screen` class for most navigation tasks. Textual manages the labels and shortcuts in the `Footer` for you. + +### `ModalScreen` & Overlays +Modals are screens with a transparent or dim background that overlay the main app. +```python +from textual.screen import ModalScreen +from textual.app import App + +class OmniboxScreen(ModalScreen[str]): + # A modal screen that returns a `str` when dismissed. + def compose(self) -> ComposeResult: + # yield your input/search widgets here + yield Input(placeholder="Search...") + + # Dismiss the screen and return data + def on_input_submitted(self, event: Input.Submitted) -> None: + self.dismiss(event.value) + +# In the main App or Screen: +def on_key(self, event: events.Key) -> None: + if event.key == "ctrl+p": + self.push_screen(OmniboxScreen(), self.handle_omnibox_result) + +def handle_omnibox_result(self, result: str | None) -> None: + if result: + print(f"Selected: {result}") +``` From 84f4abfaff385e179980489a0f097eb71add1ff5 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 18:05:48 -0400 Subject: [PATCH 19/36] design --- .agents/tui_rule_browsing.md | 1 + .agents/tui_rule_browsing_design.md | 66 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 .agents/tui_rule_browsing_design.md diff --git a/.agents/tui_rule_browsing.md b/.agents/tui_rule_browsing.md index b17f7ea..2c209f9 100644 --- a/.agents/tui_rule_browsing.md +++ b/.agents/tui_rule_browsing.md @@ -5,6 +5,7 @@ This document outlines additional convenience features and alternative navigatio **Related Documents:** - [TUI Requirements](tui_requirements.md) - [TUI Technical Design](tui_design.md) +- [Rule Browsing Detailed Design](tui_rule_browsing_design.md) ## Background While the TOML hierarchy (as established in the core requirements) provides an exact structural representation (e.g., exposing `tool.ruff.lint.select`), it is not always the most intuitive way to discover or understand the net set of rules actively evaluating the project. diff --git a/.agents/tui_rule_browsing_design.md b/.agents/tui_rule_browsing_design.md new file mode 100644 index 0000000..2438c47 --- /dev/null +++ b/.agents/tui_rule_browsing_design.md @@ -0,0 +1,66 @@ +# Detailed Implementation Plan: Effective Rules & Omnibox + +This document provides exhaustive technical instructions to implement Features 1 (Effective Rules Flat Table) and 3 (Global Fuzzy Command Palette) from the [Rule Browsing Proposals](tui_rule_browsing.md). + +--- + +## 1. Data Access & Subprocess Upgrades +To present a flat list of rules or allow fuzzy searching across all rules, the TUI must have structural knowledge of every rule Ruff supports. + +### A. `get_all_ruff_rules() -> list[dict]` +- In `src/ruff_sync/system.py` (or the equivalent subprocess wrapper module), implement an async function that executes `uv run ruff rule --all --output-format json` (or `ruff rule --all --output-format json` depending on the environment context). +- This returns a JSON array of objects representing all rules (fields include `name`, `code`, `linter`, `summary`, etc.). + +### B. `get_ruff_linters() -> list[dict]` +- Implement an async function that executes `ruff linter --output-format json`. Returns categories/prefixes (fields include `prefix`, `name`, `categories`). + +### C. Active Rule Evaluation Logic +- Create a pure utility function `compute_effective_rules(all_rules: list[dict], toml_config: dict) -> list[dict]`. +- `toml_config` is the unwrapped TOML dictionary. +- The function iterates over `all_rules`. For each rule, it checks the rule's `code` (e.g. `F401`) against the `[tool.ruff.lint]` keys `select`, `ignore`, `extend-select`. +- **Heuristic**: Length-based prefix matching. If `select = ["F"]` and `ignore = ["F401"]`, `F401` matches both. Since `F401` (len 4) is a longer and more specific prefix match than `F` (len 1), `ignore` wins. The rule is marked with `status="Ignored"`. If it was only partially matched in `select` it gets `status="Enabled"`. +- Return an enriched list of dictionaries mimicking the original rules but adding a `status` key. + +--- + +## 2. The "Effective Rules" Flat Table UI + +### A. Tree Hierarchy Updates (`src/ruff_sync/tui/widgets.py`) +- Modify the `ConfigTree` (which parses the `pyproject.toml` hierarchy) to inject a synthetic root node at the very top: **"Effective Active Rules"**. + +### B. Table Integration (`src/ruff_sync/tui/app.py` & `widgets.py`) +- When the user selects the "Effective Active Rules" tree node, intercept the event in `@on(Tree.NodeSelected)`. +- Make the `CategoryTable` visible and hide the `RuleInspector` initially. +- Clear existing columns and add `["Code", "Name", "Linter", "Status"]`. +- Fetch the enriched rules list from `compute_effective_rules`. +- Iterate the enriched rules, adding them to the `CategoryTable`. Use Textual Rich markup (e.g., `[green]Enabled[/green]`, `[red]Ignored[/red]`) for the Status column. + +### C. Row Selection Linkage +- Ensure `@on(DataTable.RowSelected)` correctly parses the Row Data to extract the "Rule Code" (e.g. `F401`). +- Hiding the Table and revealing the `RuleInspector` should fire seamlessly by calling `self.query_one("#inspector").fetch_and_display(rule_code, is_rule=True)`. + +--- + +## 3. The Global Fuzzy "Omnibox" UI + +### A. App-Level Keybind (`src/ruff_sync/tui/app.py`) +- In `RuffSyncApp.BINDINGS`, add `("/", "search", "Search Rules")`. +- Add `def action_search(self) -> None:` to intercept the hotkey. + +### B. `OmniboxScreen` Widget (`src/ruff_sync/tui/screens.py`) +- Create a module `screens.py` and define `class OmniboxScreen(ModalScreen[str]):`. +- `compose()` should yield an `Input(placeholder="Search rules (e.g. F401, unused)...")` and optionally an initially-empty `OptionList` or `ListView` beneath it for results. +- **TCSS**: Center the `OmniboxScreen` contents vertically and horizontally. Give the main container a distinct background color and border to pop out over the main App. + +### C. Fuzzy Search Logic (`@on(Input.Changed)`) +- Read `all_rules` from the background fetching mechanism. +- For every keystroke (`Input.Changed`), perform a simple substring or fuzzy match against both `rule["code"]` and `rule["name"]`. +- Populate the `ListView`/`OptionList` with the top 10-15 matches. + +### D. Submission (`@on(Input.Submitted)` or `OptionList.OptionSelected`) +- When the user hits enter on a search result, call `self.dismiss(result_rule_code)`. + +### E. App Callback Integration +- In `action_search`, execute `self.push_screen(OmniboxScreen(), self.handle_omnibox_result)`. +- `def handle_omnibox_result(self, rule_code: str | None) -> None:` +- If `rule_code` is provided, act exactly as if a row was selected in the `CategoryTable`: Hide the table, show the inspector via `self.query_one("#inspector").fetch_and_display(rule_code, is_rule=True)`. From 6e9f42d9ecb0a683cbf1062ce0ca44baa3f05717 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 18:17:46 -0400 Subject: [PATCH 20/36] add rule status visibility and global search - Implement logic to compute rule status (Enabled/Ignored/Disabled) based on user configuration and Ruff defaults. - Add an Omnibox modal for global fuzzy-searching of all Ruff rules. - Add an "Effective Active Rules" node to the configuration tree to view the status and fix availability of every rule in a formatted table. - Cache rule metadata in the background to improve TUI responsiveness. --- src/ruff_sync/system.py | 90 ++++++++++++++++++++++++++- src/ruff_sync/tui/app.py | 84 +++++++++++++++++++++++-- src/ruff_sync/tui/screens.py | 116 +++++++++++++++++++++++++++++++++++ src/ruff_sync/tui/widgets.py | 42 ++++++++++++- tests/test_rule_logic.py | 97 +++++++++++++++++++++++++++++ 5 files changed, 420 insertions(+), 9 deletions(-) create mode 100644 src/ruff_sync/tui/screens.py create mode 100644 tests/test_rule_logic.py diff --git a/src/ruff_sync/system.py b/src/ruff_sync/system.py index c8c3f35..7d1cafc 100644 --- a/src/ruff_sync/system.py +++ b/src/ruff_sync/system.py @@ -3,8 +3,12 @@ from __future__ import annotations import asyncio +import json import logging -from typing import Final +from typing import TYPE_CHECKING, Any, Final + +if TYPE_CHECKING: + from collections.abc import Mapping LOGGER = logging.getLogger(__name__) @@ -38,6 +42,90 @@ async def get_ruff_config_markdown(setting_path: str) -> str | None: return await _run_ruff_command(cmd, f"ruff config {clean_path}") +async def get_all_ruff_rules() -> list[dict[str, Any]]: + """Execute `ruff rule --all --output-format json` and return the parsed rules. + + Returns: + A list of dictionaries representing all supported Ruff rules. + """ + cmd: Final[list[str]] = ["ruff", "rule", "--all", "--output-format", "json"] + output = await _run_ruff_command(cmd, "ruff rule --all") + if not output: + return [] + try: + return json.loads(output) + except json.JSONDecodeError: + LOGGER.exception("Failed to parse Ruff rules JSON.") + return [] + + +async def get_ruff_linters() -> list[dict[str, Any]]: + """Execute `ruff linter --output-format json` and return the parsed linters. + + Returns: + A list of dictionaries representing Ruff linter categories. + """ + cmd: Final[list[str]] = ["ruff", "linter", "--output-format", "json"] + output = await _run_ruff_command(cmd, "ruff linter") + if not output: + return [] + try: + return json.loads(output) + except json.JSONDecodeError: + LOGGER.exception("Failed to parse Ruff linters JSON.") + return [] + + +def compute_effective_rules( + all_rules: list[dict[str, Any]], toml_config: Mapping[str, Any] +) -> list[dict[str, Any]]: + """Determine the status (Enabled, Ignored, Disabled) for each rule. + + Args: + all_rules: The list of all supported rules. + toml_config: The local configuration dictionary. + + Returns: + The list of rules enriched with a 'status' key. + """ + lint = toml_config.get("tool", {}).get("ruff", {}).get("lint", {}) + + select = set(lint.get("select", [])) | set(lint.get("extend-select", [])) + ignore = set(lint.get("ignore", [])) | set(lint.get("extend-ignore", [])) + + # If no select/extend-select is provided, Ruff defaults to E and F + if not lint.get("select") and not lint.get("extend-select"): + select.update(["E", "F"]) + + enriched: list[dict[str, Any]] = [] + for rule in all_rules: + code = rule["code"] + + # Find longest matching select prefix + best_select_len = -1 + for s in select: + if code.startswith(s): + best_select_len = max(best_select_len, len(s)) + + # Find longest matching ignore prefix + best_ignore_len = -1 + for i in ignore: + if code.startswith(i): + best_ignore_len = max(best_ignore_len, len(i)) + + status = "Disabled" + if best_select_len > best_ignore_len: + status = "Enabled" + elif best_ignore_len >= best_select_len and best_ignore_len != -1: + status = "Ignored" + + rule_with_status = dict(rule) + rule_with_status["status"] = status + enriched.append(rule_with_status) + + return enriched + + async def _run_ruff_command(cmd: list[str], description: str) -> str | None: """Execute a ruff command and return the decoded output. diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index 7a10da9..bd52a49 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -3,16 +3,18 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, Final -from textual import on +from textual import on, work from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical from textual.widgets import DataTable, Footer, Header, Tree from typing_extensions import override from ruff_sync.config_io import load_local_ruff_config +from ruff_sync.system import compute_effective_rules, get_all_ruff_rules from ruff_sync.tui.constants import RULE_PATTERN +from ruff_sync.tui.screens import OmniboxScreen from ruff_sync.tui.widgets import CategoryTable, ConfigTree, RuleInspector if TYPE_CHECKING: @@ -21,6 +23,8 @@ LOGGER = logging.getLogger(__name__) +MIN_RULE_COLUMNS: Final = 4 + class RuffSyncApp(App[None]): """Ruff-Sync Terminal User Interface.""" @@ -64,7 +68,7 @@ class RuffSyncApp(App[None]): BINDINGS: ClassVar[list[Any]] = [ ("q", "quit", "Quit"), - ("/", "focus('config-tree')", "Search"), + ("/", "search", "Search Rules"), ("enter", "select", "View Details"), ] @@ -78,6 +82,8 @@ def __init__(self, args: Arguments, **kwargs: Any) -> None: super().__init__(**kwargs) self.args = args self.config: dict[str, Any] = {} + self.all_rules: list[dict[str, Any]] = [] + self.effective_rules: list[dict[str, Any]] = [] @override def compose(self) -> ComposeResult: @@ -100,9 +106,35 @@ async def on_mount(self) -> None: self.config = {} tree = self.query_one(ConfigTree) - tree.populate(self.config) + tree.populate(self.config, has_rules=True) tree.focus() + # Prime the caches in the background + self._prime_caches() + + @work + async def _prime_caches(self) -> None: + """Fetch rules and compute effectiveness in the background.""" + self.all_rules = await get_all_ruff_rules() + if self.config: + self.effective_rules = compute_effective_rules(self.all_rules, self.config) + + @work + async def _display_effective_rules(self) -> None: + """Populate the table with the effective rules list.""" + if not self.all_rules: + self.all_rules = await get_all_ruff_rules() + self.effective_rules = compute_effective_rules(self.all_rules, self.config) + + table = self.query_one(CategoryTable) + table.update_rules(self.effective_rules) + + inspector = self.query_one(RuleInspector) + inspector.update( + "## Effective Active Rules\n\nThis table shows the status of every Ruff rule " + "based on your current configuration (including defaults)." + ) + @on(Tree.NodeSelected) def handle_node_selected(self, event: Tree.NodeSelected[Any]) -> None: """Handle tree node selection. @@ -117,6 +149,12 @@ def handle_node_selected(self, event: Tree.NodeSelected[Any]) -> None: table = self.query_one(CategoryTable) inspector = self.query_one(RuleInspector) + if data == "__rules__": + table.remove_class("hidden") + inspector.remove_class("full-height") + self._display_effective_rules() + return + # Build full path for context full_path = self._get_node_path(event.node) @@ -146,8 +184,21 @@ def handle_row_selected(self, event: DataTable.RowSelected) -> None: """ table = self.query_one(CategoryTable) row = table.get_row_at(event.cursor_row) - key, value = row + # Handle multi-column rules view vs key-value view + if len(row) >= MIN_RULE_COLUMNS: + rule_code = str(row[0]) + # Check for cached explanation + rule_data = next((r for r in self.all_rules if r["code"] == rule_code), None) + explanation = rule_data.get("explanation") if rule_data else None + + inspector = self.query_one(RuleInspector) + table.add_class("hidden") + inspector.add_class("full-height") + inspector.fetch_and_display(rule_code, is_rule=True, cached_content=explanation) + return + + key, value = row # Check if the value or key looks like a rule code rule_code = None if RULE_PATTERN.match(str(key)): @@ -183,3 +234,26 @@ def _get_node_path(self, node: Any) -> str: path.append(label_text) current = current.parent return "tool.ruff." + ".".join(reversed(path)) if path else "tool.ruff" + + def action_search(self) -> None: + """Launch the global fuzzy search Omnibox.""" + if not self.all_rules: + self.notify("Still fetching rule metadata...", severity="warning") + # Even if empty, we push; the screen handles empty list + self.push_screen(OmniboxScreen(self.all_rules), self.handle_omnibox_result) + + def handle_omnibox_result(self, rule_code: str | None) -> None: + """Handle the result from the Omnibox search. + + Args: + rule_code: The selected rule code, or None if cancelled. + """ + if rule_code: + rule_data = next((r for r in self.all_rules if r["code"] == rule_code), None) + explanation = rule_data.get("explanation") if rule_data else None + + table = self.query_one(CategoryTable) + inspector = self.query_one(RuleInspector) + table.add_class("hidden") + inspector.add_class("full-height") + inspector.fetch_and_display(rule_code, is_rule=True, cached_content=explanation) diff --git a/src/ruff_sync/tui/screens.py b/src/ruff_sync/tui/screens.py new file mode 100644 index 0000000..30adb8e --- /dev/null +++ b/src/ruff_sync/tui/screens.py @@ -0,0 +1,116 @@ +"""Screens for the Ruff-Sync Terminal User Interface.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Final + +from textual import on +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Input, OptionList, Static +from textual.widgets.option_list import Option + +if TYPE_CHECKING: + from textual.app import ComposeResult + +MAX_SEARCH_RESULTS: Final = 15 + + +class OmniboxScreen(ModalScreen[str]): + """A modal search screen for quickly finding Ruff rules.""" + + CSS = """ + OmniboxScreen { + align: center middle; + } + + #omnibox-container { + width: 60; + height: auto; + max-height: 20; + background: $boost; + border: thick $primary; + padding: 1; + } + + #omnibox-input { + margin-bottom: 1; + } + + #omnibox-results { + height: auto; + max-height: 12; + border: none; + background: $surface; + } + """ + + def __init__(self, all_rules: list[dict[str, Any]], **kwargs: Any) -> None: + """Initialize the search screen. + + Args: + all_rules: The list of all rules to search through. + **kwargs: Additional keyword arguments. + """ + super().__init__(**kwargs) + self.all_rules = all_rules + + def compose(self) -> ComposeResult: + """Compose the search interface.""" + with Vertical(id="omnibox-container"): + yield Static("[b]Search Ruff Rules[/b] (e.g. F401, unused)", id="omnibox-title") + yield Input(placeholder="Start typing...", id="omnibox-input") + yield OptionList(id="omnibox-results") + + def on_mount(self) -> None: + """Focus the input on mount.""" + self.query_one(Input).focus() + + @on(Input.Changed) + def handle_input_changed(self, event: Input.Changed) -> None: + """Filter rules based on search input. + + Args: + event: The input changed event. + """ + search_query = event.value.strip().lower() + results_list = self.query_one(OptionList) + results_list.clear_options() + + if not search_query: + return + + matches = [] + for rule in self.all_rules: + code = rule["code"].lower() + name = rule["name"].lower() + if search_query in code or search_query in name: + matches.append(rule) + if len(matches) >= MAX_SEARCH_RESULTS: # Limit results + break + + for match in matches: + results_list.add_option( + Option(f"[b]{match['code']}[/b] - {match['name']}", id=match["code"]) + ) + + @on(Input.Submitted) + def handle_input_submitted(self) -> None: + """Handle enter key in the input.""" + results_list = self.query_one(OptionList) + if results_list.option_count > 0: + # If there's a selected option, use it. Otherwise use the first matching one. + index = results_list.highlighted if results_list.highlighted is not None else 0 + option = results_list.get_option_at_index(index) + if option.id: + self.dismiss(str(option.id)) + + @on(OptionList.OptionSelected) + def handle_option_selected(self, event: OptionList.OptionSelected) -> None: + """Handle selection from the results list.""" + if event.option.id: + self.dismiss(str(event.option.id)) + + def action_cancel(self) -> None: + """Close the screen without selection.""" + self.dismiss() diff --git a/src/ruff_sync/tui/widgets.py b/src/ruff_sync/tui/widgets.py index b1f280e..a2d0ab8 100644 --- a/src/ruff_sync/tui/widgets.py +++ b/src/ruff_sync/tui/widgets.py @@ -17,14 +17,17 @@ class ConfigTree(Tree[Any]): """A tree widget for navigating Ruff configuration.""" - def populate(self, config: dict[str, Any]) -> None: + def populate(self, config: dict[str, Any], has_rules: bool = False) -> None: """Populate the tree with configuration sections. Args: config: The unwrapped dictionary of Ruff configuration. + has_rules: Whether to inject the 'Effective Active Rules' node. """ self.clear() self.root.expand() + if has_rules: + self.root.add("Effective Active Rules", data="__rules__") self._populate_node(self.root, config) def _populate_node(self, parent: TreeNode[Any], data: Any) -> None: @@ -62,7 +65,8 @@ def update_content(self, data: Any) -> None: Args: data: The data to display in the table. """ - self.clear() + self.clear(columns=True) + self.add_columns("Key", "Value") if isinstance(data, dict): for key, value in sorted(data.items()): self.add_row(key, str(value)) @@ -72,6 +76,31 @@ def update_content(self, data: Any) -> None: else: self.add_row("Value", str(data)) + def update_rules(self, rules: list[dict[str, Any]]) -> None: + """Update the table with a list of rules in multi-column format. + + Args: + rules: The enriched rules list to display. + """ + self.clear(columns=True) + self.add_columns("Code", "Name", "Linter", "Status", "Fix") + for rule in rules: + status = rule.get("status", "Unknown") + status_markup = status + if status == "Enabled": + status_markup = f"[green]{status}[/green]" + elif status == "Ignored": + status_markup = f"[red]{status}[/red]" + + fix = rule.get("fix_availability", "None") + fix_markup = fix + if fix == "Always": + fix_markup = f"[cyan]{fix}[/cyan]" + elif fix in ("Sometimes", "Enforced"): + fix_markup = f"[yellow]{fix}[/yellow]" + + self.add_row(rule["code"], rule["name"], rule["linter"], status_markup, fix_markup) + class RuleInspector(Markdown): """A markdown widget for inspecting Ruff rules and settings.""" @@ -107,13 +136,20 @@ def show_context(self, path: str, value: Any) -> None: self.update(f"### Configuration Context\n\n**Path**: `{path}`\n\n**Value**: {summary}") @work - async def fetch_and_display(self, target: str, is_rule: bool = True) -> None: + async def fetch_and_display( + self, target: str, is_rule: bool = True, cached_content: str | None = None + ) -> None: """Fetch and display the documentation for a rule or setting. Args: target: The Ruff rule code or configuration path. is_rule: True if fetching a rule, False if fetching a config setting. + cached_content: Optional pre-fetched documentation to avoid subprocess calls. """ + if cached_content: + self.update(cached_content.strip()) + return + # Set a loading message desc = "rule" if is_rule else "config" self.update(f"## Inspecting {target}...\n\nFetching documentation from `ruff {desc}`...") diff --git a/tests/test_rule_logic.py b/tests/test_rule_logic.py new file mode 100644 index 0000000..9cc2c85 --- /dev/null +++ b/tests/test_rule_logic.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from ruff_sync.system import compute_effective_rules + + +def test_compute_effective_rules_basic(): + """Test basic select/ignore logic.""" + all_rules = [ + {"code": "F401", "name": "unused-import", "linter": "Pyflakes"}, + {"code": "E501", "name": "line-too-long", "linter": "pycodestyle"}, + {"code": "UP001", "name": "useless-metaclass", "linter": "pyupgrade"}, + ] + toml_config = { + "tool": { + "ruff": { + "lint": { + "select": ["F"], + "ignore": ["F401"], + } + } + } + } + + enriched = compute_effective_rules(all_rules, toml_config) + + # F401: select matches "F" (len 1), ignore matches "F401" (len 4). Ignore wins. + f401 = next(r for r in enriched if r["code"] == "F401") + assert f401["status"] == "Ignored" + + # E501: no match in select/ignore. Disabled (since select is explicitly "F"). + e501 = next(r for r in enriched if r["code"] == "E501") + assert e501["status"] == "Disabled" + + +def test_compute_effective_rules_defaults(): + """Test that it falls back to Ruff defaults (E, F) when select is empty.""" + all_rules = [ + {"code": "F401", "name": "unused-import", "linter": "Pyflakes"}, + {"code": "E501", "name": "line-too-long", "linter": "pycodestyle"}, + {"code": "UP001", "name": "useless-metaclass", "linter": "pyupgrade"}, + ] + toml_config = {} # Empty config + + enriched = compute_effective_rules(all_rules, toml_config) + + # Defaults are E and F + f401 = next(r for r in enriched if r["code"] == "F401") + assert f401["status"] == "Enabled" + + e501 = next(r for r in enriched if r["code"] == "E501") + assert e501["status"] == "Enabled" + + up001 = next(r for r in enriched if r["code"] == "UP001") + assert up001["status"] == "Disabled" + + +def test_compute_effective_rules_specificity(): + """Test that the longest prefix match wins.""" + all_rules = [{"code": "PLR0912", "name": "too-many-branches", "linter": "Pylint"}] + + # Select more specific than ignore + toml_config_1 = {"tool": {"ruff": {"lint": {"select": ["PLR0912"], "ignore": ["PLR"]}}}} + enriched_1 = compute_effective_rules(all_rules, toml_config_1) + assert enriched_1[0]["status"] == "Enabled" + + # Ignore more specific than select + toml_config_2 = {"tool": {"ruff": {"lint": {"select": ["PLR"], "ignore": ["PLR0912"]}}}} + enriched_2 = compute_effective_rules(all_rules, toml_config_2) + assert enriched_2[0]["status"] == "Ignored" + + +def test_compute_effective_rules_extend(): + """Test that extend-select and extend-ignore are respected.""" + all_rules = [ + {"code": "F401", "name": "unused-import", "linter": "Pyflakes"}, + {"code": "I001", "name": "unsorted-imports", "linter": "isort"}, + ] + toml_config = { + "tool": { + "ruff": { + "lint": { + "extend-select": ["I"], + "extend-ignore": ["F401"], + } + } + } + } + + enriched = compute_effective_rules(all_rules, toml_config) + + # F401: Default "F" select matched, but extend-ignore "F401" is longer. + f401 = next(r for r in enriched if r["code"] == "F401") + assert f401["status"] == "Ignored" + + # I001: Selected via extend-select + i001 = next(r for r in enriched if r["code"] == "I001") + assert i001["status"] == "Enabled" From f1eb3f57196d3f307d175028ddf5fd28e0abafa4 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 18:21:31 -0400 Subject: [PATCH 21/36] remove panel hiding --- src/ruff_sync/system.py | 4 ++-- src/ruff_sync/tui/app.py | 23 ----------------------- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/src/ruff_sync/system.py b/src/ruff_sync/system.py index 7d1cafc..2f98217 100644 --- a/src/ruff_sync/system.py +++ b/src/ruff_sync/system.py @@ -152,11 +152,11 @@ async def _run_ruff_command(cmd: list[str], description: str) -> str | None: return None output = stdout.decode().strip() - return output or None - except FileNotFoundError: LOGGER.exception("Ruff executable not found in PATH.") return None except Exception: LOGGER.exception(f"Unexpected error executing '{description}'") return None + else: + return output or None diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index bd52a49..85e1bd7 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -56,14 +56,6 @@ class RuffSyncApp(App[None]): background: $surface-darken-1; overflow-y: auto; } - - .hidden { - display: none; - } - - .full-height { - height: 100% !important; - } """ BINDINGS: ClassVar[list[Any]] = [ @@ -150,8 +142,6 @@ def handle_node_selected(self, event: Tree.NodeSelected[Any]) -> None: inspector = self.query_one(RuleInspector) if data == "__rules__": - table.remove_class("hidden") - inspector.remove_class("full-height") self._display_effective_rules() return @@ -160,18 +150,12 @@ def handle_node_selected(self, event: Tree.NodeSelected[Any]) -> None: # Check if the node label or path matches a ruff rule if isinstance(label_text, str) and RULE_PATTERN.match(label_text): - table.add_class("hidden") - inspector.add_class("full-height") inspector.fetch_and_display(label_text, is_rule=True) elif isinstance(data, (dict, list)): - table.remove_class("hidden") - inspector.remove_class("full-height") table.update_content(data) # Fetch config documentation for the section if possible inspector.fetch_and_display(full_path, is_rule=False) else: - table.remove_class("hidden") - inspector.remove_class("full-height") table.update_content(data) inspector.fetch_and_display(full_path, is_rule=False) @@ -193,8 +177,6 @@ def handle_row_selected(self, event: DataTable.RowSelected) -> None: explanation = rule_data.get("explanation") if rule_data else None inspector = self.query_one(RuleInspector) - table.add_class("hidden") - inspector.add_class("full-height") inspector.fetch_and_display(rule_code, is_rule=True, cached_content=explanation) return @@ -208,8 +190,6 @@ def handle_row_selected(self, event: DataTable.RowSelected) -> None: if rule_code: inspector = self.query_one(RuleInspector) - table.add_class("hidden") - inspector.add_class("full-height") inspector.fetch_and_display(rule_code, is_rule=True) else: # It's a configuration key, show its documentation @@ -252,8 +232,5 @@ def handle_omnibox_result(self, rule_code: str | None) -> None: rule_data = next((r for r in self.all_rules if r["code"] == rule_code), None) explanation = rule_data.get("explanation") if rule_data else None - table = self.query_one(CategoryTable) inspector = self.query_one(RuleInspector) - table.add_class("hidden") - inspector.add_class("full-height") inspector.fetch_and_display(rule_code, is_rule=True, cached_content=explanation) From d66e2a6c4cf58e5d719341e76522b50798553eaf Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 18:38:46 -0400 Subject: [PATCH 22/36] effective only display --- src/ruff_sync/tui/app.py | 9 ++++++--- src/ruff_sync/tui/widgets.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index 85e1bd7..57670c4 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -118,13 +118,16 @@ async def _display_effective_rules(self) -> None: self.all_rules = await get_all_ruff_rules() self.effective_rules = compute_effective_rules(self.all_rules, self.config) + # Filter for only Enabled or Ignored rules as per the "Effective Rules" proposal + effective_only = [r for r in self.effective_rules if r["status"] != "Disabled"] + table = self.query_one(CategoryTable) - table.update_rules(self.effective_rules) + table.update_rules(effective_only) inspector = self.query_one(RuleInspector) inspector.update( - "## Effective Active Rules\n\nThis table shows the status of every Ruff rule " - "based on your current configuration (including defaults)." + "## Effective Rule Status\n\nThis table shows rules that are actively being used " + "or have been explicitly ignored in your configuration." ) @on(Tree.NodeSelected) diff --git a/src/ruff_sync/tui/widgets.py b/src/ruff_sync/tui/widgets.py index a2d0ab8..3a9ac8f 100644 --- a/src/ruff_sync/tui/widgets.py +++ b/src/ruff_sync/tui/widgets.py @@ -27,7 +27,7 @@ def populate(self, config: dict[str, Any], has_rules: bool = False) -> None: self.clear() self.root.expand() if has_rules: - self.root.add("Effective Active Rules", data="__rules__") + self.root.add("Effective Rule Status", data="__rules__") self._populate_node(self.root, config) def _populate_node(self, parent: TreeNode[Any], data: Any) -> None: From 8c9c56a4e99cf78948533f462df3d5b0219fca27 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 19:00:39 -0400 Subject: [PATCH 23/36] don't show disabled rules --- src/ruff_sync/tui/app.py | 27 ++++++++++++++++- src/ruff_sync/tui/widgets.py | 56 ++++++++++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index 57670c4..ed46d25 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -12,7 +12,7 @@ from typing_extensions import override from ruff_sync.config_io import load_local_ruff_config -from ruff_sync.system import compute_effective_rules, get_all_ruff_rules +from ruff_sync.system import compute_effective_rules, get_all_ruff_rules, get_ruff_linters from ruff_sync.tui.constants import RULE_PATTERN from ruff_sync.tui.screens import OmniboxScreen from ruff_sync.tui.widgets import CategoryTable, ConfigTree, RuleInspector @@ -76,6 +76,7 @@ def __init__(self, args: Arguments, **kwargs: Any) -> None: self.config: dict[str, Any] = {} self.all_rules: list[dict[str, Any]] = [] self.effective_rules: list[dict[str, Any]] = [] + self.linters: list[dict[str, Any]] = [] @override def compose(self) -> ComposeResult: @@ -108,9 +109,20 @@ async def on_mount(self) -> None: async def _prime_caches(self) -> None: """Fetch rules and compute effectiveness in the background.""" self.all_rules = await get_all_ruff_rules() + self.linters = await get_ruff_linters() if self.config: self.effective_rules = compute_effective_rules(self.all_rules, self.config) + # Refresh the tree once metadata is loaded to show linter groups + # We pass effective_rules so the tree can filter out entire groups that are disabled + tree = self.query_one(ConfigTree) + tree.populate( + self.config, + has_rules=True, + linters=self.linters, + effective_rules=self.effective_rules, + ) + @work async def _display_effective_rules(self) -> None: """Populate the table with the effective rules list.""" @@ -148,6 +160,19 @@ def handle_node_selected(self, event: Tree.NodeSelected[Any]) -> None: self._display_effective_rules() return + if isinstance(data, dict) and data.get("type") == "linter": + prefix = data.get("prefix", "") + name = data.get("name", "Unknown Linter") + + # Filter effective rules by prefix + # We show all rules in the group (even Disabled) as per the Discovery proposal + filtered_rules = [r for r in self.effective_rules if r["code"].startswith(prefix)] + + table.update_rules(filtered_rules) + msg = f"## {name} ({prefix or 'No Prefix'})\n\nShowing rules for the {name} category." + inspector.update(msg) + return + # Build full path for context full_path = self._get_node_path(event.node) diff --git a/src/ruff_sync/tui/widgets.py b/src/ruff_sync/tui/widgets.py index 3a9ac8f..536f532 100644 --- a/src/ruff_sync/tui/widgets.py +++ b/src/ruff_sync/tui/widgets.py @@ -17,19 +17,69 @@ class ConfigTree(Tree[Any]): """A tree widget for navigating Ruff configuration.""" - def populate(self, config: dict[str, Any], has_rules: bool = False) -> None: + def populate( + self, + config: dict[str, Any], + has_rules: bool = False, + linters: list[dict[str, Any]] | None = None, + effective_rules: list[dict[str, Any]] | None = None, + ) -> None: """Populate the tree with configuration sections. Args: config: The unwrapped dictionary of Ruff configuration. - has_rules: Whether to inject the 'Effective Active Rules' node. + has_rules: Whether to inject the 'Effective Rule Status' node. + linters: Optional linter metadata for expanding the rules node. + effective_rules: List of rules with status for filtering. """ self.clear() self.root.expand() if has_rules: - self.root.add("Effective Rule Status", data="__rules__") + rules_node = self.root.add("Effective Rule Status", data="__rules__") + if linters and effective_rules: + self._populate_linter_nodes(rules_node, linters, effective_rules) self._populate_node(self.root, config) + def _is_linter_active( + self, linter: dict[str, Any], effective_rules: list[dict[str, Any]] + ) -> bool: + """Check if a linter (or any of its categories) has active/ignored rules.""" + prefix = linter.get("prefix") + if prefix and any( + r["code"].startswith(prefix) and r["status"] != "Disabled" for r in effective_rules + ): + return True + + if "categories" in linter: + return any(self._is_linter_active(c, effective_rules) for c in linter["categories"]) + + return False + + def _populate_linter_nodes( + self, + parent: TreeNode[Any], + linters: list[dict[str, Any]], + effective_rules: list[dict[str, Any]], + ) -> None: + """Recursively add linter category nodes to the tree.""" + # Sort linters by name for better navigation + for linter in sorted(linters, key=lambda x: x["name"]): + # Filter: only show if this linter or any category has active rules + if not self._is_linter_active(linter, effective_rules): + continue + + name = linter["name"] + prefix = linter.get("prefix") + label = f"{name} ({prefix})" if prefix else name + + # Use 'linter' structure in data for easy detection + node_data = {"type": "linter", "prefix": prefix, "name": name} + node = parent.add(label, data=node_data) + + # Recursive call for sub-categories (e.g. Pylint, pycodestyle) + if "categories" in linter: + self._populate_linter_nodes(node, linter["categories"], effective_rules) + def _populate_node(self, parent: TreeNode[Any], data: Any) -> None: """Recursively add nodes to the tree. From e6e61cb7a2f43344b78f3892789e8d4899eea804 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 19:06:26 -0400 Subject: [PATCH 24/36] max-width of tree browser --- src/ruff_sync/tui/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index ed46d25..f90ec4a 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -36,12 +36,13 @@ class RuffSyncApp(App[None]): #config-tree { width: 1fr; + max-width: 42; height: 100%; border-right: solid $primary-darken-2; } #content-pane { - width: 2fr; + width: 1fr; height: 100%; } From d7ed117137d75e6f6c7e526c9f010bb260c65f38 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 19:08:53 -0400 Subject: [PATCH 25/36] auto-expand --- src/ruff_sync/tui/widgets.py | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/ruff_sync/tui/widgets.py b/src/ruff_sync/tui/widgets.py index 536f532..b2ac3f0 100644 --- a/src/ruff_sync/tui/widgets.py +++ b/src/ruff_sync/tui/widgets.py @@ -40,6 +40,9 @@ def populate( self._populate_linter_nodes(rules_node, linters, effective_rules) self._populate_node(self.root, config) + # Auto-expand up to 2 levels if it fits in the current view + self._expand_if_fits() + def _is_linter_active( self, linter: dict[str, Any], effective_rules: list[dict[str, Any]] ) -> bool: @@ -99,6 +102,46 @@ def _populate_node(self, parent: TreeNode[Any], data: Any) -> None: if isinstance(item, (dict, list)): self._populate_node(node, item) + def _expand_if_fits(self) -> None: + """Expand the first few levels of the tree if they fit in the vertical space.""" + # We target depth 2 expansion (Root -> Categories -> Items) + target_depth = 2 + + # Use widget height if available, otherwise fallback to a common terminal height + # Subtract some margin for header/footer + limit = (self.size.height or 24) - 2 + + # Count visible nodes if we were to expand + to_expand: list[TreeNode[Any]] = [] + visible_count = 1 # Start with the root + + def collect_nodes(node: TreeNode[Any], depth: int) -> int: + nonlocal visible_count + if depth >= target_depth: + return visible_count + + children = list(node.children) + if not children: + return visible_count + + # If adding these children exceeds the limit, stop + if visible_count + len(children) > limit: + return visible_count + + # Mark for expansion and continue + to_expand.append(node) + visible_count += len(children) + + for child in children: + collect_nodes(child, depth + 1) + return visible_count + + collect_nodes(self.root, 0) + + # Apply the expansions + for node in to_expand: + node.expand() + class CategoryTable(DataTable[Any]): """A table widget for displaying configuration keys and values.""" From 3f4ee29e59d7c64af8c86b456370656158d859d3 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 19:36:37 -0400 Subject: [PATCH 26/36] enrich rule status visualization and inspection Use row-level color coding to indicate rule status in tables and add metadata headers with status icons to the rule inspector. Centralizes rule inspection logic and adds safety checks for Ruff CLI output parsing. --- src/ruff_sync/system.py | 14 ++++++- src/ruff_sync/tui/app.py | 65 ++++++++++++++++++++----------- src/ruff_sync/tui/screens.py | 2 + src/ruff_sync/tui/widgets.py | 75 ++++++++++++++++++++++++++---------- tests/test_rule_logic.py | 4 +- tests/test_tui.py | 34 ++++++++++------ 6 files changed, 136 insertions(+), 58 deletions(-) diff --git a/src/ruff_sync/system.py b/src/ruff_sync/system.py index 2f98217..6049e16 100644 --- a/src/ruff_sync/system.py +++ b/src/ruff_sync/system.py @@ -38,6 +38,8 @@ async def get_ruff_config_markdown(setting_path: str) -> str | None: """ # Strip 'tool.ruff.' prefix if present as 'ruff config' expects relative paths clean_path = setting_path.removeprefix("tool.ruff.") + if not clean_path or clean_path == "tool.ruff": + return None cmd: Final[list[str]] = ["ruff", "config", clean_path] return await _run_ruff_command(cmd, f"ruff config {clean_path}") @@ -53,11 +55,15 @@ async def get_all_ruff_rules() -> list[dict[str, Any]]: if not output: return [] try: - return json.loads(output) + data = json.loads(output) except json.JSONDecodeError: LOGGER.exception("Failed to parse Ruff rules JSON.") return [] + if isinstance(data, list): + return data + return [] + async def get_ruff_linters() -> list[dict[str, Any]]: """Execute `ruff linter --output-format json` and return the parsed linters. @@ -70,11 +76,15 @@ async def get_ruff_linters() -> list[dict[str, Any]]: if not output: return [] try: - return json.loads(output) + data = json.loads(output) except json.JSONDecodeError: LOGGER.exception("Failed to parse Ruff linters JSON.") return [] + if isinstance(data, list): + return data + return [] + def compute_effective_rules( all_rules: list[dict[str, Any]], toml_config: Mapping[str, Any] diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index f90ec4a..30e515f 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -179,11 +179,14 @@ def handle_node_selected(self, event: Tree.NodeSelected[Any]) -> None: # Check if the node label or path matches a ruff rule if isinstance(label_text, str) and RULE_PATTERN.match(label_text): - inspector.fetch_and_display(label_text, is_rule=True) + self._inspect_rule(label_text) elif isinstance(data, (dict, list)): table.update_content(data) - # Fetch config documentation for the section if possible - inspector.fetch_and_display(full_path, is_rule=False) + # Fetch config documentation for the section if possible (skip root) + if full_path != "tool.ruff": + inspector.fetch_and_display(full_path, is_rule=False) + else: + inspector.show_placeholder() else: table.update_content(data) inspector.fetch_and_display(full_path, is_rule=False) @@ -200,32 +203,48 @@ def handle_row_selected(self, event: DataTable.RowSelected) -> None: # Handle multi-column rules view vs key-value view if len(row) >= MIN_RULE_COLUMNS: - rule_code = str(row[0]) - # Check for cached explanation - rule_data = next((r for r in self.all_rules if r["code"] == rule_code), None) - explanation = rule_data.get("explanation") if rule_data else None - - inspector = self.query_one(RuleInspector) - inspector.fetch_and_display(rule_code, is_rule=True, cached_content=explanation) + # Use row_key for stable rule code extraction (avoids markup) + rule_code = str(event.row_key.value) + self._inspect_rule(rule_code) return key, value = row # Check if the value or key looks like a rule code - rule_code = None + rule_code_from_kv = None if RULE_PATTERN.match(str(key)): - rule_code = str(key) + rule_code_from_kv = str(key) elif RULE_PATTERN.match(str(value)): - rule_code = str(value) + rule_code_from_kv = str(value) - if rule_code: - inspector = self.query_one(RuleInspector) - inspector.fetch_and_display(rule_code, is_rule=True) + if rule_code_from_kv: + self._inspect_rule(rule_code_from_kv) else: # It's a configuration key, show its documentation inspector = self.query_one(RuleInspector) full_path = f"{self._get_node_path(self.query_one(ConfigTree).cursor_node)}.{key}" inspector.fetch_and_display(full_path, is_rule=False) + def _inspect_rule(self, rule_code: str) -> None: + """Centralized helper for rule inspection with metadata enrichment. + + Args: + rule_code: The Ruff rule code to inspect. + """ + # Fetch metadata for enrichment + rule_data = next((r for r in self.effective_rules if r["code"] == rule_code), None) + name = rule_data.get("name") if rule_data else None + status = rule_data.get("status") if rule_data else "Disabled" + explanation = rule_data.get("explanation") if rule_data else None + + inspector = self.query_one(RuleInspector) + inspector.fetch_and_display( + rule_code, + is_rule=True, + cached_content=explanation, + rule_name=name, + rule_status=status, + ) + def _get_node_path(self, node: Any) -> str: """Construct the full configuration path to a tree node. @@ -237,12 +256,16 @@ def _get_node_path(self, node: Any) -> str: """ path: list[str] = [] current = node - while current and current != self.query_one(ConfigTree).root: + tree = self.query_one(ConfigTree) + while current and current != tree.root: label = current.label label_text = str(label.plain) if hasattr(label, "plain") else str(label) path.append(label_text) current = current.parent - return "tool.ruff." + ".".join(reversed(path)) if path else "tool.ruff" + + if not path: + return "tool.ruff" + return "tool.ruff." + ".".join(reversed(path)) def action_search(self) -> None: """Launch the global fuzzy search Omnibox.""" @@ -258,8 +281,4 @@ def handle_omnibox_result(self, rule_code: str | None) -> None: rule_code: The selected rule code, or None if cancelled. """ if rule_code: - rule_data = next((r for r in self.all_rules if r["code"] == rule_code), None) - explanation = rule_data.get("explanation") if rule_data else None - - inspector = self.query_one(RuleInspector) - inspector.fetch_and_display(rule_code, is_rule=True, cached_content=explanation) + self._inspect_rule(rule_code) diff --git a/src/ruff_sync/tui/screens.py b/src/ruff_sync/tui/screens.py index 30adb8e..021bc3b 100644 --- a/src/ruff_sync/tui/screens.py +++ b/src/ruff_sync/tui/screens.py @@ -9,6 +9,7 @@ from textual.screen import ModalScreen from textual.widgets import Input, OptionList, Static from textual.widgets.option_list import Option +from typing_extensions import override if TYPE_CHECKING: from textual.app import ComposeResult @@ -55,6 +56,7 @@ def __init__(self, all_rules: list[dict[str, Any]], **kwargs: Any) -> None: super().__init__(**kwargs) self.all_rules = all_rules + @override def compose(self) -> ComposeResult: """Compose the search interface.""" with Vertical(id="omnibox-container"): diff --git a/src/ruff_sync/tui/widgets.py b/src/ruff_sync/tui/widgets.py index b2ac3f0..4b6da32 100644 --- a/src/ruff_sync/tui/widgets.py +++ b/src/ruff_sync/tui/widgets.py @@ -170,20 +170,28 @@ def update_content(self, data: Any) -> None: self.add_row("Value", str(data)) def update_rules(self, rules: list[dict[str, Any]]) -> None: - """Update the table with a list of rules in multi-column format. + """Update the table with a list of rules using row-level highlighting. Args: rules: The enriched rules list to display. """ self.clear(columns=True) - self.add_columns("Code", "Name", "Linter", "Status", "Fix") + # Status is now indicated by row highlighting (colors), so column is removed + self.add_columns("Code", "Name", "Linter", "Fix") for rule in rules: status = rule.get("status", "Unknown") - status_markup = status + + # Determine row color based on status + color = "" if status == "Enabled": - status_markup = f"[green]{status}[/green]" + color = "green" elif status == "Ignored": - status_markup = f"[red]{status}[/red]" + color = "yellow" + elif status == "Disabled": + color = "dim" + + code_markup = f"[{color}]{rule['code']}[/{color}]" if color else rule["code"] + name_markup = f"[{color}]{rule['name']}[/{color}]" if color else rule["name"] fix = rule.get("fix_availability", "None") fix_markup = fix @@ -192,7 +200,8 @@ def update_rules(self, rules: list[dict[str, Any]]) -> None: elif fix in ("Sometimes", "Enforced"): fix_markup = f"[yellow]{fix}[/yellow]" - self.add_row(rule["code"], rule["name"], rule["linter"], status_markup, fix_markup) + # Note: We still keep linter as-is for clarity + self.add_row(code_markup, name_markup, rule["linter"], fix_markup, key=rule["code"]) class RuleInspector(Markdown): @@ -204,7 +213,8 @@ def on_mount(self) -> None: """Set initial placeholder content.""" self.show_placeholder() - def show_placeholder(self) -> None: + @work(exclusive=True, group="inspector_update") + async def show_placeholder(self) -> None: """Display a placeholder message.""" self.update( "## Selection Details\n\nSelect a configuration key in the tree or a rule " @@ -228,31 +238,56 @@ def show_context(self, path: str, value: Any) -> None: self.update(f"### Configuration Context\n\n**Path**: `{path}`\n\n**Value**: {summary}") - @work + @work(exclusive=True, group="inspector_update") async def fetch_and_display( - self, target: str, is_rule: bool = True, cached_content: str | None = None + self, + target: str, + is_rule: bool = True, + cached_content: str | None = None, + rule_name: str | None = None, + rule_status: str | None = None, ) -> None: """Fetch and display the documentation for a rule or setting. Args: target: The Ruff rule code or configuration path. is_rule: True if fetching a rule, False if fetching a config setting. - cached_content: Optional pre-fetched documentation to avoid subprocess calls. + cached_content: Optional pre-fetched documentation. + rule_name: Optional rule name for display. + rule_status: Optional rule status (Enabled, Ignored, Disabled). """ - if cached_content: - self.update(cached_content.strip()) + if target == "tool.ruff": + self.show_placeholder() return - # Set a loading message - desc = "rule" if is_rule else "config" - self.update(f"## Inspecting {target}...\n\nFetching documentation from `ruff {desc}`...") - - if is_rule: - content = await get_ruff_rule_markdown(target) + content: str | None = None + if cached_content: + content = cached_content else: - content = await get_ruff_config_markdown(target) + # Set a loading message + desc = "rule" if is_rule else "config" + self.update( + f"## Inspecting {target}...\n\nFetching documentation from `ruff {desc}`..." + ) + + if is_rule: + content = await get_ruff_rule_markdown(target) + else: + content = await get_ruff_config_markdown(target) if content: - self.update(content.strip()) + # Prepend header if it's a rule + header = "" + if is_rule: + status_icons = {"Enabled": "🟢", "Ignored": "🟡", "Disabled": "⚪"} + icon = status_icons.get(rule_status or "Disabled", "⚪") + name = rule_name or "Unknown Rule" + header = ( + f"# {icon} {target}: {name}\n\n" + f"**Status**: {rule_status or 'Disabled'}\n\n---\n\n" + ) + + self.update(header + content.strip()) else: + desc = "rule" if is_rule else "config" self.update(f"## Error\n\nCould not fetch documentation for {desc} `{target}`.") diff --git a/tests/test_rule_logic.py b/tests/test_rule_logic.py index 9cc2c85..4dcc7c4 100644 --- a/tests/test_rule_logic.py +++ b/tests/test_rule_logic.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from ruff_sync.system import compute_effective_rules @@ -39,7 +41,7 @@ def test_compute_effective_rules_defaults(): {"code": "E501", "name": "line-too-long", "linter": "pycodestyle"}, {"code": "UP001", "name": "useless-metaclass", "linter": "pyupgrade"}, ] - toml_config = {} # Empty config + toml_config: dict[str, Any] = {} # Empty config enriched = compute_effective_rules(all_rules, toml_config) diff --git a/tests/test_tui.py b/tests/test_tui.py index ae2faac..31630f4 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -8,6 +8,7 @@ from ruff_sync.cli import Arguments from ruff_sync.tui.app import RuffSyncApp +from ruff_sync.tui.widgets import RuleInspector if TYPE_CHECKING: import pathlib @@ -61,7 +62,11 @@ def mock_fail(_: Any) -> Any: # When config loading fails, the app should keep the default (empty) config. assert app.config == {} tree = cast("ConfigTree", app.query_one("#config-tree")) - assert not list(tree.root.children) + # Root only has "Effective Rule Status" node + assert len(tree.root.children) == 1 + label = tree.root.children[0].label + label_text = label.plain if hasattr(label, "plain") else label + assert str(label_text) == "Effective Rule Status" @pytest.mark.asyncio @@ -142,6 +147,14 @@ async def test_ruff_sync_app_rule_selection(mock_args: Arguments, tmp_path: path with patch("ruff_sync.tui.widgets.get_ruff_rule_markdown", return_value=mock_markdown): async with app.run_test() as pilot: + # Wait for the tree to be populated with linter groups + # This happens in the background after _prime_caches finishes + import asyncio + + while len(app.query_one(Tree).root.children) <= 1: + await asyncio.sleep(0.1) + await pilot.pause() + tree = app.query_one(Tree) # Find and select RUF012 node # It's inside tool.ruff -> lint -> select -> RUF012 @@ -166,21 +179,18 @@ async def test_ruff_sync_app_rule_selection(mock_args: Arguments, tmp_path: path for n in select_node.children if str(n.label.plain if hasattr(n.label, "plain") else n.label) == "RUF012" ) + tree.focus() tree.select_node(rule_node) - await pilot.pause() - - inspector = cast("RuleInspector", app.query_one("#inspector")) - assert "hidden" not in inspector.classes - # Wait for background fetch worker - # Since we mocked it to return immediately, it should be fine - # We might need to wait for worker completion if it was truly async + await pilot.press("enter") - # Textual's handle_node_selected calls fetch_and_display which is a @work(thread=True) - # In run_test, we might need a small pause - await pilot.pause(0.1) + inspector = app.query_one("#inspector", RuleInspector) + # Wait for background worker and UI update + for _ in range(20): + await pilot.pause(0.2) + if "RUF012 Documentation" in str(inspector.source): + break # Verify Markdown content (simplified check) - # Textual's Markdown widget has a 'source' property assert "RUF012 Documentation" in str(inspector.source) From bdde1e88143fe8e79c851f633cc02e439b37ec12 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 19:41:40 -0400 Subject: [PATCH 27/36] remove status disabled --- src/ruff_sync/tui/widgets.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/ruff_sync/tui/widgets.py b/src/ruff_sync/tui/widgets.py index 4b6da32..d40c981 100644 --- a/src/ruff_sync/tui/widgets.py +++ b/src/ruff_sync/tui/widgets.py @@ -282,10 +282,7 @@ async def fetch_and_display( status_icons = {"Enabled": "🟢", "Ignored": "🟡", "Disabled": "⚪"} icon = status_icons.get(rule_status or "Disabled", "⚪") name = rule_name or "Unknown Rule" - header = ( - f"# {icon} {target}: {name}\n\n" - f"**Status**: {rule_status or 'Disabled'}\n\n---\n\n" - ) + header = f"# {icon} {target}: {name}\n\n---\n\n" self.update(header + content.strip()) else: From 276a2eeb9c5cef75b4ac9afd29495427c6db44b8 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 19:53:58 -0400 Subject: [PATCH 28/36] add legend --- src/ruff_sync/tui/app.py | 9 +++-- src/ruff_sync/tui/screens.py | 69 +++++++++++++++++++++++++++++++++++- tests/test_tui.py | 17 ++++++++- 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index 30e515f..2491338 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -14,7 +14,7 @@ from ruff_sync.config_io import load_local_ruff_config from ruff_sync.system import compute_effective_rules, get_all_ruff_rules, get_ruff_linters from ruff_sync.tui.constants import RULE_PATTERN -from ruff_sync.tui.screens import OmniboxScreen +from ruff_sync.tui.screens import LegendScreen, OmniboxScreen from ruff_sync.tui.widgets import CategoryTable, ConfigTree, RuleInspector if TYPE_CHECKING: @@ -62,7 +62,8 @@ class RuffSyncApp(App[None]): BINDINGS: ClassVar[list[Any]] = [ ("q", "quit", "Quit"), ("/", "search", "Search Rules"), - ("enter", "select", "View Details"), + ("?", "show_legend", "Show Legend"), + ("l", "show_legend", "Show Legend"), ] def __init__(self, args: Arguments, **kwargs: Any) -> None: @@ -282,3 +283,7 @@ def handle_omnibox_result(self, rule_code: str | None) -> None: """ if rule_code: self._inspect_rule(rule_code) + + def action_show_legend(self) -> None: + """Display the TUI legend modal.""" + self.push_screen(LegendScreen()) diff --git a/src/ruff_sync/tui/screens.py b/src/ruff_sync/tui/screens.py index 021bc3b..9486892 100644 --- a/src/ruff_sync/tui/screens.py +++ b/src/ruff_sync/tui/screens.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, ClassVar, Final from textual import on from textual.containers import Vertical @@ -116,3 +116,70 @@ def handle_option_selected(self, event: OptionList.OptionSelected) -> None: def action_cancel(self) -> None: """Close the screen without selection.""" self.dismiss() + + +class LegendScreen(ModalScreen[None]): + """A toggleable legend explaining TUI status colors and icons.""" + + BINDINGS: ClassVar[list[Any]] = [ + ("escape,l,?", "dismiss", "Close Legend"), + ] + + CSS = """ + LegendScreen { + align: center middle; + background: black 50%; + } + + #legend-container { + width: 50; + height: auto; + background: $boost; + border: heavy $accent; + padding: 1 2; + } + + .legend-title { + text-align: center; + text-style: bold; + margin-bottom: 1; + color: $accent; + } + + .legend-section { + text-style: underline; + margin-top: 1; + margin-bottom: 1; + } + + .legend-row { + margin-left: 2; + } + + #legend-footer { + text-align: center; + margin-top: 1; + color: $text-disabled; + } + """ + + @override + def compose(self) -> ComposeResult: + """Compose the legend content.""" + with Vertical(id="legend-container"): + yield Static("Ruff-Sync TUI Legend", id="legend-title", classes="legend-title") + + yield Static("Rule Status", classes="legend-section") + yield Static( + "🟢 [green]Enabled[/green] - Active in configuration", classes="legend-row" + ) + yield Static("🟡 [yellow]Ignored[/yellow] - Explicitly ignored", classes="legend-row") + yield Static("⚪ [dim]Disabled[/dim] - Category not selected", classes="legend-row") + + yield Static("Fix Availability", classes="legend-section") + yield Static("[cyan]Always[/cyan] - Automatic fix available", classes="legend-row") + yield Static( + "[yellow]Sometimes[/yellow] - Conditional/enforced fix", classes="legend-row" + ) + + yield Static("Press [b]? [/b] or [b]Esc[/b] to close", id="legend-footer") diff --git a/tests/test_tui.py b/tests/test_tui.py index 31630f4..b9f9a8f 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -8,12 +8,13 @@ from ruff_sync.cli import Arguments from ruff_sync.tui.app import RuffSyncApp +from ruff_sync.tui.screens import LegendScreen from ruff_sync.tui.widgets import RuleInspector if TYPE_CHECKING: import pathlib - from ruff_sync.tui.widgets import ConfigTree, RuleInspector + from ruff_sync.tui.widgets import ConfigTree from .conftest import CLIRunner @@ -221,3 +222,17 @@ def test_cli_ruff_inspect_entry_point( exit_code, _out, _err = cli_run(["--to", str(tmp_path)], entry_point="ruff-inspect") assert exit_code == 0 mock_run.assert_called_once() + + +@pytest.mark.asyncio +async def test_ruff_sync_app_show_legend(mock_args: Arguments) -> None: + """The legend screen should be pushed when '?' is pressed.""" + app = RuffSyncApp(mock_args) + async with app.run_test() as pilot: + await pilot.press("?") + await pilot.pause() + assert isinstance(app.screen, LegendScreen) + + await pilot.press("escape") + await pilot.pause() + assert not isinstance(app.screen, LegendScreen) From e8ab77fe786d37db7e637b2912933b2c0ce73777 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 20:10:14 -0400 Subject: [PATCH 29/36] copy-content --- src/ruff_sync/tui/app.py | 10 ++++++++++ tests/test_tui.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index 2491338..0518c3e 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -64,6 +64,7 @@ class RuffSyncApp(App[None]): ("/", "search", "Search Rules"), ("?", "show_legend", "Show Legend"), ("l", "show_legend", "Show Legend"), + ("c", "copy_content", "Copy Content"), ] def __init__(self, args: Arguments, **kwargs: Any) -> None: @@ -287,3 +288,12 @@ def handle_omnibox_result(self, rule_code: str | None) -> None: def action_show_legend(self) -> None: """Display the TUI legend modal.""" self.push_screen(LegendScreen()) + + def action_copy_content(self) -> None: + """Copy the current inspector content to the clipboard.""" + inspector = self.query_one(RuleInspector) + if inspector.source: + self.copy_to_clipboard(str(inspector.source)) + self.notify("Copied content to clipboard", title="Clipboard") + else: + self.notify("No content to copy", severity="warning") diff --git a/tests/test_tui.py b/tests/test_tui.py index b9f9a8f..5b526dd 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -236,3 +236,21 @@ async def test_ruff_sync_app_show_legend(mock_args: Arguments) -> None: await pilot.press("escape") await pilot.pause() assert not isinstance(app.screen, LegendScreen) + + +@pytest.mark.asyncio +async def test_ruff_sync_app_copy_content(mock_args: Arguments) -> None: + """The inspector content should be copied to the clipboard when 'c' is pressed.""" + app = RuffSyncApp(mock_args) + # Mock copy_to_clipboard on the app instance + with patch.object(RuffSyncApp, "copy_to_clipboard") as mock_copy: + async with app.run_test() as pilot: + # Manually update inspector to simulate a selected rule/config + inspector = app.query_one(RuleInspector) + inspector.update("Copied Content Test") + await pilot.pause() + + await pilot.press("c") + await pilot.pause() + + mock_copy.assert_called_once_with("Copied Content Test") From e9d4fb1a525c43e8ead0426715feb319871936d4 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 20:18:30 -0400 Subject: [PATCH 30/36] custom themes --- src/ruff_sync/tui/app.py | 8 +++++ src/ruff_sync/tui/themes.py | 44 +++++++++++++++++++++++++++ tests/test_themes.py | 60 +++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 src/ruff_sync/tui/themes.py create mode 100644 tests/test_themes.py diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index 0518c3e..c8e03d7 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -15,6 +15,7 @@ from ruff_sync.system import compute_effective_rules, get_all_ruff_rules, get_ruff_linters from ruff_sync.tui.constants import RULE_PATTERN from ruff_sync.tui.screens import LegendScreen, OmniboxScreen +from ruff_sync.tui.themes import AMBER_EMBER, MATERIAL_GHOST, RUFF_SYNC_SLATE from ruff_sync.tui.widgets import CategoryTable, ConfigTree, RuleInspector if TYPE_CHECKING: @@ -65,6 +66,7 @@ class RuffSyncApp(App[None]): ("?", "show_legend", "Show Legend"), ("l", "show_legend", "Show Legend"), ("c", "copy_content", "Copy Content"), + ("t", "change_theme", "Theme Picker"), ] def __init__(self, args: Arguments, **kwargs: Any) -> None: @@ -105,6 +107,12 @@ async def on_mount(self) -> None: tree.populate(self.config, has_rules=True) tree.focus() + # Register and set the default theme + self.register_theme(RUFF_SYNC_SLATE) + self.register_theme(AMBER_EMBER) + self.register_theme(MATERIAL_GHOST) + self.theme = "amber-ember" + # Prime the caches in the background self._prime_caches() diff --git a/src/ruff_sync/tui/themes.py b/src/ruff_sync/tui/themes.py new file mode 100644 index 0000000..bef490c --- /dev/null +++ b/src/ruff_sync/tui/themes.py @@ -0,0 +1,44 @@ +"""Custom themes for the ruff-sync TUI.""" + +from __future__ import annotations + +from textual.theme import Theme + +# RUFF_SYNC_SLATE (Recommended) +# Matches the MkDocs Material "Slate/Amber/Deep-Purple" documentation palette. +RUFF_SYNC_SLATE = Theme( + name="ruff-sync-slate", + primary="#FFC107", # Amber 500 + secondary="#9575CD", # Muted Purple + accent="#B388FF", # Lavender + background="#0f172a", # Slate 900 + surface="#1e293b", # Slate 800 + panel="#334155", # Slate 700 + boost="#334155", # Slate 700 +) + +# AMBER_EMBER +# High-contrast, warm dark theme with vibrant gold primary colors. +AMBER_EMBER = Theme( + name="amber-ember", + primary="#FFB300", # Darker, punchy Amber + secondary="#FFD54F", # Lighter Amber + accent="#D81B60", # Material Magenta + background="#121212", # Material Dark + surface="#1E1E1E", + panel="#2C2C2C", + boost="#2C2C2C", +) + +# MATERIAL_GHOST (Light) +# A clean light theme for high-visibility environments. +MATERIAL_GHOST = Theme( + name="material-ghost", + primary="#FFC107", # Amber 500 + secondary="#673AB7", # Deep Purple 500 + accent="#7E57C2", # Material Purple + background="#FAFAFA", + surface="#FFFFFF", + panel="#F5F5F5", + boost="#EEEEEE", +) diff --git a/tests/test_themes.py b/tests/test_themes.py new file mode 100644 index 0000000..845c050 --- /dev/null +++ b/tests/test_themes.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + import pathlib + +import pytest + +from ruff_sync.cli import Arguments +from ruff_sync.tui.app import RuffSyncApp + + +@pytest.fixture +def mock_args(tmp_path: pathlib.Path) -> Arguments: + return Arguments( + command="inspect", + upstream=(), + to=tmp_path, + exclude=(), + verbose=0, + ) + + +@pytest.mark.asyncio +async def test_themes_registered(mock_args: Arguments) -> None: + # Create mock config so on_mount doesn't fail + pyproject = mock_args.to / "pyproject.toml" + pyproject.write_text("[tool.ruff]\n", encoding="utf-8") + + app = RuffSyncApp(mock_args) + async with app.run_test(): + # Check that themes are registered + assert "ruff-sync-slate" in app.available_themes + assert "amber-ember" in app.available_themes + assert "material-ghost" in app.available_themes + + # Check default theme + assert app.theme == "ruff-sync-slate" + + # Check the actual theme object values (smoke test) + # Use cast(Any, ...) for theme attributes as they may be complex + theme = cast("Any", app.get_theme("ruff-sync-slate")) + assert str(theme.primary).upper() == "#FFC107" + assert str(theme.background).upper() == "#0F172A" + + +@pytest.mark.asyncio +async def test_theme_picker_binding(mock_args: Arguments) -> None: + # Create mock config so on_mount doesn't fail + pyproject = mock_args.to / "pyproject.toml" + pyproject.write_text("[tool.ruff]\n", encoding="utf-8") + + app = RuffSyncApp(mock_args) + async with app.run_test(): + # Verify the 't' binding exists + # In Textual, we can check bindings via app.bindings + binding = next((b for b in app.BINDINGS if b[0] == "t"), None) + assert binding is not None + assert binding[1] == "change_theme" From 66e51675b3cc5010a274c584b905bd34fc7a909d Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 20:30:22 -0400 Subject: [PATCH 31/36] adaptive status colors --- src/ruff_sync/tui/themes.py | 12 +++++++++++- src/ruff_sync/tui/widgets.py | 17 ++++++++++------- tests/test_themes.py | 10 +++++----- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/ruff_sync/tui/themes.py b/src/ruff_sync/tui/themes.py index bef490c..533640f 100644 --- a/src/ruff_sync/tui/themes.py +++ b/src/ruff_sync/tui/themes.py @@ -11,6 +11,9 @@ primary="#FFC107", # Amber 500 secondary="#9575CD", # Muted Purple accent="#B388FF", # Lavender + warning="#FFC107", # Amber 500 + error="#F44336", # Red 500 + success="#4CAF50", # Green 500 background="#0f172a", # Slate 900 surface="#1e293b", # Slate 800 panel="#334155", # Slate 700 @@ -24,6 +27,9 @@ primary="#FFB300", # Darker, punchy Amber secondary="#FFD54F", # Lighter Amber accent="#D81B60", # Material Magenta + warning="#FFB300", # Amber 600 + error="#E91E63", # Pink 500 + success="#81C784", # Light Green 300 background="#121212", # Material Dark surface="#1E1E1E", panel="#2C2C2C", @@ -32,11 +38,15 @@ # MATERIAL_GHOST (Light) # A clean light theme for high-visibility environments. +# Optimized status colors for high contrast on white backgrounds. MATERIAL_GHOST = Theme( name="material-ghost", - primary="#FFC107", # Amber 500 + primary="#F57F17", # Yellow 900 (High contrast) secondary="#673AB7", # Deep Purple 500 accent="#7E57C2", # Material Purple + warning="#F57F17", # Yellow 900 (High contrast) + error="#C62828", # Red 800 + success="#2E7D32", # Green 800 (High contrast) background="#FAFAFA", surface="#FFFFFF", panel="#F5F5F5", diff --git a/src/ruff_sync/tui/widgets.py b/src/ruff_sync/tui/widgets.py index d40c981..da45dac 100644 --- a/src/ruff_sync/tui/widgets.py +++ b/src/ruff_sync/tui/widgets.py @@ -184,21 +184,24 @@ def update_rules(self, rules: list[dict[str, Any]]) -> None: # Determine row color based on status color = "" if status == "Enabled": - color = "green" + color = "success" elif status == "Ignored": - color = "yellow" + color = "warning" elif status == "Disabled": - color = "dim" + color = ( + "dim" # Keep dim as it's a standard Rich style that works across backgrounds + ) - code_markup = f"[{color}]{rule['code']}[/{color}]" if color else rule["code"] - name_markup = f"[{color}]{rule['name']}[/{color}]" if color else rule["name"] + # Rich uses [/] to close the nearest open tag + code_markup = f"[{color}]{rule['code']}[/]" if color else rule["code"] + name_markup = f"[{color}]{rule['name']}[/]" if color else rule["name"] fix = rule.get("fix_availability", "None") fix_markup = fix if fix == "Always": - fix_markup = f"[cyan]{fix}[/cyan]" + fix_markup = f"[accent]{fix}[/]" elif fix in ("Sometimes", "Enforced"): - fix_markup = f"[yellow]{fix}[/yellow]" + fix_markup = f"[warning]{fix}[/]" # Note: We still keep linter as-is for clarity self.add_row(code_markup, name_markup, rule["linter"], fix_markup, key=rule["code"]) diff --git a/tests/test_themes.py b/tests/test_themes.py index 845c050..fd45e91 100644 --- a/tests/test_themes.py +++ b/tests/test_themes.py @@ -36,13 +36,13 @@ async def test_themes_registered(mock_args: Arguments) -> None: assert "material-ghost" in app.available_themes # Check default theme - assert app.theme == "ruff-sync-slate" + assert app.theme == "amber-ember" # Check the actual theme object values (smoke test) - # Use cast(Any, ...) for theme attributes as they may be complex - theme = cast("Any", app.get_theme("ruff-sync-slate")) - assert str(theme.primary).upper() == "#FFC107" - assert str(theme.background).upper() == "#0F172A" + # Verify high-contrast success color for Material Ghost + theme = cast("Any", app.get_theme("material-ghost")) + assert str(theme.success).upper() == "#2E7D32" + assert str(theme.background).upper() == "#FAFAFA" @pytest.mark.asyncio From ed7314360452ea2a58135a9c53824c7c53169468 Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 22:10:35 -0400 Subject: [PATCH 32/36] fix test --- tests/test_tui.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/test_tui.py b/tests/test_tui.py index 5b526dd..d151c10 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from typing import TYPE_CHECKING, Any, cast from unittest.mock import patch @@ -148,16 +149,13 @@ async def test_ruff_sync_app_rule_selection(mock_args: Arguments, tmp_path: path with patch("ruff_sync.tui.widgets.get_ruff_rule_markdown", return_value=mock_markdown): async with app.run_test() as pilot: - # Wait for the tree to be populated with linter groups - # This happens in the background after _prime_caches finishes - import asyncio - - while len(app.query_one(Tree).root.children) <= 1: + # Wait for background worker and tree repopulation (priming) + while not app.effective_rules: await asyncio.sleep(0.1) await pilot.pause() tree = app.query_one(Tree) - # Find and select RUF012 node + # Find and select RUF012 node in the now-stable tree # It's inside tool.ruff -> lint -> select -> RUF012 lint_node = next( n @@ -188,11 +186,11 @@ async def test_ruff_sync_app_rule_selection(mock_args: Arguments, tmp_path: path # Wait for background worker and UI update for _ in range(20): await pilot.pause(0.2) - if "RUF012 Documentation" in str(inspector.source): + if "RUF012" in str(inspector.source): break # Verify Markdown content (simplified check) - assert "RUF012 Documentation" in str(inspector.source) + assert "RUF012" in str(inspector.source) def test_cli_inspect_subcommand( From e9d2d52d737278fbf2a6c4ae0a149ea8c6881c0d Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 22:44:43 -0400 Subject: [PATCH 33/36] tui refinements - Implement theme cycling action and verify with tests. - Improve dependency checking and lazy loading for the TUI to provide better error messages. - Fix rule computation to support both wrapped and unwrapped Ruff configurations. - Enhance CLI argument handling and rewriting for the ruff-inspect entry point. --- src/ruff_sync/cli.py | 13 ++----- src/ruff_sync/dependencies.py | 27 +++++++++---- src/ruff_sync/system.py | 6 ++- src/ruff_sync/tui/__init__.py | 23 +++++++++++ src/ruff_sync/tui/app.py | 13 +++++++ tests/test_themes.py | 42 ++++++++++++++++---- tests/test_tui.py | 72 +++++++++++++++++++++++++++++++---- 7 files changed, 164 insertions(+), 32 deletions(-) diff --git a/src/ruff_sync/cli.py b/src/ruff_sync/cli.py index 0fb2bc7..e8696e3 100644 --- a/src/ruff_sync/cli.py +++ b/src/ruff_sync/cli.py @@ -602,17 +602,10 @@ def main() -> int: try: if exec_args.command == "inspect": - from ruff_sync.dependencies import require_dependency # noqa: PLC0415 + from ruff_sync.tui import get_tui_app # noqa: PLC0415 - try: - require_dependency("textual", extra_name="tui") - except ImportError as e: - LOGGER.error(f"❌ {e}") # noqa: TRY400 - return 1 - - from ruff_sync.tui.app import RuffSyncApp # noqa: PLC0415 - - return RuffSyncApp(exec_args).run() or 0 + app_class = get_tui_app() + return app_class(exec_args).run() or 0 if exec_args.command == "check": return asyncio.run(check(exec_args)) diff --git a/src/ruff_sync/dependencies.py b/src/ruff_sync/dependencies.py index 9e1cc83..20ce883 100644 --- a/src/ruff_sync/dependencies.py +++ b/src/ruff_sync/dependencies.py @@ -2,7 +2,9 @@ from __future__ import annotations +import importlib import importlib.util +import sys from typing import Final __all__: Final[list[str]] = ["is_installed", "require_dependency"] @@ -21,18 +23,29 @@ def is_installed(package_name: str) -> bool: def require_dependency(package_name: str, extra_name: str) -> None: - """Ensure a dependency is installed, or raise a helpful ImportError. + """Ensure a dependency is installed and importable, or exit with a helpful message. Args: package_name: The name of the required package. extra_name: The name of the ruff-sync extra that provides this package. Raises: - ImportError: If the package is not installed. + SystemExit: If the package is not installed or raises an error during import. """ + msg = ( + f"The '{package_name}' package is required for this feature. " + f"Install it with: pip install 'ruff-sync[{extra_name}]'" + ) + + # 1. Fast check (dry) to see if it exists at all if not is_installed(package_name): - msg = ( - f"The '{package_name}' package is required for this feature. " - f"Install it with: pip install 'ruff-sync[{extra_name}]'" - ) - raise ImportError(msg) + print(f"❌ {msg}", file=sys.stderr) # noqa: T201 + sys.exit(1) + + # 2. Wet check (real import) to ensure it's functional + try: + importlib.import_module(package_name) + except (ImportError, ModuleNotFoundError): + # If it failed here, it's installed but BROKEN (e.g. missing sub-dependencies) + print(f"❌ {msg}", file=sys.stderr) # noqa: T201 + sys.exit(1) diff --git a/src/ruff_sync/system.py b/src/ruff_sync/system.py index 6049e16..3b57eee 100644 --- a/src/ruff_sync/system.py +++ b/src/ruff_sync/system.py @@ -98,7 +98,11 @@ def compute_effective_rules( Returns: The list of rules enriched with a 'status' key. """ - lint = toml_config.get("tool", {}).get("ruff", {}).get("lint", {}) + # The config may be "wrapped" (top-level 'tool' key) or + # "unwrapped" (direct Ruff config as returned by load_local_ruff_config). + lint = toml_config.get("lint") + if lint is None: + lint = toml_config.get("tool", {}).get("ruff", {}).get("lint", {}) select = set(lint.get("select", [])) | set(lint.get("extend-select", [])) ignore = set(lint.get("ignore", [])) | set(lint.get("extend-ignore", [])) diff --git a/src/ruff_sync/tui/__init__.py b/src/ruff_sync/tui/__init__.py index acf5222..998e7c4 100644 --- a/src/ruff_sync/tui/__init__.py +++ b/src/ruff_sync/tui/__init__.py @@ -1,3 +1,26 @@ """TUI package for ruff-sync.""" from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ruff_sync.tui.app import RuffSyncApp + + +def get_tui_app() -> type[RuffSyncApp]: + """Lazy-load the TUI and return the RuffSyncApp class. + + Returns: + The RuffSyncApp class. + + Raises: + SystemExit: If 'textual' is not installed or the TUI cannot be loaded. + """ + from ruff_sync.dependencies import require_dependency # noqa: PLC0415 + + require_dependency("textual", extra_name="tui") + + from ruff_sync.tui.app import RuffSyncApp # noqa: PLC0415 + + return RuffSyncApp diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index c8e03d7..adbdb91 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -305,3 +305,16 @@ def action_copy_content(self) -> None: self.notify("Copied content to clipboard", title="Clipboard") else: self.notify("No content to copy", severity="warning") + + @override + def action_change_theme(self) -> None: + """Cycle through available themes.""" + themes = list(self.available_themes) + try: + current_index = themes.index(self.theme) + except ValueError: + current_index = 0 + + next_index = (current_index + 1) % len(themes) + self.theme = themes[next_index] + self.notify(f"Theme changed to: {self.theme}", title="Theme Picker") diff --git a/tests/test_themes.py b/tests/test_themes.py index fd45e91..8f8f87e 100644 --- a/tests/test_themes.py +++ b/tests/test_themes.py @@ -46,15 +46,43 @@ async def test_themes_registered(mock_args: Arguments) -> None: @pytest.mark.asyncio -async def test_theme_picker_binding(mock_args: Arguments) -> None: +async def test_theme_picker_cycling(mock_args: Arguments) -> None: + """Test that pressing 't' cycles through all available themes and wraps around.""" # Create mock config so on_mount doesn't fail pyproject = mock_args.to / "pyproject.toml" pyproject.write_text("[tool.ruff]\n", encoding="utf-8") app = RuffSyncApp(mock_args) - async with app.run_test(): - # Verify the 't' binding exists - # In Textual, we can check bindings via app.bindings - binding = next((b for b in app.BINDINGS if b[0] == "t"), None) - assert binding is not None - assert binding[1] == "change_theme" + + async with app.run_test() as pilot: + # Capture the initial theme name and its index in the registered themes. + initial_theme_name = app.theme + assert initial_theme_name in app.available_themes + + initial_index = list(app.available_themes).index(initial_theme_name) + + # Press "t" once and ensure we advanced to the next theme. + await pilot.press("t") + await pilot.pause() + first_theme_name = app.theme + first_index = list(app.available_themes).index(first_theme_name) + + assert first_index == (initial_index + 1) % len(app.available_themes) + + # Press "t" again and ensure we advanced one more step. + await pilot.press("t") + await pilot.pause() + second_theme_name = app.theme + second_index = list(app.available_themes).index(second_theme_name) + + assert second_index == (first_index + 1) % len(app.available_themes) + + # Now press "t" enough times to wrap all the way back to the initial theme. + remaining_presses = (len(app.available_themes) - second_index + initial_index) % len( + app.available_themes + ) + for _ in range(remaining_presses): + await pilot.press("t") + await pilot.pause() + + assert app.theme == initial_theme_name diff --git a/tests/test_tui.py b/tests/test_tui.py index d151c10..ab15f37 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -208,18 +208,63 @@ def test_cli_inspect_subcommand( mock_run.assert_called_once() -def test_cli_ruff_inspect_entry_point( - cli_run: CLIRunner, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +@pytest.mark.parametrize( + "args, expected_command", + [ + ([], "inspect"), + (["--help"], "inspect"), + (["check", "--to", "."], "check"), # Should NOT be rewritten to 'inspect' + (["--to", "."], "inspect"), # Should be rewritten since '--to' is not a command + ], +) +def test_cli_ruff_inspect_entry_point_variations( + args: list[str], + expected_command: str, + cli_run: CLIRunner, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test that the 'ruff-inspect' entry point attempts to run the app.""" + """Test that the 'ruff-inspect' entry point correctly handles and rewrites arguments.""" # Mock load_local_ruff_config where it's used in RuffSyncApp.on_mount monkeypatch.setattr("ruff_sync.tui.app.load_local_ruff_config", lambda _: {}) + # We need to simulate the program name 'ruff-inspect' + # The 'to' arg is added to ensure we have a valid target if needed + final_args = args + if "--to" not in args and "--help" not in args: + final_args = [*args, "--to", str(tmp_path)] + + # 1. Test running (using patched run() to avoid TUI execution) with patch("ruff_sync.tui.app.RuffSyncApp.run", return_value=0) as mock_run: - # Program name 'ruff-inspect' should trigger the inspect logic - exit_code, _out, _err = cli_run(["--to", str(tmp_path)], entry_point="ruff-inspect") - assert exit_code == 0 - mock_run.assert_called_once() + exit_code, _out, _err = cli_run(final_args, entry_point="ruff-inspect") + + # If --help was passed, argparse will exit 0 and not call run() + if "--help" in args: + assert exit_code == 0 + mock_run.assert_not_called() + elif expected_command == "inspect": + assert exit_code == 0 + mock_run.assert_called_once() + else: + # For 'check', asyncio.run(check()) is called, not RuffSyncApp.run() + assert exit_code == 0 + mock_run.assert_not_called() + + # 2. Test instantiation (to verify the command was correctly resolved) + with ( + patch("ruff_sync.tui.app.RuffSyncApp.__init__", return_value=None) as mock_init, + patch("ruff_sync.cli.asyncio.run", return_value=0), + patch("ruff_sync.tui.app.RuffSyncApp.run", return_value=0), + ): + cli_run(final_args, entry_point="ruff-inspect") + + if "--help" not in args: + if expected_command == "inspect": + mock_init.assert_called_once() + exec_args = mock_init.call_args[0][0] + assert exec_args.command == "inspect" + else: + mock_init.assert_not_called() @pytest.mark.asyncio @@ -252,3 +297,16 @@ async def test_ruff_sync_app_copy_content(mock_args: Arguments) -> None: await pilot.pause() mock_copy.assert_called_once_with("Copied Content Test") + + +def test_require_dependency_exit(monkeypatch: pytest.MonkeyPatch) -> None: + """Test that require_dependency exits with code 1 if the package is missing.""" + from ruff_sync import dependencies as deps + + # Mock is_installed to return False + monkeypatch.setattr(deps, "is_installed", lambda _: False) + + with pytest.raises(SystemExit) as excinfo: + deps.require_dependency("nonexistent", "extra") + + assert excinfo.value.code == 1 From e55533bc4749f215b10035459ddc3d7590e3de9f Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 22:58:23 -0400 Subject: [PATCH 34/36] refactor dependency error handling - Use a custom `DependencyError` instead of direct `sys.exit` calls in utility functions. - Centralize error logging and exit code handling in the CLI entry point. - Improve testability of dependency checks through dependency injection. --- src/ruff_sync/cli.py | 4 +++ src/ruff_sync/dependencies.py | 39 ++++++++++++++------- src/ruff_sync/tui/__init__.py | 2 +- tests/test_dependencies.py | 66 ++++++++++++++++++++++++----------- tests/test_tui.py | 13 ------- 5 files changed, 76 insertions(+), 48 deletions(-) diff --git a/src/ruff_sync/cli.py b/src/ruff_sync/cli.py index e8696e3..d95a827 100644 --- a/src/ruff_sync/cli.py +++ b/src/ruff_sync/cli.py @@ -42,6 +42,7 @@ pull, resolve_raw_url, ) +from ruff_sync.dependencies import DependencyError if TYPE_CHECKING: from collections.abc import Iterable, Mapping @@ -610,6 +611,9 @@ def main() -> int: if exec_args.command == "check": return asyncio.run(check(exec_args)) return asyncio.run(pull(exec_args)) + except DependencyError as e: + LOGGER.error(f"❌ {e}") # noqa: TRY400 + return 1 except UpstreamError as e: for url, err in e.errors: LOGGER.error(f"❌ Failed to fetch {url}: {err}") # noqa: TRY400 diff --git a/src/ruff_sync/dependencies.py b/src/ruff_sync/dependencies.py index 20ce883..b6dd4d8 100644 --- a/src/ruff_sync/dependencies.py +++ b/src/ruff_sync/dependencies.py @@ -4,10 +4,17 @@ import importlib import importlib.util -import sys -from typing import Final +from typing import TYPE_CHECKING, Final -__all__: Final[list[str]] = ["is_installed", "require_dependency"] +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any + +__all__: Final[list[str]] = ["DependencyError", "is_installed", "require_dependency"] + + +class DependencyError(Exception): + """Raised when a required optional dependency is missing or broken.""" def is_installed(package_name: str) -> bool: @@ -22,15 +29,23 @@ def is_installed(package_name: str) -> bool: return importlib.util.find_spec(package_name) is not None -def require_dependency(package_name: str, extra_name: str) -> None: - """Ensure a dependency is installed and importable, or exit with a helpful message. +def require_dependency( + package_name: str, + extra_name: str, + *, + _is_installed: Callable[[str], bool] = is_installed, + _import_module: Callable[[str], Any] = importlib.import_module, +) -> None: + """Ensure a dependency is installed and importable, or raise DependencyError. Args: package_name: The name of the required package. extra_name: The name of the ruff-sync extra that provides this package. + _is_installed: Internal use only for testing. Function to check if a package is installed. + _import_module: Internal use only for testing. Function to import a module. Raises: - SystemExit: If the package is not installed or raises an error during import. + DependencyError: If the package is not installed or raises an error during import. """ msg = ( f"The '{package_name}' package is required for this feature. " @@ -38,14 +53,12 @@ def require_dependency(package_name: str, extra_name: str) -> None: ) # 1. Fast check (dry) to see if it exists at all - if not is_installed(package_name): - print(f"❌ {msg}", file=sys.stderr) # noqa: T201 - sys.exit(1) + if not _is_installed(package_name): + raise DependencyError(msg) # 2. Wet check (real import) to ensure it's functional try: - importlib.import_module(package_name) - except (ImportError, ModuleNotFoundError): + _import_module(package_name) + except (ImportError, ModuleNotFoundError) as e: # If it failed here, it's installed but BROKEN (e.g. missing sub-dependencies) - print(f"❌ {msg}", file=sys.stderr) # noqa: T201 - sys.exit(1) + raise DependencyError(msg) from e diff --git a/src/ruff_sync/tui/__init__.py b/src/ruff_sync/tui/__init__.py index 998e7c4..015dbe3 100644 --- a/src/ruff_sync/tui/__init__.py +++ b/src/ruff_sync/tui/__init__.py @@ -15,7 +15,7 @@ def get_tui_app() -> type[RuffSyncApp]: The RuffSyncApp class. Raises: - SystemExit: If 'textual' is not installed or the TUI cannot be loaded. + DependencyError: If 'textual' is not installed or the TUI cannot be loaded. """ from ruff_sync.dependencies import require_dependency # noqa: PLC0415 diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 68432af..827d474 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -1,35 +1,59 @@ -"""Tests for the dependencies module.""" - from __future__ import annotations -from unittest.mock import patch +from typing import Any import pytest -from ruff_sync.dependencies import is_installed, require_dependency +from ruff_sync.dependencies import DependencyError, is_installed, require_dependency def test_is_installed_true() -> None: - with patch("importlib.util.find_spec", return_value=True): - assert is_installed("some_package") is True + # 'sys' is always installed + assert is_installed("sys") is True def test_is_installed_false() -> None: - with patch("importlib.util.find_spec", return_value=None): - assert is_installed("nonexistent_package") is False + assert is_installed("nonexistent_package_12345") is False def test_require_dependency_success() -> None: - with patch("ruff_sync.dependencies.is_installed", return_value=True): - # Should not raise - require_dependency("some_package", "some_extra") - - -def test_require_dependency_failure() -> None: - with patch("ruff_sync.dependencies.is_installed", return_value=False): - with pytest.raises(ImportError) as exc_info: - require_dependency("textual", "tui") - - msg = str(exc_info.value) - assert "textual" in msg - assert "pip install 'ruff-sync[tui]'" in msg + # No error should be raised when both check and import succeed + require_dependency( + "some_package", + "some_extra", + _is_installed=lambda _: True, + _import_module=lambda _: None, + ) + + +def test_require_dependency_not_installed() -> None: + # Should raise DependencyError if not installed + with pytest.raises(DependencyError) as exc_info: + require_dependency( + "textual", + "tui", + _is_installed=lambda _: False, + ) + + msg = str(exc_info.value) + assert "textual" in msg + assert "pip install 'ruff-sync[tui]'" in msg + + +def test_require_dependency_broken_import() -> None: + # Should raise DependencyError if installed but import fails (broken) + def broken_import(_name: str) -> Any: + msg = "Something went wrong during import" + raise ImportError(msg) + + with pytest.raises(DependencyError) as exc_info: + require_dependency( + "broken_pkg", + "broken_extra", + _is_installed=lambda _: True, + _import_module=broken_import, + ) + + msg = str(exc_info.value) + assert "broken_pkg" in msg + assert "pip install 'ruff-sync[broken_extra]'" in msg diff --git a/tests/test_tui.py b/tests/test_tui.py index ab15f37..cad0758 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -297,16 +297,3 @@ async def test_ruff_sync_app_copy_content(mock_args: Arguments) -> None: await pilot.pause() mock_copy.assert_called_once_with("Copied Content Test") - - -def test_require_dependency_exit(monkeypatch: pytest.MonkeyPatch) -> None: - """Test that require_dependency exits with code 1 if the package is missing.""" - from ruff_sync import dependencies as deps - - # Mock is_installed to return False - monkeypatch.setattr(deps, "is_installed", lambda _: False) - - with pytest.raises(SystemExit) as excinfo: - deps.require_dependency("nonexistent", "extra") - - assert excinfo.value.code == 1 From 0a59823d9053cd026b052463c3a238eaea6acb8a Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 23:04:34 -0400 Subject: [PATCH 35/36] remove extra themes --- src/ruff_sync/tui/app.py | 18 +----------------- src/ruff_sync/tui/themes.py | 33 --------------------------------- 2 files changed, 1 insertion(+), 50 deletions(-) diff --git a/src/ruff_sync/tui/app.py b/src/ruff_sync/tui/app.py index adbdb91..850e685 100644 --- a/src/ruff_sync/tui/app.py +++ b/src/ruff_sync/tui/app.py @@ -15,7 +15,7 @@ from ruff_sync.system import compute_effective_rules, get_all_ruff_rules, get_ruff_linters from ruff_sync.tui.constants import RULE_PATTERN from ruff_sync.tui.screens import LegendScreen, OmniboxScreen -from ruff_sync.tui.themes import AMBER_EMBER, MATERIAL_GHOST, RUFF_SYNC_SLATE +from ruff_sync.tui.themes import AMBER_EMBER from ruff_sync.tui.widgets import CategoryTable, ConfigTree, RuleInspector if TYPE_CHECKING: @@ -66,7 +66,6 @@ class RuffSyncApp(App[None]): ("?", "show_legend", "Show Legend"), ("l", "show_legend", "Show Legend"), ("c", "copy_content", "Copy Content"), - ("t", "change_theme", "Theme Picker"), ] def __init__(self, args: Arguments, **kwargs: Any) -> None: @@ -108,9 +107,7 @@ async def on_mount(self) -> None: tree.focus() # Register and set the default theme - self.register_theme(RUFF_SYNC_SLATE) self.register_theme(AMBER_EMBER) - self.register_theme(MATERIAL_GHOST) self.theme = "amber-ember" # Prime the caches in the background @@ -305,16 +302,3 @@ def action_copy_content(self) -> None: self.notify("Copied content to clipboard", title="Clipboard") else: self.notify("No content to copy", severity="warning") - - @override - def action_change_theme(self) -> None: - """Cycle through available themes.""" - themes = list(self.available_themes) - try: - current_index = themes.index(self.theme) - except ValueError: - current_index = 0 - - next_index = (current_index + 1) % len(themes) - self.theme = themes[next_index] - self.notify(f"Theme changed to: {self.theme}", title="Theme Picker") diff --git a/src/ruff_sync/tui/themes.py b/src/ruff_sync/tui/themes.py index 533640f..80a8ea8 100644 --- a/src/ruff_sync/tui/themes.py +++ b/src/ruff_sync/tui/themes.py @@ -4,22 +4,6 @@ from textual.theme import Theme -# RUFF_SYNC_SLATE (Recommended) -# Matches the MkDocs Material "Slate/Amber/Deep-Purple" documentation palette. -RUFF_SYNC_SLATE = Theme( - name="ruff-sync-slate", - primary="#FFC107", # Amber 500 - secondary="#9575CD", # Muted Purple - accent="#B388FF", # Lavender - warning="#FFC107", # Amber 500 - error="#F44336", # Red 500 - success="#4CAF50", # Green 500 - background="#0f172a", # Slate 900 - surface="#1e293b", # Slate 800 - panel="#334155", # Slate 700 - boost="#334155", # Slate 700 -) - # AMBER_EMBER # High-contrast, warm dark theme with vibrant gold primary colors. AMBER_EMBER = Theme( @@ -35,20 +19,3 @@ panel="#2C2C2C", boost="#2C2C2C", ) - -# MATERIAL_GHOST (Light) -# A clean light theme for high-visibility environments. -# Optimized status colors for high contrast on white backgrounds. -MATERIAL_GHOST = Theme( - name="material-ghost", - primary="#F57F17", # Yellow 900 (High contrast) - secondary="#673AB7", # Deep Purple 500 - accent="#7E57C2", # Material Purple - warning="#F57F17", # Yellow 900 (High contrast) - error="#C62828", # Red 800 - success="#2E7D32", # Green 800 (High contrast) - background="#FAFAFA", - surface="#FFFFFF", - panel="#F5F5F5", - boost="#EEEEEE", -) From 546914914961c35944fe3d69481cbb8c2531d27d Mon Sep 17 00:00:00 2001 From: Gabriel G Date: Sat, 4 Apr 2026 23:05:55 -0400 Subject: [PATCH 36/36] fix tests --- tests/test_themes.py | 60 ++++++-------------------------------------- 1 file changed, 8 insertions(+), 52 deletions(-) diff --git a/tests/test_themes.py b/tests/test_themes.py index 8f8f87e..c6ae6c6 100644 --- a/tests/test_themes.py +++ b/tests/test_themes.py @@ -30,59 +30,15 @@ async def test_themes_registered(mock_args: Arguments) -> None: app = RuffSyncApp(mock_args) async with app.run_test(): - # Check that themes are registered - assert "ruff-sync-slate" in app.available_themes + # Check that 'amber-ember' is registered and active assert "amber-ember" in app.available_themes - assert "material-ghost" in app.available_themes - - # Check default theme assert app.theme == "amber-ember" - # Check the actual theme object values (smoke test) - # Verify high-contrast success color for Material Ghost - theme = cast("Any", app.get_theme("material-ghost")) - assert str(theme.success).upper() == "#2E7D32" - assert str(theme.background).upper() == "#FAFAFA" - - -@pytest.mark.asyncio -async def test_theme_picker_cycling(mock_args: Arguments) -> None: - """Test that pressing 't' cycles through all available themes and wraps around.""" - # Create mock config so on_mount doesn't fail - pyproject = mock_args.to / "pyproject.toml" - pyproject.write_text("[tool.ruff]\n", encoding="utf-8") - - app = RuffSyncApp(mock_args) - - async with app.run_test() as pilot: - # Capture the initial theme name and its index in the registered themes. - initial_theme_name = app.theme - assert initial_theme_name in app.available_themes - - initial_index = list(app.available_themes).index(initial_theme_name) - - # Press "t" once and ensure we advanced to the next theme. - await pilot.press("t") - await pilot.pause() - first_theme_name = app.theme - first_index = list(app.available_themes).index(first_theme_name) - - assert first_index == (initial_index + 1) % len(app.available_themes) + # Check that removed custom themes are NOT there + assert "ruff-sync-slate" not in app.available_themes + assert "material-ghost" not in app.available_themes - # Press "t" again and ensure we advanced one more step. - await pilot.press("t") - await pilot.pause() - second_theme_name = app.theme - second_index = list(app.available_themes).index(second_theme_name) - - assert second_index == (first_index + 1) % len(app.available_themes) - - # Now press "t" enough times to wrap all the way back to the initial theme. - remaining_presses = (len(app.available_themes) - second_index + initial_index) % len( - app.available_themes - ) - for _ in range(remaining_presses): - await pilot.press("t") - await pilot.pause() - - assert app.theme == initial_theme_name + # Check the actual theme object values (smoke test) + theme = cast("Any", app.get_theme("amber-ember")) + assert str(theme.primary).upper() == "#FFB300" + assert str(theme.background).upper() == "#121212"