Coverage for src/secchi/ui/widgets/detail.py: 17%

244 statements  

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

1"""Detail view — the single main screen: header, stat cards, and tabs.""" 

2 

3from __future__ import annotations 

4 

5from rich.markup import escape 

6from rich.text import Text 

7from textual.app import ComposeResult 

8from textual.containers import Container, Grid, Horizontal, VerticalScroll 

9from textual.widgets import DataTable, Markdown, Static, TabbedContent, TabPane 

10 

11from secchi.models import DerivedPackageData, FetchError, PackageInfo, PackageRef 

12from secchi.services.intelligence import SignalWarning 

13from secchi.ui import palette 

14from secchi.ui.widgets.bar import render_bar 

15from secchi.ui.widgets.overview import OverviewTab 

16from secchi.ui.widgets.stat_card import StatCard 

17from secchi.utils import ( 

18 derive_github_repo, 

19 format_age, 

20 format_pct_delta, 

21 shorten_bytes, 

22 shorten_number, 

23) 

24 

25# Kept in one place so additional package sources can be surfaced without 

26# changing the project-card layout. Only ecosystems present in source_registries 

27# are rendered today. 

28ECOSYSTEM_ICONS: dict[str, str] = { 

29 "crates.io": "🦀", 

30 "pypi": "🐍", 

31 "npm": "⬢", 

32 "homebrew": "🍺", 

33 "go": "🐹", 

34 "cran": "📈", 

35 "pub": "🎯", 

36 "docker": "🐳", 

37 "nix": "❄️", 

38 "winget": "🪟", 

39 "scoop": "📦", 

40} 

41 

42 

43class DetailView(Container): 

44 """Rich single-package view — the app's only main-content screen.""" 

45 

46 def __init__( 

47 self, 

48 ref: PackageRef, 

49 info: PackageInfo | None, 

50 error: FetchError | None, 

51 derived: DerivedPackageData | None, 

52 warnings: list[SignalWarning], 

53 parent_app: object, 

54 ) -> None: 

55 super().__init__(id="detail-view") 

56 self._ref = ref 

57 self._info = info 

58 self._error = error 

59 self._derived = derived 

60 self._warnings = warnings 

61 self._app = parent_app 

62 

63 # ── layout ── 

64 

65 def compose(self) -> ComposeResult: 

66 with VerticalScroll(id="detail-content"): 

67 yield from self._compose_summary_row() 

68 

69 if self._info and self._info.latest_version: 

70 yield from self._compose_tabs() 

71 elif self._error: 

72 yield Static(f"[b red]Error[/]\n{self._error.message}", id="error-view") 

73 else: 

74 yield Static("Loading…", id="loading-view") 

75 

76 def _compose_summary_row(self) -> ComposeResult: 

77 with Horizontal(id="summary-row"): 

78 yield Static(self._build_project_card(), id="project-card") 

79 if self._info and self._info.latest_version: 

80 yield from self._compose_stat_cards() 

81 

82 def _build_project_card(self) -> str: 

83 title = self._build_title() 

84 if not self._info: 

85 return title 

86 

87 info = self._info 

88 registries = info.source_registries or [info.registry] 

89 icons = " ".join(_registry_icon_label(registry) for registry in registries) 

90 

91 lines = [title] 

92 if info.description: 

93 lines.append(f"[#E5E7EB]{info.description[:96]}[/]") 

94 

95 repo = derive_github_repo([info.repository_url, info.homepage]) 

96 repo_str = ( 

97 f"github.com/{repo[0]}/{repo[1]}" if repo else (info.repository_url or "—") 

98 ) 

99 docs_str = ( 

100 _short_url(info.documentation_url or info.homepage) 

101 if (info.documentation_url or info.homepage) 

102 else "—" 

103 ) 

104 stars = ( 

105 shorten_number(info.github_stats.stars) 

106 if info.github_stats.resolved 

107 else "—" 

108 ) 

109 latest = f"v{info.latest_version}" if info.latest_version else "—" 

110 

111 lines.append( 

112 f"[dim]Latest[/] [{palette.GREEN}]{latest}[/] " 

113 f"[dim]Stars[/] [{palette.YELLOW}]{stars}[/]" 

114 ) 

