queue-watch 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
queue_watch/widgets.py ADDED
@@ -0,0 +1,774 @@
1
+ """Widgets and text helpers for the dashboard (see design/README.md).
2
+
3
+ Every colour is a Textual theme variable, so ``--theme`` and the command palette
4
+ restyle everything. States always carry a glyph as well as a colour, and
5
+ ``use_ascii`` swaps the glyphs for plain ASCII.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import statistics
11
+ import time
12
+ from collections import deque
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+ from typing import Any
16
+ from zoneinfo import ZoneInfo
17
+
18
+ from rich.style import Style as RichStyle
19
+ from rich.text import Text
20
+ from textual import on
21
+ from textual.app import App, ComposeResult
22
+ from textual.color import Color
23
+ from textual.containers import Horizontal, Vertical, VerticalScroll
24
+ from textual.content import Content
25
+ from textual.message import Message
26
+ from textual.widgets import Button, OptionList, Sparkline, Static
27
+ from textual.widgets.option_list import Option, OptionDoesNotExist
28
+
29
+ from .changes import GROUPS, Change, grouped, stamp
30
+ from .process import clean
31
+
32
+ PACIFIC = ZoneInfo("America/Los_Angeles")
33
+ MUTED = "$text-muted"
34
+ STATE_STYLE = {
35
+ "running": "$primary",
36
+ "queued": MUTED,
37
+ "success": "$success",
38
+ "failure": "$error",
39
+ "cancelled": MUTED,
40
+ "blocked": "$warning",
41
+ "neutral": MUTED,
42
+ "unknown": MUTED,
43
+ }
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Symbols:
48
+ glyphs: dict[str, str]
49
+ arrow: str
50
+ tee: str
51
+ elbow: str
52
+ pipe: str
53
+ bar_full: str
54
+ bar_empty: str
55
+ dot: str
56
+ rule: str
57
+ dash: str
58
+ live: str
59
+ folded: str
60
+ unfolded: str
61
+
62
+
63
+ UNICODE = Symbols(
64
+ glyphs={
65
+ "running": "●",
66
+ "queued": "◌",
67
+ "success": "✓",
68
+ "failure": "✗",
69
+ "cancelled": "⊘",
70
+ "blocked": "!",
71
+ "neutral": "·",
72
+ "unknown": "·",
73
+ },
74
+ arrow="→",
75
+ tee="├",
76
+ elbow="└",
77
+ pipe="│",
78
+ bar_full="█",
79
+ bar_empty="░",
80
+ dot="·",
81
+ rule="─",
82
+ dash="—",
83
+ live="✓",
84
+ folded="▸",
85
+ unfolded="▾",
86
+ )
87
+ ASCII = Symbols(
88
+ glyphs={
89
+ "running": "*",
90
+ "queued": "o",
91
+ "success": "+",
92
+ "failure": "x",
93
+ "cancelled": "/",
94
+ "blocked": "!",
95
+ "neutral": ".",
96
+ "unknown": ".",
97
+ },
98
+ arrow="->",
99
+ tee="|-",
100
+ elbow="`-",
101
+ pipe="| ",
102
+ bar_full="#",
103
+ bar_empty="-",
104
+ dot="|",
105
+ rule="-",
106
+ dash="-",
107
+ live="+",
108
+ folded=">",
109
+ unfolded="v",
110
+ )
111
+ SYMBOLS = UNICODE
112
+
113
+
114
+ def use_ascii() -> None:
115
+ global SYMBOLS
116
+ SYMBOLS = ASCII
117
+
118
+
119
+ # --- text helpers ---------------------------------------------------------- #
120
+
121
+
122
+ def glyph(state: str) -> tuple[str, str]:
123
+ """(glyph, style) for a normalized state, for ``Content.assemble``."""
124
+ return SYMBOLS.glyphs.get(state, SYMBOLS.glyphs["unknown"]), STATE_STYLE.get(state, MUTED)
125
+
126
+
127
+ def muted(text: str) -> tuple[str, str]:
128
+ return text, MUTED
129
+
130
+
131
+ def dim(text: str) -> Content:
132
+ return Content.assemble(muted(text))
133
+
134
+
135
+ def raw(record: dict) -> str:
136
+ return clean(record.get("raw_state") or record["state"]).replace("_", " ")
137
+
138
+
139
+ def fit(content: Content, width: int, align: str = "left") -> Content:
140
+ content = content.truncate(width, ellipsis=True)
141
+ gap = width - content.cell_length
142
+ if align == "right" and gap > 0:
143
+ return content.pad_left(gap)
144
+ return content.pad_right(max(gap, 0))
145
+
146
+
147
+ def lines(rows: list[Content]) -> Content:
148
+ return Content("\n").join(rows)
149
+
150
+
151
+ def human_seconds(seconds: float) -> str:
152
+ seconds = max(0, round(seconds))
153
+ if seconds < 60:
154
+ return f"{seconds}s"
155
+ if seconds < 3600:
156
+ minutes, rest = divmod(seconds, 60)
157
+ return f"{minutes}m{rest:02d}s" if rest else f"{minutes}m"
158
+ hours, rest = divmod(seconds, 3600)
159
+ return f"{hours}h{rest // 60:02d}m"
160
+
161
+
162
+ def duration(value: object) -> str:
163
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
164
+ return "-"
165
+ return human_seconds(value)
166
+
167
+
168
+ def eta_text(seconds: float) -> str:
169
+ """Estimates are rough: whole minutes past a minute and a half, ``~7m``."""
170
+ return f"~{human_seconds(seconds if seconds < 90 else round(seconds / 60) * 60)}"
171
+
172
+
173
+ def short_age(seconds: float | None) -> str:
174
+ """Coarse age for table cells: 45s, 12m, 3h, 2d."""
175
+ if seconds is None:
176
+ return "?"
177
+ seconds = max(0, round(seconds))
178
+ for size, unit in ((86400, "d"), (3600, "h"), (60, "m")):
179
+ if seconds >= size:
180
+ return f"{seconds // size}{unit}"
181
+ return f"{seconds}s"
182
+
183
+
184
+ def now() -> float:
185
+ """Wall-clock epoch seconds; the one place ages read the clock (tests replace it)."""
186
+ return time.time()
187
+
188
+
189
+ def ago(value: str | float | None) -> float | None:
190
+ """Seconds since an ISO timestamp or epoch seconds."""
191
+ if isinstance(value, (int, float)):
192
+ return now() - value if value else None
193
+ try:
194
+ return now() - datetime.fromisoformat(value).timestamp()
195
+ except (TypeError, ValueError):
196
+ return None
197
+
198
+
199
+ def clock(value: str | None) -> str:
200
+ try:
201
+ return datetime.fromisoformat(value).astimezone(PACIFIC).strftime("%-I:%M:%S %p %Z")
202
+ except (TypeError, ValueError):
203
+ return "unknown"
204
+
205
+
206
+ def elapsed(record: dict) -> float | None:
207
+ """How long a job or attempt took, or has taken so far while running."""
208
+ fields = record.get("fields") or {}
209
+ if isinstance(fields.get("duration_seconds"), (int, float)):
210
+ return fields["duration_seconds"]
211
+ start = stamp(record, "started_at")
212
+ if not start:
213
+ return None
214
+ if record["state"] == "running":
215
+ return now() - start
216
+ end = stamp(record, "finished_at")
217
+ return end - start if end >= start else None
218
+
219
+
220
+ def eta_of(record: dict | None) -> float | None:
221
+ value = ((record or {}).get("fields") or {}).get("estimated_seconds")
222
+ return value if isinstance(value, (int, float)) and not isinstance(value, bool) else None
223
+
224
+
225
+ def rich_style(app: App, token: str, bold: bool = False) -> RichStyle:
226
+ """Resolve a theme variable ('$success') for Rich-rendered widgets such as DataTable."""
227
+ variables = app.get_css_variables()
228
+ value = variables.get(token.lstrip("$"), variables["foreground"])
229
+ if value.startswith("auto"): # "auto 60%": foreground at 60% over the surface
230
+ alpha = float(value.split()[1].rstrip("%")) / 100
231
+ color = Color.parse(variables["foreground"]).blend(
232
+ Color.parse(variables["surface"]), 1 - alpha
233
+ )
234
+ else:
235
+ color = Color.parse(value)
236
+ return RichStyle(color=color.rich_color, bold=bold)
237
+
238
+
239
+ def state_text(app: App, record: dict) -> Text:
240
+ """Glyph plus the adapter's own state word, for DataTable cells."""
241
+ text, style = glyph(record["state"])
242
+ return Text(f"{text} {raw(record)}", style=rich_style(app, style))
243
+
244
+
245
+ # --- change summaries ------------------------------------------------------ #
246
+
247
+
248
+ def ci_status(change: Change, label: str = "CI") -> Content:
249
+ """``CI ✗ test`` (the worst workflow) or ``CI ✓ 3 runs``."""
250
+ checks = change.checks
251
+ if not checks:
252
+ return Content.assemble(muted(f"{label} {SYMBOLS.dash}"))
253
+ state = change.ci_state
254
+ if state == "success" and len(checks) > 1:
255
+ detail = f"{len(checks)} runs"
256
+ else:
257
+ worst = next(hit.record for hit in checks if hit.record["state"] == state)
258
+ detail = clean(worst["title"]) if len(checks) > 1 else raw(worst)
259
+ return Content.assemble(muted(f"{label} "), glyph(state), " ", detail)
260
+
261
+
262
+ def queue_status(change: Change, label: str = "Queue", with_eta: bool = True) -> Content:
263
+ """``Queue ! #4 unmergeable ~7m`` or, after an attempt, ``Queue ✓ merged 38s``."""
264
+ if change.entry:
265
+ entry = change.entry.record
266
+ parts: list[Any] = [muted(f"{label} "), glyph(entry["state"])]
267
+ if isinstance(entry.get("position"), int):
268
+ parts.append(f" #{entry['position']}")
269
+ parts.append(f" {raw(entry)}")
270
+ if with_eta and (eta := eta_of(entry)):
271
+ parts.append(muted(f" {eta_text(eta)}"))
272
+ return Content.assemble(*parts)
273
+ if change.attempts:
274
+ attempt = change.attempts[0].record
275
+ parts = [muted(f"{label} "), glyph(attempt["state"]), f" {raw(attempt)}"]
276
+ if with_eta and (took := elapsed(attempt)):
277
+ parts.append(muted(f" {human_seconds(took)}"))
278
+ return Content.assemble(*parts)
279
+ return Content.assemble(muted(f"{label} {SYMBOLS.dash}"))
280
+
281
+
282
+ # --- messages -------------------------------------------------------------- #
283
+
284
+
285
+ class ChangeSelected(Message):
286
+ def __init__(self, key: str) -> None:
287
+ super().__init__()
288
+ self.key = key
289
+
290
+
291
+ class ChipClicked(Message):
292
+ def __init__(self, source: str, record_id: str) -> None:
293
+ super().__init__()
294
+ self.source, self.record_id = source, record_id
295
+
296
+
297
+ # --- status bar ------------------------------------------------------------ #
298
+
299
+
300
+ @dataclass
301
+ class Health:
302
+ label: str
303
+ word: str # live, stale, paused, waiting, unavailable
304
+ age: float | None
305
+
306
+
307
+ class StatusBar(Horizontal):
308
+ """One line: brand, a pill per source (label, state, age), group counts."""
309
+
310
+ def __init__(self, count: int) -> None:
311
+ super().__init__(id="status")
312
+ self.count = count
313
+
314
+ def compose(self) -> ComposeResult:
315
+ yield Static(Content.assemble(("queue-watch", "b")), id="brand")
316
+ for index in range(self.count):
317
+ yield Static(classes="pill", id=f"pill-{index}")
318
+ yield Static(id="counts-long", classes="counts")
319
+ yield Static(id="counts-short", classes="counts")
320
+
321
+ def show(self, health: list[Health], counts: dict[str, int] | None) -> None:
322
+ """``health`` holds one entry per source, in configuration order."""
323
+ for index, item in enumerate(health):
324
+ pill = self.query_one(f"#pill-{index}", Static)
325
+ mark = SYMBOLS.live if item.word in ("live", "paused") else "!"
326
+ text = f"{item.word} {human_seconds(item.age)}" if item.age is not None else item.word
327
+ pill.update(Content.assemble((mark, "b"), f" {clean(item.label)} ", (text, "b")))
328
+ pill.set_classes(f"pill {item.word}")
329
+ for name, words in (("long", True), ("short", False)):
330
+ parts: list[Any] = []
331
+ if counts is not None:
332
+ for key, _, state in GROUPS:
333
+ parts += [glyph(state), f" {counts[key]} {key}" if words else f" {counts[key]}"]
334
+ parts.append(" " if words else " ")
335
+ self.query_one(f"#counts-{name}", Static).update(Content.assemble(*parts[:-1]))
336
+
337
+
338
+ # --- queue strip ----------------------------------------------------------- #
339
+
340
+ CHIP_MIN_WIDTH = 26
341
+
342
+
343
+ def chain_order(records: list[dict]) -> list[dict]:
344
+ """Queue order: active holders without a position first, then by position."""
345
+
346
+ def key(record: dict) -> tuple[int, float]:
347
+ position = record.get("position")
348
+ if isinstance(position, int):
349
+ return (1, position)
350
+ return (0 if record["state"] == "running" else 2, 0)
351
+
352
+ return sorted(records, key=key)
353
+
354
+
355
+ class QueueChip(Static):
356
+ """One queue entry: bordered, with its position in the border title.
357
+
358
+ On short terminals (the app's ``-short`` class) it is one borderless line instead.
359
+ """
360
+
361
+ def __init__(self, source: str, record: dict, change: Change | None, holder: bool) -> None:
362
+ super().__init__(classes="chip holder" if holder else "chip")
363
+ self.source, self.record, self.change, self.holder = source, record, change, holder
364
+ self.full = self.line = Content("")
365
+
366
+ def on_mount(self) -> None:
367
+ position = self.record.get("position")
368
+ label = str(position) if isinstance(position, int) else raw(self.record)
369
+ label = f"{label} {SYMBOLS.dot} next" if self.holder else label
370
+ # Strings assigned to border_title and tooltip parse as markup; Content does not.
371
+ self.border_title = Content(label)
372
+ title = clean(self.record["title"])
373
+ if self.change and self.change.pr and not title.startswith("#"):
374
+ title = f"#{self.change.pr} {title}"
375
+ eta = eta_of(self.record)
376
+ tail: list[Any] = [muted(f" {SYMBOLS.dot} "), (eta_text(eta), "b")] if eta else []
377
+ if not eta and self.record.get("owner"):
378
+ tail = [muted(f" {SYMBOLS.dot} {clean(self.record['owner'])}")]
379
+ state = glyph(self.record["state"])
380
+ self.full = Content.assemble((title, "b"), "\n", state, f" {raw(self.record)}", *tail)
381
+ self.line = Content.assemble(muted(f"{label} "), state, " ", (title, "b"), *tail)
382
+ self.tooltip = Content(f"{title}\n{raw(self.record)}")
383
+ self.refresh()
384
+
385
+ def render(self) -> Content:
386
+ return self.line if self.app.has_class("-short") else self.full
387
+
388
+ def on_click(self) -> None:
389
+ self.post_message(ChipClicked(self.source, self.record["id"]))
390
+
391
+
392
+ DEPTH_SAMPLES = 120 # one per fetch: half an hour at the default 15 s interval
393
+ SPARK_CELLS = 20
394
+
395
+
396
+ class QueueStrip(Vertical):
397
+ """Header (count, ETA, depth and attempt trends) over a FIFO chain of chips for one source."""
398
+
399
+ def __init__(self, source: str, label: str) -> None:
400
+ super().__init__(classes="queue")
401
+ self.source, self.label = source, label
402
+ self.entries: list[dict] = []
403
+ self.attempts: list[dict] = [] # with a duration: the "took" trend
404
+ self.merges: list[float] = [] # finish times of every successful attempt
405
+ self.history = False
406
+ self.by_record: dict[str, Change] = {}
407
+ self.built: tuple | None = None
408
+ # Queue length after each fetch this session; sources keep no history of it.
409
+ self.depth: deque[int] = deque(maxlen=DEPTH_SAMPLES)
410
+
411
+ def compose(self) -> ComposeResult:
412
+ with Horizontal(classes="queue-head"):
413
+ yield Static(classes="queue-title")
414
+ yield Static(classes="queue-eta")
415
+ yield Static(dim("depth"), classes="depth spark-label")
416
+ yield Sparkline([], classes="depth spark")
417
+ yield Static(classes="rate")
418
+ yield Static(dim("took"), classes="took spark-label")
419
+ yield Sparkline([], classes="took spark")
420
+ yield Static(classes="took took-stats")
421
+ yield Horizontal(classes="chain")
422
+
423
+ def sample(self, records: list[dict]) -> None:
424
+ """Record the queue length of one fresh snapshot."""
425
+ self.depth.append(sum(r["kind"] == "queue_entry" for r in records))
426
+
427
+ async def show(self, records: list[dict], by_record: dict[str, Change]) -> None:
428
+ self.entries = chain_order([r for r in records if r["kind"] == "queue_entry"])
429
+ self.attempts = sorted(
430
+ (r for r in records if r["kind"] == "attempt" and elapsed(r) is not None),
431
+ key=lambda r: stamp(r, "finished_at", "started_at"),
432
+ )
433
+ # Merge counts need only a finish time; a log may omit start times and durations.
434
+ finished = [r for r in records if r["kind"] == "attempt" and stamp(r, "finished_at")]
435
+ self.history = bool(finished)
436
+ self.merges = [stamp(r, "finished_at") for r in finished if r["state"] == "success"]
437
+ self.by_record = by_record
438
+ self.head()
439
+ await self.chain()
440
+
441
+ def head(self) -> None:
442
+ waiting = len(self.entries)
443
+ status = f" {waiting} waiting"
444
+ if not waiting:
445
+ status = " nothing waiting"
446
+ if self.merges:
447
+ last = short_age(now() - max(self.merges))
448
+ status += f" {SYMBOLS.dot} last merge {last} ago"
449
+ self.query_one(".queue-title", Static).update(
450
+ Content.assemble((clean(self.label).upper(), "b"), muted(status))
451
+ )
452
+ clears = max((eta_of(e) or 0 for e in self.entries), default=0)
453
+ self.query_one(".queue-eta", Static).update(
454
+ Content.assemble(muted("clears in "), (eta_text(clears), "b")) if clears else ""
455
+ )
456
+ # Visibility is by class so that the narrow layout's CSS can still hide sparklines.
457
+ self.set_class(len(self.depth) > 1, "-depth")
458
+ if len(self.depth) > 1:
459
+ depth = self.query_one(".depth.spark", Sparkline)
460
+ depth.data = list(self.depth)
461
+ depth.styles.width = min(len(self.depth), SPARK_CELLS)
462
+ merged = sum(now() - finished <= 3600 for finished in self.merges)
463
+ self.query_one(".rate", Static).update(
464
+ Content.assemble(glyph("success"), muted(f" {merged} merged/hr"))
465
+ if self.history
466
+ else ""
467
+ )
468
+ took = [elapsed(a) for a in self.attempts[-30:]]
469
+ failed = sum(a["state"] == "failure" for a in self.attempts[-30:])
470
+ self.set_class(len(took) > 1, "-took")
471
+ if len(took) > 1:
472
+ trend = self.query_one(".took.spark", Sparkline)
473
+ trend.data = took
474
+ trend.styles.width = min(len(took), SPARK_CELLS)
475
+ self.query_one(".took-stats", Static).update(
476
+ Content.assemble(
477
+ muted(
478
+ f" median {human_seconds(statistics.median(took))} {SYMBOLS.dot} "
479
+ f"max {human_seconds(max(took))} {SYMBOLS.dot} "
480
+ ),
481
+ glyph("failure"),
482
+ muted(f" {failed}/{len(took)}"),
483
+ )
484
+ )
485
+
486
+ async def chain(self) -> None:
487
+ chain = self.query_one(".chain", Horizontal)
488
+ width = chain.size.width or self.app.size.width
489
+ room = max(1, (width + 3) // (CHIP_MIN_WIDTH + 3))
490
+ shown = self.entries if len(self.entries) <= room else self.entries[: max(1, room - 1)]
491
+ signature = (
492
+ width,
493
+ tuple(
494
+ (e["id"], e["state"], e.get("position"), e["title"], eta_of(e), e.get("owner"))
495
+ for e in shown
496
+ ),
497
+ len(self.entries),
498
+ tuple(e["id"] in self.by_record for e in shown),
499
+ )
500
+ if signature == self.built:
501
+ return
502
+ self.built = signature
503
+ widgets: list[Static] = []
504
+ for index, entry in enumerate(shown):
505
+ if index:
506
+ widgets.append(Static(SYMBOLS.arrow, classes="link"))
507
+ widgets.append(
508
+ QueueChip(self.source, entry, self.by_record.get(entry["id"]), holder=index == 0)
509
+ )
510
+ if len(shown) < len(self.entries):
511
+ widgets.append(Static(f"+{len(self.entries) - len(shown)} more", classes="more"))
512
+ await chain.remove_children()
513
+ await chain.mount_all(widgets)
514
+ chain.display = bool(widgets)
515
+
516
+ async def on_resize(self) -> None:
517
+ if self.built is not None and self.built[0] != self.query_one(".chain").size.width:
518
+ await self.chain()
519
+
520
+
521
+ # --- changes table --------------------------------------------------------- #
522
+
523
+
524
+ @dataclass
525
+ class Column:
526
+ key: str
527
+ label: str
528
+ width: int # 0 takes the remaining space
529
+ align: str = "left"
530
+
531
+
532
+ def change_columns(width: int, queue: bool, comfortable: bool = False) -> list[Column]:
533
+ """Columns for a table ``width`` cells wide; narrow tables keep the essentials.
534
+
535
+ Comfortable rows carry title, owner and PR on a second line, so those columns go.
536
+ """
537
+ roomy = width >= 96
538
+ wide = roomy and not comfortable
539
+ cols = [Column("glyph", "", 1), Column("ref", "CHANGE", 18 if roomy else 16)]
540
+ if wide:
541
+ cols += [Column("pr", "PR", 6), Column("title", "TITLE", 0), Column("owner", "OWNER", 10)]
542
+ cols.append(Column("ci", "CI", 20 if queue else 26))
543
+ if queue:
544
+ cols.append(Column("queue", "QUEUE", 24 if roomy else 16))
545
+ cols.append(Column("age", "AGE", 4, "right"))
546
+ flex = width - sum(c.width for c in cols) - (len(cols) - 1)
547
+ if not wide or flex < 12:
548
+ cols = [c for c in cols if c.key not in ("title", "owner")]
549
+ flex = width - sum(c.width for c in cols) - (len(cols) - 1)
550
+ if flex >= 0:
551
+ cols[1].width += flex
552
+ # Too narrow: give back cells from the text columns, down to a readable minimum.
553
+ for col in cols:
554
+ floor = {"ref": 8, "ci": 10, "queue": 8}.get(col.key, col.width)
555
+ take = min(-flex, col.width - floor) if flex < 0 else 0
556
+ col.width -= take
557
+ flex += take
558
+ for col in cols:
559
+ if col.width == 0:
560
+ col.width = flex
561
+ return cols
562
+
563
+
564
+ def change_cell(col: Column, change: Change, compact: bool) -> Content:
565
+ match col.key:
566
+ case "glyph":
567
+ return Content.assemble(glyph(change.state))
568
+ case "ref":
569
+ name = change.ref or change.key[:7]
570
+ return Content.assemble((clean(name), "b"))
571
+ case "pr":
572
+ return dim(f"#{change.pr}" if change.pr else "")
573
+ case "title":
574
+ return Content(clean(change.title))
575
+ case "owner":
576
+ return dim(clean(change.owner or ""))
577
+ case "ci":
578
+ return ci_status(change)
579
+ case "queue":
580
+ return Content.assemble(
581
+ muted(f"{SYMBOLS.arrow} "), queue_status(change, "Q" if compact else "Queue")
582
+ )
583
+ case "age":
584
+ return dim(short_age(ago(change.activity)))
585
+ return Content("")
586
+
587
+
588
+ def change_detail(change: Change, width: int) -> Content:
589
+ """Second line of a comfortable row: title, owner and PR under the change name."""
590
+ parts = [
591
+ part
592
+ for part in (
593
+ clean(change.title or ""),
594
+ clean(change.owner or ""),
595
+ f"#{change.pr}" if change.pr else "",
596
+ )
597
+ if part
598
+ ]
599
+ indent = 2 # under the change name, past the glyph column
600
+ detail = dim(f" {SYMBOLS.dot} ".join(parts) or SYMBOLS.dash)
601
+ return Content.assemble(" " * indent, fit(detail, width - indent, "left"))
602
+
603
+
604
+ class ChangeTable(Vertical):
605
+ """Header line over an OptionList of group rows and change rows.
606
+
607
+ Enter or a click on a group row folds or unfolds it. Done starts folded whenever
608
+ another group has changes; a group the user has toggled keeps that choice.
609
+ """
610
+
611
+ def __init__(self, density: str = "compact") -> None:
612
+ super().__init__(id="changes")
613
+ self.changes: list[Change] = []
614
+ self.queue = False
615
+ self.selected: str | None = None
616
+ self.width = 0
617
+ self.empty = "Waiting for sources..."
618
+ self.density = density
619
+ self.folds: dict[str, bool] = {} # explicit user choices by group key
620
+
621
+ def folded(self, key: str) -> bool:
622
+ if key in self.folds:
623
+ return self.folds[key]
624
+ others = any(c.group != "done" for c in self.changes)
625
+ return key == "done" and others
626
+
627
+ def compose(self) -> ComposeResult:
628
+ yield Static(id="colhead")
629
+ yield OptionList(id="rows")
630
+
631
+ def on_resize(self) -> None:
632
+ # Options are padded one cell either side; the list's scrollbar is one cell (app CSS).
633
+ width = self.size.width - 3
634
+ if width != self.width and width > 20:
635
+ self.width = width
636
+ self.rebuild()
637
+
638
+ def show(self, changes: list[Change], queue: bool, selected: str | None, empty: str) -> None:
639
+ self.changes, self.queue, self.selected, self.empty = changes, queue, selected, empty
640
+ self.rebuild()
641
+
642
+ def rebuild(self) -> None:
643
+ if not self.width:
644
+ return
645
+ comfortable = self.density == "comfortable"
646
+ cols = change_columns(self.width, self.queue, comfortable)
647
+ compact = self.width < 96
648
+ head = Content(" ").join(
649
+ fit(Content.assemble(muted(c.label)), c.width, c.align) for c in cols
650
+ )
651
+ self.query_one("#colhead", Static).update(head)
652
+ options: list[Option] = []
653
+ for key, label, state, changes in grouped(self.changes):
654
+ if not changes:
655
+ continue
656
+ folded = self.folded(key)
657
+ marker = SYMBOLS.folded if folded else SYMBOLS.unfolded
658
+ title = Content.assemble(
659
+ glyph(state),
660
+ " ",
661
+ (label.upper(), f"b {STATE_STYLE[state]}"),
662
+ muted(f" {len(changes)} {marker} "),
663
+ )
664
+ rule = SYMBOLS.rule * max(0, self.width - title.cell_length)
665
+ options.append(Option(Content.assemble(title, muted(rule)), id=f"g:{key}"))
666
+ if folded:
667
+ continue
668
+ for change in changes:
669
+ row = Content(" ").join(
670
+ fit(change_cell(c, change, compact), c.width, c.align) for c in cols
671
+ )
672
+ if comfortable:
673
+ row = Content("\n").join([row, change_detail(change, self.width)])
674
+ options.append(Option(row, id=f"c:{change.key}"))
675
+ if not options:
676
+ options.append(Option(dim(self.empty), id="g:empty", disabled=True))
677
+ rows = self.query_one("#rows", OptionList)
678
+ rows.clear_options()
679
+ rows.add_options(options)
680
+ self.highlight(self.selected)
681
+
682
+ def highlight(self, key: str | None, reveal: bool = False) -> None:
683
+ """Move the cursor to a change; ``reveal`` unfolds its group if needed.
684
+
685
+ A change in a folded group puts the cursor on that group's row, so a rebuild
686
+ never leaves the list without a cursor.
687
+ """
688
+ if key is None:
689
+ return
690
+ change = next((c for c in self.changes if c.key == key), None)
691
+ if reveal and change and self.folded(change.group):
692
+ self.folds[change.group] = False
693
+ self.rebuild()
694
+ rows = self.query_one("#rows", OptionList)
695
+ targets = [f"c:{key}"] + ([f"g:{change.group}"] if change else [])
696
+ for target in targets:
697
+ try:
698
+ index = rows.get_option_index(target)
699
+ except OptionDoesNotExist:
700
+ continue
701
+ if rows.highlighted != index:
702
+ rows.highlighted = index
703
+ return
704
+
705
+ def toggle(self, group: str) -> None:
706
+ self.folds[group] = not self.folded(group)
707
+ self.rebuild()
708
+ rows = self.query_one("#rows", OptionList)
709
+ rows.highlighted = rows.get_option_index(f"g:{group}")
710
+
711
+ @on(OptionList.OptionHighlighted)
712
+ @on(OptionList.OptionSelected)
713
+ def _highlighted(self, event) -> None:
714
+ event.stop()
715
+ rows = event.option_list
716
+ # Highlight events arrive asynchronously; one queued by a rebuild can land after a
717
+ # newer selection (a chip click) and must not drag the selection back.
718
+ current = rows.highlighted
719
+ if current is None or rows.get_option_at_index(current).id != event.option_id:
720
+ return
721
+ option_id = event.option_id or ""
722
+ if option_id.startswith("c:"):
723
+ self.post_message(ChangeSelected(option_id[2:]))
724
+ elif option_id.startswith("g:") and isinstance(event, OptionList.OptionSelected):
725
+ self.toggle(option_id[2:])
726
+
727
+
728
+ # --- inspector ------------------------------------------------------------- #
729
+
730
+
731
+ class Inspector(VerticalScroll):
732
+ """Detail for the selection: a head, Open and Copy SHA, then titled sections."""
733
+
734
+ def __init__(self) -> None:
735
+ super().__init__(id="inspector")
736
+ self.pairs: list[tuple[Static, Static]] = []
737
+
738
+ def compose(self) -> ComposeResult:
739
+ yield Static(dim("Select a change to inspect it."), id="insp-head")
740
+ with Horizontal(id="insp-actions"):
741
+ yield Button("Open", id="open", compact=True, disabled=True)
742
+ yield Button("Copy SHA", id="copy", compact=True, disabled=True)
743
+ yield Vertical(id="sections")
744
+
745
+ async def show(
746
+ self,
747
+ head: Content,
748
+ sections: list[tuple[Content, Content]],
749
+ *,
750
+ url: bool,
751
+ sha: bool,
752
+ reset: bool,
753
+ ) -> None:
754
+ self.query_one("#insp-head", Static).update(head)
755
+ self.query_one("#open", Button).disabled = not url
756
+ self.query_one("#copy", Button).disabled = not sha
757
+ holder = self.query_one("#sections", Vertical)
758
+ while len(self.pairs) < len(sections):
759
+ pair = (Static(classes="section-title"), Static(classes="section-body"))
760
+ self.pairs.append(pair)
761
+ await holder.mount_all(pair)
762
+ for index, (title, body) in enumerate(self.pairs):
763
+ visible = index < len(sections)
764
+ title.display = body.display = visible
765
+ if visible:
766
+ title.update(sections[index][0])
767
+ body.update(sections[index][1])
768
+ if reset:
769
+ self.scroll_home(animate=False)
770
+
771
+ def section(self, index: int, body: Content) -> None:
772
+ """Replace one section body (lazy details) without touching the others."""
773
+ if index < len(self.pairs):
774
+ self.pairs[index][1].update(body)