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

1"""Modal dialog for configuring a web crawl.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6from typing import ClassVar 

7 

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 

13 

14from lilbee.cli.tui import messages as msg 

15from lilbee.core.config import cfg 

16from lilbee.core.config.enums import CrawlRenderMode 

17 

18 

19@dataclass(frozen=True) 

20class CrawlParams: 

21 """Validated crawl parameters returned by CrawlDialog. 

22 

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 """ 

28 

29 url: str 

30 depth: int | None 

31 max_pages: int 

32 render_mode: CrawlRenderMode 

33 

34 

35class CrawlDialog(ModalScreen[CrawlParams | None]): 

36 """Modal dialog that collects URL, recursion toggle, and optional caps.""" 

37 

38 CSS_PATH = "crawl_dialog.tcss" 

39 AUTO_FOCUS = "#crawl-url-input" 

40 

41 BINDINGS: ClassVar[list[BindingType]] = [ 

42 Binding("escape", "cancel", "Cancel", show=False), 

43 ] 

44 

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") 

88 

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) 

94 

95 def on_input_submitted(self, _event: Input.Submitted) -> None: 

96 self._try_submit() 

97 

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. 

101 

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 

112 

113 @staticmethod 

114 def _parse_max_pages(value: str) -> int: 

115 """Parse the max-pages field. Empty means unlimited (the user cleared it). 

116 

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 

121 

122 if not value: 

123 return CRAWL_PAGES_UNLIMITED 

124 n = int(value) 

125 if n <= 0: 

126 raise ValueError 

127 return n 

128 

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 

133 

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() 

139 

140 render_mode = CrawlRenderMode.BROWSER if use_browser else CrawlRenderMode.HTTP 

141 

142 if not url: 

143 return msg.CRAWL_DIALOG_URL_REQUIRED 

144 

145 if not is_url(url): 

146 url = f"https://{url}" 

147 

148 try: 

149 require_valid_crawl_url(url) 

150 except ValueError as exc: 

151 return msg.CRAWL_DIALOG_INVALID_URL.format(error=exc) 

152 

153 if not recursive: 

154 return CrawlParams( 

155 url=url, depth=0, max_pages=CRAWL_PAGES_UNLIMITED, render_mode=render_mode 

156 ) 

157 

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) 

163 

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) 

168 

169 return CrawlParams(url=url, depth=depth, max_pages=max_pages, render_mode=render_mode) 

170 

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) 

181 

182 def action_cancel(self) -> None: 

183 self.dismiss(None)