115 lines.append( 

116 f"[dim]Repo[/] [{palette.CYAN}]{repo_str}[/] " 

117 f"[dim]Docs[/] [{palette.CYAN}]{docs_str}[/]" 

118 ) 

119 

120 size = _resolve_size(info) 

121 meta: list[str] = [] 

122 if icons: 

123 meta.append(f"[dim]Ecosystem[/] {icons}") 

124 if info.license: 

125 meta.append(f"[dim]License[/] [{palette.PURPLE}]{info.license}[/]") 

126 if size: 

127 meta.append(f"[dim]Size[/] {shorten_bytes(size)}") 

128 if meta: 

129 lines.append(" ".join(meta)) 

130 return "\n".join(lines) 

131 

132 def _compose_stat_cards(self) -> ComposeResult: 

133 info = self._info 

134 derived = self._derived 

135 assert info is not None 

136 

137 total = derived.downloads_30d_total if derived else info.download_counts.month 

138 pct = derived.downloads_30d_pct_change if derived else None 

139 pct_text, pct_color = format_pct_delta(pct) 

140 adoption_card = StatCard( 

141 "ADOPTION", 

142 shorten_number(total), 

143 delta=pct_text, 

144 signal=_trend_label(pct), 

145 delta_color=pct_color, 

146 signal_color=_trend_color(pct), 

147 ) 

148 

149 health = derived.health_score if derived else None 

150 grade = health.grade if health else "—" 

151 grade_color = _grade_color(grade) 

152 health_delta, health_delta_color = _health_mom(derived) 

153 health_card = StatCard( 

154 "HEALTH", 

155 f"{health.total} / 100" if health else "—", 

156 delta=health_delta, 

157 signal=_health_label(health.total) if health else "", 

158 delta_color=health_delta_color, 

159 signal_color=grade_color, 

160 ) 

161 

162 deps = derived.reverse_dependency_summary if derived else None 

163 dep_growth = deps.monthly_growth if deps else None 

164 dep_card = StatCard( 

165 "DEPENDENTS", 

166 shorten_number(deps.count) if deps and deps.count is not None else "—", 

167 delta=_dependent_delta(dep_growth), 

168 signal=_dependent_signal(dep_growth, deps.count if deps else None), 

169 delta_color=_growth_color(dep_growth), 

170 signal_color=_growth_color(dep_growth), 

171 ) 

172 

173 latest_pct = _latest_version_adoption(info, derived) 

174 latest_card = StatCard( 

175 "LATEST VERSION", 

176 f"v{info.latest_version}" if info.latest_version else "—", 

177 delta=f"{latest_pct:.0f}% adoption" 

178 if latest_pct is not None 

179 else "— adoption", 

180 signal=_rollout_signal(latest_pct), 

181 delta_color=_rollout_color(latest_pct), 

182 signal_color=_rollout_color(latest_pct), 

183 ) 

184 

185 with Grid(id="stat-cards"): 

186 yield adoption_card 

187 yield health_card 

188 yield dep_card 

189 yield latest_card 

190 

191 def _compose_tabs(self) -> ComposeResult: 

192 info = self._info 

193 derived = self._derived 

194 assert info is not None and derived is not None 

195 with TabbedContent(id="detail-tabs"): 

196 with TabPane("Overview", id="tab-overview"): 

197 yield OverviewTab(info, derived) 

198 with TabPane("Security", id="tab-security"): 

199 yield self._security_pane() 

200 with TabPane("Releases", id="tab-releases"): 

201 yield self._releases_pane() 

202 with TabPane("Dependencies", id="tab-deps"): 

203 yield self._deps_pane() 

204 with TabPane("Versions", id="tab-versions"): 

205 yield self._versions_pane() 

206 with TabPane("Activity", id="tab-activity"): 

207 yield self._activity_pane() 

208 

209 # ── content builders ── 

210 

211 def _build_title(self) -> str: 

212 star = f" [{palette.YELLOW}]★[/]" if self._ref.favorite else "" 

213 name = self._ref.name 

