Coverage for src / lilbee / cli / tui / widgets / crawl_dialog.py: 100%
97 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +0000
1"""Modal dialog for configuring a web crawl."""
3from __future__ import annotations
5from dataclasses import dataclass
6from typing import ClassVar
8from textual.app import ComposeResult
9from textual.binding import Binding, BindingType
10from textual.containers import Center, VerticalScroll
11from textual.screen import ModalScreen
12from textual.widgets import Button, Checkbox, Input, Label, Static
14from lilbee.cli.tui import messages as msg
15from lilbee.core.config import cfg
16from lilbee.core.config.enums import CrawlRenderMode
19@dataclass(frozen=True)
20class CrawlParams:
21 """Validated crawl parameters returned by CrawlDialog.
23 depth: None = whole-site unbounded. 0 = single URL only. Positive int =
24 explicit link-follow depth cap. max_pages: CRAWL_PAGES_UNLIMITED (0) = no
25 limit (the user cleared the field); positive int = explicit page cap.
26 render_mode: http (browserless) or browser (Chromium with JavaScript).
27 """
29 url: str
30 depth: int | None
31 max_pages: int
32 render_mode: CrawlRenderMode
35class CrawlDialog(ModalScreen[CrawlParams | None]):
36 """Modal dialog that collects URL, recursion toggle, and optional caps."""
38 CSS_PATH = "crawl_dialog.tcss"
39 AUTO_FOCUS = "#crawl-url-input"
41 BINDINGS: ClassVar[list[BindingType]] = [
42 Binding("escape", "cancel", "Cancel", show=False),
43 ]
45 def compose(self) -> ComposeResult:
46 # Scrollable body so the full form (incl. depth + the action buttons)
47 # stays reachable on short terminals rather than clipping at max-height.
48 with VerticalScroll():
49 yield Static(msg.CRAWL_DIALOG_TITLE, id="crawl-title")
50 yield Label(msg.CRAWL_DIALOG_URL_LABEL)
51 yield Input(
52 placeholder=msg.CRAWL_DIALOG_URL_PLACEHOLDER,
53 id="crawl-url-input",
54 )
55 yield Checkbox(
56 msg.CRAWL_DIALOG_RECURSIVE_LABEL,
57 value=True,
58 id="crawl-recursive-checkbox",
59 )
60 # Defaults to the persisted crawl_render_mode; toggling it sticks
61 # (the chat screen writes the choice back when the crawl starts).
62 yield Checkbox(
63 msg.CRAWL_DIALOG_BROWSER_LABEL,
64 value=cfg.crawl_render_mode is CrawlRenderMode.BROWSER,
65 id="crawl-browser-checkbox",
66 )
67 # Max pages is the cap users actually reach for, so it sits at the top
68 # level (not behind Advanced) prefilled with the protective default;
69 # clearing it crawls unlimited without a trip to settings.
70 yield Label(msg.CRAWL_DIALOG_MAX_PAGES_LABEL, classes="crawl-field-label")
71 yield Input(
72 value=str(cfg.crawl_safety_max_pages),
73 placeholder=msg.CRAWL_DIALOG_MAX_PAGES_PLACEHOLDER,
74 id="crawl-max-pages-input",
75 )
76 # Depth sits at the top level (not behind a collapsible) so it is
77 # discoverable and always reachable; it only applies to recursive
78 # crawls and is ignored when Recursive is unchecked.
79 yield Label(msg.CRAWL_DIALOG_DEPTH_LABEL, classes="crawl-field-label")
80 yield Input(
81 placeholder=msg.CRAWL_DIALOG_DEPTH_PLACEHOLDER,
82 id="crawl-depth-input",
83 )
84 yield Static("", id="crawl-error")
85 with Center():
86 yield Button(msg.CRAWL_DIALOG_SUBMIT, variant="primary", id="crawl-submit")
87 yield Button(msg.CRAWL_DIALOG_CANCEL, variant="default", id="crawl-cancel")
89 def on_button_pressed(self, event: Button.Pressed) -> None:
90 if event.button.id == "crawl-submit":
91 self._try_submit()
92 else:
93 self.dismiss(None)
95 def on_input_submitted(self, _event: Input.Submitted) -> None:
96 self._try_submit()
98 @staticmethod
99 def _parse_optional_non_negative_int(value: str) -> int | None:
100 """Parse a non-negative integer from *value*; empty string returns None.
102 None means "no cap" in the crawl API. Zero is meaningful for the
103 depth field (single-URL crawl per the crawler contract). Raises
104 ValueError on non-numeric input or negative integers.
105 """
106 if not value:
107 return None
108 n = int(value)
109 if n < 0:
110 raise ValueError
111 return n
113 @staticmethod
114 def _parse_max_pages(value: str) -> int:
115 """Parse the max-pages field. Empty means unlimited (the user cleared it).
117 Returns ``CRAWL_PAGES_UNLIMITED`` (0) for empty input, a positive int for
118 an explicit cap. Raises ValueError on non-numeric or non-positive input.
119 """
120 from lilbee.crawler.models import CRAWL_PAGES_UNLIMITED
122 if not value:
123 return CRAWL_PAGES_UNLIMITED
124 n = int(value)
125 if n <= 0:
126 raise ValueError
127 return n
129 def _validate(self) -> CrawlParams | str:
130 """Validate inputs. Returns CrawlParams on success, error message on failure."""
131 from lilbee.crawler import is_url, require_valid_crawl_url
132 from lilbee.crawler.models import CRAWL_PAGES_UNLIMITED
134 url = self.query_one("#crawl-url-input", Input).value.strip()
135 recursive = self.query_one("#crawl-recursive-checkbox", Checkbox).value
136 use_browser = self.query_one("#crawl-browser-checkbox", Checkbox).value
137 depth_str = self.query_one("#crawl-depth-input", Input).value.strip()
138 max_pages_str = self.query_one("#crawl-max-pages-input", Input).value.strip()
140 render_mode = CrawlRenderMode.BROWSER if use_browser else CrawlRenderMode.HTTP
142 if not url:
143 return msg.CRAWL_DIALOG_URL_REQUIRED
145 if not is_url(url):
146 url = f"https://{url}"
148 try:
149 require_valid_crawl_url(url)
150 except ValueError as exc:
151 return msg.CRAWL_DIALOG_INVALID_URL.format(error=exc)
153 if not recursive:
154 return CrawlParams(
155 url=url, depth=0, max_pages=CRAWL_PAGES_UNLIMITED, render_mode=render_mode
156 )
158 try:
159 # depth=0 means "single URL" per the crawler contract; allow it.
160 depth = self._parse_optional_non_negative_int(depth_str)
161 except ValueError:
162 return msg.CRAWL_DIALOG_INVALID_NUMBER.format(field=msg.CRAWL_DIALOG_DEPTH_LABEL)
164 try:
165 max_pages = self._parse_max_pages(max_pages_str)
166 except ValueError:
167 return msg.CRAWL_DIALOG_INVALID_NUMBER.format(field=msg.CRAWL_DIALOG_MAX_PAGES_LABEL)
169 return CrawlParams(url=url, depth=depth, max_pages=max_pages, render_mode=render_mode)
171 def _try_submit(self) -> None:
172 """Validate inputs and dismiss with CrawlParams or show an error."""
173 result = self._validate()
174 error_widget = self.query_one("#crawl-error", Static)
175 # _validate returns str (error) or CrawlParams; isinstance disambiguates
176 if isinstance(result, str):
177 error_widget.update(result)
178 return
179 error_widget.update("")
180 self.dismiss(result)
182 def action_cancel(self) -> None:
183 self.dismiss(None)