Coverage for src/secchi/ui/app.py: 33%

300 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-04 22:15 +0000

1"""Main Textual App — single-screen package-intelligence dashboard.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import logging 

7from datetime import datetime, timezone 

8from pathlib import Path 

9from typing import ClassVar 

10 

11from textual import on 

12from textual.app import App, ComposeResult 

13from textual.binding import Binding 

14from textual.containers import Container, Horizontal 

15 

16from secchi import derived as derive 

17from secchi.export import save_report 

18from secchi.models import ( 

19 DerivedPackageData, 

20 FetchError, 

21 PackageInfo, 

22 PackageRef, 

23 Project, 

24) 

25from secchi.renderers.reports import ( 

26 build_project_report, 

27 render_project_report, 

28 render_report, 

29) 

30from secchi.services.intelligence import ( 

31 IntelligenceResult, 

32 PackageIntelligenceService, 

33 SignalWarning, 

34) 

35from secchi.spotlight import fetch_spotlight, spotlight_disabled 

36from secchi.trending import fetch_trending, load_cached_trending, save_cached_trending 

37from secchi.ui.widgets.detail import DetailView 

38from secchi.ui.widgets.header_bar import SecchiHeader 

39from secchi.ui.widgets.modals import ExportScreen, HelpScreen, SearchScreen 

40from secchi.ui.widgets.sidebar import Sidebar 

41from secchi.ui.widgets.status_bar import SecchiFooter 

42from secchi.workspace import ( 

43 WorkspaceState, 

44 combine_install_breakdown, 

45 combine_package_infos, 

46 logical_package_refs, 

47 package_key, 

48) 

49 

50logger = logging.getLogger(__name__) 

51 

52 

53class Secchi(App[None]): 

54 """Single-screen dashboard — sidebar + rich per-package detail view.""" 

55 

56 CSS_PATH = "styles/dashboard.tcss" 

57 TITLE = "secchi" 

58 

59 BINDINGS: ClassVar[list[Binding]] = [ 

60 Binding("r", "refresh", "Refresh", priority=True), 

61 Binding("e", "export", "Export"), 

62 Binding("slash", "search", "Search"), 

63 Binding("question_mark", "help", "Help"), 

64 Binding("f", "toggle_filter", "Filter"), 

65 Binding("q", "quit", "Quit"), 

66 Binding("ctrl+c", "quit", "Quit", show=False), 

67 ] 

68 

69 def __init__( 

70 self, 

71 project: Project, 

72 config_path: Path, 

73 force_refresh: bool = False, 

74 workspace: list[Project] | None = None, 

75 intelligence: PackageIntelligenceService | None = None, 

76 ) -> None: 

77 super().__init__() 

78 self._project = project 

79 self._workspace = workspace or [] 

80 self._config_path = config_path 

81 self._force_refresh = force_refresh 

82 self._package_data: dict[str, PackageInfo] = {} 

83 self._package_errors: dict[str, FetchError] = {} 

84 self._package_warnings: dict[str, list[SignalWarning]] = {} 

85 self._derived_data: dict[str, DerivedPackageData] = {} 

86 self._refreshed_at: datetime | None = None 

87 self._workspace_state = WorkspaceState() 

88 self._render_in_progress = False 

89 self._render_again = False 

90 self._intelligence = intelligence or PackageIntelligenceService() 

91 

92 @property 

93 def project(self) -> Project: 

94 return self._project 

95 

96 @property 

97 def visible_packages(self) -> list[PackageRef]: 

98 if self._workspace: 

99 return list(self._project.packages) 

100 return logical_package_refs(self._project.packages) 

101 

102 @property 

103 def workspace_projects(self) -> list[Project]: 

104 return self._workspace 

105 

106 @property 

107 def refreshed_at(self) -> datetime | None: 

108 return self._refreshed_at 

109 

110 @property 

111 def package_data(self) -> dict[str, PackageInfo]: 

112 return self._package_data 

113 

114 def get_package_info(self, ref: PackageRef) -> PackageInfo | None: 

115 refs = self._matching_refs(ref) 

116 infos = [ 

117 self._package_data[package_key(r)] 

118 for r in refs 

119 if package_key(r) in self._package_data 

120 ] 

121 if len(infos) <= 1: 

122 return infos[0] if infos else None 

123 return combine_package_infos(ref, infos) 

124 

125 def get_package_error(self, ref: PackageRef) -> FetchError | None: 

126 for r in self._matching_refs(ref): 

127 error = self._package_errors.get(package_key(r)) 

128 if error: 

129 return error 

130 return None 

131 

132 def get_package_warnings(self, ref: PackageRef) -> list[SignalWarning]: 

133 warnings: list[SignalWarning] = [] 

134 for item in self._matching_refs(ref): 

135 warnings.extend(self._package_warnings.get(package_key(item), [])) 

136 return warnings 

137 

138 def get_derived(self, ref: PackageRef) -> DerivedPackageData | None: 

139 refs = self._matching_refs(ref) 

140 derived_items = [ 

141 self._derived_data[package_key(r)] 

142 for r in refs 

143 if package_key(r) in self._derived_data 

144 ] 

145 if not derived_items: 

146 return None 

147 if len(derived_items) == 1: 

148 return derived_items[0] 

149 

150 info = self.get_package_info(ref) 

151 if info is None: 

152 return None 

153 combined = derive.compute_all(info) 

154 combined.install_breakdown = combine_install_breakdown( 

155 [ 

156 self._package_data[package_key(r)] 

157 for r in refs 

158 if package_key(r) in self._package_data 

159 ] 

160 ) 

161 return combined 

162 

163 def _matching_refs(self, ref: PackageRef) -> list[PackageRef]: 

164 target = ref.name.lower() 

165 refs = [ 

166 r 

167 for r in self._project.packages 

168 if r.name.lower() == target 

169 and (not ref.project_name or r.project_name == ref.project_name) 

170 ] 

171 return refs or [ref] 

172 

173 def compose(self) -> ComposeResult: 

174 yield SecchiHeader() 

175 yield Horizontal( 

176 Sidebar(), 

177 Container(id="main-content"), 

178 ) 

179 yield SecchiFooter(self._config_path) 

180 

181 def on_mount(self) -> None: 

182 self.theme = "textual-dark" 

183 self._select_default_package(render=False) 

184 self._start_spotlight_fetch() 

185 self._start_trending_fetch() 

186 self._start_data_fetch(force=self._force_refresh) 

187 

188 # ── actions ── 

189 

190 def action_refresh(self) -> None: 

191 selected_ref = self._workspace_state.selected_ref 

192 project_name = selected_ref.project_name if selected_ref else None 

193 self._start_data_fetch(force=True, project_name=project_name) 

194 

195 def action_toggle_filter(self) -> None: 

196 self.query_one(Sidebar).toggle_favorites_filter() 

197 

198 def action_search(self) -> None: 

199 def _on_result(ref: PackageRef | None) -> None: 

200 if ref is not None: 

201 self.navigate_to_package(ref) 

202 

203 self.push_screen(SearchScreen(self._project), _on_result) 

204 

205 def action_help(self) -> None: 

206 self.push_screen(HelpScreen()) 

207 

208 def action_export(self) -> None: 

209 if self._workspace_state.selected_ref is None: 

210 self.notify("No package selected.", severity="warning") 

211 return 

212 

213 project_scope = bool(self._workspace or len(self._project.packages) > 1) 

214 

215 def _on_export(format_id: str | None) -> None: 

216 if format_id is None: 

217 return 

218 ref = self._workspace_state.selected_ref 

219 if ref is None: 

220 return 

221 format_name = format_id 

222 if project_scope: 

223 project = next( 

224 (item for item in self._workspace if item.name == ref.project_name), 

225 self._project, 

226 ) 

227 results = { 

228 package_key(source_ref): IntelligenceResult( 

229 ref=source_ref, 

230 info=self._package_data.get(package_key(source_ref)), 

231 derived=self._derived_data.get(package_key(source_ref)), 

232 warnings=self._package_warnings.get( 

233 package_key(source_ref), [] 

234 ), 

235 error=self._package_errors.get(package_key(source_ref)), 

236 ) 

237 for source_ref in project.packages 

238 } 

239 report = build_project_report(project, results) 

240 content = render_project_report(format_name, report) 

241 subject = project.title or project.name 

242 project_name = project.name 

243 else: 

244 info = self.get_package_info(ref) 

245 derived = self.get_derived(ref) 

246 if info is None or derived is None: 

247 self.notify("No data loaded yet.", severity="warning") 

248 return 

249 content = render_report( 

250 format_name, info, derived, ref, self._project.name 

251 ) 

252 subject = ref.name 

253 project_name = self._project.name 

254 path = save_report(content, project_name, subject, format_name) 

255 self.notify(f"Exported to {path.name}", title="Export") 

256 

257 self.push_screen(ExportScreen(project_scope=project_scope), _on_export) 

258 

259 def _start_spotlight_fetch(self) -> None: 

260 if spotlight_disabled(): 

261 try: 

262 self.query_one(Sidebar).set_spotlight(None) 

263 except Exception: 

264 # The sidebar may not be mounted during app startup/shutdown. 

265 logger.debug("Unable to clear the spotlight card", exc_info=True) 

266 return 

267 self.run_worker(self._fetch_spotlight(), exclusive=False, group="spotlight") 

268 

269 async def _fetch_spotlight(self) -> None: 

270 spotlight = await fetch_spotlight() 

271 try: 

272 self.query_one(Sidebar).set_spotlight(spotlight) 

273 except Exception: 

274 # A best-effort remote card must not interrupt dashboard rendering. 

275 logger.debug("Unable to update the spotlight card", exc_info=True) 

276 

277 def _start_trending_fetch(self) -> None: 

278 try: 

279 sidebar = self.query_one(Sidebar) 

280 cached = load_cached_trending() 

281 if cached is not None: 

282 sidebar.set_trending(cached) 

283 except Exception: 

284 # Cached sidebar content is optional while the app is mounting. 

285 logger.debug("Unable to load cached trending content", exc_info=True) 

286 self.run_worker(self._fetch_trending(), exclusive=False, group="trending") 

287 

288 async def _fetch_trending(self) -> None: 

289 trending = await fetch_trending() 

290 if trending is not None: 

291 save_cached_trending(trending) 

292 try: 

293 self.query_one(Sidebar).set_trending(trending) 

294 except Exception: 

295 # Trending is an optional sidebar enhancement. 

296 logger.debug("Unable to update the trending card", exc_info=True) 

297 

298 # ── navigation ── 

299 

300 def _select_default_package(self, *, render: bool = True) -> None: 

301 packages = self.visible_packages 

302 if not packages: 

303 return 

304 if self._workspace: 

305 favorite_project = next((p for p in self._workspace if p.favorite), None) 

306 project = favorite_project or self._workspace[0] 

307 selected = project.packages[0] if project.packages else None 

308 else: 

309 selected = next((r for r in packages if r.favorite), None) or packages[0] 

310 if selected is not None: 

311 self.navigate_to_package(selected, render=render, load=False) 

312 

313 def navigate_to_package( 

314 self, ref: PackageRef, *, render: bool = True, load: bool = True 

315 ) -> None: 

316 self._workspace_state.select(ref) 

317 if load and self._workspace and ref.project_name: 

318 self._start_data_fetch(project_name=ref.project_name) 

319 if render: 

320 self._render_selected() 

321 try: 

322 self.query_one(Sidebar).select_package(ref) 

323 except Exception: 

324 # Navigation can race with sidebar mount/unmount transitions. 

325 logger.debug("Unable to select package in the sidebar", exc_info=True) 

326 

327 @on(Sidebar.PackageSelected) 

328 def _on_package_selected(self, event: Sidebar.PackageSelected) -> None: 

329 self.navigate_to_package(event.ref) 

330 

331 @on(Sidebar.ProjectSelected) 

332 def _on_project_selected(self, event: Sidebar.ProjectSelected) -> None: 

333 if not event.project.packages: 

334 self.notify("This project has no package sources.", severity="warning") 

335 return 

336 self.navigate_to_package(event.project.packages[0]) 

337 

338 def _render_selected(self) -> None: 

339 if self._workspace_state.selected_ref is None: 

340 return 

341 if self._render_in_progress: 

342 self._render_again = True 

343 return 

344 self._render_in_progress = True 

345 self.run_worker(self._render_selected_loop, exclusive=False, group="render") 

346 

347 async def _render_selected_loop(self) -> None: 

348 try: 

349 while True: 

350 self._render_again = False 

351 await self._render_selected_once() 

352 if not self._render_again: 

353 break 

354 except asyncio.CancelledError: 

355 return 

356 finally: 

357 self._render_in_progress = False 

358 if self._render_again: 

359 self._render_selected() 

360 

361 async def _render_selected_once(self) -> None: 

362 try: 

363 ref = self._workspace_state.selected_ref 

364 if ref is None: 

365 return 

366 try: 

367 main = self.query_one("#main-content", Container) 

368 except Exception: 

369 # A worker can finish while the screen is being replaced. 

370 logger.debug("Main content container is not mounted", exc_info=True) 

371 return 

372 info = self.get_package_info(ref) 

373 error = self.get_package_error(ref) 

374 derived = self.get_derived(ref) 

375 warnings = self.get_package_warnings(ref) 

376 await main.remove_children() 

377 if ref != self._workspace_state.selected_ref: 

378 return 

379 await main.mount( 

380 DetailView(ref, info, error, derived, warnings, parent_app=self) 

381 ) 

382 except asyncio.CancelledError: 

383 return 

384 

385 # ── data fetching ── 

386 

387 def _start_data_fetch( 

388 self, *, force: bool = False, project_name: str | None = None 

389 ) -> None: 

390 self._refreshed_at = None 

391 refs = self._project.packages 

392 effective_project = project_name 

393 if self._workspace: 

394 target = project_name or ( 

395 self._workspace_state.selected_ref.project_name 

396 if self._workspace_state.selected_ref 

397 else "" 

398 ) 

399 if not target: 

400 return 

401 if not self._workspace_state.begin_load(target, force=force): 

402 return 

403 refs = [ref for ref in self._project.packages if ref.project_name == target] 

404 effective_project = target 

405 

406 for ref in refs: 

407 key = package_key(ref) 

408 if key not in self._package_data: 

409 self._package_data[key] = PackageInfo( 

410 name=ref.name, registry=ref.registry 

411 ) 

412 group = f"fetch:{effective_project}" if effective_project else "fetch" 

413 self.run_worker( 

414 self._fetch_all_packages(refs, force=force, project_name=effective_project), 

415 exclusive=False, 

416 group=group, 

417 ) 

418 

419 async def _fetch_all_packages( 

420 self, 

421 refs: list[PackageRef], 

422 *, 

423 force: bool = False, 

424 project_name: str | None = None, 

425 ) -> None: 

426 result = await self._intelligence.fetch_project(refs, force_refresh=force) 

427 for key, package_result in result.results.items(): 

428 if package_result.info is not None: 

429 self._package_data[key] = package_result.info 

430 self._package_errors.pop(key, None) 

431 if package_result.derived is not None: 

432 self._derived_data[key] = package_result.derived 

433 self._package_warnings[key] = package_result.warnings 

434 if package_result.error is not None: 

435 self._package_errors[key] = package_result.error 

436 self._refreshed_at = result.refreshed_at or datetime.now(timezone.utc) 

437 if project_name: 

438 self._workspace_state.finish_load(project_name) 

439 self._notify_ui_update() 

440 

441 async def _fetch_single_package( 

442 self, ref: PackageRef, *, force: bool = False 

443 ) -> None: 

444 key = package_key(ref) 

445 result = await self._intelligence.fetch_package(ref, force_refresh=force) 

446 if result.info is not None: 

447 self._package_data[key] = result.info 

448 if result.derived is not None: 

449 self._derived_data[key] = result.derived 

450 self._package_warnings[key] = result.warnings 

451 if result.error is not None: 

452 self._package_errors[key] = result.error 

453 self._notify_ui_update(changed_ref=ref) 

454 

455 def _notify_ui_update(self, changed_ref: PackageRef | None = None) -> None: 

456 try: 

457 sidebar = self.query_one(Sidebar) 

458 sidebar.refresh_versions() 

459 except Exception: 

460 # Data workers may finish during app teardown; the refresh is optional. 

461 logger.debug("Unable to refresh sidebar versions", exc_info=True) 

462 

463 # Re-render the detail view when its package's data lands. 

464 if self._workspace_state.selected_ref is not None and ( 

465 changed_ref is None 

466 or changed_ref.name.lower() 

467 == self._workspace_state.selected_ref.name.lower() 

468 ): 

469 self._render_selected()