214 return f"[b {palette.GREEN}]{name}[/]{star}" 

215 

216 def _releases_pane(self) -> VerticalScroll: 

217 info = self._info 

218 derived = self._derived 

219 assert info is not None and derived is not None 

220 adoption = derived.release_adoption 

221 lines: list[str] = [] 

222 for ver in info.versions[:30]: 

223 pct = adoption.get(ver.version, 0.0) 

224 bar = render_bar(pct / 100, width=12) if pct else "" 

225 age = format_age(ver.release_date) 

226 yanked = f" [{palette.RED}](yanked)[/]" if ver.is_yanked else "" 

227 lines.append( 

228 f"[{palette.GREEN}]{ver.version:<12}[/]{yanked} [dim]{age:<14}[/] {bar} " 

229 f"[b]{pct:4.1f}%[/]" 

230 ) 

231 body = "\n".join(lines) if lines else "[dim]No releases.[/]" 

232 children: list = [Static(body, classes="pane-block")] 

233 if info.release_notes: 

234 children.append(Static("\n[b]Release Notes[/]\n", classes="pane-heading")) 

235 children.append(Markdown(info.release_notes)) 

236 return VerticalScroll(*children, classes="tab-scroll") 

237 

238 def _deps_pane(self) -> VerticalScroll: 

239 table = DataTable(cursor_type=None, zebra_stripes=True) 

240 table.add_columns("Package", "Requirement", "Type") 

241 for dep in self._info.dependencies if self._info else []: 

242 table.add_row( 

243 Text(dep.name), 

244 dep.requirement or "—", 

245 "optional" if dep.optional else "required", 

246 ) 

247 if not (self._info and self._info.dependencies): 

248 table.add_row(Text("—"), "no dependencies", "") 

249 return VerticalScroll(table, classes="tab-scroll") 

250 

251 def _security_pane(self) -> VerticalScroll: 

252 advisories = self._info.security_advisories if self._info else [] 

253 if not advisories: 

254 return VerticalScroll( 

255 Static( 

256 "[b green]No known advisories[/]\n" 

257 "No OSV advisories affect the latest published version.", 

258 classes="pane-block", 

259 ), 

260 classes="tab-scroll", 

261 ) 

262 

263 children: list[Static] = [ 

264 Static( 

265 f"[b red]{len(advisories)} advisory(ies)[/] affect the latest version.", 

266 classes="pane-heading", 

267 ) 

268 ] 

269 for advisory in advisories: 

270 fixed = ", ".join(advisory.fixed_versions) or "No fixed version listed" 

271 lines = [ 

272 f"[b red]{escape(advisory.id)}[/] {escape(advisory.severity or 'Severity unavailable')}", 

273 escape(advisory.summary or "No summary available."), 

274 f"Fixed versions: {escape(fixed)}", 

275 f"[dim]{escape(advisory.url)}[/]", 

276 ] 

277 children.append(Static("\n".join(lines), classes="pane-block")) 

278 return VerticalScroll(*children, classes="tab-scroll") 

279 

280 def _versions_pane(self) -> VerticalScroll: 

281 table = DataTable(cursor_type=None, zebra_stripes=True) 

282 table.add_columns("Version", "Released", "Downloads", "Size") 

283 for ver in self._info.versions[:100] if self._info else []: 

284 date_str = ( 

285 ver.release_date.strftime("%Y-%m-%d") if ver.release_date else "—" 

286 ) 

287 label = f"(yanked) {ver.version}" if ver.is_yanked else ver.version 

288 table.add_row( 

289 Text(label, style=palette.RED if ver.is_yanked else ""), 

290 date_str, 

291 shorten_number(ver.downloads) if ver.downloads else "—", 

292 shorten_bytes(ver.size_bytes), 

293 ) 

294 return VerticalScroll(table, classes="tab-scroll") 

295 

296 def _activity_pane(self) -> VerticalScroll: 

297 derived = self._derived 

298 events = derived.activity if derived else [] 

299 lines: list[str] = [] 

300 for ev in events: 

301 ref = f"[{palette.BLUE}]{ev.ref}[/]: " if ev.ref else "" 

