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

244 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 23:28 +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 "docker": "🐳", 

36 "nix": "❄️", 

37 "winget": "🪟", 

38 "scoop": "📦", 

39} 

40 

41 

42class DetailView(Container): 

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

44 

45 def __init__( 

46 self, 

47 ref: PackageRef, 

48 info: PackageInfo | None, 

49 error: FetchError | None, 

50 derived: DerivedPackageData | None, 

51 warnings: list[SignalWarning], 

52 parent_app: object, 

53 ) -> None: 

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

55 self._ref = ref 

56 self._info = info 

57 self._error = error 

58 self._derived = derived 

59 self._warnings = warnings 

60 self._app = parent_app 

61 

62 # ── layout ── 

63 

64 def compose(self) -> ComposeResult: 

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

66 yield from self._compose_summary_row() 

67 

68 if self._info and self._info.latest_version: 

69 yield from self._compose_tabs() 

70 elif self._error: 

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

72 else: 

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

74 

75 def _compose_summary_row(self) -> ComposeResult: 

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

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

78 if self._info and self._info.latest_version: 

79 yield from self._compose_stat_cards() 

80 

81 def _build_project_card(self) -> str: 

82 title = self._build_title() 

83 if not self._info: 

84 return title 

85 

86 info = self._info 

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

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

89 

90 lines = [title] 

91 if info.description: 

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

93 

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

95 repo_str = ( 

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

97 ) 

98 docs_str = ( 

99 _short_url(info.documentation_url or info.homepage) 

100 if (info.documentation_url or info.homepage) 

101 else "—" 

102 ) 

103 stars = ( 

104 shorten_number(info.github_stats.stars) 

105 if info.github_stats.resolved 

106 else "—" 

107 ) 

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

109 

110 lines.append( 

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

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

113 ) 

114 lines.append( 

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

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

117 ) 

118 

119 size = _resolve_size(info) 

120 meta: list[str] = [] 

121 if icons: 

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

123 if info.license: 

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

125 if size: 

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

127 if meta: 

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

129 return "\n".join(lines) 

130 

131 def _compose_stat_cards(self) -> ComposeResult: 

132 info = self._info 

133 derived = self._derived 

134 assert info is not None 

135 

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

137 pct = derived.downloads_30d_pct_change if derived else None 

138 pct_text, pct_color = format_pct_delta(pct) 

139 adoption_card = StatCard( 

140 "ADOPTION", 

141 shorten_number(total), 

142 delta=pct_text, 

143 signal=_trend_label(pct), 

144 delta_color=pct_color, 

145 signal_color=_trend_color(pct), 

146 ) 

147 

148 health = derived.health_score if derived else None 

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

150 grade_color = _grade_color(grade) 

151 health_delta, health_delta_color = _health_mom(derived) 

152 health_card = StatCard( 

153 "HEALTH", 

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

155 delta=health_delta, 

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

157 delta_color=health_delta_color, 

158 signal_color=grade_color, 

159 ) 

160 

161 deps = derived.reverse_dependency_summary if derived else None 

162 dep_growth = deps.monthly_growth if deps else None 

163 dep_card = StatCard( 

164 "DEPENDENTS", 

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

166 delta=_dependent_delta(dep_growth), 

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

168 delta_color=_growth_color(dep_growth), 

169 signal_color=_growth_color(dep_growth), 

170 ) 

171 

172 latest_pct = _latest_version_adoption(info, derived) 

173 latest_card = StatCard( 

174 "LATEST VERSION", 

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

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

177 if latest_pct is not None 

178 else "— adoption", 

179 signal=_rollout_signal(latest_pct), 

180 delta_color=_rollout_color(latest_pct), 

181 signal_color=_rollout_color(latest_pct), 

182 ) 

183 

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

185 yield adoption_card 

186 yield health_card 

187 yield dep_card 

188 yield latest_card 

189 

190 def _compose_tabs(self) -> ComposeResult: 

191 info = self._info 

192 derived = self._derived 

193 assert info is not None and derived is not None 

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

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

