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
« 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."""
3from __future__ import annotations
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
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)
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}
42class DetailView(Container):
43 """Rich single-package view — the app's only main-content screen."""
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
62 # ── layout ──
64 def compose(self) -> ComposeResult:
65 with VerticalScroll(id="detail-content"):
66 yield from self._compose_summary_row()
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")
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()
81 def _build_project_card(self) -> str:
82 title = self._build_title()
83 if not self._info:
84 return title
86 info = self._info
87 registries = info.source_registries or [info.registry]
88 icons = " ".join(_registry_icon_label(registry) for registry in registries)
90 lines = [title]
91 if info.description:
92 lines.append(f"[#E5E7EB]{info.description[:96]}[/]")
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 "—"
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 )
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)
131 def _compose_stat_cards(self) -> ComposeResult:
132 info = self._info
133 derived = self._derived
134 assert info is not None
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 )
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 )
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 )
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 )
184 with Grid(id="stat-cards"):
185 yield adoption_card
186 yield health_card
187 yield dep_card
188 yield latest_card
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()
208 # ── content builders ──
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}"
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")
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")
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 )
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")
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")
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")
310# ── helpers ──
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
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"
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"
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
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
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"
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"
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
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)
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"
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
407def _short_url(url: str) -> str:
408 return url.replace("https://", "").replace("http://", "").rstrip("/")
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
422def _registry_icon_label(registry) -> str:
423 return ECOSYSTEM_ICONS.get(registry.value, registry.icon)