302 title = ev.title if len(ev.title) <= 70 else ev.title[:69] + "…" 

303 lines.append( 

304 f"[{palette.GREEN}]{ev.kind.icon}[/] [b]{ev.kind.label:<12}[/] {ref}{title}" 

305 f" [dim]{format_age(ev.timestamp)}[/]" 

306 ) 

307 body = "\n".join(lines) if lines else "[dim]No recent activity.[/]" 

308 return VerticalScroll(Static(body, classes="pane-block"), classes="tab-scroll") 

309 

310 

311# ── helpers ── 

312 

313 

314def _grade_color(grade: str) -> str: 

315 if grade in {"A", "B"}: 

316 return palette.GREEN 

317 if grade == "C": 

318 return palette.HEALTH_FAIR 

319 return palette.RED 

320 

321 

322def _health_label(score: int) -> str: 

323 if score >= 90: 

324 return "Excellent" 

325 if score >= 75: 

326 return "Good" 

327 if score >= 50: 

328 return "Fair" 

329 return "Needs Attention" 

330 

331 

332def _trend_label(pct: float | None) -> str: 

333 if pct is None or abs(pct) < 5: 

334 return "Stable" 

335 return "Growing" if pct > 0 else "Declining" 

336 

337 

338def _trend_color(pct: float | None) -> str: 

339 if pct is None or abs(pct) < 5: 

340 return "dim" 

341 return palette.GREEN if pct > 0 else palette.RED 

342 

343 

344def _health_mom(derived: DerivedPackageData | None) -> tuple[str, str]: 

345 points = derived.health_timeline if derived else [] 

346 if len(points) < 2: 

347 return "— MoM", "dim" 

348 delta = points[-1].value - points[-2].value 

349 sign = "+" if delta > 0 else "" 

350 color = palette.GREEN if delta >= 0 else palette.RED 

351 return f"{sign}{delta} MoM", color 

352 

353 

354def _dependent_delta(growth: int | None) -> str: 

355 if growth is None: 

356 return "— this month" 

357 sign = "+" if growth >= 0 else "" 

358 return f"{sign}{shorten_number(growth)} this month" 

359 

360 

361def _dependent_signal(growth: int | None, count: int | None) -> str: 

362 if count is None: 

363 return "Unavailable" 

364 if growth is None: 

365 return "Tracking" 

366 if growth > 0: 

367 return "Accelerating" 

368 if growth == 0: 

369 return "Stable" 

370 return "Contracting" 

371 

372 

373def _growth_color(growth: int | None) -> str: 

374 if growth is None or growth == 0: 

375 return "dim" 

376 return palette.GREEN if growth > 0 else palette.RED 

377 

378 

379def _latest_version_adoption( 

380 info: PackageInfo, 

381 derived: DerivedPackageData | None, 

382) -> float | None: 

383 if not info.latest_version or not derived: 

384 return None 

385 return derived.release_adoption.get(info.latest_version) 

386 

387 

388def _rollout_signal(pct: float | None) -> str: 

389 if pct is None: 

390 return "Unknown rollout" 

391 if pct >= 50: 

392 return "Healthy rollout" 

393 if pct >= 25: 

394 return "Mixed rollout" 

395 return "Lagging rollout" 

396 

397 

398def _rollout_color(pct: float | None) -> str: 

399 if pct is None: 

400 return "dim" 

401 if pct >= 50: 

402 return palette.GREEN 

403 if pct >= 25: 

404 return palette.HEALTH_FAIR 

405 return palette.RED 

406 

407 

408def _short_url(url: str) -> str: 

409 return url.replace("https://", "").replace("http://", "").rstrip("/") 

410 

411 

412def _resolve_size(info) -> int | None: 

413 for v in info.versions: 

414 if v.version == info.latest_version and v.size_bytes: 

415 return v.size_bytes 

416 if info.latest_release_files: 

417 total = sum(f.size for f in info.latest_release_files) 

418 if total: 

419 return total 

420 return None 

421 

422 

423def _registry_icon_label(registry) -> str: 

424 return ECOSYSTEM_ICONS.get(registry.value, registry.icon)