196 yield OverviewTab(info, derived) 

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

198 yield self._security_pane() 

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

200 yield self._releases_pane() 

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

202 yield self._deps_pane() 

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

204 yield self._versions_pane() 

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

206 yield self._activity_pane() 

207 

208 # ── content builders ── 

209 

210 def _build_title(self) -> str: 

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

212 name = self._ref.name 

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

214 

215 def _releases_pane(self) -> VerticalScroll: 

216 info = self._info 

217 derived = self._derived 

218 assert info is not None and derived is not None 

219 adoption = derived.release_adoption 

220 lines: list[str] = [] 

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

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

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

224 age = format_age(ver.release_date) 

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

226 lines.append( 

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

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

229 ) 

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

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

232 if info.release_notes: 

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

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

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

236 

237 def _deps_pane(self) -> VerticalScroll: 

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

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

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

241 table.add_row( 

242 Text(dep.name), 

243 dep.requirement or "—", 

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

245 ) 

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

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

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

249 

250 def _security_pane(self) -> VerticalScroll: 

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

252 if not advisories: 

253 return VerticalScroll( 

254 Static( 

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

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

257 classes="pane-block", 

258 ), 

259 classes="tab-scroll", 

260 ) 

261 

262 children: list[Static] = [ 

263 Static( 

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

265 classes="pane-heading", 

266 ) 

267 ] 

268 for advisory in advisories: 

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

270 lines = [ 

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

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

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

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

275 ] 

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

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

278 

279 def _versions_pane(self) -> VerticalScroll: 

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

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

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

283 date_str = ( 

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

285 ) 

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

287 table.add_row( 

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

289 date_str, 

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

291 shorten_bytes(ver.size_bytes), 

292 ) 

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

294 

295 def _activity_pane(self) -> VerticalScroll: 

296 derived = self._derived 

297 events = derived.activity if derived else [] 

298 lines: list[str] = [] 

299 for ev in events: 

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

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

302 lines.append( 

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

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

305 ) 

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

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

308 

309 

310# ── helpers ── 

311 

312 

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

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

315 return palette.GREEN 

316 if grade == "C": 

317 return palette.HEALTH_FAIR 

318 return palette.RED 

319 

320 

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

322 if score >= 90: 

323 return "Excellent" 

324 if score >= 75: 

325 return "Good" 

326 if score >= 50: 

327 return "Fair" 

328 return "Needs Attention" 

329 

330 

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

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

333 return "Stable" 

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

335 

336 

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

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

339 return "dim" 

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

341 

342 

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

344 points = derived.health_timeline if derived else [] 

345 if len(points) < 2: 

346 return "— MoM", "dim" 

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

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

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

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

351 

352 

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

354 if growth is None: 

355 return "— this month" 

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

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

358 

359 

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

361 if count is None: 

362 return "Unavailable" 

363 if growth is None: 

364 return "Tracking" 

365 if growth > 0: 

366 return "Accelerating" 

367 if growth == 0: 

368 return "Stable" 

369 return "Contracting" 

370 

371 

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

373 if growth is None or growth == 0: 

374 return "dim" 

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

376 

377 

378def _latest_version_adoption( 

379 info: PackageInfo, 

380 derived: DerivedPackageData | None, 

381) -> float | None: 

382 if not info.latest_version or not derived: 

383 return None 

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

385 

386 

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

388 if pct is None: 

389 return "Unknown rollout" 

390 if pct >= 50: 

391 return "Healthy rollout" 

392 if pct >= 25: 

393 return "Mixed rollout" 

394 return "Lagging rollout" 

395 

396 

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

398 if pct is None: 

399 return "dim" 

400 if pct >= 50: 

401 return palette.GREEN 

402 if pct >= 25: 

403 return palette.HEALTH_FAIR 

404 return palette.RED 

405 

406 

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

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

409 

410 

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

412 for v in info.versions: 

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

414 return v.size_bytes 

415 if info.latest_release_files: 

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

417 if total: 

418 return total 

419 return None 

420 

421 

422def _registry_icon_label(registry) -> str: 

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